diff options
Diffstat (limited to 'src/core/chunks/reducer.test.ts')
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 201 |
1 files changed, 193 insertions, 8 deletions
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index 058552e..10d662e 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -7,6 +7,7 @@ import type { TurnInputEvent, TurnProviderRetryEvent, TurnReasoningDeltaEvent, + TurnReasoningEndEvent, TurnSealedEvent, TurnStartEvent, TurnSteeringEvent, @@ -45,6 +46,12 @@ const reasoningDelta = (turnId: string, delta: string): TurnReasoningDeltaEvent delta, }); +const reasoningEnd = (turnId: string): TurnReasoningEndEvent => ({ + type: "reasoning-end", + conversationId: "c1", + turnId, +}); + const toolCall = ( turnId: string, toolCallId: string, @@ -226,7 +233,7 @@ describe("clearGenerating", () => { s = foldEvent(s, textDelta("t1", "partial")); const cleared = clearGenerating(s); expect(cleared.generating).toBe(false); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(cleared.currentTurnId).toBe("t1"); }); }); @@ -236,7 +243,7 @@ describe("foldEvent — text-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -245,7 +252,7 @@ describe("foldEvent — text-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello ")); s = foldEvent(s, textDelta("t1", "world")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello world" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello world", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -254,7 +261,7 @@ describe("foldEvent — text-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "thinking...")); s = foldEvent(s, textDelta("t1", "answer")); - expect(s.accumulating).toEqual({ kind: "text", text: "answer" }); + expect(s.accumulating).toEqual({ kind: "text", text: "answer", turnId: "t1" }); expect(s.provisional).toHaveLength(1); expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); expect(s.provisional[0]?.role).toBe("assistant"); @@ -266,7 +273,7 @@ describe("foldEvent — reasoning-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm", turnId: "t1" }); }); it("successive reasoning-deltas extend the same chunk", () => { @@ -274,7 +281,73 @@ describe("foldEvent — reasoning-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm ")); s = foldEvent(s, reasoningDelta("t1", "ok")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok", turnId: "t1" }); + }); +}); + +describe("foldEvent — reasoning-end (thinking block boundary)", () => { + it("closes each block so consecutive deltas become separate chunks", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "a")); + s = foldEvent(s, reasoningEnd("t1")); + s = foldEvent(s, reasoningDelta("t1", "b")); + s = foldEvent(s, reasoningEnd("t1")); + expect(s.provisional.map((p) => p.chunk)).toEqual([ + { type: "thinking", text: "a" }, + { type: "thinking", text: "b" }, + ]); + expect(s.accumulating).toBeNull(); + }); + + it("still merges legacy deltas with no boundary (no reasoning-end)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "a")); + s = foldEvent(s, reasoningDelta("t1", "b")); + expect(s.accumulating).toEqual({ kind: "thinking", text: "ab", turnId: "t1" }); + }); + + it("end with nothing open appends an empty thinking chunk for the turn", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningEnd("t1")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "" }); + expect(s.provisional[0]?.turnId).toBe("t1"); + expect(s.accumulating).toBeNull(); + }); + + it("text-delta then end flushes the text, then appends an empty thinking chunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "x")); + s = foldEvent(s, reasoningEnd("t1")); + expect(s.provisional.map((p) => p.chunk)).toEqual([ + { type: "text", text: "x" }, + { type: "thinking", text: "" }, + ]); + }); + + it("end for another turn flushes the open thinking and appends an empty one", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "open")); + s = foldEvent(s, reasoningEnd("t2")); + expect(s.provisional.map((p) => [p.turnId, p.chunk])).toEqual([ + ["t1", { type: "thinking", text: "open" }], + ["t2", { type: "thinking", text: "" }], + ]); + expect(s.accumulating).toBeNull(); + }); + + it("clears a pending provider-retry banner", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, reasoningEnd("t1")); + expect(selectProviderRetry(s)).toBeNull(); }); }); @@ -531,7 +604,7 @@ describe("clearGenerating also clears a stale provider-retry banner (reconnect)" s = foldEvent(s, textDelta("t1", "partial")); s = foldEvent(s, providerRetry("t1", 0, 5000)); const cleared = clearGenerating(s); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(selectProviderRetry(cleared)).toBeNull(); }); }); @@ -859,7 +932,7 @@ describe("appendUserMessage", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "partial")); - expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(s.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); s = appendUserMessage(s, "user msg"); expect(s.accumulating).toBeNull(); @@ -1022,3 +1095,115 @@ describe("applyHistory — multi-chunk image echo is superseded by committed", ( expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); }); }); + +describe("turnId provenance on live chunks", () => { + it("tags every live chunk with its event's turnId, and the optimistic echo on turn-start", () => { + let s = initialState(); + s = appendUserMessage(s, "q", [{ url: "data:image/png;base64,AA", mimeType: "image/png" }]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual([undefined, undefined]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a")); + const chunks = selectChunks(s); + expect(chunks.map((c) => c.turnId)).toEqual(["t1", "t1", "t1"]); + }); + + it("turn-start does not re-tag an echo already owned by an earlier turn", () => { + let s = initialState(); + s = appendUserMessage(s, "q1"); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a1")); + s = foldEvent(s, turnStart("t2")); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t1", "t1"]); + }); + + it("committed chunks expose StoredChunk.turnId", () => { + const s = applyHistory(initialState(), [ + { seq: 1, role: "user", chunk: { type: "text", text: "q" }, turnId: "t9" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "a" } }, + ]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t9", undefined]); + }); +}); + +describe("foldEvent — additive wire compatibility", () => { + it.each(["future-event"])("preserves transcript identity for %s", (type) => { + const state = foldEvent(foldEvent(initialState(), turnStart("t1")), textDelta("t1", "partial")); + const before = structuredClone(state); + const event = { type, conversationId: "c1", turnId: "t1", reason: "sealed" }; + expect(foldEvent(state, event as Parameters<typeof foldEvent>[1])).toBe(state); + expect(state).toEqual(before); + }); +}); + +describe("foldEvent — authoritative turn terminal", () => { + it.each([ + "sealed", + "aborted", + "error", + "rejected", + ] as const)("ends generation on %s", (reason) => { + const before = foldEvent( + foldEvent(initialState(), turnStart("t1")), + textDelta("t1", "partial"), + ); + const next = foldEvent(before, { + type: "turn-ended", + conversationId: "c1", + turnId: "t1", + reason, + }); + expect(next.generating).toBe(false); + expect(next.accumulating).toBeNull(); + expect(selectChunks(next).map((c) => c.chunk)).toEqual([{ type: "text", text: "partial" }]); + 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", + ]); + }); +}); |
