diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 22:21:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 22:23:39 +0900 |
| commit | c333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch) | |
| tree | 0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/core/metrics | |
| parent | 1285564f12238b22f6b39b9f3fbcecaca8456911 (diff) | |
| download | dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip | |
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/core/metrics')
| -rw-r--r-- | src/core/metrics/format.test.ts | 658 | ||||
| -rw-r--r-- | src/core/metrics/format.ts | 158 | ||||
| -rw-r--r-- | src/core/metrics/index.ts | 48 | ||||
| -rw-r--r-- | src/core/metrics/place.test.ts | 976 | ||||
| -rw-r--r-- | src/core/metrics/place.ts | 448 | ||||
| -rw-r--r-- | src/core/metrics/reducer.test.ts | 822 | ||||
| -rw-r--r-- | src/core/metrics/reducer.ts | 362 | ||||
| -rw-r--r-- | src/core/metrics/types.ts | 114 |
8 files changed, 1793 insertions, 1793 deletions
diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index 6a4bd38..c7c4fbb 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -1,369 +1,369 @@ import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - computeCachePct, - computeContextUsage, - computeExpectedCachePct, - computeTps, - formatCompactTokens, - formatContextSize, - viewCacheRate, - viewExpectedCache, - viewStepMetrics, - viewTurnMetrics, + computeCachePct, + computeContextUsage, + computeExpectedCachePct, + computeTps, + formatCompactTokens, + formatContextSize, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, } from "./format"; describe("computeTps", () => { - it("null when elapsed missing", () => { - expect(computeTps(100, undefined)).toBeNull(); - }); + it("null when elapsed missing", () => { + expect(computeTps(100, undefined)).toBeNull(); + }); - it("null when elapsed is zero", () => { - expect(computeTps(100, 0)).toBeNull(); - }); + it("null when elapsed is zero", () => { + expect(computeTps(100, 0)).toBeNull(); + }); - it("null when elapsed is negative", () => { - expect(computeTps(100, -100)).toBeNull(); - }); + it("null when elapsed is negative", () => { + expect(computeTps(100, -100)).toBeNull(); + }); - it("computes tokens per second", () => { - expect(computeTps(1000, 2000)).toBe(500); - }); + it("computes tokens per second", () => { + expect(computeTps(1000, 2000)).toBe(500); + }); - it("computes fractional tps", () => { - expect(computeTps(100, 3000)).toBeCloseTo(33.33, 1); - }); + it("computes fractional tps", () => { + expect(computeTps(100, 3000)).toBeCloseTo(33.33, 1); + }); }); describe("viewStepMetrics", () => { - it("formats tokens with thousands separator, tps, and durations", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 1234, outputTokens: 567 }, - ttftMs: 820, - decodeMs: 1200, - genTotalMs: 2020, - }; - const view = viewStepMetrics(step, 0); - expect(view.label).toBe("step 1"); - expect(view.tokensLabel).toBe("1,801 tok"); - expect(view.tps).toBe("473 tok/s"); - expect(view.ttft).toBe("820ms"); - expect(view.decode).toBe("1.2s"); - expect(view.genTotal).toBe("2.0s"); - }); - - it("handles missing timing fields", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }; - const view = viewStepMetrics(step, 0); - expect(view.tps).toBeNull(); - expect(view.ttft).toBeNull(); - expect(view.decode).toBeNull(); - expect(view.genTotal).toBeNull(); - }); - - it("formats duration < 1s as ms", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - ttftMs: 42, - }; - const view = viewStepMetrics(step, 0); - expect(view.ttft).toBe("42ms"); - }); - - it("formats duration >= 1s as seconds", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - genTotalMs: 3200, - }; - const view = viewStepMetrics(step, 0); - expect(view.genTotal).toBe("3.2s"); - }); - - it("uses step index for label", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }; - expect(viewStepMetrics(step, 2).label).toBe("step 3"); - }); - - it("tps uses decodeMs (not genTotalMs)", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - decodeMs: 500, - genTotalMs: 800, - }; - const view = viewStepMetrics(step, 0); - // 50 / (500/1000) = 100 tok/s, NOT 50/(800/1000)=62.5 - expect(view.tps).toBe("100 tok/s"); - }); - - it("tps falls back to genTotalMs when decodeMs absent", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }; - const view = viewStepMetrics(step, 0); - // 50 / (800/1000) = 62.5 → rounds to 63 - expect(view.tps).toBe("63 tok/s"); - }); + it("formats tokens with thousands separator, tps, and durations", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 1234, outputTokens: 567 }, + ttftMs: 820, + decodeMs: 1200, + genTotalMs: 2020, + }; + const view = viewStepMetrics(step, 0); + expect(view.label).toBe("step 1"); + expect(view.tokensLabel).toBe("1,801 tok"); + expect(view.tps).toBe("473 tok/s"); + expect(view.ttft).toBe("820ms"); + expect(view.decode).toBe("1.2s"); + expect(view.genTotal).toBe("2.0s"); + }); + + it("handles missing timing fields", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }; + const view = viewStepMetrics(step, 0); + expect(view.tps).toBeNull(); + expect(view.ttft).toBeNull(); + expect(view.decode).toBeNull(); + expect(view.genTotal).toBeNull(); + }); + + it("formats duration < 1s as ms", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + ttftMs: 42, + }; + const view = viewStepMetrics(step, 0); + expect(view.ttft).toBe("42ms"); + }); + + it("formats duration >= 1s as seconds", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + genTotalMs: 3200, + }; + const view = viewStepMetrics(step, 0); + expect(view.genTotal).toBe("3.2s"); + }); + + it("uses step index for label", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }; + expect(viewStepMetrics(step, 2).label).toBe("step 3"); + }); + + it("tps uses decodeMs (not genTotalMs)", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + decodeMs: 500, + genTotalMs: 800, + }; + const view = viewStepMetrics(step, 0); + // 50 / (500/1000) = 100 tok/s, NOT 50/(800/1000)=62.5 + expect(view.tps).toBe("100 tok/s"); + }); + + it("tps falls back to genTotalMs when decodeMs absent", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }; + const view = viewStepMetrics(step, 0); + // 50 / (800/1000) = 62.5 → rounds to 63 + expect(view.tps).toBe("63 tok/s"); + }); }); describe("viewTurnMetrics", () => { - it("formats total tokens and breakdown", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 1000, outputTokens: 234 }, - durationMs: 5000, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 1000, outputTokens: 234 }, - decodeMs: 3000, - genTotalMs: 4000, - }, - ], - }; - const view = viewTurnMetrics(turn); - expect(view.tokensLabel).toBe("1,234 tok"); - expect(view.breakdown).toBe("1,000 in / 234 out"); - expect(view.tps).toBe("78 tok/s"); - expect(view.duration).toBe("5.0s"); - }); - - it("breakdown includes cache only when present", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 1000, outputTokens: 234, cacheReadTokens: 500 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache"); - }); - - it("breakdown omits cache when not present", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("100 in / 50 out"); - }); - - it("tps is null when no step has decodeMs or genTotalMs", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }, - ], - }; - const view = viewTurnMetrics(turn); - expect(view.tps).toBeNull(); - }); - - it("duration is null when durationMs absent", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.duration).toBeNull(); - }); - - it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 300, outputTokens: 150 }, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - decodeMs: 800, - genTotalMs: 1000, - }, - { - stepId: "s2" as StepId, - usage: { inputTokens: 200, outputTokens: 100 }, - genTotalMs: 2000, - }, - ], - }; - const view = viewTurnMetrics(turn); - // step1 uses decodeMs=800, step2 falls back to genTotalMs=2000 → total=2800ms - // 150 / (2800/1000) = 53.57 → rounds to 54 - expect(view.tps).toBe("54 tok/s"); - }); + it("formats total tokens and breakdown", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 234 }, + durationMs: 5000, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 1000, outputTokens: 234 }, + decodeMs: 3000, + genTotalMs: 4000, + }, + ], + }; + const view = viewTurnMetrics(turn); + expect(view.tokensLabel).toBe("1,234 tok"); + expect(view.breakdown).toBe("1,000 in / 234 out"); + expect(view.tps).toBe("78 tok/s"); + expect(view.duration).toBe("5.0s"); + }); + + it("breakdown includes cache only when present", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 234, cacheReadTokens: 500 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache"); + }); + + it("breakdown omits cache when not present", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.breakdown).toBe("100 in / 50 out"); + }); + + it("tps is null when no step has decodeMs or genTotalMs", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }, + ], + }; + const view = viewTurnMetrics(turn); + expect(view.tps).toBeNull(); + }); + + it("duration is null when durationMs absent", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.duration).toBeNull(); + }); + + it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 300, outputTokens: 150 }, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + decodeMs: 800, + genTotalMs: 1000, + }, + { + stepId: "s2" as StepId, + usage: { inputTokens: 200, outputTokens: 100 }, + genTotalMs: 2000, + }, + ], + }; + const view = viewTurnMetrics(turn); + // step1 uses decodeMs=800, step2 falls back to genTotalMs=2000 → total=2800ms + // 150 / (2800/1000) = 53.57 → rounds to 54 + expect(view.tps).toBe("54 tok/s"); + }); }); describe("computeCachePct", () => { - it("is cacheReadTokens / inputTokens as a rounded percentage", () => { - expect(computeCachePct({ inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 })).toBe( - 94, - ); - expect(computeCachePct({ inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 })).toBe(14); - }); - - it("is 0 when cacheReadTokens absent (legitimate miss, not missing data)", () => { - expect(computeCachePct({ inputTokens: 1000, outputTokens: 50 })).toBe(0); - }); - - it("is 0 when there are no input tokens (guard divide-by-zero)", () => { - expect(computeCachePct({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 5 })).toBe(0); - }); - - it("clamps to 100 if read somehow exceeds input", () => { - expect(computeCachePct({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 })).toBe(100); - }); + it("is cacheReadTokens / inputTokens as a rounded percentage", () => { + expect(computeCachePct({ inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 })).toBe( + 94, + ); + expect(computeCachePct({ inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 })).toBe(14); + }); + + it("is 0 when cacheReadTokens absent (legitimate miss, not missing data)", () => { + expect(computeCachePct({ inputTokens: 1000, outputTokens: 50 })).toBe(0); + }); + + it("is 0 when there are no input tokens (guard divide-by-zero)", () => { + expect(computeCachePct({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 5 })).toBe(0); + }); + + it("clamps to 100 if read somehow exceeds input", () => { + expect(computeCachePct({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 })).toBe(100); + }); }); describe("viewCacheRate", () => { - it("success level for a high hit rate (>= 66)", () => { - const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 93 }); - expect(v.pct).toBe(93); - expect(v.level).toBe("success"); - expect(v.isHit).toBe(true); - }); - - it("warning level for a mid hit rate (33..65)", () => { - const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 54 }); - expect(v.pct).toBe(54); - expect(v.level).toBe("warning"); - }); - - it("error level for a low hit rate (< 33), including a legitimate 0%", () => { - expect(viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 14 }).level).toBe( - "error", - ); - const miss = viewCacheRate({ inputTokens: 1000, outputTokens: 50 }); - expect(miss.pct).toBe(0); - expect(miss.level).toBe("error"); - expect(miss.isHit).toBe(false); - }); + it("success level for a high hit rate (>= 66)", () => { + const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 93 }); + expect(v.pct).toBe(93); + expect(v.level).toBe("success"); + expect(v.isHit).toBe(true); + }); + + it("warning level for a mid hit rate (33..65)", () => { + const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 54 }); + expect(v.pct).toBe(54); + expect(v.level).toBe("warning"); + }); + + it("error level for a low hit rate (< 33), including a legitimate 0%", () => { + expect(viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 14 }).level).toBe( + "error", + ); + const miss = viewCacheRate({ inputTokens: 1000, outputTokens: 50 }); + expect(miss.pct).toBe(0); + expect(miss.level).toBe("error"); + expect(miss.isHit).toBe(false); + }); }); describe("computeExpectedCachePct", () => { - it("null when there is no prior turn (first turn has no baseline)", () => { - expect(computeExpectedCachePct({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); - }); - - it("null when the prior turn cached nothing (denominator 0)", () => { - const prev = { inputTokens: 100, outputTokens: 0 }; - const current = { inputTokens: 200, outputTokens: 0, cacheReadTokens: 50 }; - expect(computeExpectedCachePct(current, prev)).toBeNull(); - }); - - it("100% when the whole prior cached prefix was read back (backend worked example)", () => { - // turn 1: cacheRead 0, cacheWrite 5146 → prefix 5146; turn 2 reads 5146 back. - const prev = { inputTokens: 5149, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5146 }; - const current = { - inputTokens: 8462, - outputTokens: 0, - cacheReadTokens: 5146, - cacheWriteTokens: 3313, - }; - expect(computeExpectedCachePct(current, prev)).toBe(100); - }); - - it("drops below 100% when the cache busted (read < prior prefix)", () => { - const prev = { - inputTokens: 1000, - outputTokens: 0, - cacheReadTokens: 100, - cacheWriteTokens: 900, - }; - const current = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 500 }; - // 500 / (100 + 900) = 50% - expect(computeExpectedCachePct(current, prev)).toBe(50); - }); - - it("clamps to 100 if read somehow exceeds the prior prefix", () => { - const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 }; - const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 }; - expect(computeExpectedCachePct(current, prev)).toBe(100); - }); + it("null when there is no prior turn (first turn has no baseline)", () => { + expect(computeExpectedCachePct({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); + }); + + it("null when the prior turn cached nothing (denominator 0)", () => { + const prev = { inputTokens: 100, outputTokens: 0 }; + const current = { inputTokens: 200, outputTokens: 0, cacheReadTokens: 50 }; + expect(computeExpectedCachePct(current, prev)).toBeNull(); + }); + + it("100% when the whole prior cached prefix was read back (backend worked example)", () => { + // turn 1: cacheRead 0, cacheWrite 5146 → prefix 5146; turn 2 reads 5146 back. + const prev = { inputTokens: 5149, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5146 }; + const current = { + inputTokens: 8462, + outputTokens: 0, + cacheReadTokens: 5146, + cacheWriteTokens: 3313, + }; + expect(computeExpectedCachePct(current, prev)).toBe(100); + }); + + it("drops below 100% when the cache busted (read < prior prefix)", () => { + const prev = { + inputTokens: 1000, + outputTokens: 0, + cacheReadTokens: 100, + cacheWriteTokens: 900, + }; + const current = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 500 }; + // 500 / (100 + 900) = 50% + expect(computeExpectedCachePct(current, prev)).toBe(50); + }); + + it("clamps to 100 if read somehow exceeds the prior prefix", () => { + const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 }; + const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 }; + expect(computeExpectedCachePct(current, prev)).toBe(100); + }); }); describe("viewExpectedCache", () => { - it("null view when it cannot be derived (no prior turn)", () => { - expect(viewExpectedCache({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); - }); - - it("success level + hit flag for full retention", () => { - const prev = { inputTokens: 5149, outputTokens: 0, cacheWriteTokens: 5146 }; - const current = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; - const v = viewExpectedCache(current, prev); - expect(v?.pct).toBe(100); - expect(v?.level).toBe("success"); - expect(v?.isHit).toBe(true); - }); + it("null view when it cannot be derived (no prior turn)", () => { + expect(viewExpectedCache({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); + }); + + it("success level + hit flag for full retention", () => { + const prev = { inputTokens: 5149, outputTokens: 0, cacheWriteTokens: 5146 }; + const current = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; + const v = viewExpectedCache(current, prev); + expect(v?.pct).toBe(100); + expect(v?.level).toBe("success"); + expect(v?.isHit).toBe(true); + }); }); describe("formatContextSize", () => { - it("formats a defined count with thousands separators", () => { - expect(formatContextSize(34102)).toBe("34,102 tokens in context"); - }); + it("formats a defined count with thousands separators", () => { + expect(formatContextSize(34102)).toBe("34,102 tokens in context"); + }); - it("renders a placeholder for undefined (never 0)", () => { - expect(formatContextSize(undefined)).toBe("context size unknown"); - }); + it("renders a placeholder for undefined (never 0)", () => { + expect(formatContextSize(undefined)).toBe("context size unknown"); + }); - it("renders an explicit 0 as zero tokens (a real reported value)", () => { - expect(formatContextSize(0)).toBe("0 tokens in context"); - }); + it("renders an explicit 0 as zero tokens (a real reported value)", () => { + expect(formatContextSize(0)).toBe("0 tokens in context"); + }); }); describe("formatCompactTokens", () => { - it("renders sub-1k counts as-is", () => { - expect(formatCompactTokens(0)).toBe("0"); - expect(formatCompactTokens(812)).toBe("812"); - }); - - it("renders thousands with one decimal (rounded ≥100k)", () => { - expect(formatCompactTokens(12300)).toBe("12.3k"); - expect(formatCompactTokens(150000)).toBe("150k"); - }); - - it("renders millions with one decimal", () => { - expect(formatCompactTokens(1_200_000)).toBe("1.2M"); - expect(formatCompactTokens(1_000_000)).toBe("1.0M"); - }); + it("renders sub-1k counts as-is", () => { + expect(formatCompactTokens(0)).toBe("0"); + expect(formatCompactTokens(812)).toBe("812"); + }); + + it("renders thousands with one decimal (rounded ≥100k)", () => { + expect(formatCompactTokens(12300)).toBe("12.3k"); + expect(formatCompactTokens(150000)).toBe("150k"); + }); + + it("renders millions with one decimal", () => { + expect(formatCompactTokens(1_200_000)).toBe("1.2M"); + expect(formatCompactTokens(1_000_000)).toBe("1.0M"); + }); }); describe("computeContextUsage", () => { - it("computes an unrounded clamped percent against the limit", () => { - const u = computeContextUsage(34102, 1_000_000); - expect(u.current).toBe(34102); - expect(u.max).toBe(1_000_000); - expect(u.percent).toBeCloseTo(3.4102, 4); - }); - - it("treats unknown contextSize as current 0", () => { - const u = computeContextUsage(undefined, 1_000_000); - expect(u.current).toBe(0); - expect(u.percent).toBe(0); - }); - - it("clamps percent to [0,100] and over-limit reads 100", () => { - expect(computeContextUsage(2_000_000, 1_000_000).percent).toBe(100); - }); - - it("max null (no/zero limit) ⇒ percent null", () => { - expect(computeContextUsage(5000, null).percent).toBeNull(); - expect(computeContextUsage(5000, 0).percent).toBeNull(); - expect(computeContextUsage(5000, null).max).toBeNull(); - }); + it("computes an unrounded clamped percent against the limit", () => { + const u = computeContextUsage(34102, 1_000_000); + expect(u.current).toBe(34102); + expect(u.max).toBe(1_000_000); + expect(u.percent).toBeCloseTo(3.4102, 4); + }); + + it("treats unknown contextSize as current 0", () => { + const u = computeContextUsage(undefined, 1_000_000); + expect(u.current).toBe(0); + expect(u.percent).toBe(0); + }); + + it("clamps percent to [0,100] and over-limit reads 100", () => { + expect(computeContextUsage(2_000_000, 1_000_000).percent).toBe(100); + }); + + it("max null (no/zero limit) ⇒ percent null", () => { + expect(computeContextUsage(5000, null).percent).toBeNull(); + expect(computeContextUsage(5000, 0).percent).toBeNull(); + expect(computeContextUsage(5000, null).max).toBeNull(); + }); }); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 534277c..56e74e4 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -2,19 +2,19 @@ import type { StepMetrics, TurnMetrics, Usage } from "@dispatch/wire"; import type { CacheRateView, StepMetricsView, TurnMetricsView } from "./types"; function formatTokens(n: number): string { - return n.toLocaleString("en-US"); + return n.toLocaleString("en-US"); } function formatDuration(ms: number | undefined): string | null { - if (ms === undefined || ms <= 0) return null; - if (ms < 1000) return `${Math.round(ms)}ms`; - return `${(ms / 1000).toFixed(1)}s`; + if (ms === undefined || ms <= 0) return null; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; } function formatTps(tps: number | null): string | null { - if (tps === null) return null; - if (tps < 10) return `${tps.toFixed(1)} tok/s`; - return `${Math.round(tps)} tok/s`; + if (tps === null) return null; + if (tps < 10) return `${tps.toFixed(1)} tok/s`; + return `${Math.round(tps)} tok/s`; } /** @@ -24,8 +24,8 @@ function formatTps(tps: number | null): string | null { * Never renders `0` for the unknown case. */ export function formatContextSize(n: number | undefined): string { - if (n === undefined) return "context size unknown"; - return `${formatTokens(n)} tokens in context`; + if (n === undefined) return "context size unknown"; + return `${formatTokens(n)} tokens in context`; } /** @@ -33,13 +33,13 @@ export function formatContextSize(n: number | undefined): string { * thousands-separated numbers live elsewhere; this trades precision for width. */ export function formatCompactTokens(n: number): string { - if (n < 1000) return `${n}`; - if (n < 1_000_000) { - const k = n / 1000; - return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`; - } - const m = n / 1_000_000; - return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`; + if (n < 1000) return `${n}`; + if (n < 1_000_000) { + const k = n / 1000; + return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`; + } + const m = n / 1_000_000; + return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`; } /** @@ -52,51 +52,51 @@ export function formatCompactTokens(n: number): string { * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator). */ export interface ContextUsage { - readonly current: number; - readonly max: number | null; - readonly percent: number | null; + readonly current: number; + readonly max: number | null; + readonly percent: number | null; } export function computeContextUsage( - contextSize: number | undefined, - contextLimit: number | null | undefined, + contextSize: number | undefined, + contextLimit: number | null | undefined, ): ContextUsage { - const current = contextSize ?? 0; - const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; - const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); - return { current, max, percent }; + const current = contextSize ?? 0; + const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; + const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); + return { current, max, percent }; } /** Compute tokens-per-second. Returns null when elapsed time is absent or zero. */ export function computeTps(outputTokens: number, elapsedMs: number | undefined): number | null { - if (elapsedMs === undefined || elapsedMs <= 0) return null; - return outputTokens / (elapsedMs / 1000); + if (elapsedMs === undefined || elapsedMs <= 0) return null; + return outputTokens / (elapsedMs / 1000); } function totalTokens(u: Usage): number { - return u.inputTokens + u.outputTokens; + return u.inputTokens + u.outputTokens; } function formatBreakdown(u: Usage): string { - let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`; - if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) { - s += ` / ${formatTokens(u.cacheReadTokens)} cache`; - } - return s; + let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`; + if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) { + s += ` / ${formatTokens(u.cacheReadTokens)} cache`; + } + return s; } /** Build a formatted view of a single step's metrics. */ export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsView { - const total = totalTokens(step.usage); - const tps = computeTps(step.usage.outputTokens, step.decodeMs ?? step.genTotalMs); - return { - label: `step ${index + 1}`, - tokensLabel: `${formatTokens(total)} tok`, - tps: formatTps(tps), - ttft: formatDuration(step.ttftMs), - decode: formatDuration(step.decodeMs), - genTotal: formatDuration(step.genTotalMs), - }; + const total = totalTokens(step.usage); + const tps = computeTps(step.usage.outputTokens, step.decodeMs ?? step.genTotalMs); + return { + label: `step ${index + 1}`, + tokensLabel: `${formatTokens(total)} tok`, + tps: formatTps(tps), + ttft: formatDuration(step.ttftMs), + decode: formatDuration(step.decodeMs), + genTotal: formatDuration(step.genTotalMs), + }; } /** @@ -105,24 +105,24 @@ export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsVi * missing data). Returns 0 when there are no input tokens. */ export function computeCachePct(u: Usage): number { - const read = u.cacheReadTokens ?? 0; - if (u.inputTokens <= 0) return 0; - const rate = read / u.inputTokens; - const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; - return Math.round(clamped * 100); + const read = u.cacheReadTokens ?? 0; + if (u.inputTokens <= 0) return 0; + const rate = read / u.inputTokens; + const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; + return Math.round(clamped * 100); } /** Colour severity for a cache hit percentage (badge colour). */ function cacheLevel(pct: number): "success" | "warning" | "error" { - if (pct >= 66) return "success"; - if (pct >= 33) return "warning"; - return "error"; + if (pct >= 66) return "success"; + if (pct >= 33) return "warning"; + return "error"; } /** Build a view of a cache hit rate (percentage + colour level + hit flag). */ export function viewCacheRate(u: Usage): CacheRateView { - const pct = computeCachePct(u); - return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 }; + const pct = computeCachePct(u); + return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 }; } /** @@ -135,13 +135,13 @@ export function viewCacheRate(u: Usage): CacheRateView { * prior turn cached nothing (denominator <= 0) — distinct from a real 0%. */ export function computeExpectedCachePct(current: Usage, prev: Usage | null): number | null { - if (prev === null) return null; - const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0); - if (denom <= 0) return null; - const read = current.cacheReadTokens ?? 0; - const rate = read / denom; - const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; - return Math.round(clamped * 100); + if (prev === null) return null; + const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0); + if (denom <= 0) return null; + const read = current.cacheReadTokens ?? 0; + const rate = read / denom; + const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; + return Math.round(clamped * 100); } /** @@ -149,27 +149,27 @@ export function computeExpectedCachePct(current: Usage, prev: Usage | null): num * or `null` when it can't be derived (see `computeExpectedCachePct`). */ export function viewExpectedCache(current: Usage, prev: Usage | null): CacheRateView | null { - const pct = computeExpectedCachePct(current, prev); - if (pct === null) return null; - return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; + const pct = computeExpectedCachePct(current, prev); + if (pct === null) return null; + return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; } /** Build a formatted view of a turn's aggregate metrics. */ export function viewTurnMetrics(turn: TurnMetrics, turnNumber?: number): TurnMetricsView { - const total = totalTokens(turn.usage); - let totalGenMs: number | undefined; - for (const step of turn.steps) { - const stepMs = step.decodeMs ?? step.genTotalMs; - if (stepMs !== undefined) { - totalGenMs = (totalGenMs ?? 0) + stepMs; - } - } - const tps = computeTps(turn.usage.outputTokens, totalGenMs); - return { - label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn", - tokensLabel: `${formatTokens(total)} tok`, - breakdown: formatBreakdown(turn.usage), - tps: formatTps(tps), - duration: formatDuration(turn.durationMs), - }; + const total = totalTokens(turn.usage); + let totalGenMs: number | undefined; + for (const step of turn.steps) { + const stepMs = step.decodeMs ?? step.genTotalMs; + if (stepMs !== undefined) { + totalGenMs = (totalGenMs ?? 0) + stepMs; + } + } + const tps = computeTps(turn.usage.outputTokens, totalGenMs); + return { + label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn", + tokensLabel: `${formatTokens(total)} tok`, + breakdown: formatBreakdown(turn.usage), + tps: formatTps(tps), + duration: formatDuration(turn.durationMs), + }; } diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts index 36cd96f..d3c9669 100644 --- a/src/core/metrics/index.ts +++ b/src/core/metrics/index.ts @@ -1,31 +1,31 @@ export { - type ContextUsage, - computeCachePct, - computeContextUsage, - computeExpectedCachePct, - computeTps, - formatCompactTokens, - formatContextSize, - viewCacheRate, - viewExpectedCache, - viewStepMetrics, - viewTurnMetrics, + type ContextUsage, + computeCachePct, + computeContextUsage, + computeExpectedCachePct, + computeTps, + formatCompactTokens, + formatContextSize, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, } from "./format"; export { interleaveTurnMetrics } from "./place"; export { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - selectCurrentContextSize, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectCurrentContextSize, + selectOrderedTurnMetrics, } from "./reducer"; export type { - CacheRateView, - MetricsRow, - MetricsState, - StepMetrics, - StepMetricsView, - TurnMetrics, - TurnMetricsEntry, - TurnMetricsView, + CacheRateView, + MetricsRow, + MetricsState, + StepMetrics, + StepMetricsView, + TurnMetrics, + TurnMetricsEntry, + TurnMetricsView, } from "./types"; diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts index 22f8639..c05ba3b 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -5,536 +5,536 @@ import { interleaveTurnMetrics } from "./place"; import type { MetricsRow, TurnMetricsEntry } from "./types"; function userGroup(seq: number, text: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "user", - chunk: { type: "text", text }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "user", + chunk: { type: "text", text }, + provisional: false, + }, + }; } function assistantGroup(seq: number, text: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "assistant", - chunk: { type: "text", text }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "assistant", + chunk: { type: "text", text }, + provisional: false, + }, + }; } function toolCallGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId, - toolName: "test", - input: {}, - stepId: stepId as StepId, - }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId, + toolName: "test", + input: {}, + stepId: stepId as StepId, + }, + provisional: false, + }, + }; } function toolResultGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "tool", - chunk: { - type: "tool-result", - toolCallId, - toolName: "test", - content: "", - isError: false, - stepId: stepId as StepId, - }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "tool", + chunk: { + type: "tool-result", + toolCallId, + toolName: "test", + content: "", + isError: false, + stepId: stepId as StepId, + }, + provisional: false, + }, + }; } function toolBatchGroup(stepId: string, toolCallIds: string[]): RenderGroup { - return { - kind: "tool-batch", - stepId, - entries: toolCallIds.map((id) => ({ - call: { - type: "tool-call" as const, - toolCallId: id, - toolName: "test", - input: {}, - stepId: stepId as StepId, - }, - result: null, - })), - provisional: false, - }; + return { + kind: "tool-batch", + stepId, + entries: toolCallIds.map((id) => ({ + call: { + type: "tool-call" as const, + toolCallId: id, + toolName: "test", + input: {}, + stepId: stepId as StepId, + }, + result: null, + })), + provisional: false, + }; } function makeStep(stepId: string, inputTokens: number, outputTokens: number): StepMetrics { - return { - stepId: stepId as StepId, - usage: { inputTokens, outputTokens }, - }; + return { + stepId: stepId as StepId, + usage: { inputTokens, outputTokens }, + }; } function makeTurn( - turnId: string, - inputTokens: number, - outputTokens: number, - steps: StepMetrics[] = [], + turnId: string, + inputTokens: number, + outputTokens: number, + steps: StepMetrics[] = [], ): TurnMetrics { - return { - turnId, - usage: { inputTokens, outputTokens }, - steps, - }; + return { + turnId, + usage: { inputTokens, outputTokens }, + steps, + }; } function makeEntry( - turnId: string, - inputTokens: number, - outputTokens: number, - steps: StepMetrics[] = [], + turnId: string, + inputTokens: number, + outputTokens: number, + steps: StepMetrics[] = [], ): TurnMetricsEntry { - return { - turnId, - steps, - total: makeTurn(turnId, inputTokens, outputTokens, steps), - }; + return { + turnId, + steps, + total: makeTurn(turnId, inputTokens, outputTokens, steps), + }; } function makeProgressiveEntry(turnId: string, steps: StepMetrics[]): TurnMetricsEntry { - return { - turnId, - steps, - total: null, - }; + return { + turnId, + steps, + total: null, + }; } function expectGroupAt( - rows: readonly { readonly kind: string }[], - index: number, - expected: RenderGroup, + rows: readonly { readonly kind: string }[], + index: number, + expected: RenderGroup, ): void { - const row = rows[index]; - expect(row?.kind).toBe("group"); - expect((row as { readonly group: RenderGroup } | undefined)?.group).toBe(expected); + const row = rows[index]; + expect(row?.kind).toBe("group"); + expect((row as { readonly group: RenderGroup } | undefined)?.group).toBe(expected); } function expectStepMetricsAt( - rows: readonly { readonly kind: string }[], - index: number, - expectedStepId: string, - expectedIndex: number, + rows: readonly { readonly kind: string }[], + index: number, + expectedStepId: string, + expectedIndex: number, ): void { - const row = rows[index]; - expect(row?.kind).toBe("step-metrics"); - const sm = row as { readonly step: StepMetrics; readonly index: number } | undefined; - expect(sm?.step.stepId).toBe(expectedStepId); - expect(sm?.index).toBe(expectedIndex); + const row = rows[index]; + expect(row?.kind).toBe("step-metrics"); + const sm = row as { readonly step: StepMetrics; readonly index: number } | undefined; + expect(sm?.step.stepId).toBe(expectedStepId); + expect(sm?.index).toBe(expectedIndex); } function expectTurnMetricsAt( - rows: readonly { readonly kind: string }[], - index: number, - expectedTurnId: string, + rows: readonly { readonly kind: string }[], + index: number, + expectedTurnId: string, ): void { - const row = rows[index]; - expect(row?.kind).toBe("turn-metrics"); - expect((row as { readonly turn: TurnMetrics } | undefined)?.turn.turnId).toBe(expectedTurnId); + const row = rows[index]; + expect(row?.kind).toBe("turn-metrics"); + expect((row as { readonly turn: TurnMetrics } | undefined)?.turn.turnId).toBe(expectedTurnId); } describe("interleaveTurnMetrics", () => { - it("no metrics: rows are all groups, unchanged order", () => { - const g1 = userGroup(1, "q"); - const g2 = assistantGroup(2, "a"); - const rows = interleaveTurnMetrics([g1, g2], []); - expect(rows).toHaveLength(2); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - }); - - it("head-aligned: segment i gets entries[i]", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = toolCallGroup(4, "s2", "c2"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - expectStepMetricsAt(rows, 6, "s2", 0); - expectTurnMetricsAt(rows, 7, "t2"); - }); - - it("a trailing segment with no entry (in-flight turn) renders no metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = assistantGroup(4, "a2"); - const step = makeStep("s1", 100, 50); - const rows = interleaveTurnMetrics([g1, g2, g3, g4], [makeEntry("t1", 100, 50, [step])]); - - expect(rows).toHaveLength(6); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - }); - - it("single text-only turn: no step row (unanchored), turn-metrics at tail", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("tool step anchors inline after its tool-batch group", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1", "c2"]); - const g3 = assistantGroup(3, "a1"); - const step0 = makeStep("t#0", 100, 50); - const step1 = makeStep("t#1", 200, 80); - const turn = makeEntry("t1", 300, 130, [step0, step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "t#0", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("single tool-call group anchors its step", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = assistantGroup(3, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("single tool-result group anchors its step", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolResultGroup(2, "s1", "c1"); - const g3 = assistantGroup(3, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("multi-step: each tool step inline, unanchored text step skipped", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1"]); - const g3 = assistantGroup(2, "thinking"); - const g4 = toolBatchGroup("t#1", ["c2", "c3"]); - const g5 = assistantGroup(3, "a1"); - const step0 = makeStep("t#0", 100, 50); - const step1 = makeStep("t#1", 200, 80); - const step2 = makeStep("t#2", 50, 20); - const turn = makeEntry("t1", 350, 150, [step0, step1, step2]); - const rows = interleaveTurnMetrics([g1, g2, g3, g4, g5], [turn]); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "t#0", 0); - expectGroupAt(rows, 3, g3); - expectGroupAt(rows, 4, g4); - expectStepMetricsAt(rows, 5, "t#1", 1); - expectGroupAt(rows, 6, g5); - expectTurnMetricsAt(rows, 7, "t1"); - }); - - it("multiple turns head-aligned with inline steps", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const g4 = userGroup(3, "q2"); - const g5 = toolCallGroup(4, "s2", "c2"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4, g5], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - expect(rows).toHaveLength(9); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - expectGroupAt(rows, 5, g4); - expectGroupAt(rows, 6, g5); - expectStepMetricsAt(rows, 7, "s2", 0); - expectTurnMetricsAt(rows, 8, "t2"); - }); - - it("unanchored step (stepId not in groups) is skipped — only turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step0 = makeStep("orphan", 100, 50); - const turn = makeEntry("t1", 100, 50, [step0]); - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("fewer metrics than segments: trailing segments are bare", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = assistantGroup(4, "a2"); - const g5 = userGroup(5, "q3"); - const g6 = assistantGroup(6, "a3"); - const step = makeStep("s1", 300, 120); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4, g5, g6], - [makeEntry("t1", 300, 120, [step])], - ); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - expectGroupAt(rows, 6, g5); - expectGroupAt(rows, 7, g6); - }); - - it("in-flight turn (no durationMs) still produces turn row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const step = makeStep("s1", 100, 50); - const turn: TurnMetricsEntry = { - turnId: "t1", - steps: [step], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [step], - }, - }; - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(4); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - const metricsRow = rows[3] as { readonly turn: TurnMetrics } | undefined; - expect(metricsRow?.turn.durationMs).toBeUndefined(); - }); - - it("leading non-turn groups emit as plain group rows", () => { - const g0 = assistantGroup(1, "system msg"); - const g1 = userGroup(2, "q1"); - const g2 = toolCallGroup(3, "s1", "c1"); - const step = makeStep("s1", 100, 50); - const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g0); - expect(rows[1]?.kind).toBe("group"); - expect(rows[2]?.kind).toBe("group"); - expectStepMetricsAt(rows, 3, "s1", 0); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - // Unmatched entry (t2) emits a standalone turn-metrics row at the top. - expect(rows).toHaveLength(5); - expectTurnMetricsAt(rows, 0, "t2"); - expectGroupAt(rows, 1, g1); - expectGroupAt(rows, 2, g2); - expectStepMetricsAt(rows, 3, "s1", 0); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("turn with no steps emits only turn-metrics (no step-metrics)", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const rows = interleaveTurnMetrics([g1, g2], [makeEntry("t1", 100, 50)]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const step1 = makeStep("s1", 100, 50); - const entry = makeProgressiveEntry("t1", [step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); - - expect(rows).toHaveLength(4); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - }); - - it("entry with total emits step rows + a turn-metrics row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const step1 = makeStep("s1", 100, 50); - const entry = makeEntry("t1", 100, 50, [step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("progressive multi-step: unanchored steps skipped, no turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step0 = makeStep("s1", 100, 50); - const step1 = makeStep("s2", 200, 80); - const entry = makeProgressiveEntry("t1", [step0, step1]); - const rows = interleaveTurnMetrics([g1, g2], [entry]); - - expect(rows).toHaveLength(2); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - }); + it("no metrics: rows are all groups, unchanged order", () => { + const g1 = userGroup(1, "q"); + const g2 = assistantGroup(2, "a"); + const rows = interleaveTurnMetrics([g1, g2], []); + expect(rows).toHaveLength(2); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + }); + + it("head-aligned: segment i gets entries[i]", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = toolCallGroup(4, "s2", "c2"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + expectStepMetricsAt(rows, 6, "s2", 0); + expectTurnMetricsAt(rows, 7, "t2"); + }); + + it("a trailing segment with no entry (in-flight turn) renders no metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = assistantGroup(4, "a2"); + const step = makeStep("s1", 100, 50); + const rows = interleaveTurnMetrics([g1, g2, g3, g4], [makeEntry("t1", 100, 50, [step])]); + + expect(rows).toHaveLength(6); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + }); + + it("single text-only turn: no step row (unanchored), turn-metrics at tail", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("tool step anchors inline after its tool-batch group", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("t#0", ["c1", "c2"]); + const g3 = assistantGroup(3, "a1"); + const step0 = makeStep("t#0", 100, 50); + const step1 = makeStep("t#1", 200, 80); + const turn = makeEntry("t1", 300, 130, [step0, step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "t#0", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("single tool-call group anchors its step", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = assistantGroup(3, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("single tool-result group anchors its step", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolResultGroup(2, "s1", "c1"); + const g3 = assistantGroup(3, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("multi-step: each tool step inline, unanchored text step skipped", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("t#0", ["c1"]); + const g3 = assistantGroup(2, "thinking"); + const g4 = toolBatchGroup("t#1", ["c2", "c3"]); + const g5 = assistantGroup(3, "a1"); + const step0 = makeStep("t#0", 100, 50); + const step1 = makeStep("t#1", 200, 80); + const step2 = makeStep("t#2", 50, 20); + const turn = makeEntry("t1", 350, 150, [step0, step1, step2]); + const rows = interleaveTurnMetrics([g1, g2, g3, g4, g5], [turn]); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "t#0", 0); + expectGroupAt(rows, 3, g3); + expectGroupAt(rows, 4, g4); + expectStepMetricsAt(rows, 5, "t#1", 1); + expectGroupAt(rows, 6, g5); + expectTurnMetricsAt(rows, 7, "t1"); + }); + + it("multiple turns head-aligned with inline steps", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const g4 = userGroup(3, "q2"); + const g5 = toolCallGroup(4, "s2", "c2"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4, g5], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + expect(rows).toHaveLength(9); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + expectGroupAt(rows, 5, g4); + expectGroupAt(rows, 6, g5); + expectStepMetricsAt(rows, 7, "s2", 0); + expectTurnMetricsAt(rows, 8, "t2"); + }); + + it("unanchored step (stepId not in groups) is skipped — only turn-metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step0 = makeStep("orphan", 100, 50); + const turn = makeEntry("t1", 100, 50, [step0]); + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("fewer metrics than segments: trailing segments are bare", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = assistantGroup(4, "a2"); + const g5 = userGroup(5, "q3"); + const g6 = assistantGroup(6, "a3"); + const step = makeStep("s1", 300, 120); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4, g5, g6], + [makeEntry("t1", 300, 120, [step])], + ); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + expectGroupAt(rows, 6, g5); + expectGroupAt(rows, 7, g6); + }); + + it("in-flight turn (no durationMs) still produces turn row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const step = makeStep("s1", 100, 50); + const turn: TurnMetricsEntry = { + turnId: "t1", + steps: [step], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [step], + }, + }; + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(4); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + const metricsRow = rows[3] as { readonly turn: TurnMetrics } | undefined; + expect(metricsRow?.turn.durationMs).toBeUndefined(); + }); + + it("leading non-turn groups emit as plain group rows", () => { + const g0 = assistantGroup(1, "system msg"); + const g1 = userGroup(2, "q1"); + const g2 = toolCallGroup(3, "s1", "c1"); + const step = makeStep("s1", 100, 50); + const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g0); + expect(rows[1]?.kind).toBe("group"); + expect(rows[2]?.kind).toBe("group"); + expectStepMetricsAt(rows, 3, "s1", 0); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + // Unmatched entry (t2) emits a standalone turn-metrics row at the top. + expect(rows).toHaveLength(5); + expectTurnMetricsAt(rows, 0, "t2"); + expectGroupAt(rows, 1, g1); + expectGroupAt(rows, 2, g2); + expectStepMetricsAt(rows, 3, "s1", 0); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("turn with no steps emits only turn-metrics (no step-metrics)", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const rows = interleaveTurnMetrics([g1, g2], [makeEntry("t1", 100, 50)]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const step1 = makeStep("s1", 100, 50); + const entry = makeProgressiveEntry("t1", [step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); + + expect(rows).toHaveLength(4); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + }); + + it("entry with total emits step rows + a turn-metrics row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const step1 = makeStep("s1", 100, 50); + const entry = makeEntry("t1", 100, 50, [step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("progressive multi-step: unanchored steps skipped, no turn-metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step0 = makeStep("s1", 100, 50); + const step1 = makeStep("s2", 200, 80); + const entry = makeProgressiveEntry("t1", [step0, step1]); + const rows = interleaveTurnMetrics([g1, g2], [entry]); + + expect(rows).toHaveLength(2); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + }); }); describe("interleaveTurnMetrics — cumulative usage (cache total)", () => { - function turnMetricsRows(rows: readonly MetricsRow[]) { - return rows.filter((r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => { - return r.kind === "turn-metrics"; - }); - } - - function cacheEntry( - turnId: string, - inputTokens: number, - outputTokens: number, - cacheReadTokens: number, - ): TurnMetricsEntry { - const total: TurnMetrics = { - turnId, - usage: { inputTokens, outputTokens, cacheReadTokens }, - steps: [], - }; - return { turnId, steps: [], total }; - } - - it("turn-metrics row carries this turn's usage and the running cumulative", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1")], - [makeEntry("t1", 1000, 100)], - ); - const tm = turnMetricsRows(rows); - expect(tm).toHaveLength(1); - expect(tm[0]?.turn.turnId).toBe("t1"); - expect(tm[0]?.cumulativeUsage).toEqual({ inputTokens: 1000, outputTokens: 100 }); - }); - - it("accumulates cache read + input across turns (chat total)", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], - ); - const tm = turnMetricsRows(rows); - expect(tm).toHaveLength(2); - // turn 1: only its own usage - expect(tm[0]?.cumulativeUsage.inputTokens).toBe(2669); - expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(384); - // turn 2: sum of both (input 5406, cacheRead 2944 → matches the backend's 54% example) - expect(tm[1]?.cumulativeUsage.inputTokens).toBe(5406); - expect(tm[1]?.cumulativeUsage.cacheReadTokens).toBe(2944); - }); - - it("an in-flight (total=null) turn does not contribute to the cumulative", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 1000, 10, 500), makeProgressiveEntry("t2", [makeStep("s1", 200, 5)])], - ); - const tm = turnMetricsRows(rows); - // only the finalized turn emits a turn-metrics row; its cumulative is just itself - expect(tm).toHaveLength(1); - expect(tm[0]?.cumulativeUsage.inputTokens).toBe(1000); - expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); - }); - - it("carries the prior finalized turn's usage as the retention baseline", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], - ); - const tm = turnMetricsRows(rows); - // first finalized turn has no earlier baseline - expect(tm[0]?.prevTurnUsage).toBeNull(); - // second turn's baseline is the first turn's usage - expect(tm[1]?.prevTurnUsage?.inputTokens).toBe(2669); - expect(tm[1]?.prevTurnUsage?.cacheReadTokens).toBe(384); - }); + function turnMetricsRows(rows: readonly MetricsRow[]) { + return rows.filter((r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => { + return r.kind === "turn-metrics"; + }); + } + + function cacheEntry( + turnId: string, + inputTokens: number, + outputTokens: number, + cacheReadTokens: number, + ): TurnMetricsEntry { + const total: TurnMetrics = { + turnId, + usage: { inputTokens, outputTokens, cacheReadTokens }, + steps: [], + }; + return { turnId, steps: [], total }; + } + + it("turn-metrics row carries this turn's usage and the running cumulative", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1")], + [makeEntry("t1", 1000, 100)], + ); + const tm = turnMetricsRows(rows); + expect(tm).toHaveLength(1); + expect(tm[0]?.turn.turnId).toBe("t1"); + expect(tm[0]?.cumulativeUsage).toEqual({ inputTokens: 1000, outputTokens: 100 }); + }); + + it("accumulates cache read + input across turns (chat total)", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + ); + const tm = turnMetricsRows(rows); + expect(tm).toHaveLength(2); + // turn 1: only its own usage + expect(tm[0]?.cumulativeUsage.inputTokens).toBe(2669); + expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(384); + // turn 2: sum of both (input 5406, cacheRead 2944 → matches the backend's 54% example) + expect(tm[1]?.cumulativeUsage.inputTokens).toBe(5406); + expect(tm[1]?.cumulativeUsage.cacheReadTokens).toBe(2944); + }); + + it("an in-flight (total=null) turn does not contribute to the cumulative", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 1000, 10, 500), makeProgressiveEntry("t2", [makeStep("s1", 200, 5)])], + ); + const tm = turnMetricsRows(rows); + // only the finalized turn emits a turn-metrics row; its cumulative is just itself + expect(tm).toHaveLength(1); + expect(tm[0]?.cumulativeUsage.inputTokens).toBe(1000); + expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); + }); + + it("carries the prior finalized turn's usage as the retention baseline", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + ); + const tm = turnMetricsRows(rows); + // first finalized turn has no earlier baseline + expect(tm[0]?.prevTurnUsage).toBeNull(); + // second turn's baseline is the first turn's usage + expect(tm[1]?.prevTurnUsage?.inputTokens).toBe(2669); + expect(tm[1]?.prevTurnUsage?.cacheReadTokens).toBe(384); + }); }); diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index 0048fa0..b165fd0 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -3,22 +3,22 @@ import type { RenderGroup } from "../chunks"; import type { MetricsRow, TurnMetricsEntry } from "./types"; function groupStepId(g: RenderGroup): string | undefined { - if (g.kind === "tool-batch") return g.stepId; - const c = g.chunk.chunk; - return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined; + if (g.kind === "tool-batch") return g.stepId; + const c = g.chunk.chunk; + return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined; } /** Element-wise sum of two token usages (cache fields included only when nonzero). */ function addUsage(a: Usage, b: Usage): Usage { - const out: Usage = { - inputTokens: a.inputTokens + b.inputTokens, - outputTokens: a.outputTokens + b.outputTokens, - }; - const read = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); - const write = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0); - if (read > 0) (out as { cacheReadTokens?: number }).cacheReadTokens = read; - if (write > 0) (out as { cacheWriteTokens?: number }).cacheWriteTokens = write; - return out; + const out: Usage = { + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + }; + const read = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); + const write = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0); + if (read > 0) (out as { cacheReadTokens?: number }).cacheReadTokens = read; + if (write > 0) (out as { cacheWriteTokens?: number }).cacheWriteTokens = write; + return out; } /** @@ -49,243 +49,243 @@ function addUsage(a: Usage, b: Usage): Usage { * of which turns were trimmed. */ export function interleaveTurnMetrics( - groups: readonly RenderGroup[], - entries: readonly TurnMetricsEntry[], + groups: readonly RenderGroup[], + entries: readonly TurnMetricsEntry[], ): readonly MetricsRow[] { - if (entries.length === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); - } + if (entries.length === 0) { + return groups.map((g) => ({ kind: "group" as const, group: g })); + } - const segmentStarts: number[] = []; - for (let i = 0; i < groups.length; i++) { - const g = groups[i]; - if (g !== undefined && g.kind === "single" && g.chunk.role === "user") { - segmentStarts.push(i); - } - } + const segmentStarts: number[] = []; + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + if (g !== undefined && g.kind === "single" && g.chunk.role === "user") { + segmentStarts.push(i); + } + } - let T = segmentStarts.length; + let T = segmentStarts.length; - // No user messages — e.g. a compacted conversation whose history starts - // with a system summary. Treat the entire transcript as one segment so - // turn/step metrics can still be placed. - if (T === 0 && entries.length > 0) { - segmentStarts.push(0); - T = 1; - } + // No user messages — e.g. a compacted conversation whose history starts + // with a system summary. Treat the entire transcript as one segment so + // turn/step metrics can still be placed. + if (T === 0 && entries.length > 0) { + segmentStarts.push(0); + T = 1; + } - if (T === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); - } + if (T === 0) { + return groups.map((g) => ({ kind: "group" as const, group: g })); + } - const K = entries.length; + const K = entries.length; - // Build stepId → entry-index lookup for matching. - const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId))); + // Build stepId → entry-index lookup for matching. + const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId))); - // Match segments to entries. Pass 1: match by stepId overlap (handles - // trimming where head-alignment would be wrong). Pass 2: sequential fallback - // for unmatched segments (text-only turns with no stepId-bearing groups). - const usedEntries = new Set<number>(); - const segmentEntry = new Map<number, TurnMetricsEntry>(); - const segmentEntryIndex = new Map<number, number>(); + // Match segments to entries. Pass 1: match by stepId overlap (handles + // trimming where head-alignment would be wrong). Pass 2: sequential fallback + // for unmatched segments (text-only turns with no stepId-bearing groups). + const usedEntries = new Set<number>(); + const segmentEntry = new Map<number, TurnMetricsEntry>(); + const segmentEntryIndex = new Map<number, number>(); - // Pass 1: stepId matching. - for (let seg = 0; seg < T; seg++) { - const start = segmentStarts[seg] ?? 0; - const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; + // Pass 1: stepId matching. + for (let seg = 0; seg < T; seg++) { + const start = segmentStarts[seg] ?? 0; + const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; - const segStepIds = new Set<string>(); - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g === undefined) continue; - const sid = groupStepId(g); - if (sid !== undefined) segStepIds.add(sid); - } - if (segStepIds.size === 0) continue; // text-only — defer to pass 2 + const segStepIds = new Set<string>(); + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g === undefined) continue; + const sid = groupStepId(g); + if (sid !== undefined) segStepIds.add(sid); + } + if (segStepIds.size === 0) continue; // text-only — defer to pass 2 - let bestEntry = -1; - let bestMatch = 0; - for (let i = 0; i < K; i++) { - if (usedEntries.has(i)) continue; - let match = 0; - for (const sid of segStepIds) { - if (entryStepIds[i]?.has(sid)) match++; - } - if (match > bestMatch) { - bestMatch = match; - bestEntry = i; - } - } - if (bestEntry >= 0) { - usedEntries.add(bestEntry); - const e = entries[bestEntry]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, bestEntry); - } - } - } + let bestEntry = -1; + let bestMatch = 0; + for (let i = 0; i < K; i++) { + if (usedEntries.has(i)) continue; + let match = 0; + for (const sid of segStepIds) { + if (entryStepIds[i]?.has(sid)) match++; + } + if (match > bestMatch) { + bestMatch = match; + bestEntry = i; + } + } + if (bestEntry >= 0) { + usedEntries.add(bestEntry); + const e = entries[bestEntry]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, bestEntry); + } + } + } - // Pass 2: sequential fallback for unmatched segments. - // If NO segments were matched by stepId (pass 1), use TAIL-ALIGNMENT: - // the loaded chunks are always the NEWEST (chat-limit/windowing keeps the - // newest and trims the oldest), so match the LAST T entries to the T - // segments. This prevents misaligning oldest (trimmed) entries to newest - // segments — which would show "turn 1" on turn 20's content. - const pass1Matches = segmentEntry.size; - if (pass1Matches === 0 && K >= T) { - // Tail-align: skip the first K-T entries (trimmed turns). - for (let seg = 0; seg < T; seg++) { - if (segmentEntry.has(seg)) continue; - const entryIdx = K - T + seg; - if (entryIdx < K && !usedEntries.has(entryIdx)) { - usedEntries.add(entryIdx); - const e = entries[entryIdx]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, entryIdx); - } - } - } - } else { - // Head-align fallback for remaining unmatched segments. - let nextUnused = 0; - for (let seg = 0; seg < T; seg++) { - if (segmentEntry.has(seg)) continue; - while (nextUnused < K && usedEntries.has(nextUnused)) nextUnused++; - if (nextUnused < K) { - usedEntries.add(nextUnused); - const e = entries[nextUnused]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, nextUnused); - } - nextUnused++; - } - } - } + // Pass 2: sequential fallback for unmatched segments. + // If NO segments were matched by stepId (pass 1), use TAIL-ALIGNMENT: + // the loaded chunks are always the NEWEST (chat-limit/windowing keeps the + // newest and trims the oldest), so match the LAST T entries to the T + // segments. This prevents misaligning oldest (trimmed) entries to newest + // segments — which would show "turn 1" on turn 20's content. + const pass1Matches = segmentEntry.size; + if (pass1Matches === 0 && K >= T) { + // Tail-align: skip the first K-T entries (trimmed turns). + for (let seg = 0; seg < T; seg++) { + if (segmentEntry.has(seg)) continue; + const entryIdx = K - T + seg; + if (entryIdx < K && !usedEntries.has(entryIdx)) { + usedEntries.add(entryIdx); + const e = entries[entryIdx]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, entryIdx); + } + } + } + } else { + // Head-align fallback for remaining unmatched segments. + let nextUnused = 0; + for (let seg = 0; seg < T; seg++) { + if (segmentEntry.has(seg)) continue; + while (nextUnused < K && usedEntries.has(nextUnused)) nextUnused++; + if (nextUnused < K) { + usedEntries.add(nextUnused); + const e = entries[nextUnused]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, nextUnused); + } + nextUnused++; + } + } + } - // Running cumulative usage across ALL finalized turns (in entry order), for - // the per-turn "chat total" cache rate. Alongside it, the previous finalized - // turn's usage at each index — the baseline for cross-turn retention. - const cumulativeByEntry: Usage[] = []; - const prevUsageByEntry: (Usage | null)[] = []; - let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; - let lastFinalizedUsage: Usage | null = null; - for (const e of entries) { - prevUsageByEntry.push(lastFinalizedUsage); - if (e.total !== null) { - runningUsage = addUsage(runningUsage, e.total.usage); - lastFinalizedUsage = e.total.usage; - } - cumulativeByEntry.push(runningUsage); - } + // Running cumulative usage across ALL finalized turns (in entry order), for + // the per-turn "chat total" cache rate. Alongside it, the previous finalized + // turn's usage at each index — the baseline for cross-turn retention. + const cumulativeByEntry: Usage[] = []; + const prevUsageByEntry: (Usage | null)[] = []; + let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; + let lastFinalizedUsage: Usage | null = null; + for (const e of entries) { + prevUsageByEntry.push(lastFinalizedUsage); + if (e.total !== null) { + runningUsage = addUsage(runningUsage, e.total.usage); + lastFinalizedUsage = e.total.usage; + } + cumulativeByEntry.push(runningUsage); + } - const rows: MetricsRow[] = []; + const rows: MetricsRow[] = []; - const firstUserIdx = segmentStarts[0] ?? 0; + const firstUserIdx = segmentStarts[0] ?? 0; - // Emit turn-metrics rows for entries that weren't matched to any segment - // (fully trimmed turns — their content was unloaded by the chat limit, but - // their aggregate metrics still show so the user knows what was trimmed). - for (let i = 0; i < entries.length; i++) { - if (usedEntries.has(i)) continue; - const e = entries[i]; - if (e === undefined || e.total === null) continue; - rows.push({ - kind: "turn-metrics", - turn: e.total, - turnNumber: i + 1, - cumulativeUsage: cumulativeByEntry[i] ?? e.total.usage, - prevTurnUsage: prevUsageByEntry[i] ?? null, - }); - } + // Emit turn-metrics rows for entries that weren't matched to any segment + // (fully trimmed turns — their content was unloaded by the chat limit, but + // their aggregate metrics still show so the user knows what was trimmed). + for (let i = 0; i < entries.length; i++) { + if (usedEntries.has(i)) continue; + const e = entries[i]; + if (e === undefined || e.total === null) continue; + rows.push({ + kind: "turn-metrics", + turn: e.total, + turnNumber: i + 1, + cumulativeUsage: cumulativeByEntry[i] ?? e.total.usage, + prevTurnUsage: prevUsageByEntry[i] ?? null, + }); + } - for (let i = 0; i < firstUserIdx; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - } + for (let i = 0; i < firstUserIdx; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + } - for (let seg = 0; seg < T; seg++) { - const start = segmentStarts[seg] ?? 0; - const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; + for (let seg = 0; seg < T; seg++) { + const start = segmentStarts[seg] ?? 0; + const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; - const entry = segmentEntry.get(seg); + const entry = segmentEntry.get(seg); - if (entry === undefined) { - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - } - continue; - } + if (entry === undefined) { + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + } + continue; + } - const entryIdx = segmentEntryIndex.get(seg) ?? 0; + const entryIdx = segmentEntryIndex.get(seg) ?? 0; - // Build anchor map: for each stepId, the LAST group index in this segment. - const anchorByStepId = new Map<string, number>(); - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g === undefined) continue; - const sid = groupStepId(g); - if (sid !== undefined) { - anchorByStepId.set(sid, i); - } - } + // Build anchor map: for each stepId, the LAST group index in this segment. + const anchorByStepId = new Map<string, number>(); + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g === undefined) continue; + const sid = groupStepId(g); + if (sid !== undefined) { + anchorByStepId.set(sid, i); + } + } - // Classify each step as anchored or unanchored. Unanchored steps - // (content trimmed, or text-only steps with no tool chunks) are SKIPPED — - // step-metrics are only shown inline next to the content they describe. - const anchored: Map<number, { stepIndex: number; step: (typeof entry.steps)[number] }[]> = - new Map(); + // Classify each step as anchored or unanchored. Unanchored steps + // (content trimmed, or text-only steps with no tool chunks) are SKIPPED — + // step-metrics are only shown inline next to the content they describe. + const anchored: Map<number, { stepIndex: number; step: (typeof entry.steps)[number] }[]> = + new Map(); - for (let i = 0; i < entry.steps.length; i++) { - const step = entry.steps[i]; - if (step === undefined) continue; - const anchorGroupIdx = anchorByStepId.get(step.stepId); - if (anchorGroupIdx !== undefined) { - let arr = anchored.get(anchorGroupIdx); - if (arr === undefined) { - arr = []; - anchored.set(anchorGroupIdx, arr); - } - arr.push({ stepIndex: i, step }); - } - // Unanchored steps (no matching group) are skipped — no tail bubbles. - } + for (let i = 0; i < entry.steps.length; i++) { + const step = entry.steps[i]; + if (step === undefined) continue; + const anchorGroupIdx = anchorByStepId.get(step.stepId); + if (anchorGroupIdx !== undefined) { + let arr = anchored.get(anchorGroupIdx); + if (arr === undefined) { + arr = []; + anchored.set(anchorGroupIdx, arr); + } + arr.push({ stepIndex: i, step }); + } + // Unanchored steps (no matching group) are skipped — no tail bubbles. + } - // Emit groups; after each anchored group, emit its step-metrics rows. - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - const stepsHere = anchored.get(i); - if (stepsHere !== undefined) { - stepsHere.sort((a, b) => a.stepIndex - b.stepIndex); - for (const { step, stepIndex } of stepsHere) { - rows.push({ kind: "step-metrics", step, index: stepIndex }); - } - } - } + // Emit groups; after each anchored group, emit its step-metrics rows. + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + const stepsHere = anchored.get(i); + if (stepsHere !== undefined) { + stepsHere.sort((a, b) => a.stepIndex - b.stepIndex); + for (const { step, stepIndex } of stepsHere) { + rows.push({ kind: "step-metrics", step, index: stepIndex }); + } + } + } - // Turn-metrics row (only when the turn is finalized). Unanchored steps - // are skipped — no tail bubbles. - if (entry.total !== null) { - rows.push({ - kind: "turn-metrics", - turn: entry.total, - turnNumber: entryIdx + 1, - cumulativeUsage: cumulativeByEntry[entryIdx] ?? entry.total.usage, - prevTurnUsage: prevUsageByEntry[entryIdx] ?? null, - }); - } - } + // Turn-metrics row (only when the turn is finalized). Unanchored steps + // are skipped — no tail bubbles. + if (entry.total !== null) { + rows.push({ + kind: "turn-metrics", + turn: entry.total, + turnNumber: entryIdx + 1, + cumulativeUsage: cumulativeByEntry[entryIdx] ?? entry.total.usage, + prevTurnUsage: prevUsageByEntry[entryIdx] ?? null, + }); + } + } - return rows; + return rows; } 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); + }); }); diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts index 1e66cc8..bebef1d 100644 --- a/src/core/metrics/reducer.ts +++ b/src/core/metrics/reducer.ts @@ -2,127 +2,127 @@ import type { AgentEvent, StepId, StepMetrics, TurnMetrics, Usage } from "@dispa import type { BuildingStep, LiveTurn, MetricsState, TurnMetricsEntry } from "./types"; function sumStepUsages(steps: readonly BuildingStep[]): Usage { - let inputTokens = 0; - let outputTokens = 0; - let hasCacheRead = false; - let hasCacheWrite = false; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + let hasCacheRead = false; + let hasCacheWrite = false; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; - for (const step of steps) { - if (step.usage === undefined) continue; - inputTokens += step.usage.inputTokens; - outputTokens += step.usage.outputTokens; - if (step.usage.cacheReadTokens !== undefined && step.usage.cacheReadTokens > 0) { - hasCacheRead = true; - cacheReadTokens += step.usage.cacheReadTokens; - } - if (step.usage.cacheWriteTokens !== undefined && step.usage.cacheWriteTokens > 0) { - hasCacheWrite = true; - cacheWriteTokens += step.usage.cacheWriteTokens; - } - } + for (const step of steps) { + if (step.usage === undefined) continue; + inputTokens += step.usage.inputTokens; + outputTokens += step.usage.outputTokens; + if (step.usage.cacheReadTokens !== undefined && step.usage.cacheReadTokens > 0) { + hasCacheRead = true; + cacheReadTokens += step.usage.cacheReadTokens; + } + if (step.usage.cacheWriteTokens !== undefined && step.usage.cacheWriteTokens > 0) { + hasCacheWrite = true; + cacheWriteTokens += step.usage.cacheWriteTokens; + } + } - const base: Usage = { inputTokens, outputTokens }; - if (hasCacheRead) { - (base as { cacheReadTokens?: number }).cacheReadTokens = cacheReadTokens; - } - if (hasCacheWrite) { - (base as { cacheWriteTokens?: number }).cacheWriteTokens = cacheWriteTokens; - } - return base; + const base: Usage = { inputTokens, outputTokens }; + if (hasCacheRead) { + (base as { cacheReadTokens?: number }).cacheReadTokens = cacheReadTokens; + } + if (hasCacheWrite) { + (base as { cacheWriteTokens?: number }).cacheWriteTokens = cacheWriteTokens; + } + return base; } function buildingStepToMetrics(bs: BuildingStep): StepMetrics { - const usage: Usage = bs.usage ?? { inputTokens: 0, outputTokens: 0 }; - const base: StepMetrics = { stepId: bs.stepId as StepId, usage }; - if (bs.ttftMs !== undefined) { - (base as { ttftMs?: number }).ttftMs = bs.ttftMs; - } - if (bs.decodeMs !== undefined) { - (base as { decodeMs?: number }).decodeMs = bs.decodeMs; - } - if (bs.genTotalMs !== undefined) { - (base as { genTotalMs?: number }).genTotalMs = bs.genTotalMs; - } - return base; + const usage: Usage = bs.usage ?? { inputTokens: 0, outputTokens: 0 }; + const base: StepMetrics = { stepId: bs.stepId as StepId, usage }; + if (bs.ttftMs !== undefined) { + (base as { ttftMs?: number }).ttftMs = bs.ttftMs; + } + if (bs.decodeMs !== undefined) { + (base as { decodeMs?: number }).decodeMs = bs.decodeMs; + } + if (bs.genTotalMs !== undefined) { + (base as { genTotalMs?: number }).genTotalMs = bs.genTotalMs; + } + return base; } function getStep(lt: LiveTurn, id: string): BuildingStep { - const step = lt.stepMap.get(id); - if (step === undefined) throw new Error(`Missing step ${id} in live turn`); - return step; + const step = lt.stepMap.get(id); + if (step === undefined) throw new Error(`Missing step ${id} in live turn`); + return step; } function liveTurnToMetrics(lt: LiveTurn): TurnMetrics { - const buildingSteps = lt.stepOrder.map((id) => getStep(lt, id)); - const steps = buildingSteps.map((bs) => buildingStepToMetrics(bs)); - const usage = lt.doneUsage ?? sumStepUsages(buildingSteps); - const base: TurnMetrics = { turnId: lt.turnId, usage, steps }; - if (lt.durationMs !== undefined) { - (base as { durationMs?: number }).durationMs = lt.durationMs; - } - if (lt.doneContextSize !== undefined) { - (base as { contextSize?: number }).contextSize = lt.doneContextSize; - } - return base; + const buildingSteps = lt.stepOrder.map((id) => getStep(lt, id)); + const steps = buildingSteps.map((bs) => buildingStepToMetrics(bs)); + const usage = lt.doneUsage ?? sumStepUsages(buildingSteps); + const base: TurnMetrics = { turnId: lt.turnId, usage, steps }; + if (lt.durationMs !== undefined) { + (base as { durationMs?: number }).durationMs = lt.durationMs; + } + if (lt.doneContextSize !== undefined) { + (base as { contextSize?: number }).contextSize = lt.doneContextSize; + } + return base; } function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] { - const existing = state.live.get(turnId); - if (existing !== undefined) return [state, existing]; + const existing = state.live.get(turnId); + if (existing !== undefined) return [state, existing]; - const newTurn: LiveTurn = { - turnId, - done: false, - durationMs: undefined, - doneUsage: undefined, - doneContextSize: undefined, - stepMap: new Map(), - stepOrder: [], - }; - const newLive = new Map(state.live); - newLive.set(turnId, newTurn); - return [{ ...state, live: newLive, liveOrder: [...state.liveOrder, turnId] }, newTurn]; + const newTurn: LiveTurn = { + turnId, + done: false, + durationMs: undefined, + doneUsage: undefined, + doneContextSize: undefined, + stepMap: new Map(), + stepOrder: [], + }; + const newLive = new Map(state.live); + newLive.set(turnId, newTurn); + return [{ ...state, live: newLive, liveOrder: [...state.liveOrder, turnId] }, newTurn]; } function upsertStep(lt: LiveTurn, stepId: string, update: Partial<BuildingStep>): LiveTurn { - const existing = lt.stepMap.get(stepId); - if (existing !== undefined) { - const merged: BuildingStep = { - stepId, - usage: update.usage ?? existing.usage, - ttftMs: update.ttftMs ?? existing.ttftMs, - decodeMs: update.decodeMs ?? existing.decodeMs, - genTotalMs: update.genTotalMs ?? existing.genTotalMs, - complete: update.complete ?? existing.complete, - }; - const newMap = new Map(lt.stepMap); - newMap.set(stepId, merged); - return { ...lt, stepMap: newMap }; - } + const existing = lt.stepMap.get(stepId); + if (existing !== undefined) { + const merged: BuildingStep = { + stepId, + usage: update.usage ?? existing.usage, + ttftMs: update.ttftMs ?? existing.ttftMs, + decodeMs: update.decodeMs ?? existing.decodeMs, + genTotalMs: update.genTotalMs ?? existing.genTotalMs, + complete: update.complete ?? existing.complete, + }; + const newMap = new Map(lt.stepMap); + newMap.set(stepId, merged); + return { ...lt, stepMap: newMap }; + } - const fresh: BuildingStep = { - stepId, - usage: update.usage, - ttftMs: update.ttftMs, - decodeMs: update.decodeMs, - genTotalMs: update.genTotalMs, - complete: update.complete ?? false, - }; - const newMap = new Map(lt.stepMap); - newMap.set(stepId, fresh); - return { ...lt, stepMap: newMap, stepOrder: [...lt.stepOrder, stepId] }; + const fresh: BuildingStep = { + stepId, + usage: update.usage, + ttftMs: update.ttftMs, + decodeMs: update.decodeMs, + genTotalMs: update.genTotalMs, + complete: update.complete ?? false, + }; + const newMap = new Map(lt.stepMap); + newMap.set(stepId, fresh); + return { ...lt, stepMap: newMap, stepOrder: [...lt.stepOrder, stepId] }; } /** The initial empty metrics state. */ export function initialMetricsState(): MetricsState { - return { - live: new Map(), - liveOrder: [], - durable: new Map(), - durableOrder: [], - }; + return { + live: new Map(), + liveOrder: [], + durable: new Map(), + durableOrder: [], + }; } /** @@ -135,46 +135,46 @@ export function initialMetricsState(): MetricsState { * - All other event types: return state unchanged. */ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): MetricsState { - switch (event.type) { - case "usage": { - if (event.stepId === undefined) return state; - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated = upsertStep(lt, event.stepId, { usage: event.usage }); - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + switch (event.type) { + case "usage": { + if (event.stepId === undefined) return state; + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated = upsertStep(lt, event.stepId, { usage: event.usage }); + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - case "step-complete": { - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated = upsertStep(lt, event.stepId, { - ttftMs: event.ttftMs, - decodeMs: event.decodeMs, - genTotalMs: event.genTotalMs, - complete: true, - }); - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + case "step-complete": { + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated = upsertStep(lt, event.stepId, { + ttftMs: event.ttftMs, + decodeMs: event.decodeMs, + genTotalMs: event.genTotalMs, + complete: true, + }); + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - case "done": { - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated: LiveTurn = { - ...lt, - done: true, - durationMs: event.durationMs ?? lt.durationMs, - doneUsage: event.usage ?? lt.doneUsage, - doneContextSize: event.contextSize ?? lt.doneContextSize, - }; - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + case "done": { + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated: LiveTurn = { + ...lt, + done: true, + durationMs: event.durationMs ?? lt.durationMs, + doneUsage: event.usage ?? lt.doneUsage, + doneContextSize: event.contextSize ?? lt.doneContextSize, + }; + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - default: - return state; - } + default: + return state; + } } /** @@ -182,22 +182,22 @@ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): Metric * for any shared `turnId`. */ export function applyDurableMetrics( - state: MetricsState, - turns: readonly TurnMetrics[], + state: MetricsState, + turns: readonly TurnMetrics[], ): MetricsState { - const newDurable = new Map(state.durable); - const newDurableOrder = [...state.durableOrder]; - for (const turn of turns) { - if (!newDurable.has(turn.turnId)) { - newDurableOrder.push(turn.turnId); - } - newDurable.set(turn.turnId, turn); - } - return { - ...state, - durable: newDurable, - durableOrder: newDurableOrder, - }; + const newDurable = new Map(state.durable); + const newDurableOrder = [...state.durableOrder]; + for (const turn of turns) { + if (!newDurable.has(turn.turnId)) { + newDurableOrder.push(turn.turnId); + } + newDurable.set(turn.turnId, turn); + } + return { + ...state, + durable: newDurable, + durableOrder: newDurableOrder, + }; } /** @@ -210,37 +210,37 @@ export function applyDurableMetrics( * Live turns with no completed steps and not done are omitted. */ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetricsEntry[] { - const result: TurnMetricsEntry[] = []; - const seen = new Set<string>(); + const result: TurnMetricsEntry[] = []; + const seen = new Set<string>(); - for (const turnId of state.durableOrder) { - const tm = state.durable.get(turnId); - if (tm !== undefined) { - result.push({ turnId, steps: tm.steps, total: tm }); - seen.add(turnId); - } - } + for (const turnId of state.durableOrder) { + const tm = state.durable.get(turnId); + if (tm !== undefined) { + result.push({ turnId, steps: tm.steps, total: tm }); + seen.add(turnId); + } + } - for (const turnId of state.liveOrder) { - if (seen.has(turnId)) continue; - const lt = state.live.get(turnId); - if (lt === undefined) continue; + for (const turnId of state.liveOrder) { + if (seen.has(turnId)) continue; + const lt = state.live.get(turnId); + if (lt === undefined) continue; - const completeSteps = lt.stepOrder - .map((id) => lt.stepMap.get(id)) - .filter((s): s is BuildingStep => s?.complete === true) - .map((s) => buildingStepToMetrics(s)); + const completeSteps = lt.stepOrder + .map((id) => lt.stepMap.get(id)) + .filter((s): s is BuildingStep => s?.complete === true) + .map((s) => buildingStepToMetrics(s)); - if (completeSteps.length === 0 && !lt.done) continue; + if (completeSteps.length === 0 && !lt.done) continue; - result.push({ - turnId, - steps: completeSteps, - total: lt.done ? liveTurnToMetrics(lt) : null, - }); - } + result.push({ + turnId, + steps: completeSteps, + total: lt.done ? liveTurnToMetrics(lt) : null, + }); + } - return result; + return result; } /** @@ -254,10 +254,10 @@ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetr * live for a shared `turnId` (it is the persisted, authoritative value). */ export function selectCurrentContextSize(state: MetricsState): number | undefined { - const ordered = selectOrderedTurnMetrics(state); - for (let i = ordered.length - 1; i >= 0; i--) { - const total = ordered[i]?.total; - if (total?.contextSize !== undefined) return total.contextSize; - } - return undefined; + const ordered = selectOrderedTurnMetrics(state); + for (let i = ordered.length - 1; i >= 0; i--) { + const total = ordered[i]?.total; + if (total?.contextSize !== undefined) return total.contextSize; + } + return undefined; } diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts index 5b96e0f..84d1904 100644 --- a/src/core/metrics/types.ts +++ b/src/core/metrics/types.ts @@ -5,28 +5,28 @@ export type { StepMetrics, TurnMetrics }; /** A step being built from live events (may be incomplete). */ export interface BuildingStep { - readonly stepId: string; - readonly usage: Usage | undefined; - readonly ttftMs: number | undefined; - readonly decodeMs: number | undefined; - readonly genTotalMs: number | undefined; - readonly complete: boolean; + readonly stepId: string; + readonly usage: Usage | undefined; + readonly ttftMs: number | undefined; + readonly decodeMs: number | undefined; + readonly genTotalMs: number | undefined; + readonly complete: boolean; } /** A turn being built from live events (in-flight). */ export interface LiveTurn { - readonly turnId: string; - readonly done: boolean; - readonly durationMs: number | undefined; - readonly doneUsage: Usage | undefined; - /** - * Context size carried on the turn's `done` event (the turn's FINAL step - * `inputTokens + outputTokens` — current context occupancy). `undefined` when - * the provider reported no per-step usage; never coerced to `0`. - */ - readonly doneContextSize: number | undefined; - readonly stepMap: ReadonlyMap<string, BuildingStep>; - readonly stepOrder: readonly string[]; + readonly turnId: string; + readonly done: boolean; + readonly durationMs: number | undefined; + readonly doneUsage: Usage | undefined; + /** + * Context size carried on the turn's `done` event (the turn's FINAL step + * `inputTokens + outputTokens` — current context occupancy). `undefined` when + * the provider reported no per-step usage; never coerced to `0`. + */ + readonly doneContextSize: number | undefined; + readonly stepMap: ReadonlyMap<string, BuildingStep>; + readonly stepOrder: readonly string[]; } /** @@ -36,62 +36,62 @@ export interface LiveTurn { * - `durable`: sealed turns keyed by `turnId` in the order they arrived. */ export interface MetricsState { - readonly live: ReadonlyMap<string, LiveTurn>; - readonly liveOrder: readonly string[]; - readonly durable: ReadonlyMap<string, TurnMetrics>; - readonly durableOrder: readonly string[]; + readonly live: ReadonlyMap<string, LiveTurn>; + readonly liveOrder: readonly string[]; + readonly durable: ReadonlyMap<string, TurnMetrics>; + readonly durableOrder: readonly string[]; } /** Per-turn placement entry: completed steps so far + optional turn total. */ export interface TurnMetricsEntry { - readonly turnId: string; - readonly steps: readonly StepMetrics[]; - readonly total: TurnMetrics | null; + readonly turnId: string; + readonly steps: readonly StepMetrics[]; + readonly total: TurnMetrics | null; } /** A row in the interleaved transcript: a render group, per-step metrics, or turn metrics. */ export type MetricsRow = - | { readonly kind: "group"; readonly group: RenderGroup } - | { readonly kind: "step-metrics"; readonly step: StepMetrics; readonly index: number } - | { - readonly kind: "turn-metrics"; - readonly turn: TurnMetrics; - /** 1-based turn number (the entry's position in the metrics array + 1). */ - readonly turnNumber: number; - /** Cumulative usage across all finalized turns up to and including this one. */ - readonly cumulativeUsage: Usage; - /** - * Usage of the most recent EARLIER finalized turn, or `null` when this is the - * first finalized turn. The baseline for cross-turn retention (expected cache). - */ - readonly prevTurnUsage: Usage | null; - }; + | { readonly kind: "group"; readonly group: RenderGroup } + | { readonly kind: "step-metrics"; readonly step: StepMetrics; readonly index: number } + | { + readonly kind: "turn-metrics"; + readonly turn: TurnMetrics; + /** 1-based turn number (the entry's position in the metrics array + 1). */ + readonly turnNumber: number; + /** Cumulative usage across all finalized turns up to and including this one. */ + readonly cumulativeUsage: Usage; + /** + * Usage of the most recent EARLIER finalized turn, or `null` when this is the + * first finalized turn. The baseline for cross-turn retention (expected cache). + */ + readonly prevTurnUsage: Usage | null; + }; /** Formatted cache hit-rate view: percentage + colour severity + hit flag. */ export interface CacheRateView { - /** Cache hit rate as a 0..100 integer percentage (`cacheReadTokens / inputTokens`). */ - readonly pct: number; - /** Colour severity for a badge (maps to DaisyUI `badge-{level}`). */ - readonly level: "success" | "warning" | "error"; - /** Whether any input tokens were served from cache. */ - readonly isHit: boolean; + /** Cache hit rate as a 0..100 integer percentage (`cacheReadTokens / inputTokens`). */ + readonly pct: number; + /** Colour severity for a badge (maps to DaisyUI `badge-{level}`). */ + readonly level: "success" | "warning" | "error"; + /** Whether any input tokens were served from cache. */ + readonly isHit: boolean; } /** Formatted per-step view for display. */ export interface StepMetricsView { - readonly label: string; - readonly tokensLabel: string; - readonly tps: string | null; - readonly ttft: string | null; - readonly decode: string | null; - readonly genTotal: string | null; + readonly label: string; + readonly tokensLabel: string; + readonly tps: string | null; + readonly ttft: string | null; + readonly decode: string | null; + readonly genTotal: string | null; } /** Formatted per-turn view for display. */ export interface TurnMetricsView { - readonly label: string; - readonly tokensLabel: string; - readonly breakdown: string; - readonly tps: string | null; - readonly duration: string | null; + readonly label: string; + readonly tokensLabel: string; + readonly breakdown: string; + readonly tps: string | null; + readonly duration: string | null; } |
