mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
55b3762afe
* feat(daemon): capture per-request request_id + token usage for claude_code runs The claude-stream-json parser read every assistant message's `message.id` only to dedup streamed text/thinking and then dropped it, and never read `message.usage` at all — so a run with N model requests collapsed into a single run-level `result.usage` record with no request id. That left request-level cost/percentile analysis stuck at run-level (~0.9% coverage). Surface the per-request granularity the wire already carries: - claude-stream.ts emits a new `request_usage` event per assistant message, keyed by `message.id` (the provider `msg_…` request id), carrying the per-message input/output/cache tokens via a `perRequestUsageFrom` helper. - packages/contracts adds `request_usage` to both the live SSE `DaemonAgentPayload` and the `PersistedAgentEvent` union; server.ts maps the live event to its persisted form. - mocks/lib/format-claude.mjs now emits per-message `usage` summing to the run-level `result.usage`, so the capture path is verifiable by local replay without burning provider budget. The per-request token sum reconciles against the run-level `result.usage` aggregate — the correctness anchor. New daemon test plus regenerated claude golden cover both the synthetic stream and the mock-replay path. No change to run execution; only telemetry/contract surface is added. claude-stream- json only for now; other runtime families ship per-request shapes in follow-ups. Closes #4610 * feat(daemon): thread per-request usage through Langfuse + PostHog run telemetry Addresses review on #4620: the parser emitted the per-request records but they stopped at SSE/message persistence and never reached the telemetry sinks named in #4610's acceptance criteria, so PostHog/Langfuse still had no per-request request_id + token coverage. Wire the records through the existing run telemetry choke point: - run-analytics-observability.ts adds `scanRunEventsForPerRequestUsageAnalytics`, a pure scanner that collects the per-request records keyed by request_id and checks the reconciliation invariant (per-request token sum == run-level `result.usage`). Both sinks read this one source. - langfuse-bridge.ts `collectAgentEvents` exports one `agent-request-usage` Langfuse event per model request, keyed by the provider request id. - routes/runs.ts adds `run_finished` coverage properties (`request_usage_count`, per-request token sums, and `request_usage_reconciles_aggregate`) so the PostHog request-level surface lifts off the ~0.9% floor; contract fields added to RunFinishedProps. Test extended: a multi-request claude run produces telemetry records keyed by request_id whose token sum reconciles with `result.usage`, plus a non-reconciling guard. langfuse-bridge + run-analytics suites stay green. * fix(daemon): fold cache tokens into per-request usage reconciliation Addresses review on #4620: request_usage_reconciles_aggregate compared only input/output tokens, so a run whose prompt/completion tokens matched but whose cache creation/read tokens drifted still reported `true` — letting the new PostHog/Langfuse telemetry certify the #4610 invariant when part of `result.usage` had not reconciled. scanRunEventsForPerRequestUsageAnalytics now reads the aggregate `cache_creation_input_tokens` / `cache_read_input_tokens` and folds every field `result.usage` reported into the boolean (each compared only when the aggregate carries it, mirroring the input/output leniency). Added a fixture where cache tokens mismatch while input/output still match, so the flag stays false and this cannot regress. * feat(daemon): map request_usage to durable PersistedAgentEvent * feat(daemon): wire per-request usage rollup into run_finished analytics lifecycle * ci: trigger fresh validation run for approved changes * feat(daemon): preserve per-request usage in truncation-proof run side ledger
551 lines
20 KiB
TypeScript
551 lines
20 KiB
TypeScript
// The physical-Run analytics lifecycle, driven directly.
|
|
//
|
|
// The real-server chain test proves the lifecycle is installed for every Run
|
|
// of an automatic chain; this one proves what it reports once a Run settles.
|
|
// Cancellation is the case that motivated it: a Run the user stopped has to
|
|
// land as `cancelled`, not as a missing row, or the cancellation rate is
|
|
// computed against a smaller denominator than the truth.
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
createRunAnalyticsLifecycle,
|
|
inheritedRunLineageHints,
|
|
} from '../../src/services/run-analytics-lifecycle.js';
|
|
import {
|
|
createRunSideEffectLedger,
|
|
foldEventIntoRunSideEffectLedger,
|
|
} from '../../src/runtimes/run-lifecycle-analytics.js';
|
|
import { createChatRunService } from '../../src/runtimes/runs.js';
|
|
|
|
type Captured = {
|
|
eventName: string;
|
|
properties: Record<string, unknown>;
|
|
insertId: string;
|
|
context: { deviceId: string };
|
|
};
|
|
|
|
function harness(finishCaptureResult: unknown = {
|
|
status: 'queued',
|
|
acknowledgement: 'local_buffer',
|
|
errorType: null,
|
|
}) {
|
|
const captured: Captured[] = [];
|
|
const recoveries: Array<{ runId: string; properties: Record<string, unknown>; insertId: string }> = [];
|
|
const completed: string[] = [];
|
|
const deliveryBegun: string[] = [];
|
|
const deliveryFinalized: Array<{ runId: string; result: unknown }> = [];
|
|
let settle: (status: { status: string; errorCode?: string | null; exitCode?: number | null }) => void = () => {};
|
|
const terminal = new Promise<{ status: string }>((resolve) => {
|
|
settle = (status) => resolve(status as { status: string });
|
|
});
|
|
|
|
const lifecycle = createRunAnalyticsLifecycle({
|
|
db: {} as never,
|
|
design: {
|
|
runs: {
|
|
wait: () => terminal as never,
|
|
setAnalyticsRecovery: (run, recovery) => {
|
|
recoveries.push({
|
|
runId: run.id,
|
|
properties: recovery.properties,
|
|
insertId: recovery.insertId,
|
|
});
|
|
},
|
|
beginAnalyticsDelivery: (run) => { deliveryBegun.push(run.id); },
|
|
finalizeAnalyticsDelivery: (run, result) => {
|
|
deliveryFinalized.push({ runId: run.id, result });
|
|
},
|
|
markAnalyticsCompleted: (run) => { completed.push(run.id); },
|
|
setDeliverableValidation: () => {},
|
|
},
|
|
analytics: {
|
|
capture: (args) => {
|
|
captured.push(args as Captured);
|
|
return args.eventName === 'run_finished'
|
|
? finishCaptureResult
|
|
: { status: 'queued', acknowledgement: 'local_buffer', errorType: null };
|
|
},
|
|
},
|
|
getAppVersion: () => '0.0.0-test',
|
|
},
|
|
paths: { PROJECTS_DIR: '/nonexistent/projects', RUNTIME_DATA_DIR: '/nonexistent/data' },
|
|
agents: { detectAgents: async () => [] },
|
|
telemetry: {
|
|
reportRunCompletionTelemetryFallback: () => {},
|
|
resolveRunProjectKindForAnalytics: () => null,
|
|
runArtifactBaselines: { take: () => undefined },
|
|
runRetryEventsForAnalytics: () => [],
|
|
},
|
|
});
|
|
|
|
return {
|
|
captured,
|
|
completed,
|
|
deliveryBegun,
|
|
deliveryFinalized,
|
|
lifecycle,
|
|
recoveries,
|
|
settle,
|
|
};
|
|
}
|
|
|
|
function fakeRun(overrides: Record<string, unknown> = {}) {
|
|
const now = Date.now();
|
|
return {
|
|
id: 'run-under-test',
|
|
projectId: null,
|
|
conversationId: null,
|
|
assistantMessageId: null,
|
|
clientRequestId: 'client-request-1',
|
|
agentId: 'codex',
|
|
status: 'running',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
events: [],
|
|
clients: new Set(),
|
|
...overrides,
|
|
} as never;
|
|
}
|
|
|
|
const CONTEXT = { deviceId: 'device-1', sessionId: 'session-1', clientType: 'web', locale: 'en' };
|
|
|
|
async function settled(h: ReturnType<typeof harness>, eventName: string) {
|
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
const found = h.captured.find((event) => event.eventName === eventName);
|
|
if (found) return found;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error(
|
|
`no ${eventName} captured; saw ${JSON.stringify(h.captured.map((e) => e.eventName))}`,
|
|
);
|
|
}
|
|
|
|
describe('run analytics lifecycle', () => {
|
|
it('reports a created run and pairs its finish under the same insert id', async () => {
|
|
const h = harness();
|
|
h.lifecycle.install({
|
|
run: fakeRun(),
|
|
body: { agentId: 'codex' },
|
|
requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
|
|
const created = await settled(h, 'run_created');
|
|
expect(created.context.deviceId).toBe('device-1');
|
|
h.settle({ status: 'succeeded' });
|
|
|
|
const finished = await settled(h, 'run_finished');
|
|
expect(finished.properties.result).toBe('success');
|
|
// The replay in `reconcileDurableRunTerminals` appends `-finish` to the
|
|
// stored id, so the stored id must stay the base one.
|
|
expect(finished.insertId).toBe(`${created.insertId}-finish`);
|
|
expect(h.recoveries.at(-1)?.insertId).toBe(created.insertId);
|
|
expect(h.completed).toEqual(['run-under-test']);
|
|
});
|
|
|
|
it('keeps failed PostHog enqueue delivery recoverable instead of marking analytics complete', async () => {
|
|
const deliveryFailure = {
|
|
status: 'failed',
|
|
acknowledgement: 'none',
|
|
errorType: 'enqueue_failed',
|
|
};
|
|
const h = harness(deliveryFailure);
|
|
h.lifecycle.install({
|
|
run: fakeRun(),
|
|
body: { agentId: 'codex' },
|
|
requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await settled(h, 'run_created');
|
|
|
|
h.settle({ status: 'failed', errorCode: 'AGENT_EXIT_1' });
|
|
await settled(h, 'run_finished');
|
|
await vi.waitFor(() => expect(h.deliveryFinalized).toHaveLength(1));
|
|
|
|
expect(h.deliveryBegun).toEqual(['run-under-test']);
|
|
expect(h.deliveryFinalized).toEqual([{
|
|
runId: 'run-under-test',
|
|
result: deliveryFailure,
|
|
}]);
|
|
expect(h.completed).toEqual([]);
|
|
});
|
|
|
|
it('publishes the bounded terminal lifecycle envelope for the current attempt', async () => {
|
|
const h = harness();
|
|
h.lifecycle.install({
|
|
run: fakeRun({
|
|
terminalLifecycle: {
|
|
version: 1,
|
|
runAttempt: 2,
|
|
runtimeGenerationId: '0f2d4d9e-f034-4ed5-8330-314bd1d525cc',
|
|
terminationOrigin: 'watchdog_cleanup',
|
|
terminalIntegrity: 'late',
|
|
terminalPersistence: { status: 'acknowledged', errorType: null },
|
|
posthogDelivery: {
|
|
status: 'in_flight',
|
|
acknowledgement: 'none',
|
|
attemptCount: 1,
|
|
errorType: null,
|
|
},
|
|
unfinishedState: 'recovery_pending',
|
|
duplicateTerminalCount: 1,
|
|
lateTerminalCount: 1,
|
|
},
|
|
}),
|
|
body: { agentId: 'amr' },
|
|
requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await settled(h, 'run_created');
|
|
|
|
h.settle({ status: 'failed', errorCode: 'AGENT_EXIT_1' });
|
|
const finished = await settled(h, 'run_finished');
|
|
|
|
expect(finished.properties).toMatchObject({
|
|
terminal_integrity: 'late',
|
|
run_attempt: 2,
|
|
runtime_generation_id: '0f2d4d9e-f034-4ed5-8330-314bd1d525cc',
|
|
termination_origin: 'watchdog_cleanup',
|
|
terminal_persistence_status: 'acknowledged',
|
|
terminal_persistence_error_type: null,
|
|
posthog_delivery_status: 'queued',
|
|
posthog_acknowledgement: 'local_buffer',
|
|
posthog_delivery_attempt_count: 1,
|
|
posthog_error_type: null,
|
|
mature_unfinished_state: 'unknown',
|
|
duplicate_terminal_count: 1,
|
|
late_terminal_count: 1,
|
|
});
|
|
});
|
|
|
|
it('settles matching and conflicting terminal claims before capturing run_finished', async () => {
|
|
const captured: Captured[] = [];
|
|
const runs = createChatRunService({
|
|
createSseResponse: () => ({
|
|
send: vi.fn(() => true),
|
|
end: vi.fn(),
|
|
cleanup: vi.fn(),
|
|
}),
|
|
createSseErrorPayload: (code: string, message: string) => ({
|
|
error: { code, message },
|
|
}),
|
|
shutdownGraceMs: 10,
|
|
ttlMs: 60_000,
|
|
});
|
|
const lifecycle = createRunAnalyticsLifecycle({
|
|
db: {} as never,
|
|
design: {
|
|
runs: {
|
|
...runs,
|
|
beginAnalyticsDelivery: (run: ReturnType<typeof runs.create>) => {
|
|
runs.beginAnalyticsDelivery(run);
|
|
queueMicrotask(() => {
|
|
runs.finish(run, 'failed', 1, null);
|
|
runs.finish(run, 'succeeded', 0, null);
|
|
});
|
|
},
|
|
} as never,
|
|
analytics: {
|
|
capture: (args) => {
|
|
captured.push(args as Captured);
|
|
return {
|
|
status: 'queued',
|
|
acknowledgement: 'local_buffer',
|
|
errorType: null,
|
|
};
|
|
},
|
|
},
|
|
getAppVersion: () => '0.0.0-test',
|
|
},
|
|
paths: {
|
|
PROJECTS_DIR: '/nonexistent/projects',
|
|
RUNTIME_DATA_DIR: '/nonexistent/data',
|
|
},
|
|
agents: { detectAgents: async () => [] },
|
|
telemetry: {
|
|
reportRunCompletionTelemetryFallback: () => {},
|
|
resolveRunProjectKindForAnalytics: () => null,
|
|
runArtifactBaselines: { take: () => undefined },
|
|
runRetryEventsForAnalytics: () => [],
|
|
},
|
|
});
|
|
const run = runs.create({
|
|
projectId: null,
|
|
conversationId: null,
|
|
agentId: 'amr',
|
|
});
|
|
lifecycle.install({
|
|
run: run as never,
|
|
body: { agentId: 'amr' },
|
|
requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(captured.some((event) => event.eventName === 'run_created')).toBe(true);
|
|
});
|
|
|
|
runs.finish(run, 'failed', 1, null);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(captured.some((event) => event.eventName === 'run_finished')).toBe(true);
|
|
});
|
|
const finished = captured.find((event) => event.eventName === 'run_finished');
|
|
expect(finished?.properties).toMatchObject({
|
|
terminal_integrity: 'late',
|
|
duplicate_terminal_count: 1,
|
|
late_terminal_count: 1,
|
|
});
|
|
});
|
|
|
|
it('publishes only bounded prompt budget facts on run_finished', async () => {
|
|
const h = harness();
|
|
h.lifecycle.install({
|
|
run: fakeRun({
|
|
agentId: 'amr',
|
|
events: [{
|
|
event: 'agent',
|
|
data: {
|
|
type: 'diagnostic',
|
|
name: 'prompt_budget_v1',
|
|
source: 'acp-json-rpc',
|
|
schemaVersion: 1,
|
|
frameBytes: 34_810,
|
|
promptBytes: 34_222,
|
|
promptTokenEstimate: 11_408,
|
|
tokenEstimateMethod: 'utf8_bytes_div_3_ceil_v1',
|
|
sessionMode: 'resume',
|
|
modelId: 'claude-opus-5',
|
|
contextWindowSource: 'model_metadata',
|
|
contextWindowTokens: 200_000,
|
|
priorSessionUsageSource: 'agent_session',
|
|
priorSessionInputTokens: 123_456,
|
|
prompt: 'PRIVATE_PROMPT_MUST_NOT_LEAK',
|
|
sessionId: 'PRIVATE_SESSION_MUST_NOT_LEAK',
|
|
path: '/PRIVATE_PATH_MUST_NOT_LEAK',
|
|
},
|
|
}],
|
|
}),
|
|
body: { agentId: 'amr' },
|
|
requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await settled(h, 'run_created');
|
|
h.settle({ status: 'succeeded' });
|
|
|
|
const finished = await settled(h, 'run_finished');
|
|
expect(finished.properties).toMatchObject({
|
|
prompt_budget_version: 'prompt_budget_v1',
|
|
prompt_frame_bytes: 34_810,
|
|
prompt_bytes: 34_222,
|
|
prompt_token_estimate: 11_408,
|
|
prompt_token_estimate_method: 'utf8_bytes_div_3_ceil_v1',
|
|
prompt_session_mode: 'resume',
|
|
prompt_model_id: 'claude-opus-5',
|
|
prompt_context_window_source: 'model_metadata',
|
|
prompt_context_window_tokens: 200_000,
|
|
prompt_prior_session_usage_source: 'agent_session',
|
|
prompt_prior_session_input_tokens: 123_456,
|
|
});
|
|
expect(JSON.stringify(finished.properties)).not.toContain('PRIVATE_');
|
|
});
|
|
|
|
it.each([
|
|
['canceled', 'cancelled'],
|
|
['failed', 'failed'],
|
|
['succeeded', 'success'],
|
|
])('reports a %s run as result %s', async (status, result) => {
|
|
const h = harness();
|
|
h.lifecycle.install({
|
|
run: fakeRun(),
|
|
body: {},
|
|
requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await settled(h, 'run_created');
|
|
h.settle({ status, errorCode: status === 'failed' ? 'AGENT_EXIT_1' : null });
|
|
|
|
const finished = await settled(h, 'run_finished');
|
|
expect(finished.properties.result).toBe(result);
|
|
if (result === 'failed') {
|
|
// Dashboards keyed on error_code must never see a blank cell.
|
|
expect(finished.properties.error_code).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('publishes current-attempt admission evidence with legacy failure fields intact', async () => {
|
|
const h = harness();
|
|
const message = '[code=model_limit_exceeded] model usage limit exceeded';
|
|
h.lifecycle.install({
|
|
run: fakeRun({ agentId: 'amr', events: [
|
|
{ event: 'start', data: { model: 'example-chat-model', streamFormat: 'acp-json-rpc' } },
|
|
{ event: 'agent', data: { type: 'status', label: 'waiting_for_first_output' } },
|
|
{ event: 'agent', data: { type: 'text_delta', delta: 'Example output' } },
|
|
{ event: 'error', data: { error: { code: 'RATE_LIMITED', message } } },
|
|
] }),
|
|
body: { agentId: 'amr' }, requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await settled(h, 'run_created');
|
|
h.settle({ status: 'failed', errorCode: 'RATE_LIMITED' });
|
|
const finished = await settled(h, 'run_finished');
|
|
expect(finished.properties).toMatchObject({
|
|
result: 'failed', error_code: 'RATE_LIMITED',
|
|
failure_category: 'rate_limit', failure_detail: 'model_window_limit',
|
|
classifier_version: 'run-failure-v3', policy_reason: 'model_window_limit',
|
|
admission_phase: 'during_execution', admission_status: 'admitted',
|
|
});
|
|
expect(h.recoveries.at(-1)?.properties).toMatchObject({ classifier_version: 'run-failure-v3', admission_status: 'admitted' });
|
|
});
|
|
|
|
it('preserves early AMR admission evidence beyond the event ring-buffer cap', async () => {
|
|
const h = harness();
|
|
const message = '[code=model_limit_exceeded] model usage limit exceeded';
|
|
const fullEvents = [
|
|
{ event: 'start', data: { agentId: 'amr', model: 'example-chat-model', streamFormat: 'acp-json-rpc' } },
|
|
{ event: 'agent', data: { type: 'status', label: 'waiting_for_first_output' } },
|
|
{ event: 'agent', data: { type: 'text_delta', delta: 'Example output' } },
|
|
...Array.from({ length: 2_001 }, (_, index) => ({
|
|
event: 'diagnostic', data: { index },
|
|
})),
|
|
{ event: 'error', data: { message } },
|
|
];
|
|
const sideEffectLedger = createRunSideEffectLedger();
|
|
for (const event of fullEvents) foldEventIntoRunSideEffectLedger(sideEffectLedger, event);
|
|
h.lifecycle.install({
|
|
run: fakeRun({
|
|
agentId: 'amr', sideEffectLedger, events: fullEvents.slice(-2_000),
|
|
}),
|
|
body: { agentId: 'amr' }, requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await settled(h, 'run_created');
|
|
h.settle({ status: 'failed', errorCode: 'AGENT_EXECUTION_FAILED' });
|
|
const finished = await settled(h, 'run_finished');
|
|
expect(finished.properties).toMatchObject({
|
|
policy_reason: 'model_window_limit', admission_phase: 'during_execution',
|
|
admission_status: 'admitted',
|
|
});
|
|
});
|
|
|
|
it('does not count replayed non-AMR ACP history when the start is truncated', async () => {
|
|
const h = harness();
|
|
const message = '[code=model_limit_exceeded] model usage limit exceeded';
|
|
const fullEvents = [
|
|
{ event: 'start', data: { agentId: 'hermes', model: 'example-chat-model', streamFormat: 'acp-json-rpc' } },
|
|
...Array.from({ length: 10 }, (_, index) => ({
|
|
event: 'diagnostic', data: { index },
|
|
})),
|
|
{ event: 'agent', data: { type: 'text_delta', delta: 'Replayed history' } },
|
|
{ event: 'agent', data: { type: 'status', label: 'waiting_for_first_output' } },
|
|
{ event: 'agent', data: { type: 'text_delta', delta: 'Host notice', hostSynthesized: true } },
|
|
...Array.from({ length: 1_988 }, (_, index) => ({
|
|
event: 'diagnostic', data: { index },
|
|
})),
|
|
{ event: 'error', data: { message } },
|
|
{ event: 'agent', data: { type: 'text_delta', delta: 'Late activity' } },
|
|
];
|
|
const sideEffectLedger = createRunSideEffectLedger();
|
|
for (const event of fullEvents) foldEventIntoRunSideEffectLedger(sideEffectLedger, event);
|
|
h.lifecycle.install({
|
|
run: fakeRun({
|
|
agentId: 'hermes', sideEffectLedger, events: fullEvents.slice(-2_000),
|
|
}),
|
|
body: { agentId: 'hermes' }, requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await settled(h, 'run_created');
|
|
h.settle({ status: 'failed', errorCode: 'AGENT_EXECUTION_FAILED' });
|
|
const finished = await settled(h, 'run_finished');
|
|
expect(finished.properties).toMatchObject({
|
|
policy_reason: 'model_window_limit', admission_phase: 'unknown',
|
|
admission_status: 'unknown',
|
|
});
|
|
});
|
|
|
|
it('stays silent for a run nobody asked for', async () => {
|
|
// A scheduled Automation has no caller to attribute the Run to. Silence is
|
|
// the correct outcome — inventing an identity would be worse than a gap.
|
|
const h = harness();
|
|
h.lifecycle.install({ run: fakeRun(), body: {}, requestAnalyticsContext: null });
|
|
h.settle({ status: 'succeeded' });
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
expect(h.captured).toEqual([]);
|
|
expect(h.recoveries).toEqual([]);
|
|
});
|
|
|
|
it('publishes per-request usage count, sums, and reconciliation invariant to run_finished', async () => {
|
|
const h = harness();
|
|
const run = fakeRun({
|
|
agentId: 'claude',
|
|
events: [
|
|
{
|
|
event: 'agent',
|
|
data: {
|
|
type: 'request_usage',
|
|
requestId: 'msg_1',
|
|
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 },
|
|
},
|
|
},
|
|
{
|
|
event: 'agent',
|
|
data: {
|
|
type: 'request_usage',
|
|
requestId: 'msg_2',
|
|
usage: { input_tokens: 20, output_tokens: 15, cache_read_input_tokens: 3 },
|
|
},
|
|
},
|
|
{
|
|
event: 'agent',
|
|
data: {
|
|
type: 'usage',
|
|
usage: { input_tokens: 30, output_tokens: 20, cache_read_input_tokens: 5 },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
h.lifecycle.install({
|
|
run,
|
|
body: { agentId: 'claude' },
|
|
requestAnalyticsContext: CONTEXT as never,
|
|
});
|
|
await settled(h, 'run_created');
|
|
h.settle({ status: 'succeeded' });
|
|
|
|
const finished = await settled(h, 'run_finished');
|
|
expect(finished.properties.request_usage_count).toBe(2);
|
|
expect(finished.properties.request_usage_input_tokens_sum).toBe(30);
|
|
expect(finished.properties.request_usage_output_tokens_sum).toBe(20);
|
|
expect(finished.properties.request_usage_reconciles_aggregate).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('inherited run lineage', () => {
|
|
it('carries the source run resolved lineage onto the next physical run', () => {
|
|
expect(
|
|
inheritedRunLineageHints(
|
|
{ id: 'run-2', clientRequestId: 'odnext_run_b' },
|
|
{ analyticsHints: { taskExecutionId: 'task-1', initialRunId: 'run-1' } },
|
|
2,
|
|
),
|
|
).toEqual({
|
|
taskExecutionId: 'task-1',
|
|
initialRunId: 'run-1',
|
|
taskRunIndex: 2,
|
|
sourceRunId: 'run-2',
|
|
});
|
|
});
|
|
|
|
it('falls back the same way the lifecycle does when the source carried no hints', () => {
|
|
// The first Run of a task resolves its own id from `clientRequestId`, so
|
|
// inheriting must land on the same value — otherwise the second Run starts
|
|
// a second task in the warehouse.
|
|
expect(
|
|
inheritedRunLineageHints({ id: 'run-1', clientRequestId: 'client-request-1' }, {}, 1),
|
|
).toEqual({
|
|
taskExecutionId: 'client-request-1',
|
|
initialRunId: 'run-1',
|
|
taskRunIndex: 1,
|
|
sourceRunId: 'run-1',
|
|
});
|
|
});
|
|
|
|
it('falls back to the run id when there is no client request id either', () => {
|
|
expect(inheritedRunLineageHints({ id: 'run-1' }, undefined, 1)).toMatchObject({
|
|
taskExecutionId: 'run-1',
|
|
initialRunId: 'run-1',
|
|
});
|
|
});
|
|
});
|