mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(analytics): stamp $os/$os_version on daemon-emitted events posthog-node (unlike posthog-js) does not auto-enrich device props, so every daemon-emitted event (run_created/run_finished/project_create_result/ file_upload_result/...) landed in the null/unknown bucket on any OS breakdown. The daemon runs on the user's machine, so process.platform is authoritative; stamp the canonical PostHog $os (Mac OS X|Windows|Linux) + $os_version on both capture() and captureSafety() so backend events join the same OS segmentation as the web client's posthog-js events. * feat(analytics): track HyperFrames as a first-class project_kind HyperFrames is a peer task type in the router (its own Home chip), but the data model stores it as a 'video' project with videoModel='hyperframes-html', so analytics recorded project_kind=video and HyperFrames was invisible in the project-type dimension. Add 'hyperframes' to TrackingProjectKind, make projectKindToTracking(kind, videoModel?) split it out of generic video, and thread videoModel through every web/daemon emit site (project_create_result, run_created/run_finished, recent projects, DS apply target). * feat(analytics): instrument chat composer controls + per-send run context Adds ui_click coverage across the home + in-project chat composer bottom bar, which was almost entirely untracked: - ask/design session-mode toggle (ComposerSessionModeClickProps, home + chat_panel) - design toolbox open / action / resource (DesignToolboxClickProps) - the rest of the bar (ComposerBarClickProps): + menu open/pick/add connector|plugin|mcp, design-system switch, working-dir switch, agent + model selection, context-chip removal - home composer: working-dir picker, task-type chips And enriches run_created/run_finished with the per-send context the spec asks for: session_mode, plugin_id, mcp_ids[], skill_ids[] (model_id/agent_provider_id/ design_system_id already present; mcp_id no longer hardcoded null). * feat(analytics): instrument the workspace tab launcher The tab strip's '+' launcher (search/filter files, New Terminal, New Browser, open file as tab, focus an open tab) had no tracking. Add TabLauncherClickProps (page_name=file_manager, area=tab_launcher) and an onTrack hook on TabLauncherMenu that FileWorkspace fills with page/area/project_id, covering open / filter / create / open_file / open_tab. * fix(analytics): cover home + menu and desktop working-dir switch Review follow-up: - HomeChatComposerClickProps gains plus_menu_open / plus_pick / plus_add (+ resource_kind/resource_id); HomeHero wraps the ComposerPlusMenu open/pick/add callbacks so the home '+' menu emits the same gestures the in-project composer already records. - WorkingDirPill funnels both success paths (browser applyDir + desktop host pickAndReplaceHostProjectWorkingDir) through a shared trackWorkingDirSwitch() so packaged/desktop replacements are counted too. * fix(analytics): fold persistent skillId into run skill_ids set Review follow-up: skill_ids only carried the staged per-turn skillIds, so a project run with a persisted skillId but no staged skills emitted skill_id=<id> but skill_ids=[]. Build a de-duped skill_ids from [skillId, ...skillIds] so the array field is the complete per-send skill context; keep singular skill_id. * fix(analytics): emit agent_selector_open when the AvatarMenu opens Review follow-up: the contract added ComposerBarClickProps.element= 'agent_selector_open' but nothing emitted it, so open-to-select dropoff was unmeasurable. Add an onOpen prop to AvatarMenu fired on the closed->open transition (not on close) and wire ProjectView to emit agent_selector_open with the same project_id context as agent_select/agent_model_select. * fix(analytics): gate session_mode to composer-originated runs Review follow-up: session_mode defaulted a missing reqBody.sessionMode to 'ask', so DS-generation runs (DesignSystemFlow sends no sessionMode) were classified as Ask runs on run_created/run_finished, polluting composer-mode dashboards. Only spread session_mode when !isDesignSystemRun && typeof reqBody.sessionMode === 'string'; the field is optional in the contract for DS runs. --------- Co-authored-by: qiongyu1999 <2694684348@qq.com>
209 lines
8.4 KiB
TypeScript
209 lines
8.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
__forTestRetryFinalResultForRunStatus,
|
|
__forTestRunRetryEventsForAnalytics,
|
|
__forTestResolveRunProjectKindForAnalytics,
|
|
__forTestScanRunEventsForRetrySideEffects,
|
|
__forTestScanRunEventsForFinishedProps,
|
|
} from '../src/server.js';
|
|
import { hasExplicitRequestedModelForAnalytics } from '../src/run-analytics-observability.js';
|
|
|
|
describe('run lifecycle analytics', () => {
|
|
it('falls back to stored project metadata when analytics hints omit project kind', () => {
|
|
expect(
|
|
__forTestResolveRunProjectKindForAnalytics({
|
|
hintProjectKind: null,
|
|
projectMetadata: { kind: 'prototype' },
|
|
}),
|
|
).toBe('prototype');
|
|
});
|
|
|
|
it('maps project metadata kind to the analytics project_kind enum', () => {
|
|
expect(
|
|
__forTestResolveRunProjectKindForAnalytics({
|
|
hintProjectKind: null,
|
|
projectMetadata: { kind: 'deck' },
|
|
}),
|
|
).toBe('slide_deck');
|
|
});
|
|
|
|
it('preserves explicit analytics hints over project metadata', () => {
|
|
expect(
|
|
__forTestResolveRunProjectKindForAnalytics({
|
|
hintProjectKind: 'design_system',
|
|
projectMetadata: { kind: 'other' },
|
|
}),
|
|
).toBe('design_system');
|
|
});
|
|
|
|
it('classifies design-system workspace projects when hints are absent', () => {
|
|
expect(
|
|
__forTestResolveRunProjectKindForAnalytics({
|
|
hintProjectKind: null,
|
|
projectMetadata: { kind: 'other', importedFrom: 'design-system' },
|
|
}),
|
|
).toBe('design_system');
|
|
});
|
|
|
|
it('splits HyperFrames out of generic video via videoModel', () => {
|
|
expect(
|
|
__forTestResolveRunProjectKindForAnalytics({
|
|
hintProjectKind: null,
|
|
projectMetadata: { kind: 'video', videoModel: 'hyperframes-html' },
|
|
}),
|
|
).toBe('hyperframes');
|
|
});
|
|
|
|
it('keeps AI video projects as video when videoModel is not HyperFrames', () => {
|
|
expect(
|
|
__forTestResolveRunProjectKindForAnalytics({
|
|
hintProjectKind: null,
|
|
projectMetadata: { kind: 'video', videoModel: 'kling-v2' },
|
|
}),
|
|
).toBe('video');
|
|
});
|
|
});
|
|
|
|
describe('scanRunEventsForFinishedProps', () => {
|
|
function usageEvent(inputTokens: number, outputTokens: number) {
|
|
return { event: 'agent', data: { type: 'usage', usage: { input_tokens: inputTokens, output_tokens: outputTokens } } };
|
|
}
|
|
|
|
function initializingEvent(model: string) {
|
|
return { event: 'agent', data: { type: 'status', label: 'initializing', model } };
|
|
}
|
|
|
|
function modelEvent(model: string) {
|
|
return { event: 'agent', data: { type: 'status', label: 'model', model } };
|
|
}
|
|
|
|
it('extracts agent model from initializing event when usage event follows it (real run order)', () => {
|
|
// Append order mirrors a real run: initializing first, usage last.
|
|
// Reverse scan must not stop at usage before reading the model signal.
|
|
const events = [initializingEvent('claude-opus-4'), usageEvent(100, 200)];
|
|
const result = __forTestScanRunEventsForFinishedProps(events, '');
|
|
expect(result.agentReportedModel).toBe('claude-opus-4');
|
|
expect(result.inputTokens).toBe(100);
|
|
expect(result.outputTokens).toBe(200);
|
|
});
|
|
|
|
it('extracts agent model from ACP status:model event when usage follows it', () => {
|
|
const events = [modelEvent('gpt-4o'), usageEvent(50, 75)];
|
|
const result = __forTestScanRunEventsForFinishedProps(events, '');
|
|
expect(result.agentReportedModel).toBe('gpt-4o');
|
|
expect(result.inputTokens).toBe(50);
|
|
});
|
|
|
|
it('reads model from detail field when model field is absent', () => {
|
|
const events = [
|
|
{ event: 'agent', data: { type: 'status', label: 'initializing', detail: 'gemini-pro' } },
|
|
usageEvent(10, 20),
|
|
];
|
|
const result = __forTestScanRunEventsForFinishedProps(events, '');
|
|
expect(result.agentReportedModel).toBe('gemini-pro');
|
|
});
|
|
|
|
it('stops early on usage when reqBodyModel is set (no need to scan for agent model)', () => {
|
|
// When the user picked a model, needAgentModel=false so the loop exits
|
|
// as soon as usage tokens are found — it does not need to walk back to
|
|
// the initializing event.
|
|
const events = [initializingEvent('claude-opus-4'), usageEvent(30, 40)];
|
|
const result = __forTestScanRunEventsForFinishedProps(events, 'claude-haiku-4-5');
|
|
expect(result.inputTokens).toBe(30);
|
|
expect(result.outputTokens).toBe(40);
|
|
// agentReportedModel may or may not be found (early exit), but the caller
|
|
// ignores it when reqBodyModel is set — no assertion on its value here.
|
|
});
|
|
|
|
it('treats synthetic default request model as unresolved and reads the agent model', () => {
|
|
const events = [initializingEvent('gpt-5.4-mini'), usageEvent(30, 40)];
|
|
const result = __forTestScanRunEventsForFinishedProps(events, 'default');
|
|
expect(result.agentReportedModel).toBe('gpt-5.4-mini');
|
|
expect(result.inputTokens).toBe(30);
|
|
expect(result.outputTokens).toBe(40);
|
|
});
|
|
|
|
it('returns null agentReportedModel when no status event is present', () => {
|
|
const events = [usageEvent(5, 10)];
|
|
const result = __forTestScanRunEventsForFinishedProps(events, '');
|
|
expect(result.agentReportedModel).toBeNull();
|
|
expect(result.inputTokens).toBe(5);
|
|
});
|
|
|
|
it('handles empty event list', () => {
|
|
const result = __forTestScanRunEventsForFinishedProps([], '');
|
|
expect(result.agentReportedModel).toBeNull();
|
|
expect(result.inputTokens).toBeUndefined();
|
|
expect(result.outputTokens).toBeUndefined();
|
|
});
|
|
|
|
it('uses terminal usage event tokens when multiple usage events exist', () => {
|
|
// Multi-step/multi-turn runs emit one usage event per step/turn (json-event-stream,
|
|
// pi-rpc). Reverse scan hits the terminal (highest-index) usage first; the
|
|
// !haveUsageTokens guard must prevent earlier usage events from overwriting those values
|
|
// while the loop continues scanning back for agentReportedModel.
|
|
const events = [
|
|
initializingEvent('claude-opus-4'),
|
|
usageEvent(100, 200), // step 1 — must NOT overwrite terminal values
|
|
usageEvent(500, 750), // terminal turn — seen first in reverse, values must survive
|
|
];
|
|
const result = __forTestScanRunEventsForFinishedProps(events, '');
|
|
expect(result.agentReportedModel).toBe('claude-opus-4');
|
|
expect(result.inputTokens).toBe(500);
|
|
expect(result.outputTokens).toBe(750);
|
|
});
|
|
});
|
|
|
|
describe('hasExplicitRequestedModelForAnalytics', () => {
|
|
it('only treats concrete non-default model strings as explicit selections', () => {
|
|
expect(hasExplicitRequestedModelForAnalytics(null)).toBe(false);
|
|
expect(hasExplicitRequestedModelForAnalytics('')).toBe(false);
|
|
expect(hasExplicitRequestedModelForAnalytics(' default ')).toBe(false);
|
|
expect(hasExplicitRequestedModelForAnalytics('claude-opus-4-7')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('run retry analytics helpers', () => {
|
|
it('detects retry-blocking side effects from run events', () => {
|
|
expect(__forTestScanRunEventsForRetrySideEffects([
|
|
{ event: 'stderr', data: { chunk: 'HTTP 503' } },
|
|
])).toEqual({
|
|
userVisibleOutputSeen: false,
|
|
toolCallSeen: false,
|
|
artifactWriteSeen: false,
|
|
liveArtifactSeen: false,
|
|
});
|
|
|
|
expect(__forTestScanRunEventsForRetrySideEffects([
|
|
{ event: 'agent', data: { type: 'text_delta', delta: 'hello' } },
|
|
{ event: 'agent', data: { type: 'tool_use', id: 't1', name: 'Read', input: {} } },
|
|
{ event: 'agent', data: { type: 'live_artifact' } },
|
|
])).toMatchObject({
|
|
userVisibleOutputSeen: true,
|
|
toolCallSeen: true,
|
|
liveArtifactSeen: true,
|
|
});
|
|
});
|
|
|
|
it('derives retry final result from terminal status and attempt count', () => {
|
|
expect(__forTestRetryFinalResultForRunStatus('succeeded', 0)).toBe('not_attempted');
|
|
expect(__forTestRetryFinalResultForRunStatus('failed', 0)).toBe('suppressed');
|
|
expect(__forTestRetryFinalResultForRunStatus('succeeded', 1)).toBe('success');
|
|
expect(__forTestRetryFinalResultForRunStatus('failed', 1)).toBe('failed');
|
|
expect(__forTestRetryFinalResultForRunStatus('canceled', 1)).toBe('suppressed');
|
|
});
|
|
|
|
it('selects retry events for daemon-side analytics replay', () => {
|
|
const events = [
|
|
{ event: 'start', data: {} },
|
|
{ event: 'run_retry_attempted', data: { retry_attempt_index: 1 } },
|
|
{ event: 'run_retry_finished', data: { retry_result: 'success' } },
|
|
{ event: 'end', data: {} },
|
|
];
|
|
expect(__forTestRunRetryEventsForAnalytics(events).map((event) => event.event)).toEqual([
|
|
'run_retry_attempted',
|
|
'run_retry_finished',
|
|
]);
|
|
});
|
|
});
|