diff options
| author | Adam Malczewski <[email protected]> | 2026-10-22 15:48:50 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-22 15:48:50 +0900 |
| commit | 53686ae6446c5c59928244f65c08150fb564b375 (patch) | |
| tree | 1b033bf09d30872c04f098a3c016e09481a7209b /src/core/chunks/reducer.test.ts | |
| parent | 511d415b4ec07d435ea73ab8e5421949392d922d (diff) | |
| download | dispatch-web-53686ae6446c5c59928244f65c08150fb564b375.tar.gz dispatch-web-53686ae6446c5c59928244f65c08150fb564b375.zip | |
fix(chunks): keep tool calls visible when opening an in-flight chat
Opening (or switching back to) a conversation with a running turn showed a
tail of "Thoughts" blocks with no tool cards. The late-join replay re-delivers
the whole in-flight turn as provisional chunks; committedOverlap hides the ones
already in committed history by aligning both runs chunk-by-chunk. When that
alignment failed, the whole replay rendered as a duplicate, and because tool
cards de-dup by toolCallId onto the first (committed) occurrence, the duplicate
tail showed only thinking/text.
The alignment broke in four ways, all reproduced against a live server:
- Claude returns several thinking blocks per step (one signature each).
History persists them separately, but reasoning-deltas carry no block
boundary, so live/replay merges them. Now one provisional text/thinking
chunk matches a run of committed chunks whose texts concatenate to it.
- Images persisted mid-turn (tool attachments) have no live event. The walk
now steps over them.
- The chat-limit window/trim unloads the committed turn's head, so the
committed run is a suffix while the replay starts at turn-start. Alignment
now re-anchors (committed head, else the run's first tool chunk; text
anchors must verify the whole overlap) and hides replayed chunks whose
committed twins were unloaded.
- Re-subscribing (tab switch / reconnect) replays the turn on top of the
existing provisional copy. A turn-start for a turn that already has
provisional content now replaces the stale copy.
trimTranscript also counted those invisible duplicates toward the chat
limit, unloading real history (incl. the turn head) to make room for them;
it now counts rendered chunks (selectRenderedCount).
svelte-check 0 errors · biome clean · 2488 tests pass · build OK
Diffstat (limited to 'src/core/chunks/reducer.test.ts')
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 49 |
1 files changed, 49 insertions, 0 deletions
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index 22b94d4..4996e88 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -1085,3 +1085,52 @@ describe("foldEvent — authoritative turn terminal", () => { expect(before.accumulating?.text).toBe("partial"); }); }); + +describe("foldEvent — re-replay of an in-flight turn (re-subscribe after tab switch / reconnect)", () => { + const userMessage = (turnId: string, text: string): TurnInputEvent => ({ + type: "user-message", + conversationId: "c1", + turnId, + text, + }); + const replay = (upTo: number) => + [ + userMessage("t1", "go"), + turnStart("t1"), + reasoningDelta("t1", "plan"), + toolCall("t1", "tc1", "read", {}), + toolResult("t1", "tc1", "read", "out"), + { type: "steering", conversationId: "c1", turnId: "t1", text: "also this" } as const, + reasoningDelta("t1", "more"), + toolCall("t1", "tc2", "read", {}), + ].slice(0, upTo); + + it("replaces the stale copy instead of appending a duplicate turn", () => { + let s = replay(7).reduce(foldEvent, initialState()); // first view, mid-thinking + s = replay(8).reduce(foldEvent, s); // came back: the whole turn replays again + const once = replay(8).reduce(foldEvent, initialState()); + expect(selectChunks(s)).toEqual(selectChunks(once)); + expect(s.accumulating).toBeNull(); + }); + + it("drops a stale steering bubble that ended the old copy", () => { + let s = replay(6).reduce(foldEvent, initialState()); // old copy ends in steering + s = replay(8).reduce(foldEvent, s); + expect(selectChunks(s).filter((c) => c.role === "user")).toHaveLength(2); // opening + steer + }); + + it("keeps other turns' provisional chunks", () => { + let s = [turnStart("t0"), textDelta("t0", "earlier"), doneEvent("t0")].reduce( + foldEvent, + initialState(), + ); + s = replay(4).reduce(foldEvent, s); + s = replay(4).reduce(foldEvent, s); + expect(selectChunks(s).map((c) => c.chunk.type)).toEqual([ + "text", + "text", + "thinking", + "tool-call", + ]); + }); +}); |
