diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/store.test.ts | 16 | ||||
| -rw-r--r-- | src/core/metrics/place.test.ts | 97 | ||||
| -rw-r--r-- | src/core/metrics/place.ts | 75 |
3 files changed, 140 insertions, 48 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 2cf473c..e1a11e0 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -553,13 +553,17 @@ describe("createAppStore", () => { event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" }, }); - await new Promise((r) => setTimeout(r, 50)); - - expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); - - await new Promise((r) => setTimeout(r, 50)); + // `turn-sealed` triggers an async `syncTail` (cache.sinceSeq → historySync + // → cache.commit → applyHistory). Poll for the side-effect rather than + // guessing a fixed delay — under suite load a fixed `setTimeout` raced the + // fetch chain and flaked here. + await vi.waitFor(() => { + expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); + }); - expect(store.activeChat.chunks.length).toBeGreaterThan(0); + await vi.waitFor(() => { + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + }); store.dispose(); }); diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts index c05ba3b..9c925a3 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -354,6 +354,81 @@ describe("interleaveTurnMetrics", () => { expectGroupAt(rows, 7, g6); }); + it("trimmed leading turns: a mixed tool+text transcript tail-aligns text-only turns to their OWN (newest) entries, not stale trimmed ones", () => { + // A long conversation where the chat limit unloaded the oldest turn (t1). + // Metrics still hold all three turns; the loaded transcript is turns 2-3. + // Turn 2 is a tool turn (matched by stepId); turn 3 is text-only (no + // stepId groups) — the failure case. The text-only turn MUST get its OWN + // entry (t3), NOT the trimmed t1's stale metrics. + const g3 = userGroup(3, "q2"); + const g4 = toolBatchGroup("s2", ["c2"]); + const g5 = assistantGroup(4, "tool-reply"); + const g6 = userGroup(5, "q3"); + const g7 = assistantGroup(6, "text-reply"); + const step1 = makeStep("s1", 11, 1); // t1 (trimmed) + const step2 = makeStep("s2", 22, 2); // t2 (loaded, tool) + const step3 = makeStep("s3", 33, 3); // t3 (loaded, text-only — unanchored) + const entries = [ + makeEntry("t1", 11, 1, [step1]), + makeEntry("t2", 22, 2, [step2]), + makeEntry("t3", 33, 3, [step3]), + ]; + const rows = interleaveTurnMetrics([g3, g4, g5, g6, g7], entries); + + const tmRows = rows.filter( + (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics", + ); + // Two loaded turns → two turn-metrics rows. The trimmed t1 does NOT render. + expect(tmRows).toHaveLength(2); + // CRITICAL: the text-only turn (segment 1) got t3 (its own newest entry), + // not t1 (the stale trimmed one). A misaligned head-align would show t1. + expect(tmRows[1]?.turn.turnId).toBe("t3"); + expect(tmRows[0]?.turn.turnId).toBe("t2"); + // And t1 never appears as a rendered row. + expect(tmRows.some((r) => r.turn.turnId === "t1")).toBe(false); + }); + + it("trimmed turn still counts toward the cumulative 'chat total' on the first visible turn", () => { + // t1 is trimmed (no segment) but finalized; t2 is the loaded visible turn. + // t2's "Chat Total" cumulative must INCLUDE t1's usage (the whole chat), + // even though t1 renders no row of its own. + const g1 = userGroup(2, "q2"); + const g2 = assistantGroup(3, "a2"); + const entries = [ + { + turnId: "t1", + steps: [], + total: { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 }, + steps: [], + }, + }, + { + turnId: "t2", + steps: [], + total: { + turnId: "t2", + usage: { inputTokens: 2000, outputTokens: 20, cacheReadTokens: 1600 }, + steps: [], + }, + }, + ]; + const rows = interleaveTurnMetrics([g1, g2], entries); + const tmRows = rows.filter( + (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics", + ); + // Only the loaded turn renders a row; the trimmed t1 does not. + expect(tmRows).toHaveLength(1); + expect(tmRows[0]?.turn.turnId).toBe("t2"); + // Cumulative includes BOTH turns (t1 + t2): input 3000, cacheRead 2100. + expect(tmRows[0]?.cumulativeUsage.inputTokens).toBe(3000); + expect(tmRows[0]?.cumulativeUsage.cacheReadTokens).toBe(2100); + // Retention baseline is the prior finalized turn (t1, even though trimmed). + expect(tmRows[0]?.prevTurnUsage?.inputTokens).toBe(1000); + expect(tmRows[0]?.prevTurnUsage?.cacheReadTokens).toBe(500); + }); + it("in-flight turn (no durationMs) still produces turn row", () => { const g1 = userGroup(1, "q1"); const g2 = toolCallGroup(2, "s1", "c1"); @@ -391,7 +466,7 @@ describe("interleaveTurnMetrics", () => { expectTurnMetricsAt(rows, 4, "t1"); }); - it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => { + it("trimmed turn (more metrics than segments) does NOT emit a standalone row at the top", () => { const g1 = userGroup(1, "q1"); const g2 = toolCallGroup(2, "s1", "c1"); const step1 = makeStep("s1", 100, 50); @@ -401,13 +476,19 @@ describe("interleaveTurnMetrics", () => { [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"); + // t2's content was unloaded by the chat limit (no segment for it); its + // metrics must NOT render a standalone row piled at the top. Only the + // loaded turn's content + its matched metrics appear. (t2 still counts + // toward the cumulative "chat total" — see the cache-total tests.) + expect(rows).toHaveLength(4); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + // No standalone turn-metrics row for t2 anywhere. + const tmRows = rows.filter((r) => r.kind === "turn-metrics"); + expect(tmRows).toHaveLength(1); + expect((tmRows[0] as { readonly turn: TurnMetrics }).turn.turnId).toBe("t1"); }); it("turn with no steps emits only turn-metrics (no step-metrics)", () => { diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index b165fd0..7122b09 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -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,9 +45,13 @@ 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[], @@ -84,8 +89,9 @@ export function interleaveTurnMetrics( 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). + // 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>(); @@ -127,19 +133,36 @@ export function interleaveTurnMetrics( } } - // 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) { + // 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 < K && !usedEntries.has(entryIdx)) { + if (entryIdx >= 0 && entryIdx < K && !usedEntries.has(entryIdx)) { usedEntries.add(entryIdx); const e = entries[entryIdx]; if (e !== undefined) { @@ -149,7 +172,7 @@ export function interleaveTurnMetrics( } } } else { - // Head-align fallback for remaining unmatched segments. + // 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; @@ -186,22 +209,6 @@ export function interleaveTurnMetrics( 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) { |
