Skip to content

Commit bb984de

Browse files
gewenyu99claude
andcommitted
feat(analytics): identify the user so feature flags can target by email
identifyUser sets the distinct id and person properties (email, name) before flag evaluation, and getFeatureFlag/getAllFlagsForWizard now send them. Previously the wizard sent only $app_name, so email-targeted flags never matched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4d2633 commit bb984de

8 files changed

Lines changed: 335 additions & 16 deletions

File tree

src/lib/agent/agent-interface.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,11 @@ export async function runAgent(
803803
abortCases?: readonly AbortCaseMatcher[];
804804
/** Request the end-of-run reflection remark. Defaults to true. */
805805
requestRemark?: boolean;
806+
/**
807+
* Extra properties attached to this run's `agent completed` / `agent
808+
* aborted` events (e.g. the orchestrator's task type and id).
809+
*/
810+
analyticsProperties?: Record<string, unknown>;
806811
},
807812
middleware?: {
808813
onMessage(message: any): void;
@@ -880,9 +885,27 @@ export async function runAgent(
880885
analytics.capture(WIZARD_REMARK_EVENT_NAME, { remark });
881886
}
882887

888+
// Token usage comes from the SDK result message and is per agent run —
889+
// for the orchestrator that means per task, the secondary cost to watch.
890+
const usage = lastResultMessage?.usage as
891+
| {
892+
input_tokens?: number;
893+
output_tokens?: number;
894+
cache_creation_input_tokens?: number;
895+
cache_read_input_tokens?: number;
896+
}
897+
| undefined;
883898
analytics.wizardCapture('agent completed', {
884899
duration_ms: durationMs,
885900
duration_seconds: durationSeconds,
901+
model: agentConfig.model,
902+
num_turns: lastResultMessage?.num_turns,
903+
total_cost_usd: lastResultMessage?.total_cost_usd,
904+
input_tokens: usage?.input_tokens,
905+
output_tokens: usage?.output_tokens,
906+
cache_creation_input_tokens: usage?.cache_creation_input_tokens,
907+
cache_read_input_tokens: usage?.cache_read_input_tokens,
908+
...config?.analyticsProperties,
886909
});
887910
try {
888911
middleware?.finalize(lastResultMessage, durationMs);
@@ -1280,6 +1303,8 @@ export async function runAgent(
12801303
analytics.wizardCapture('agent aborted', {
12811304
duration_ms: durationMs,
12821305
duration_seconds: Math.round(durationMs / 1000),
1306+
model: agentConfig.model,
1307+
...config?.analyticsProperties,
12831308
});
12841309
}
12851310
}

src/lib/agent/agent-runner.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,12 +326,18 @@ async function bootstrapProgram(
326326
getUI().setRoleAtOrganization(roleAtOrganization);
327327
getUI().setApiUser(user);
328328

329+
// Identify the user (email, name) before evaluating flags, so flags can target
330+
// the individual user and not just $app_name.
331+
if (user) analytics.identifyUser(user);
329332
analytics.setGroups(groupsFromUser(user, host));
330333

331334
// Feature flags, variant metadata, and MCP url. Both arms need these, and the
332335
// fork decision reads the flags.
333336
const wizardFlags = await analytics.getAllFlagsForWizard();
334337
const wizardMetadata = buildWizardMetadata(wizardFlags);
338+
// Tag every wizard event with the variant so runs segment in PostHog; the
339+
// orchestrator arm overwrites this with its own variant when it forks.
340+
analytics.setTag('variant', wizardMetadata.VARIANT);
335341

336342
const mcpUrl = session.localMcp
337343
? 'http://localhost:8787/mcp'

src/lib/programs/orchestrator/__tests__/agent-prompt-loader.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ import * as os from 'os';
33
import * as path from 'path';
44
import {
55
agentRunTools,
6+
assembleTaskPrompt,
67
buildRegistry,
78
parseAgentPrompt,
89
resolveTask,
10+
taskModel,
911
type AgentPrompt,
1012
type AgentRegistry,
13+
type OrchestratorPromptContext,
1114
} from '../agent-prompt-loader';
1215
import { QueueStore } from '../queue';
1316

@@ -203,3 +206,42 @@ describe('resolveTask', () => {
203206
);
204207
});
205208
});
209+
210+
describe('taskModel', () => {
211+
const prompt = parseAgentPrompt(
212+
'---\nmodel: prompt-model\n---\nx',
213+
'capture',
214+
);
215+
216+
it('prefers the enqueue override, then the prompt, then the default', () => {
217+
const registry = registryOf([prompt]);
218+
const task = { type: 'capture' };
219+
expect(taskModel(registry, { ...task, model: 'override' } as never)).toBe(
220+
'override',
221+
);
222+
expect(taskModel(registry, task as never)).toBe('prompt-model');
223+
expect(taskModel(registryOf([]), task as never)).toBe('claude-sonnet-4-6');
224+
});
225+
});
226+
227+
describe('assembleTaskPrompt', () => {
228+
const ctx: OrchestratorPromptContext = {
229+
projectId: 1,
230+
projectApiKey: 'phc_x',
231+
host: 'https://us.posthog.com',
232+
};
233+
234+
it('points the agent at its installed task instructions', () => {
235+
const assembled = assembleTaskPrompt(ctx, 'do the task', [
236+
'.posthog-wizard/skills/capture/SKILL.md',
237+
]);
238+
expect(assembled).toContain('.posthog-wizard/skills/capture/SKILL.md');
239+
expect(assembled).toContain('do the task');
240+
});
241+
242+
it('omits the instructions section when no skills are installed', () => {
243+
expect(assembleTaskPrompt(ctx, 'do the task')).not.toContain(
244+
'task instructions',
245+
);
246+
});
247+
});

