summaryrefslogtreecommitdiffhomepage
path: root/src/core/metrics/place.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/metrics/place.ts')
-rw-r--r--src/core/metrics/place.ts469
1 files changed, 238 insertions, 231 deletions
diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts
index 0048fa0..7122b09 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;
}
/**
@@ -27,10 +27,11 @@ function addUsage(a: Usage, b: Usage): Usage {
* Splits groups into per-turn segments: a new segment begins at each `single`
* group with `group.chunk.role === "user"`. Segments are matched to entries
* by `stepId` presence when possible (robust against chat-limit trimming: when
- * a turn's user message is trimmed, head-alignment would be off by one, but
+ * a turn's user message is trimmed, positional alignment would be off, but
* stepId matching still finds the right entry). Segments with no stepId-bearing
- * groups (text-only turns) fall back to sequential matching against unused
- * entries.
+ * groups (text-only turns) fall back to POSITIONAL tail-alignment: since the
+ * loaded transcript is always a SUFFIX of the full turn history (the chat limit
+ * keeps the newest and unloads the oldest), segment `seg` ↔ entry `K - T + seg`.
*
* Within a segment that has a matched entry, each completed step's metrics
* are placed INLINE right after the last group bearing that step's `stepId`.
@@ -44,248 +45,254 @@ function addUsage(a: Usage, b: Usage): Usage {
* is finalized via `done` or durable data). A still-generating turn emits no
* turn-total row.
*
- * Cumulative usage is computed across finalized turns in entry-array order
- * (turn order), so the per-turn "chat total" cache rate is correct regardless
- * of which turns were trimmed.
+ * Fully trimmed turns (entries whose content was unloaded by the chat limit and
+ * which match no segment) are NOT rendered as standalone rows — that previously
+ * piled a wall of stale cache badges at the top of a long, trimmed transcript.
+ * Their usage still counts toward the per-turn "chat total" cumulative (computed
+ * across ALL finalized turns in entry-array order), so the running cache rate
+ * stays correct regardless of which turns were trimmed; paging earlier history
+ * back in ("Show earlier messages") re-matches them and re-renders their rows.
*/
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 positional alignment alone could be ambiguous). Pass 2:
+ // positional tail-alignment 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: positional fallback for segments pass 1 left unmatched
+ // (text-only turns with no stepId-bearing groups to anchor on).
+ //
+ // The loaded transcript is always a SUFFIX of the full turn history —
+ // chat-limit/windowing keeps the NEWEST chunks and unloads the OLDEST — so
+ // the T loaded segments correspond to the LAST T entries. TAIL-ALIGNMENT
+ // (segment `seg` ↔ entry `K - T + seg`) is therefore correct whenever the
+ // metrics hold at least as many turns as there are loaded segments
+ // (`K >= T`): the leading `K - T` entries are TRIMMED turns (their content
+ // was unloaded) and must be skipped, never matched to a newer segment.
+ //
+ // This MUST run even when pass 1 matched SOME segments (tool turns). The
+ // earlier code only tail-aligned when pass 1 matched NONE, falling back to
+ // HEAD-alignment otherwise — which, with leading trimmed entries, matched a
+ // brand-new text-only turn to an old (trimmed) entry's STALE metrics (the
+ // "new steps show no / wrong cache" failure). Tail-aligning by position is
+ // safe alongside pass 1: stepIds are unique per turn, so pass 1 already
+ // grabbed each tool turn's positionally-correct entry, leaving the right
+ // entry free for each text-only turn.
+ //
+ // Only when `K < T` (fewer entries than segments — some loaded turns have no
+ // metrics yet, e.g. a metrics sync still pending or a freshly loaded
+ // transcript) do we head-align, assigning the first K entries to the first K
+ // unmatched segments (the turns that DO have metrics sit at the front).
+ if (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 >= 0 && 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 (K < T): first K entries to first K 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,
- });
- }
+ 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;
}