diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 80 | ||||
| -rw-r--r-- | src/core/chunks/reducer.ts | 11 | ||||
| -rw-r--r-- | src/features/chat/store.svelte.ts | 5 |
3 files changed, 96 insertions, 0 deletions
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index a346545..c479488 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -551,6 +551,86 @@ describe("applyHistory", () => { expect(s.committed).toHaveLength(2); expect(s.committed.map((c) => c.seq)).toEqual([1, 2]); }); + + it("clears provisional when new committed chunks arrive during generation (CR-6)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello")); + s = foldEvent(s, toolCall("t1", "tc1", "run_shell", {})); + s = foldEvent(s, toolResult("t1", "tc1", "run_shell", "output")); + expect(s.generating).toBe(true); + expect(s.provisional.length).toBeGreaterThan(0); + + s = applyHistory(s, [ + storedChunk(1, "assistant", { type: "text", text: "hello" }), + storedChunk(2, "assistant", { + type: "tool-call", + toolCallId: "tc1", + toolName: "run_shell", + input: {}, + stepId: "s0" as StepId, + }), + storedChunk(3, "tool", { + type: "tool-result", + toolCallId: "tc1", + toolName: "run_shell", + content: "output", + isError: false, + stepId: "s0" as StepId, + }), + ]); + + expect(s.provisional).toEqual([]); + expect(s.generating).toBe(true); + expect(s.committed).toHaveLength(3); + }); + + it("keeps provisional when no new committed chunks arrive during generation", () => { + let s = initialState(); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello")); + s = foldEvent(s, toolCall("t1", "tc1", "run_shell", {})); + // At this point: provisional has [text "hello", tool-call] + expect(s.provisional.length).toBeGreaterThan(0); + + // applyHistory with chunks already in committed — no new additions + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + + expect(s.provisional.length).toBeGreaterThan(0); + }); + + it("keeps accumulating when clearing provisional during generation (CR-6)", () => { + let s = initialState(); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, toolCall("t1", "tc1", "run_shell", {})); + s = foldEvent(s, toolResult("t1", "tc1", "run_shell", "output")); + // Start accumulating text for the NEXT step + s = foldEvent(s, textDelta("t1", "streaming...")); + expect(s.accumulating).not.toBeNull(); + + s = applyHistory(s, [ + storedChunk(2, "assistant", { + type: "tool-call", + toolCallId: "tc1", + toolName: "run_shell", + input: {}, + stepId: "s0" as StepId, + }), + storedChunk(3, "tool", { + type: "tool-result", + toolCallId: "tc1", + toolName: "run_shell", + content: "output", + isError: false, + stepId: "s0" as StepId, + }), + ]); + + expect(s.provisional).toEqual([]); + expect(s.accumulating).not.toBeNull(); + }); }); describe("selectChunks", () => { diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 035846c..37f0164 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,15 @@ export function applyHistory( }; } + // During generation: if new committed chunks arrived (CR-6 — backend + // persists at step boundaries), clear provisional chunks. They're + // duplicates — the same content was folded from live events but is now + // persisted with seq. Keep the accumulating chunk (current in-progress + // step, not yet persisted). + if (addedNew && state.generating) { + return { ...state, committed, provisional: [], accumulating: state.accumulating }; + } + return { ...state, committed }; } diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 9beabfc..f6c23fc 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -284,6 +284,11 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { if (transcript.sealedTurnId !== null) { void syncTail(); void syncMetrics(); + } else if (msg.event.type === "step-complete") { + // CR-6: backend persists chunks at step boundaries. Fetch them + // as committed so trimTranscript can unload oldest chunks + // uniformly — no unbounded provisional growth during long turns. + void syncTail(); } }, |
