summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/core/chunks/reducer.test.ts80
-rw-r--r--src/core/chunks/reducer.ts11
-rw-r--r--src/features/chat/store.svelte.ts5
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();
}
},