summaryrefslogtreecommitdiffhomepage
path: root/src/core/metrics
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:21:55 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commitc333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch)
tree0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/core/metrics
parent1285564f12238b22f6b39b9f3fbcecaca8456911 (diff)
downloaddispatch-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.ts658
-rw-r--r--src/core/metrics/format.ts158
-rw-r--r--src/core/metrics/index.ts48
-rw-r--r--src/core/metrics/place.test.ts976
-rw-r--r--src/core/metrics/place.ts448
-rw-r--r--src/core/metrics/reducer.test.ts822
-rw-r--r--src/core/metrics/reducer.ts362
-rw-r--r--src/core/metrics/types.ts114
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;
}