diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 00:43:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 00:43:55 +0900 |
| commit | e2646ad2b0c28bd64ad4efd6b154f97f8a35e0ad (patch) | |
| tree | 0f739576bbabf19ebb0e254776188b0950c4f489 /packages/session-orchestrator/src | |
| parent | e7eada4802ceebd86c83bcd6e3eca70152e7f331 (diff) | |
| download | dispatch-e2646ad2b0c28bd64ad4efd6b154f97f8a35e0ad.tar.gz dispatch-e2646ad2b0c28bd64ad4efd6b154f97f8a35e0ad.zip | |
feat(metrics): expose current context size to the frontend
contextSize = the turn's FINAL step inputTokens+outputTokens (true context
occupancy; NOT the aggregate usage, which sums per-step prompts and overcounts
multi-step turns). Stamped on both the live done event (kernel) and persisted
TurnMetrics (session-orchestrator); a client reads the latest turn's value.
- @dispatch/wire 0.4.0->0.5.0: optional contextSize on TurnDoneEvent + TurnMetrics
- @dispatch/transport-contract 0.5.0->0.6.0 (re-export only)
- glossary: context size (reserve 'context window' for the model limit, later)
- FE courier: frontend-context-size-handoff.md
881 vitest pass; tsc -b EXIT 0; biome clean.
Diffstat (limited to 'packages/session-orchestrator/src')
| -rw-r--r-- | packages/session-orchestrator/src/metrics.test.ts | 105 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/metrics.ts | 14 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 60 |
3 files changed, 179 insertions, 0 deletions
diff --git a/packages/session-orchestrator/src/metrics.test.ts b/packages/session-orchestrator/src/metrics.test.ts index c123dba..1920fc0 100644 --- a/packages/session-orchestrator/src/metrics.test.ts +++ b/packages/session-orchestrator/src/metrics.test.ts @@ -261,4 +261,109 @@ describe("createMetricsAccumulator", () => { expect(tm.usage.inputTokens).toBe(0); expect(tm.usage.outputTokens).toBe(0); }); + + it("contextSize equals inputTokens + outputTokens for a single-step turn", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 10, outputTokens: 5 }, + }); + + const tm = acc.build("t1"); + expect(tm.contextSize).toBe(15); + }); + + it("contextSize equals ONLY the last step's inputTokens + outputTokens for a multi-step turn", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + usage: { inputTokens: 10, outputTokens: 5 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + }); + acc.ingest({ + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#1"), + usage: { inputTokens: 20, outputTokens: 10 }, + }); + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#1"), + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 100, outputTokens: 50 }, + }); + + const tm = acc.build("t1"); + expect(tm.contextSize).toBe(30); + expect(tm.contextSize).not.toBe(tm.usage.inputTokens); + }); + + it("contextSize is undefined when the turn has no steps", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + }); + + const tm = acc.build("t1"); + expect(tm.contextSize).toBeUndefined(); + }); + + it("contextSize is undefined when the last step has no usable per-step usage", () => { + const acc = createMetricsAccumulator(); + + acc.ingest({ + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: stepId("t1#0"), + genTotalMs: 200, + }); + acc.ingest({ + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + }); + + const tm = acc.build("t1"); + expect(tm.contextSize).toBeUndefined(); + }); }); diff --git a/packages/session-orchestrator/src/metrics.ts b/packages/session-orchestrator/src/metrics.ts index e953bd9..2dfa533 100644 --- a/packages/session-orchestrator/src/metrics.ts +++ b/packages/session-orchestrator/src/metrics.ts @@ -100,6 +100,20 @@ export function createMetricsAccumulator(): MetricsAccumulator { if (doneDurationMs !== undefined) { (tm as { durationMs?: number }).durationMs = doneDurationMs; } + + // contextSize = final step's inputTokens + outputTokens (true context occupancy). + // Omit when no steps or the last step had no usable per-step usage event. + if (stepMetrics.length > 0) { + const lastStep = stepMetrics[stepMetrics.length - 1]; + if (lastStep !== undefined) { + const lastAcc = steps.get(lastStep.stepId); + if (lastAcc?.usage !== undefined) { + (tm as { contextSize?: number }).contextSize = + lastStep.usage.inputTokens + lastStep.usage.outputTokens; + } + } + } + return tm; } diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 33deb15..b33bdcc 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -834,6 +834,66 @@ describe("turn metrics persistence", () => { expect(tm.usage.outputTokens).toBe(15); }); + it("persists contextSize as the last step's inputTokens + outputTokens", async () => { + const store = createInMemoryStore(); + const tool = createFakeTool("echo", async () => ({ content: "echoed" })); + + let callIndex = 0; + const provider: ProviderContract = { + id: "fake", + stream() { + const idx = callIndex++; + return (async function* () { + if (idx === 0) { + yield { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: {}, + } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 10, outputTokens: 5 }, + } as ProviderEvent; + yield { type: "finish", reason: "tool-calls" } as ProviderEvent; + } else { + yield { type: "text-delta", delta: "Step2" } as ProviderEvent; + yield { + type: "usage", + usage: { inputTokens: 20, outputTokens: 10 }, + } as ProviderEvent; + yield { type: "finish", reason: "stop" } as ProviderEvent; + } + })(); + }, + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [tool], + applyToolsFilter: identityApplyToolsFilter, + runTurn, + now: () => 1000, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-context-size", + text: "test", + onEvent: () => {}, + }); + + const metrics = store.metricsData.get("conv-context-size"); + expect(metrics).toBeDefined(); + expect(metrics).toHaveLength(1); + + const tm = metrics?.[0]; + if (tm === undefined) throw new Error("expected metrics"); + + expect(tm.steps.length).toBeGreaterThanOrEqual(2); + expect(tm.contextSize).toBe(30); + }); + it("does not persist metrics nor emit turn-sealed when chunk append fails", async () => { const provider = createFakeProvider([ [ |
