diff options
| author | Adam Malczewski <[email protected]> | 2026-10-10 00:06:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-10 00:06:31 +0900 |
| commit | 9f2dcfaab421fa84837685a0b65678acf03d9ded (patch) | |
| tree | a1ef40fbbf98b136a471889c5b02795b739753be /src/features | |
| parent | 591e2dbe5cbf2283d58debdf92943a075edc4569 (diff) | |
| download | dispatch-web-9f2dcfaab421fa84837685a0b65678acf03d9ded.tar.gz dispatch-web-9f2dcfaab421fa84837685a0b65678acf03d9ded.zip | |
fix(turn-metrics): exact per-turn placement via turnId, decode-only TPS, step-level retention
Placement
- Chunks carry their producing turn: StoredChunk.turnId (backend [email protected]) for
committed rows (persisted in the IDB cache; old cached rows are upgraded on
sync) and the live event's turnId for provisional ones (the optimistic echo is
tagged on turn-start).
- interleaveTurnMetrics attributes every group to its turn (turnId, else the
turn owning its stepId) and puts the turn total after the turn's LAST visible
group. No user-message counting: sending, streaming, steering, images, errored
turns and chat-limit unloading can no longer shift metrics between turns — at
any point during a turn, not just after it ends. A turn cut in half by the
chat limit keeps its total under its visible tail.
- Legacy rows (no turnId) fall back to an order-preserving, count-guarded match
that leaves a turn blank rather than show another turn's numbers.
- A turn that errors re-syncs metrics (the backend now persists them).
TPS
- Step TPS = outputTokens / decodeMs only (no genTotalMs fallback — it includes
time-to-first-token). Turn TPS sums tokens and decode time over timed steps
only. Tool execution is excluded by the backend.
Display
- Retention = this turn's first-step cache reads / previous turn's last-step
prompt (step-level; turn sums made it pin at 100%).
- Turn row shows output tokens + input processed (with cached), instead of a
misleading 'total tok' that summed every step's full prompt.
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/chat/store.svelte.ts | 4 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 17 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 8 | ||||
| -rw-r--r-- | src/features/conversation-cache/logic.test.ts | 23 | ||||
| -rw-r--r-- | src/features/conversation-cache/logic.ts | 22 |
5 files changed, 54 insertions, 20 deletions
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index c83c5fd..45f1ed1 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -342,6 +342,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { if (transcript.sealedTurnId !== null) { void syncTail(); void syncMetrics(); + } else if (msg.event.type === "error") { + // A turn that FAILS never seals, but the backend still persists the + // metrics it accumulated — pull them so the turn gets its row. + void syncMetrics(); } }, diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 9568813..bea5e77 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -373,8 +373,8 @@ describe("ChatView", () => { expect(screen.getByText("Hi")).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/150 tok/)).toHaveLength(2); - expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 50 tok out · 100 in/)).toBeInTheDocument(); expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); }); @@ -478,11 +478,11 @@ describe("ChatView", () => { // Both step-metrics and turn-metrics render expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 20 tok out/)).toBeInTheDocument(); // They are in separate elements (different rows) - const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); - const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); + const stepEl = screen.getByText(/step 1 · 80 in \/ 20 out/).closest("div"); + const turnEl = screen.getByText(/turn 1 · 20 tok out/).closest("div"); expect(stepEl).not.toBe(turnEl); }); @@ -554,9 +554,9 @@ describe("ChatView", () => { // Step metrics rendered expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/15 tok/)).toHaveLength(2); + expect(screen.getByText(/step 1 · 10 in \/ 5 out/)).toBeInTheDocument(); // Turn metrics rendered - expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 5 tok out · 10 in/)).toBeInTheDocument(); // No "null" or "undefined" in the DOM expect(screen.queryByText("null")).toBeNull(); expect(screen.queryByText("undefined")).toBeNull(); @@ -602,8 +602,7 @@ describe("ChatView", () => { render(ChatView, { props: { chunks, turnMetrics } }); // Step metrics should render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/150 tok/)).toBeInTheDocument(); + expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument(); // Turn total should NOT render (total is null — turn still in progress) expect(screen.queryByText(/^turn/)).toBeNull(); diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index 6805b20..182b3d6 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -254,8 +254,8 @@ {sv.label} · {sv.tokensLabel} {#if sv.tps} · {sv.tps}{/if} - {#if sv.genTotal} - · {sv.genTotal}{/if} + {#if sv.ttft} + · {sv.ttft} to first token{/if} </div> </div> </div> @@ -263,12 +263,12 @@ {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} - {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} + {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-col gap-1 text-xs"> <div class="opacity-70"> - {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) + {turnView.label} · {turnView.tokensLabel} · {turnView.breakdown} {#if turnView.tps} · {turnView.tps}{/if} {#if turnView.duration} diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts index 880c32e..7a31783 100644 --- a/src/features/conversation-cache/logic.test.ts +++ b/src/features/conversation-cache/logic.test.ts @@ -138,3 +138,26 @@ describe("selectEvictions", () => { expect(result).toEqual(["b"]); }); }); + +describe("reconcileCache — turnId provenance upgrade", () => { + it("upgrades a cached row lacking turnId when the incoming copy carries it", () => { + const cached: StoredChunk[] = [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }]; + const incoming: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "yo" }, turnId: "t1" }, + ]; + const { merged, toAppend } = reconcileCache(cached, incoming); + expect(merged.map((c) => c.turnId)).toEqual(["t1", "t1"]); + expect(toAppend.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("does not rewrite a cached row that already has a turnId", () => { + const cached: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + ]; + const { toAppend } = reconcileCache(cached, [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + ]); + expect(toAppend).toEqual([]); + }); +}); diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts index 3cb23e2..344a061 100644 --- a/src/features/conversation-cache/logic.ts +++ b/src/features/conversation-cache/logic.ts @@ -5,27 +5,35 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types"; * Merge authoritative seq-keyed chunks with cached chunks. * * Deduplicates by `seq`, produces seq-monotonic order. - * `toAppend` = the incoming chunks whose `seq` is not already in `cached` - * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`. + * `toAppend` = the incoming chunks whose `seq` is not already in `cached`, + * plus cached rows UPGRADED with a `turnId` the incoming copy carries and the + * cached copy lacks (exactly what to persist — the store upserts by seq). Idempotent; tolerant of out-of-order/overlapping `incoming`. */ export function reconcileCache( cached: readonly StoredChunk[], incoming: readonly StoredChunk[], ): ReconcileResult { - const seen = new Set<number>(); + const bySeq = new Map<number, StoredChunk>(); for (const chunk of cached) { - seen.add(chunk.seq); + bySeq.set(chunk.seq, chunk); } const toAppend: StoredChunk[] = []; for (const chunk of incoming) { - if (!seen.has(chunk.seq)) { + const have = bySeq.get(chunk.seq); + if (have === undefined) { toAppend.push(chunk); - seen.add(chunk.seq); + bySeq.set(chunk.seq, chunk); + } else if (have.turnId === undefined && chunk.turnId !== undefined) { + // Upgrade a cached row written before the server stamped `turnId` + // (seqs are immutable, so only this provenance field can differ). + const upgraded = { ...have, turnId: chunk.turnId }; + toAppend.push(upgraded); + bySeq.set(chunk.seq, upgraded); } } - const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); + const merged = [...bySeq.values()].sort((a, b) => a.seq - b.seq); return { merged, toAppend }; } |
