diff options
Diffstat (limited to 'src/core/chunks')
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 20 | ||||
| -rw-r--r-- | src/core/chunks/reducer.ts | 22 |
2 files changed, 42 insertions, 0 deletions
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index a346545..883461e 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -551,6 +551,26 @@ describe("applyHistory", () => { expect(s.committed).toHaveLength(2); expect(s.committed.map((c) => c.seq)).toEqual([1, 2]); }); + + it("removes provisional duplicate when committed user message arrives during generation", () => { + // Simulate: send() appends provisional user message, then syncTail + // fetches the same message as committed (CR-6: persisted at turn start). + let s = initialState(); + s = appendUserMessage(s, "hello"); + s = foldEvent(s, turnStart("t1")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.role).toBe("user"); + expect(s.generating).toBe(true); + + // syncTail fetches the persisted user message as committed + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "hello" })]); + + // The provisional duplicate is removed — no double render + expect(s.provisional).toEqual([]); + expect(s.committed).toHaveLength(1); + expect(s.committed[0]?.role).toBe("user"); + expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hello" }); + }); }); describe("selectChunks", () => { diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 035846c..0783c22 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -54,8 +54,10 @@ export function applyHistory( ): TranscriptState { const seqMap = new Map<number, StoredChunk>(); for (const c of state.committed) seqMap.set(c.seq, c); + let addedNew = false; for (const c of chunks) { if (c.seq < state.hiddenBeforeSeq) continue; + if (!seqMap.has(c.seq)) addedNew = true; seqMap.set(c.seq, c); } const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq); @@ -70,6 +72,26 @@ export function applyHistory( }; } + // During generation: if new committed chunks arrived, the provisional + // array may contain duplicates — the optimistic echo from `appendUserMessage` + // is now backed by a committed chunk (CR-6: user message persisted at turn + // start). Remove provisional chunks that match the last committed chunk + // (role + chunk content), keeping only the accumulating (streaming) chunk. + if (addedNew && state.generating && state.provisional.length > 0) { + const lastCommitted = committed[committed.length - 1]; + if (lastCommitted !== undefined) { + const provisional = state.provisional.filter((p) => { + if (p.role !== lastCommitted.role) return true; + if (p.chunk.type !== lastCommitted.chunk.type) return true; + if (p.chunk.type === "text" && lastCommitted.chunk.type === "text") { + return p.chunk.text !== lastCommitted.chunk.text; + } + return true; + }); + return { ...state, committed, provisional, accumulating: state.accumulating }; + } + } + return { ...state, committed }; } |
