summaryrefslogtreecommitdiffhomepage
path: root/src/core/metrics/reducer.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/metrics/reducer.test.ts')
-rw-r--r--src/core/metrics/reducer.test.ts689
1 files changed, 689 insertions, 0 deletions
diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts
new file mode 100644
index 0000000..581a8b7
--- /dev/null
+++ b/src/core/metrics/reducer.test.ts
@@ -0,0 +1,689 @@
+import type { StepId, TurnDoneEvent, TurnStepCompleteEvent, TurnUsageEvent } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ selectCurrentContextSize,
+ selectOrderedTurnMetrics,
+} from "./reducer";
+
+const usageEvent = (
+ turnId: string,
+ inputTokens: number,
+ outputTokens: number,
+ stepId?: string,
+): TurnUsageEvent => {
+ const base = {
+ type: "usage" as const,
+ conversationId: "c1",
+ turnId,
+ usage: { inputTokens, outputTokens },
+ };
+ if (stepId !== undefined) {
+ return { ...base, stepId: stepId as StepId };
+ }
+ return base;
+};
+
+const stepCompleteEvent = (
+ turnId: string,
+ stepId: string,
+ timing: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = {},
+): TurnStepCompleteEvent => ({
+ type: "step-complete",
+ conversationId: "c1",
+ turnId,
+ stepId: stepId as StepId,
+ ...timing,
+});
+
+const doneEvent = (
+ turnId: string,
+ extra: {
+ durationMs?: number;
+ usage?: { inputTokens: number; outputTokens: number };
+ contextSize?: number;
+ } = {},
+): TurnDoneEvent => ({
+ type: "done",
+ conversationId: "c1",
+ turnId,
+ reason: "stop",
+ ...extra,
+});
+
+describe("initialMetricsState", () => {
+ it("starts empty", () => {
+ const s = initialMetricsState();
+ expect(s.live.size).toBe(0);
+ expect(s.liveOrder).toEqual([]);
+ expect(s.durable.size).toBe(0);
+ expect(s.durableOrder).toEqual([]);
+ });
+});
+
+describe("foldMetricsEvent", () => {
+ it("folds per-step usage by stepId into a turn", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ s = foldMetricsEvent(s, doneEvent("t1"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(1);
+ expect(ordered[0]?.turnId).toBe("t1");
+ expect(ordered[0]?.steps).toHaveLength(2);
+ expect(ordered[0]?.steps[0]?.stepId).toBe("s1");
+ expect(ordered[0]?.steps[0]?.usage).toEqual({ inputTokens: 100, outputTokens: 50 });
+ expect(ordered[0]?.steps[1]?.stepId).toBe("s2");
+ expect(ordered[0]?.steps[1]?.usage).toEqual({ inputTokens: 200, outputTokens: 80 });
+ });
+
+ it("folds step-complete timing and merges with same-step usage", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(
+ s,
+ stepCompleteEvent("t1", "s1", { ttftMs: 200, decodeMs: 800, genTotalMs: 1000 }),
+ );
+ s = foldMetricsEvent(s, doneEvent("t1"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(1);
+ const step = ordered[0]?.steps[0];
+ expect(step?.usage).toEqual({ inputTokens: 100, outputTokens: 50 });
+ expect(step?.ttftMs).toBe(200);
+ expect(step?.decodeMs).toBe(800);
+ expect(step?.genTotalMs).toBe(1000);
+ });
+
+ it("step-complete before usage defaults usage to zeros", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 }));
+ s = foldMetricsEvent(s, doneEvent("t1"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ const step = ordered[0]?.steps[0];
+ expect(step?.usage).toEqual({ inputTokens: 0, outputTokens: 0 });
+ expect(step?.genTotalMs).toBe(500);
+ });
+
+ it("done sets durationMs and aggregate usage", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(
+ s,
+ doneEvent("t1", {
+ durationMs: 5000,
+ usage: { inputTokens: 300, outputTokens: 150 },
+ }),
+ );
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered[0]?.total?.durationMs).toBe(5000);
+ expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 150 });
+ });
+
+ it("aggregate usage sums steps when done.usage absent", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ s = foldMetricsEvent(s, doneEvent("t1"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 });
+ });
+
+ it("aggregate usage includes cache only when a step had cache", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s1" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 30 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ s = foldMetricsEvent(s, doneEvent("t1"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered[0]?.total?.usage.cacheReadTokens).toBe(30);
+ expect(ordered[0]?.total?.usage.cacheWriteTokens).toBeUndefined();
+ });
+
+ it("tolerates missing clock (no genTotalMs/ttft/decode)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ const step = ordered[0]?.steps[0];
+ expect(step?.ttftMs).toBeUndefined();
+ expect(step?.decodeMs).toBeUndefined();
+ expect(step?.genTotalMs).toBeUndefined();
+ expect(ordered[0]?.total?.durationMs).toBeUndefined();
+ });
+
+ it("usage without stepId does not create a turn", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(0);
+ });
+
+ it("ignores non-metrics events", () => {
+ const s = initialMetricsState();
+ const next = foldMetricsEvent(s, {
+ type: "status",
+ conversationId: "c1",
+ status: "running",
+ });
+ expect(next).toBe(s);
+ });
+
+ it("preserves first-seen order of steps", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 10, 5, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered[0]?.steps[0]?.stepId).toBe("s2");
+ expect(ordered[0]?.steps[1]?.stepId).toBe("s1");
+ });
+
+ it("preserves first-seen order of turns", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t2", 10, 5, "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1"));
+ s = foldMetricsEvent(s, doneEvent("t2"));
+ s = foldMetricsEvent(s, doneEvent("t1"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered[0]?.turnId).toBe("t2");
+ expect(ordered[1]?.turnId).toBe("t1");
+ });
+});
+
+describe("selectOrderedTurnMetrics", () => {
+ it("durable wins over live by turnId, live-done appended last", () => {
+ let s = initialMetricsState();
+
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, usageEvent("t2", 200, 80, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t2"));
+
+ s = applyDurableMetrics(s, [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 999, outputTokens: 999 },
+ durationMs: 3000,
+ steps: [
+ {
+ stepId: "s1" as StepId,
+ usage: { inputTokens: 999, outputTokens: 999 },
+ genTotalMs: 3000,
+ },
+ ],
+ },
+ ]);
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(2);
+ expect(ordered[0]?.turnId).toBe("t1");
+ expect(ordered[0]?.total?.usage.inputTokens).toBe(999);
+ expect(ordered[0]?.total?.durationMs).toBe(3000);
+ expect(ordered[1]?.turnId).toBe("t2");
+ expect(ordered[1]?.total?.durationMs).toBeUndefined();
+ });
+
+ it("empty state returns empty", () => {
+ const s = initialMetricsState();
+ expect(selectOrderedTurnMetrics(s)).toEqual([]);
+ });
+
+ it("selectOrderedTurnMetrics: in-flight turn exposes only completed steps and total=null", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 }));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(1);
+ expect(ordered[0]?.turnId).toBe("t1");
+ expect(ordered[0]?.steps).toHaveLength(1);
+ expect(ordered[0]?.steps[0]?.stepId).toBe("s1");
+ expect(ordered[0]?.total).toBeNull();
+ });
+
+ it("selectOrderedTurnMetrics: a turn with no complete step and not done is omitted", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(0);
+ });
+
+ it("selectOrderedTurnMetrics: after done, total is present", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 }));
+ s = foldMetricsEvent(s, doneEvent("t1", { durationMs: 2000 }));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(1);
+ expect(ordered[0]?.turnId).toBe("t1");
+ expect(ordered[0]?.total?.durationMs).toBe(2000);
+ expect(ordered[0]?.steps).toHaveLength(1);
+ });
+
+ it("step-complete marks the step complete", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 }));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(1);
+ expect(ordered[0]?.steps).toHaveLength(1);
+ expect(ordered[0]?.steps[0]?.stepId).toBe("s1");
+ expect(ordered[0]?.steps[0]?.genTotalMs).toBe(500);
+ });
+
+ it("selectOrderedTurnMetrics: durable turn → steps + total present", () => {
+ let s = initialMetricsState();
+ s = applyDurableMetrics(s, [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 300, outputTokens: 150 },
+ durationMs: 5000,
+ steps: [
+ {
+ stepId: "s1" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 1000,
+ },
+ {
+ stepId: "s2" as StepId,
+ usage: { inputTokens: 200, outputTokens: 100 },
+ genTotalMs: 2000,
+ },
+ ],
+ },
+ ]);
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered).toHaveLength(1);
+ expect(ordered[0]?.turnId).toBe("t1");
+ expect(ordered[0]?.steps).toHaveLength(2);
+ expect(ordered[0]?.steps[0]?.stepId).toBe("s1");
+ expect(ordered[0]?.steps[1]?.stepId).toBe("s2");
+ expect(ordered[0]?.total?.usage.inputTokens).toBe(300);
+ expect(ordered[0]?.total?.durationMs).toBe(5000);
+ });
+});
+
+describe("applyDurableMetrics", () => {
+ it("stores durable turns in order", () => {
+ let s = initialMetricsState();
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] },
+ { turnId: "t2", usage: { inputTokens: 20, outputTokens: 8 }, steps: [] },
+ ]);
+ expect(s.durableOrder).toEqual(["t1", "t2"]);
+ expect(s.durable.size).toBe(2);
+ });
+
+ it("is idempotent for same turnId", () => {
+ let s = initialMetricsState();
+ const turn = {
+ turnId: "t1",
+ usage: { inputTokens: 10, outputTokens: 5 },
+ steps: [],
+ };
+ s = applyDurableMetrics(s, [turn]);
+ s = applyDurableMetrics(s, [turn]);
+ expect(s.durableOrder).toEqual(["t1"]);
+ expect(s.durable.size).toBe(1);
+ });
+
+ it("overwrites durable turn data for same turnId", () => {
+ let s = initialMetricsState();
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] },
+ ]);
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 99, outputTokens: 99 }, steps: [] },
+ ]);
+ expect(s.durable.get("t1")?.usage.inputTokens).toBe(99);
+ });
+});
+
+describe("contextSize / selectCurrentContextSize", () => {
+ it("live done carries contextSize onto the turn total", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 1234 }));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered[0]?.total?.contextSize).toBe(1234);
+ expect(selectCurrentContextSize(s)).toBe(1234);
+ });
+
+ it("contextSize is NOT the aggregate usage sum (multi-step turn)", () => {
+ let s = initialMetricsState();
+ // Two steps: usage sums to 300 in / 130 out = 430, but contextSize is the
+ // backend-stamped final-step occupancy, independent of the sum.
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 250 }));
+
+ const ordered = selectOrderedTurnMetrics(s);
+ expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 });
+ expect(ordered[0]?.total?.contextSize).toBe(250);
+ expect(selectCurrentContextSize(s)).toBe(250);
+ });
+
+ it("persisted (durable) contextSize is preserved and selected", () => {
+ let s = initialMetricsState();
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [], contextSize: 4096 },
+ ]);
+ expect(s.durable.get("t1")?.contextSize).toBe(4096);
+ expect(selectCurrentContextSize(s)).toBe(4096);
+ });
+
+ it("selectCurrentContextSize returns the LATEST turn's value", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 100 }));
+ s = foldMetricsEvent(s, doneEvent("t2", { contextSize: 900 }));
+ expect(selectCurrentContextSize(s)).toBe(900);
+ });
+
+ it("selectCurrentContextSize skips a later turn that lacks contextSize", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 finishes but the provider reported no per-step usage → no contextSize.
+ s = foldMetricsEvent(s, doneEvent("t2"));
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("selectCurrentContextSize is undefined (not 0) when nothing reported", () => {
+ let s = initialMetricsState();
+ expect(selectCurrentContextSize(s)).toBeUndefined();
+ s = foldMetricsEvent(s, doneEvent("t1"));
+ expect(selectCurrentContextSize(s)).toBeUndefined();
+ });
+
+ it("durable contextSize wins over live for a shared turnId", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 }));
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 },
+ ]);
+ expect(selectCurrentContextSize(s)).toBe(222);
+ });
+
+ it("in-flight turn updates context size after the first step completes", () => {
+ // Before the requirement: an in-flight turn had total=null so its step usage
+ // was ignored until `done`. Now the latest step's input+output is used.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+
+ // Still generating (no done) — context = step 1 input+output = 5200.
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+
+ it("in-flight turn updates progressively as each step reports usage", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ // Step 2 reports usage mid-stream (before its step-complete): each step's
+ // input already includes all prior context, so the last step's input+output
+ // is the current occupancy.
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("in-flight context size is the latest step with usage, NOT the aggregate sum", () => {
+ // Mirrors the finalized-turn test: contextSize is the FINAL step's
+ // input+output, not the sum across steps (which would overcount a
+ // multi-step turn because every step re-prefills the growing prompt).
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ // Aggregate would be 300+130=430; the latest step is 200+80=280.
+ expect(selectCurrentContextSize(s)).toBe(280);
+ });
+
+ it("in-flight turn with a step-complete but no usage falls back to older turn", () => {
+ // step-complete before usage → the step has no usage yet, so the in-flight
+ // turn exposes no context size and the display falls back to the prior
+ // finalized turn's value (never 0).
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1", { genTotalMs: 500 }));
+
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("in-flight turn with no steps/usage returns undefined (falls back)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 just started — no usage, no complete step — omitted entirely.
+ s = foldMetricsEvent(s, { type: "turn-start", conversationId: "c1", turnId: "t2" });
+ expect(selectCurrentContextSize(s)).toBe(700);
+
+ // t2's first step reports usage → the display jumps to t2's live value.
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("done finalizes the in-flight progressive value with the authoritative contextSize", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ // done stamps the authoritative contextSize (the final step's input+output).
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 5350 }));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("in-flight context size excludes cache tokens (they are a subset of inputTokens)", () => {
+ // cacheReadTokens / cacheWriteTokens are portions of inputTokens already
+ // counted — adding them would double-count. Only input+output is occupancy.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s1" as StepId,
+ usage: {
+ inputTokens: 5000,
+ outputTokens: 200,
+ cacheReadTokens: 4000,
+ cacheWriteTokens: 1000,
+ },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // 5000+200=5200, NOT 9200 (with cacheRead) or 10200 (with both).
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+
+ it("multiple in-flight turns: the newest turn's live value wins", () => {
+ let s = initialMetricsState();
+ // t1 (older) in-flight with one completed step → 5200.
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // t2 (newer, seen later → last in liveOrder) in-flight → 8000.
+ s = foldMetricsEvent(s, usageEvent("t2", 7800, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(8000);
+ });
+
+ it("out-of-order step IDs: usage for step 2 before step 1's step-complete still scans newest-first", () => {
+ // stepOrder is FIRST-SEEN: s1 (its usage arrived first), then s2. So s2 is
+ // the newest step regardless of when each step's step-complete arrives.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ // Neither step complete yet → the turn is omitted (no complete step), so the
+ // display can't update until the first step completes.
+ expect(selectCurrentContextSize(s)).toBeUndefined();
+
+ // s1 completes AFTER s2's usage was reported. The turn is now visible; the
+ // newest-first scan picks s2 (the later step), not s1 (the just-completed one).
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ // s2 completes — still s2, unchanged.
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("done turn without contextSize falls back to an older turn (even with step usage)", () => {
+ // Contract lock-in: a done turn's step usage is NOT consulted for the
+ // context display — only its authoritative total.contextSize is. When that
+ // is absent, the display falls back to the next older finalized turn rather
+ // than synthesizing a value from the step usage.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 done WITH step usage but NO done.contextSize (edge case: the done event
+ // omitted contextSize despite per-step usage).
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t2"));
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("in-flight context size skips a step with unsafe usage (NaN / negative)", () => {
+ // A corrupt provider report must never reach the status bar. The newest
+ // step with invalid counters is skipped, falling back to the prior valid one.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // s2 reports NaN input (e.g. a non-numeric provider field coerced).
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s2" as StepId,
+ usage: { inputTokens: Number.NaN, outputTokens: 150 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ // s2 skipped (NaN) → falls back to s1's 5200, NOT NaN.
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ // Negative tokens are likewise skipped.
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s3" as StepId,
+ usage: { inputTokens: -10, outputTokens: 5 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s3"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+});
+
+describe("applyDurableMetrics pruning", () => {
+ it("prunes a live turn once durable data covers it (no unbounded growth)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 }));
+ expect(s.live.has("t1")).toBe(true);
+ expect(s.liveOrder).toContain("t1");
+
+ s = applyDurableMetrics(s, [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ steps: [{ stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 } }],
+ contextSize: 150,
+ },
+ ]);
+ // The live copy is gone; the durable (authoritative) entry replaces it.
+ expect(s.live.has("t1")).toBe(false);
+ expect(s.liveOrder).not.toContain("t1");
+ expect(s.durable.has("t1")).toBe(true);
+ // The display still reads the durable value atomically (no gap).
+ expect(selectCurrentContextSize(s)).toBe(150);
+ });
+
+ it("prunes only the turns present in the durable batch (leaves other live turns)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 }));
+ // t2 still in flight — must NOT be pruned when only t1 seals.
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, steps: [], contextSize: 150 },
+ ]);
+ expect(s.live.has("t1")).toBe(false);
+ expect(s.live.has("t2")).toBe(true);
+ expect(s.liveOrder).toEqual(["t2"]);
+ // The newest (in-flight) turn's live value still wins.
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("is a no-op when no incoming turn is live (no live mutation)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ const before = s;
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [] },
+ ]);
+ // t1 was never live → the live map/order are unchanged (same reference).
+ expect(s.live).toBe(before.live);
+ expect(s.liveOrder).toBe(before.liveOrder);
+ // t1 (durable) is older; the in-flight t2 still wins.
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("durable wins over live for a shared turnId (pruned live no longer consulted)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 }));
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 },
+ ]);
+ // The live (111) copy is pruned; only durable (222) remains.
+ expect(s.live.has("t1")).toBe(false);
+ expect(selectCurrentContextSize(s)).toBe(222);
+ });
+});