From 9f2dcfaab421fa84837685a0b65678acf03d9ded Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 10 Oct 2026 00:06:31 +0900 Subject: fix(turn-metrics): exact per-turn placement via turnId, decode-only TPS, step-level retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placement - Chunks carry their producing turn: StoredChunk.turnId (backend wire@0.13) 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. --- src/core/chunks/reducer.test.ts | 45 +++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) (limited to 'src/core/chunks/reducer.test.ts') diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index 058552e..cba6fad 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -226,7 +226,7 @@ describe("clearGenerating", () => { s = foldEvent(s, textDelta("t1", "partial")); const cleared = clearGenerating(s); expect(cleared.generating).toBe(false); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(cleared.currentTurnId).toBe("t1"); }); }); @@ -236,7 +236,7 @@ describe("foldEvent — text-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -245,7 +245,7 @@ describe("foldEvent — text-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello ")); s = foldEvent(s, textDelta("t1", "world")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello world" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello world", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -254,7 +254,7 @@ describe("foldEvent — text-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "thinking...")); s = foldEvent(s, textDelta("t1", "answer")); - expect(s.accumulating).toEqual({ kind: "text", text: "answer" }); + expect(s.accumulating).toEqual({ kind: "text", text: "answer", turnId: "t1" }); expect(s.provisional).toHaveLength(1); expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); expect(s.provisional[0]?.role).toBe("assistant"); @@ -266,7 +266,7 @@ describe("foldEvent — reasoning-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm", turnId: "t1" }); }); it("successive reasoning-deltas extend the same chunk", () => { @@ -274,7 +274,7 @@ describe("foldEvent — reasoning-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm ")); s = foldEvent(s, reasoningDelta("t1", "ok")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok", turnId: "t1" }); }); }); @@ -531,7 +531,7 @@ describe("clearGenerating also clears a stale provider-retry banner (reconnect)" s = foldEvent(s, textDelta("t1", "partial")); s = foldEvent(s, providerRetry("t1", 0, 5000)); const cleared = clearGenerating(s); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(selectProviderRetry(cleared)).toBeNull(); }); }); @@ -859,7 +859,7 @@ describe("appendUserMessage", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "partial")); - expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(s.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); s = appendUserMessage(s, "user msg"); expect(s.accumulating).toBeNull(); @@ -1022,3 +1022,32 @@ describe("applyHistory — multi-chunk image echo is superseded by committed", ( expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); }); }); + +describe("turnId provenance on live chunks", () => { + it("tags every live chunk with its event's turnId, and the optimistic echo on turn-start", () => { + let s = initialState(); + s = appendUserMessage(s, "q", [{ url: "data:image/png;base64,AA", mimeType: "image/png" }]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual([undefined, undefined]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a")); + const chunks = selectChunks(s); + expect(chunks.map((c) => c.turnId)).toEqual(["t1", "t1", "t1"]); + }); + + it("turn-start does not re-tag an echo already owned by an earlier turn", () => { + let s = initialState(); + s = appendUserMessage(s, "q1"); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a1")); + s = foldEvent(s, turnStart("t2")); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t1", "t1"]); + }); + + it("committed chunks expose StoredChunk.turnId", () => { + const s = applyHistory(initialState(), [ + { seq: 1, role: "user", chunk: { type: "text", text: "q" }, turnId: "t9" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "a" } }, + ]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t9", undefined]); + }); +}); -- cgit v1.2.3