summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/chunks')
-rw-r--r--src/core/chunks/reducer.test.ts73
-rw-r--r--src/core/chunks/reducer.ts27
-rw-r--r--src/core/chunks/selectors.test.ts67
3 files changed, 167 insertions, 0 deletions
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts
index 4996e88..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,
@@ -278,6 +285,72 @@ describe("foldEvent — reasoning-delta", () => {
});
});
+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();
+ });
+});
+
describe("foldEvent — tool-call then tool-result", () => {
it("tool-call then tool-result render in order", () => {
let s = initialState();
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index 35bb1bb..71df4dd 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -284,6 +284,8 @@ export function applyHistory(
* optimistic echo) so a watcher renders it mid-turn.
* - `text-delta` extends the current accumulating TextChunk (or starts one).
* - `reasoning-delta` extends the current accumulating ThinkingChunk (or starts one).
+ * - `reasoning-end` closes the open ThinkingChunk as its own chunk; with none open
+ * (a redacted/empty block) it appends an empty thinking chunk, mirroring history.
* - `tool-call` / `tool-result` / `error` finalize any accumulating chunk and
* add a new provisional chunk.
* - `steering` appends a user bubble mid-turn (drained from the message queue
@@ -415,6 +417,30 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
};
}
+ case "reasoning-end": {
+ const acc = state.accumulating;
+ // Close the open thinking block of this turn: flush it as its own provisional chunk.
+ if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) {
+ return {
+ ...state,
+ provisional: flushAccumulating(state.provisional, acc),
+ accumulating: null,
+ generating: true,
+ };
+ }
+ // No open thinking (a redacted/empty block with no deltas): history persists an
+ // EMPTY thinking chunk for it, so mirror that 1:1.
+ return {
+ ...state,
+ provisional: [
+ ...flushAccumulating(state.provisional, acc),
+ { role: "assistant", chunk: { type: "thinking", text: "" }, turnId: event.turnId },
+ ],
+ accumulating: null,
+ generating: true,
+ };
+ }
+
case "tool-call": {
const provisional = flushAccumulating(state.provisional, state.accumulating);
const chunk: Chunk = {
@@ -568,6 +594,7 @@ const RETRY_CLEARING_EVENTS: ReadonlySet<AgentEvent["type"]> = new Set([
"turn-start",
"text-delta",
"reasoning-delta",
+ "reasoning-end",
"tool-call",
"tool-result",
"error",
diff --git a/src/core/chunks/selectors.test.ts b/src/core/chunks/selectors.test.ts
index 3afc324..90bb5cc 100644
--- a/src/core/chunks/selectors.test.ts
+++ b/src/core/chunks/selectors.test.ts
@@ -20,6 +20,7 @@ const think = (delta: string): AgentEvent => ({
turnId: T,
delta,
});
+const thinkEnd: AgentEvent = { type: "reasoning-end", conversationId: C, turnId: T };
const text = (delta: string): AgentEvent => ({
type: "text-delta",
conversationId: C,
@@ -299,6 +300,72 @@ describe("selectChunks — de-dup of a late-join replay against committed histor
expect(provisional.map((r) => r.chunk)).toMatchObject([{ type: "tool-call", toolCallId: "b" }]);
});
+ it("aligns a boundary-carrying replay 1:1 against split committed blocks", () => {
+ const split: StoredChunk[] = [
+ committedStep0[0] as StoredChunk,
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T },
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 4 },
+ { ...(committedStep0[3] as StoredChunk), seq: 5 },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [
+ userMsg,
+ turnStart,
+ think("first. "),
+ thinkEnd,
+ think("second."),
+ thinkEnd,
+ call("a"),
+ result("a"),
+ text("next"),
+ ]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "thinking"],
+ [4, "tool-call"],
+ [5, "tool-result"],
+ [null, "text"],
+ ]);
+ expect(rendered[5]?.streaming).toBe(true);
+ });
+
+ it("aligns a redacted empty block with a bare reasoning-end", () => {
+ const split: StoredChunk[] = [
+ committedStep0[0] as StoredChunk,
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T },
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "" }, turnId: T },
+ { seq: 4, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 5 },
+ { ...(committedStep0[3] as StoredChunk), seq: 6 },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [
+ userMsg,
+ turnStart,
+ think("first. "),
+ thinkEnd,
+ thinkEnd,
+ think("second."),
+ thinkEnd,
+ call("a"),
+ result("a"),
+ text("next"),
+ ]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "thinking"],
+ [4, "thinking"],
+ [5, "tool-call"],
+ [6, "tool-result"],
+ [null, "text"],
+ ]);
+ });
+
it("is the identity when there is no committed history", () => {
const s = fold(initialState(), [userMsg, turnStart, text("hello")]);
expect(selectChunks(s)).toHaveLength(2);