summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend-handoff.md22
-rw-r--r--src/core/chunks/reducer.test.ts80
-rw-r--r--src/core/chunks/reducer.ts11
-rw-r--r--src/core/chunks/trim.test.ts9
-rw-r--r--src/core/chunks/trim.ts32
-rw-r--r--src/features/chat/store.svelte.ts5
-rw-r--r--src/features/chat/store.test.ts8
7 files changed, 49 insertions, 118 deletions
diff --git a/backend-handoff.md b/backend-handoff.md
index 4b3a8d6..8e86ce4 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -64,17 +64,17 @@ The backend now persists chunks **incrementally at step boundaries** during gene
FE's existing `syncTail` already polls this — it will find new chunks as each step completes. No
wire/transport-contract change needed (`StoredChunk` already has `seq`; `AgentEvent` types unchanged).
-**FE adoption plan (option (c) from the CR):**
-- Fold events for the **current in-progress step** only (streaming text, thinking dots) — provisional state shrinks to one step's worth of chunks, never a trim concern.
-- `syncTail` for **sealed steps** — picks up committed chunks incrementally as each step completes.
-- `trimTranscript` drops oldest committed chunks uniformly — no special provisional case.
-- `turn-sealed` becomes a "refresh" signal — all chunks already committed.
-- "Show earlier" works uniformly for all turns (including in-flight — sealed steps have seq).
-- Remove `hiddenThinkingCount` (no provisional thinking blocks to track) and the `sealedTurnId` flush.
-
-**Not yet implemented.** The FE currently still uses the provisional/committed split. The interim
-fix (frontend-only cache for dropped provisional chunks) was not built — CR-6 resolution makes it
-unnecessary. Adoption is the next FE task when prioritized.
+**FE adoption: NOT pursuing syncTail-during-generation.** Investigation revealed
+the kernel emits `step-complete` (line 360 of `run-turn.ts`) BEFORE calling
+`onStepComplete` (line 542) — the step's chunks are persisted only AFTER tool
+results come back, not when `step-complete` fires. So `syncTail` triggered by
+`step-complete` finds nothing. Moving the emission after `onStepComplete` would
+be a kernel change.
+
+Instead, the FE now trims provisional chunks directly in `trimTranscript` when
+committed is exhausted — no `syncTail` needed. Dropped provisional chunks are
+lost temporarily (no "Show earlier" for them) but come back as committed when
+the turn seals and `syncTail` fetches everything.
---
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts
index c479488..a346545 100644
--- a/src/core/chunks/reducer.test.ts
+++ b/src/core/chunks/reducer.test.ts
@@ -551,86 +551,6 @@ 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 37f0164..035846c 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -54,10 +54,8 @@ 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);
@@ -72,15 +70,6 @@ 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/core/chunks/trim.test.ts b/src/core/chunks/trim.test.ts
index 7914f35..aa4b0e3 100644
--- a/src/core/chunks/trim.test.ts
+++ b/src/core/chunks/trim.test.ts
@@ -86,7 +86,7 @@ describe("trimTranscript", () => {
expect(next.hiddenBeforeSeq).toBe(51);
});
- it("counts provisional + accumulating toward the limit but never drops them", () => {
+ it("counts provisional + accumulating toward the limit (drops committed first)", () => {
const base = stateWith(chunks(1, 98));
const state: TranscriptState = {
...base,
@@ -103,17 +103,18 @@ describe("trimTranscript", () => {
expect(next.accumulating).not.toBeNull();
});
- it("caps the drop at the committed length", () => {
+ it("drops oldest provisional when committed is exhausted", () => {
const base = stateWith(chunks(1, 2));
const provisional = Array.from({ length: 20 }, (_, i) => ({
role: "assistant" as const,
chunk: { type: "text" as const, text: `p${i}` },
}));
const state: TranscriptState = { ...base, provisional };
+ // 2 + 20 = 22 > 10. quarter = 3. Drop 2 committed, then drop
+ // ceil((20-10)/3)*3 = 12 provisional → 8 remain.
const next = trimTranscript(state, 10);
expect(next.committed).toHaveLength(0);
- expect(next.provisional).toHaveLength(20);
- // Watermark advances past the last dropped committed chunk.
+ expect(next.provisional).toHaveLength(8);
expect(next.hiddenBeforeSeq).toBe(3);
});
diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts
index 94065b3..7791721 100644
--- a/src/core/chunks/trim.ts
+++ b/src/core/chunks/trim.ts
@@ -84,7 +84,9 @@ function dropOldest(state: TranscriptState, drop: number): TranscriptState {
* quarters (`unloadCount(limit)` each) of the OLDEST committed chunks until back
* at/under the limit — normally exactly one quarter (limit 100: 101 → 76); more
* only when trimming was deferred (e.g. while the reader was scrolled up).
- * At/under the limit this is the identity. Never drops provisional chunks.
+ * At/under the limit this is the identity. When committed chunks are
+ * exhausted, also drops the oldest provisional chunks (the in-flight turn)
+ * to keep the browser responsive during very long turns.
*/
export function trimTranscript(state: TranscriptState, limit: number): TranscriptState {
if (!Number.isFinite(limit) || limit <= 0) return state;
@@ -92,9 +94,31 @@ export function trimTranscript(state: TranscriptState, limit: number): Transcrip
if (total <= limit) return state;
const quarter = unloadCount(limit);
const passes = Math.ceil((total - limit) / quarter);
- const drop = Math.min(passes * quarter, state.committed.length);
- if (drop <= 0) return state;
- return dropOldest(state, drop);
+
+ // First, drop oldest committed chunks (the usual path).
+ const committedDrop = Math.min(passes * quarter, state.committed.length);
+ let next = committedDrop > 0 ? dropOldest(state, committedDrop) : state;
+
+ // If still over the limit and committed is exhausted, drop oldest
+ // provisional chunks (the in-flight turn). These chunks have no seq
+ // (not yet persisted) and can't be "Show earlier" — but dropping them
+ // keeps the browser responsive. They'll come back as committed when
+ // the turn seals and syncTail fetches them from the server.
+ const remaining = totalCount(next);
+ if (remaining > limit && next.provisional.length > 0) {
+ const provisionalDrop = Math.min(
+ Math.ceil((remaining - limit) / quarter) * quarter,
+ next.provisional.length,
+ );
+ if (provisionalDrop > 0) {
+ next = {
+ ...next,
+ provisional: next.provisional.slice(provisionalDrop),
+ };
+ }
+ }
+
+ return next;
}
/**
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index f6c23fc..9beabfc 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -284,11 +284,6 @@ 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();
}
},
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index 2d75139..c1d62a6 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -1057,8 +1057,9 @@ describe("createChatStore", () => {
}
expect(store.chunks).toHaveLength(15);
- // Reader returns to the bottom — but provisional chunks are never unloaded,
- // so the deferred trim still can't shrink an all-provisional transcript.
+ // Reader returns to the bottom — the deferred trim now catches up.
+ // With no committed chunks, it drops the oldest provisional chunks
+ // (the in-flight turn) to stay within the limit.
atBottom = true;
store.handleDelta(
deltaEvent({
@@ -1071,7 +1072,8 @@ describe("createChatStore", () => {
stepId: "t1#15" as StepId,
}),
);
- expect(store.chunks).toHaveLength(16);
+ // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain.
+ expect(store.chunks).toHaveLength(10);
store.dispose();
});