summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 00:43:55 +0900
committerAdam Malczewski <[email protected]>2026-06-12 00:43:55 +0900
commite2646ad2b0c28bd64ad4efd6b154f97f8a35e0ad (patch)
tree0f739576bbabf19ebb0e254776188b0950c4f489 /packages/session-orchestrator/src
parente7eada4802ceebd86c83bcd6e3eca70152e7f331 (diff)
downloaddispatch-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.ts105
-rw-r--r--packages/session-orchestrator/src/metrics.ts14
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts60
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([
[