src/lib/programs/orchestrator/__tests__/queue.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
type TaskHandoff,
88
} from '@lib/programs/orchestrator/queue';
99

10+
jest.mock('@utils/analytics', () => ({
11+
analytics: { captureException: jest.fn(), wizardCapture: jest.fn() },
12+
}));
13+
1014
function tmpDir(): string {
1115
return fs.mkdtempSync(path.join(os.tmpdir(), 'queue-test-'));
1216
}
@@ -132,4 +136,40 @@ describe('QueueStore', () => {
132136
expect(file.tasks[0].status).toBe('done');
133137
expect(file.tasks[0].handoff?.did).toBe('d');
134138
});
139+
140+
it('notifies the transition listener with post-transition task state', () => {
141+
const seen: Array<{ event: string; status: string; attempts: number }> = [];
142+
const listened = new QueueStore(dir, 'run-2', {
143+
onTransition: (event, task) =>
144+
seen.push({ event, status: task.status, attempts: task.attempts }),
145+
});
146+
147+
const t = listened.enqueue({ type: 'install' });
148+
listened.start(t.id);
149+
listened.fail(t.id, { type: 'API_ERROR', message: 'boom' });
150+
listened.requeue(t.id);
151+
listened.start(t.id);
152+
listened.complete(t.id);
153+
154+
expect(seen).toEqual([
155+
{ event: 'enqueue', status: 'pending', attempts: 0 },
156+
{ event: 'start', status: 'in_progress', attempts: 1 },
157+
{ event: 'fail', status: 'failed', attempts: 1 },
158+
{ event: 'requeue', status: 'pending', attempts: 1 },
159+
{ event: 'start', status: 'in_progress', attempts: 2 },
160+
{ event: 'complete', status: 'done', attempts: 2 },
161+
]);
162+
});
163+
164+
it('a throwing listener does not break transitions', () => {
165+
const listened = new QueueStore(dir, 'run-3', {
166+
onTransition: () => {
167+
throw new Error('listener boom');
168+
},
169+
});
170+
const t = listened.enqueue({ type: 'install' });
171+
listened.start(t.id);
172+
listened.complete(t.id);
173+
expect(listened.get(t.id)?.status).toBe('done');
174+
});
135175
});

src/lib/programs/orchestrator/agent-prompt-loader.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,30 @@ const TASK_BASICS = `You are one isolated task in a larger PostHog workflow, run
5151

5252
const SEED_BASICS = `You are the orchestrator. Plan the work and seed the queue with enqueue_task — each call returns an id you can pass as a dependency to a later task. Give each task a short label for the UI — the action in a few words, not file names, class names, or other specifics. You are not a task yourself: do not call complete_task and do not edit the project.`;
5353

54+
/**
55+
* Points the agent at its installed task instructions (the HOW). They live under
56+
* the wizard's run dir, not `.claude/skills/`, so the SDK does not auto-load
57+
* them — the prompt has to name them.
58+
*/
59+
function skillReference(paths: readonly string[]): string | null {
60+
if (paths.length === 0) return null;
61+
const list = paths.map((p) => `\`${p}\``).join(', ');
62+
return `Your task instructions are at ${list}. Read them before you start and follow them. They are wizard scaffolding, not part of the project.`;
63+
}
64+
5465
/** A task agent's full prompt: injected basics, then the authored intent. */
5566
export function assembleTaskPrompt(
5667
ctx: OrchestratorPromptContext,
5768
body: string,
69+
skillPaths: readonly string[] = [],
5870
): string {
59-
return [projectContext(ctx), exampleReference(ctx), TASK_BASICS, body]
71+
return [
72+
projectContext(ctx),
73+
exampleReference(ctx),
74+
skillReference(skillPaths),
75+
TASK_BASICS,
76+
body,
77+
]
6078
.filter(Boolean)
6179
.join('\n\n');
6280
}
@@ -288,9 +306,14 @@ export function resolveTask(
288306
.join('\n\n');
289307

290308
return {
291-
model: task.model ?? prompt.model ?? DEFAULT_TASK_MODEL,
309+
model: taskModel(registry, task),
292310
...agentRunTools(prompt),
293311
prompt: body,
294312
skills: prompt.skills,
295313
};
296314
}
315+
316+
/** The model a task runs on: enqueue override, then prompt frontmatter, then default. */
317+
export function taskModel(registry: AgentRegistry, task: QueuedTask): string {
318+
return task.model ?? registry.get(task.type)?.model ?? DEFAULT_TASK_MODEL;
319+
}

0 commit comments

Comments
 (0)