diff options
Diffstat (limited to 'src/core/metrics/reducer.test.ts')
| -rw-r--r-- | src/core/metrics/reducer.test.ts | 822 |
1 files changed, 411 insertions, 411 deletions
diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts index cd9f673..7d0a270 100644 --- a/src/core/metrics/reducer.test.ts +++ b/src/core/metrics/reducer.test.ts @@ -1,442 +1,442 @@ import type { StepId, TurnDoneEvent, TurnStepCompleteEvent, TurnUsageEvent } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - selectCurrentContextSize, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectCurrentContextSize, + selectOrderedTurnMetrics, } from "./reducer"; const usageEvent = ( - turnId: string, - inputTokens: number, - outputTokens: number, - stepId?: string, + 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 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 } = {}, + turnId: string, + stepId: string, + timing: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = {}, ): TurnStepCompleteEvent => ({ - type: "step-complete", - conversationId: "c1", - turnId, - stepId: stepId as StepId, - ...timing, + 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; - } = {}, + turnId: string, + extra: { + durationMs?: number; + usage?: { inputTokens: number; outputTokens: number }; + contextSize?: number; + } = {}, ): TurnDoneEvent => ({ - type: "done", - conversationId: "c1", - turnId, - reason: "stop", - ...extra, + 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([]); - }); + 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"); - }); + 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); - }); + 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); - }); + 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("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); + }); }); |
