diff options
| -rw-r--r-- | backend-handoff.md | 27 | ||||
| -rw-r--r-- | src/app/App.svelte | 2 | ||||
| -rw-r--r-- | src/app/store.test.ts | 16 | ||||
| -rw-r--r-- | src/core/metrics/format.test.ts | 8 | ||||
| -rw-r--r-- | src/core/metrics/format.ts | 20 | ||||
| -rw-r--r-- | src/core/metrics/place.test.ts | 97 | ||||
| -rw-r--r-- | src/core/metrics/place.ts | 75 | ||||
| -rw-r--r-- | src/core/metrics/reducer.test.ts | 247 | ||||
| -rw-r--r-- | src/core/metrics/reducer.ts | 92 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 9 | ||||
| -rw-r--r-- | src/features/chat/ui/Composer.svelte | 9 |
11 files changed, 532 insertions, 70 deletions
diff --git a/backend-handoff.md b/backend-handoff.md index 025d290..1efe323 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -1033,6 +1033,33 @@ conversation → confirm the persisted image renders from the `/images/…` endp --- +## 2k. Step-level context-window usage (progressive) → **FE BUILT; no backend change** + +The context-window usage indicator at the bottom of the screen (Composer status +bar) now updates **after each step** during a multi-step turn, instead of only +when the turn seals. Pure FE change — consumes wire events the backend ALREADY +sends (`usage` per step + `step-complete` + `done.contextSize`); no contract +change, re-pin, or re-mirror needed. + +- `selectCurrentContextSize` (`core/metrics/reducer.ts`) now, for an IN-FLIGHT + (not-done) turn, returns the most recent step WITH USAGE's + `inputTokens + outputTokens` as the live context occupancy. Per the wire + contract each step's input already includes all prior context (the prompt is + re-prefilled every step), so the last step's input+output is the true occupancy + — the same definition `TurnDoneEvent.contextSize` stamps at turn end. +- A finalized turn (`done` / durable) still wins with its authoritative + `contextSize`; durable still wins over live for a shared `turnId`. An in-flight + turn with no step usage yet falls back to the next older finalized turn (never + `0`). +- New helper `liveTurnContextSize`; updated doc on `ChatStore.currentContextSize` + + the Composer `contextSize` prop. 7 new reducer tests (35 total), 1026 green. + +### FE summary (this slice) +No backend ask. The backend already emits per-step `usage` (token counts, may +arrive mid-stream) and `step-complete` (timing) joined by `stepId`, plus +`done.contextSize` (final step's input+output) — the FE just wasn't reading the +per-step usage for the live indicator. Now it does. + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns diff --git a/src/app/App.svelte b/src/app/App.svelte index 59f949c..e508203 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -501,7 +501,7 @@ the top row now shows the active tab's title on the left (or "New Tab" for an unstarted draft), with the build version + sidebar toggle on the right. --> - <div class="flex items-center justify-between gap-2 px-2"> + <div class="flex items-center justify-between gap-2 px-2 py-2"> <span class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70" data-testid="top-bar-title" diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 7fb1469..1048d87 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/format.test.ts b/src/core/metrics/format.test.ts index c7c4fbb..97170d0 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -351,8 +351,14 @@ describe("computeContextUsage", () => { expect(u.percent).toBeCloseTo(3.4102, 4); }); - it("treats unknown contextSize as current 0", () => { + it("treats unknown contextSize as current null (never 0)", () => { const u = computeContextUsage(undefined, 1_000_000); + expect(u.current).toBeNull(); + expect(u.percent).toBeNull(); + }); + + it("an explicit 0 context size is a real reported value (current 0)", () => { + const u = computeContextUsage(0, 1_000_000); expect(u.current).toBe(0); expect(u.percent).toBe(0); }); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 56e74e4..894bd54 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -45,14 +45,17 @@ export function formatCompactTokens(n: number): string { /** * Context-window occupancy: the current size against a max window limit. * - * `current` is the latest turn's context size (0 when unknown); `max` is the - * model's window limit (or `null` when unknown). `percent` is - * `current / max * 100` clamped to [0, 100], UNROUNDED (the UI picks the - * precision) — so a few-thousand-token context against a 1,000,000 window still - * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator). + * `current` is the latest turn's context size, or `null` when unknown (no + * per-step usage reported yet) — NEVER coerced to `0`, so a consumer cannot + * silently render "0 tokens / 1M"; it must branch on `current === null` and show + * a placeholder instead. `max` is the model's window limit (or `null` when + * unknown). `percent` is `current / max * 100` clamped to [0, 100], UNROUNDED + * (the UI picks the precision) — so a few-thousand-token context against a + * 1,000,000 window still reads non-zero. `percent` is `null` when `current` OR + * `max` is unknown (no bar/denominator). */ export interface ContextUsage { - readonly current: number; + readonly current: number | null; readonly max: number | null; readonly percent: number | null; } @@ -61,9 +64,10 @@ export function computeContextUsage( contextSize: number | undefined, contextLimit: number | null | undefined, ): ContextUsage { - const current = contextSize ?? 0; + const current = contextSize ?? null; const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; - const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); + const percent = + current === null || max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); return { current, max, percent }; } 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) { diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts index 7d0a270..581a8b7 100644 --- a/src/core/metrics/reducer.test.ts +++ b/src/core/metrics/reducer.test.ts @@ -439,4 +439,251 @@ describe("contextSize / selectCurrentContextSize", () => { ]); expect(selectCurrentContextSize(s)).toBe(222); }); + + it("in-flight turn updates context size after the first step completes", () => { + // Before the requirement: an in-flight turn had total=null so its step usage + // was ignored until `done`. Now the latest step's input+output is used. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + + // Still generating (no done) — context = step 1 input+output = 5200. + expect(selectCurrentContextSize(s)).toBe(5200); + }); + + it("in-flight turn updates progressively as each step reports usage", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5200); + + // Step 2 reports usage mid-stream (before its step-complete): each step's + // input already includes all prior context, so the last step's input+output + // is the current occupancy. + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("in-flight context size is the latest step with usage, NOT the aggregate sum", () => { + // Mirrors the finalized-turn test: contextSize is the FINAL step's + // input+output, not the sum across steps (which would overcount a + // multi-step turn because every step re-prefills the growing prompt). + 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")); + // Aggregate would be 300+130=430; the latest step is 200+80=280. + expect(selectCurrentContextSize(s)).toBe(280); + }); + + it("in-flight turn with a step-complete but no usage falls back to older turn", () => { + // step-complete before usage → the step has no usage yet, so the in-flight + // turn exposes no context size and the display falls back to the prior + // finalized turn's value (never 0). + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1", { genTotalMs: 500 })); + + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("in-flight turn with no steps/usage returns undefined (falls back)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 just started — no usage, no complete step — omitted entirely. + s = foldMetricsEvent(s, { type: "turn-start", conversationId: "c1", turnId: "t2" }); + expect(selectCurrentContextSize(s)).toBe(700); + + // t2's first step reports usage → the display jumps to t2's live value. + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("done finalizes the in-flight progressive value with the authoritative contextSize", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5200); + + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + + // done stamps the authoritative contextSize (the final step's input+output). + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 5350 })); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("in-flight context size excludes cache tokens (they are a subset of inputTokens)", () => { + // cacheReadTokens / cacheWriteTokens are portions of inputTokens already + // counted — adding them would double-count. Only input+output is occupancy. + let s = initialMetricsState(); + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s1" as StepId, + usage: { + inputTokens: 5000, + outputTokens: 200, + cacheReadTokens: 4000, + cacheWriteTokens: 1000, + }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // 5000+200=5200, NOT 9200 (with cacheRead) or 10200 (with both). + expect(selectCurrentContextSize(s)).toBe(5200); + }); + + it("multiple in-flight turns: the newest turn's live value wins", () => { + let s = initialMetricsState(); + // t1 (older) in-flight with one completed step → 5200. + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // t2 (newer, seen later → last in liveOrder) in-flight → 8000. + s = foldMetricsEvent(s, usageEvent("t2", 7800, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + expect(selectCurrentContextSize(s)).toBe(8000); + }); + + it("out-of-order step IDs: usage for step 2 before step 1's step-complete still scans newest-first", () => { + // stepOrder is FIRST-SEEN: s1 (its usage arrived first), then s2. So s2 is + // the newest step regardless of when each step's step-complete arrives. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2")); + // Neither step complete yet → the turn is omitted (no complete step), so the + // display can't update until the first step completes. + expect(selectCurrentContextSize(s)).toBeUndefined(); + + // s1 completes AFTER s2's usage was reported. The turn is now visible; the + // newest-first scan picks s2 (the later step), not s1 (the just-completed one). + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + expect(selectCurrentContextSize(s)).toBe(5350); + + // s2 completes — still s2, unchanged. + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + expect(selectCurrentContextSize(s)).toBe(5350); + }); + + it("done turn without contextSize falls back to an older turn (even with step usage)", () => { + // Contract lock-in: a done turn's step usage is NOT consulted for the + // context display — only its authoritative total.contextSize is. When that + // is absent, the display falls back to the next older finalized turn rather + // than synthesizing a value from the step usage. + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 done WITH step usage but NO done.contextSize (edge case: the done event + // omitted contextSize despite per-step usage). + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + s = foldMetricsEvent(s, doneEvent("t2")); + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("in-flight context size skips a step with unsafe usage (NaN / negative)", () => { + // A corrupt provider report must never reach the status bar. The newest + // step with invalid counters is skipped, falling back to the prior valid one. + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + // s2 reports NaN input (e.g. a non-numeric provider field coerced). + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s2" as StepId, + usage: { inputTokens: Number.NaN, outputTokens: 150 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + // s2 skipped (NaN) → falls back to s1's 5200, NOT NaN. + expect(selectCurrentContextSize(s)).toBe(5200); + + // Negative tokens are likewise skipped. + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s3" as StepId, + usage: { inputTokens: -10, outputTokens: 5 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s3")); + expect(selectCurrentContextSize(s)).toBe(5200); + }); +}); + +describe("applyDurableMetrics pruning", () => { + it("prunes a live turn once durable data covers it (no unbounded growth)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 })); + expect(s.live.has("t1")).toBe(true); + expect(s.liveOrder).toContain("t1"); + + s = applyDurableMetrics(s, [ + { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [{ stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 } }], + contextSize: 150, + }, + ]); + // The live copy is gone; the durable (authoritative) entry replaces it. + expect(s.live.has("t1")).toBe(false); + expect(s.liveOrder).not.toContain("t1"); + expect(s.durable.has("t1")).toBe(true); + // The display still reads the durable value atomically (no gap). + expect(selectCurrentContextSize(s)).toBe(150); + }); + + it("prunes only the turns present in the durable batch (leaves other live turns)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 })); + // t2 still in flight — must NOT be pruned when only t1 seals. + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, steps: [], contextSize: 150 }, + ]); + expect(s.live.has("t1")).toBe(false); + expect(s.live.has("t2")).toBe(true); + expect(s.liveOrder).toEqual(["t2"]); + // The newest (in-flight) turn's live value still wins. + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("is a no-op when no incoming turn is live (no live mutation)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + const before = s; + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [] }, + ]); + // t1 was never live → the live map/order are unchanged (same reference). + expect(s.live).toBe(before.live); + expect(s.liveOrder).toBe(before.liveOrder); + // t1 (durable) is older; the in-flight t2 still wins. + expect(selectCurrentContextSize(s)).toBe(810); + }); + + it("durable wins over live for a shared turnId (pruned live no longer consulted)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 })); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 }, + ]); + // The live (111) copy is pruned; only durable (222) remains. + expect(s.live.has("t1")).toBe(false); + expect(selectCurrentContextSize(s)).toBe(222); + }); }); diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts index bebef1d..39fc5ee 100644 --- a/src/core/metrics/reducer.ts +++ b/src/core/metrics/reducer.ts @@ -68,6 +68,51 @@ function liveTurnToMetrics(lt: LiveTurn): TurnMetrics { return base; } +/** + * A step's contribution to the live context size: `inputTokens + outputTokens`, + * or `undefined` when the step has no usage yet OR its counters are not safe to + * sum (non-finite / negative — defensive: a corrupt provider report must never + * reach the status bar as NaN/Infinity). Cache tokens are deliberately NOT + * included: `cacheReadTokens` / `cacheWriteTokens` are a SUBSET of + * `inputTokens`, so adding them would double-count. + */ +function stepContextSize(usage: Usage | undefined): number | undefined { + if (usage === undefined) return undefined; + const { inputTokens, outputTokens } = usage; + if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) return undefined; + if (inputTokens < 0 || outputTokens < 0) return undefined; + return inputTokens + outputTokens; +} + +/** + * The context size an IN-FLIGHT (not-done) turn occupies right now — for + * progressive display DURING a turn (before it seals), so the indicator updates + * after each step instead of waiting for `done`. + * + * CONTRACT: only call this on a turn whose `done` event has NOT arrived (the + * caller, `selectCurrentContextSize`, reaches it solely for entries with + * `total === null`, i.e. `lt.done === false`). Finalized turns use their + * authoritative `contextSize` instead; `doneContextSize` is read on the + * `total` path, never here. + * + * Returns the most recent step WITH USABLE USAGE's `inputTokens + outputTokens` + * (scanning newest → oldest by first-seen step order): each step's input + * already includes all prior context (the prompt is re-prefilled every step), so + * the last step's input+output is the true occupancy — the same definition + * `TurnDoneEvent.contextSize` stamps at turn end. A just-reported step's usage + * wins immediately, even mid-stream. Steps with no usage or unsafe usage are + * skipped, falling back to the next older usable step. `undefined` when no step + * has reported usable usage yet. + */ +function liveTurnContextSize(lt: LiveTurn): number | undefined { + for (let i = lt.stepOrder.length - 1; i >= 0; i--) { + const step = lt.stepMap.get(lt.stepOrder[i] ?? ""); + const ctx = stepContextSize(step?.usage); + if (ctx !== undefined) return ctx; + } + return undefined; +} + function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] { const existing = state.live.get(turnId); if (existing !== undefined) return [state, existing]; @@ -180,6 +225,12 @@ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): Metric /** * Store durable (sealed) metrics from the backend. These win over live data * for any shared `turnId`. + * + * Once durable (authoritative) data covers a turn, its live (in-memory) copy + * is REDUNDANT and is pruned from `state.live` / `liveOrder` so the live map + * doesn't grow unbounded over a long conversation. There is no display gap: + * the durable entry replaces the live one atomically in the same fold, and + * `selectOrderedTurnMetrics` / `selectCurrentContextSize` read durable for it. */ export function applyDurableMetrics( state: MetricsState, @@ -187,14 +238,27 @@ export function applyDurableMetrics( ): MetricsState { const newDurable = new Map(state.durable); const newDurableOrder = [...state.durableOrder]; + const prunedIds = new Set<string>(); for (const turn of turns) { if (!newDurable.has(turn.turnId)) { newDurableOrder.push(turn.turnId); } newDurable.set(turn.turnId, turn); + if (state.live.has(turn.turnId)) prunedIds.add(turn.turnId); } + + if (prunedIds.size === 0) { + return { ...state, durable: newDurable, durableOrder: newDurableOrder }; + } + + const newLive = new Map(state.live); + for (const id of prunedIds) newLive.delete(id); + const newLiveOrder = state.liveOrder.filter((id) => !prunedIds.has(id)); + return { ...state, + live: newLive, + liveOrder: newLiveOrder, durable: newDurable, durableOrder: newDurableOrder, }; @@ -247,17 +311,35 @@ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetr * Select the conversation's CURRENT context size — the tokens it occupies right * now. Per the wire contract a client reads the LATEST turn's `contextSize`; we * scan the merged ordered turns NEWEST → OLDEST and return the first DEFINED - * `contextSize` (a finalized turn whose provider reported per-step usage). + * value. + * + * For a FINALIZED turn (`done` event or durable data) we use its authoritative + * `contextSize`. For an IN-FLIGHT (not-done) turn we compute it PROGRESSIVELY + * from the most recent step WITH USAGE — its `inputTokens + outputTokens` is the + * current occupancy (mirroring `TurnDoneEvent.contextSize`'s definition) — so + * the indicator updates after each step completes instead of waiting for the + * turn to seal. An in-flight turn with no step usage yet is skipped, falling + * back to the next older finalized turn. * - * Returns `undefined` ("unknown") when no finalized turn carries a context size — - * the caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over + * Returns `undefined` ("unknown") when no turn carries a context size — the + * caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over * 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; + const entry = ordered[i]; + if (entry === undefined) continue; + if (entry.total !== null) { + if (entry.total.contextSize !== undefined) return entry.total.contextSize; + continue; + } + // In-flight turn: progressive context size from the latest step with usage. + const lt = state.live.get(entry.turnId); + if (lt !== undefined) { + const live = liveTurnContextSize(lt); + if (live !== undefined) return live; + } } return undefined; } diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 5278737..9911438 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -74,9 +74,12 @@ export interface ChatStore { readonly chunks: readonly RenderedChunk[]; readonly turnMetrics: readonly TurnMetricsEntry[]; /** - * The conversation's current context size (tokens occupied) — the latest - * finalized turn's `contextSize`, or `undefined` ("unknown") when none is - * known yet. Never `0` for the unknown case. + * The conversation's current context size (tokens occupied) — updated + * PROGRESSIVELY: during an in-flight turn, the most recent step's + * `inputTokens + outputTokens` (each step's input already includes all prior + * context); once the turn seals, its authoritative `contextSize`. `undefined` + * ("unknown") when no step has reported usage yet. Never `0` for the unknown + * case. */ readonly currentContextSize: number | undefined; /** diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index afe1e3c..04c28cd 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -39,8 +39,10 @@ onQueue?: (text: string) => void; /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ onStop?: () => void; - // Current context occupancy (latest turn's contextSize), or `undefined` - // when unknown — the status bar then shows "— tokens", never 0%. + // Current context occupancy — updated progressively during a turn (the + // latest step's input+output) and finalized to the turn's `contextSize` on + // seal, or `undefined` when unknown — the status bar then shows + // "— tokens", never 0%. contextSize?: number | undefined; /** Per-model context window (max tokens) from `GET /models` modelInfo. */ contextWindow?: number | undefined; @@ -66,7 +68,6 @@ const canSend = $derived(hasText || hasImages); const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); const usage = $derived(computeContextUsage(contextSize, effectiveMax)); - const hasUsage = $derived(contextSize !== undefined); // One button, three modes: // - idle → "Send" (starts a turn via chat.send) @@ -395,7 +396,7 @@ {/if} <span class="shrink-0 whitespace-nowrap font-mono"> - {#if hasUsage} + {#if usage.current !== null} {formatCompactTokens(usage.current)}{#if usage.max !== null}<span class="text-base-content/40" > |
