summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/chunks')
-rw-r--r--src/core/chunks/groups.test.ts204
-rw-r--r--src/core/chunks/groups.ts118
-rw-r--r--src/core/chunks/index.ts38
-rw-r--r--src/core/chunks/reducer.test.ts1546
-rw-r--r--src/core/chunks/reducer.ts488
-rw-r--r--src/core/chunks/retry-banner.test.ts102
-rw-r--r--src/core/chunks/retry-banner.ts38
-rw-r--r--src/core/chunks/selectors.ts72
-rw-r--r--src/core/chunks/trim.test.ts404
-rw-r--r--src/core/chunks/trim.ts160
-rw-r--r--src/core/chunks/types.ts120
11 files changed, 1645 insertions, 1645 deletions
diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts
index fbfda83..c8b0fa2 100644
--- a/src/core/chunks/groups.test.ts
+++ b/src/core/chunks/groups.test.ts
@@ -4,122 +4,122 @@ import { groupRenderedChunks } from "./groups";
import type { RenderedChunk } from "./types";
const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({
- seq,
- role,
- chunk: { type: "text", text: t },
- provisional,
+ seq,
+ role,
+ chunk: { type: "text", text: t },
+ provisional,
});
const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
- seq,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: id,
- toolName: `tool-${id}`,
- input: { id },
- ...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
- },
- provisional,
+ seq,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: id,
+ toolName: `tool-${id}`,
+ input: { id },
+ ...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
+ },
+ provisional,
});
const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
- seq,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: id,
- toolName: `tool-${id}`,
- content: `result-${id}`,
- isError: false,
- ...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
- },
- provisional,
+ seq,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: id,
+ toolName: `tool-${id}`,
+ content: `result-${id}`,
+ isError: false,
+ ...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
+ },
+ provisional,
});
describe("groupRenderedChunks", () => {
- it("returns no groups for an empty stream", () => {
- expect(groupRenderedChunks([])).toEqual([]);
- });
+ it("returns no groups for an empty stream", () => {
+ expect(groupRenderedChunks([])).toEqual([]);
+ });
- it("passes non-tool chunks through as single groups, in order", () => {
- const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]);
- expect(groups).toHaveLength(2);
- expect(groups.every((g) => g.kind === "single")).toBe(true);
- });
+ it("passes non-tool chunks through as single groups, in order", () => {
+ const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]);
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.kind === "single")).toBe(true);
+ });
- it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => {
- const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]);
- expect(groups).toHaveLength(2);
- expect(groups.map((g) => g.kind)).toEqual(["single", "single"]);
- });
+ it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => {
+ const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]);
+ expect(groups).toHaveLength(2);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "single"]);
+ });
- it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => {
- const groups = groupRenderedChunks([
- call(1, "a"),
- call(2, "b"),
- result(3, "a"),
- result(4, "b"),
- ]);
- expect(groups).toHaveLength(4);
- expect(groups.every((g) => g.kind === "single")).toBe(true);
- });
+ it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => {
+ const groups = groupRenderedChunks([
+ call(1, "a"),
+ call(2, "b"),
+ result(3, "a"),
+ result(4, "b"),
+ ]);
+ expect(groups).toHaveLength(4);
+ expect(groups.every((g) => g.kind === "single")).toBe(true);
+ });
- it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => {
- const groups = groupRenderedChunks([
- call(1, "a", "s1"),
- call(2, "b", "s1"),
- result(3, "a", "s1"),
- result(4, "b", "s1"),
- ]);
- expect(groups).toHaveLength(1);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.stepId).toBe("s1");
- expect(g.entries).toHaveLength(2);
- expect(g.entries[0]?.call.toolCallId).toBe("a");
- expect(g.entries[0]?.result?.content).toBe("result-a");
- expect(g.entries[1]?.call.toolCallId).toBe("b");
- expect(g.entries[1]?.result?.content).toBe("result-b");
- });
+ it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => {
+ const groups = groupRenderedChunks([
+ call(1, "a", "s1"),
+ call(2, "b", "s1"),
+ result(3, "a", "s1"),
+ result(4, "b", "s1"),
+ ]);
+ expect(groups).toHaveLength(1);
+ const g = groups[0];
+ if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
+ expect(g.stepId).toBe("s1");
+ expect(g.entries).toHaveLength(2);
+ expect(g.entries[0]?.call.toolCallId).toBe("a");
+ expect(g.entries[0]?.result?.content).toBe("result-a");
+ expect(g.entries[1]?.call.toolCallId).toBe("b");
+ expect(g.entries[1]?.result?.content).toBe("result-b");
+ });
- it("positions the batch at the first call and keeps surrounding chunks in order", () => {
- const groups = groupRenderedChunks([
- text(1, "assistant", "before"),
- call(2, "a", "s1"),
- call(3, "b", "s1"),
- result(4, "a", "s1"),
- result(5, "b", "s1"),
- text(6, "assistant", "after"),
- ]);
- expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]);
- });
+ it("positions the batch at the first call and keeps surrounding chunks in order", () => {
+ const groups = groupRenderedChunks([
+ text(1, "assistant", "before"),
+ call(2, "a", "s1"),
+ call(3, "b", "s1"),
+ result(4, "a", "s1"),
+ result(5, "b", "s1"),
+ text(6, "assistant", "after"),
+ ]);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]);
+ });
- it("marks the batch provisional when any of its calls/results is provisional", () => {
- const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]);
- const g = groups[0];
- if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(g.provisional).toBe(true);
- expect(g.entries).toHaveLength(2);
- expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet)
- });
+ it("marks the batch provisional when any of its calls/results is provisional", () => {
+ const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]);
+ const g = groups[0];
+ if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
+ expect(g.provisional).toBe(true);
+ expect(g.entries).toHaveLength(2);
+ expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet)
+ });
- it("batches one step while leaving a different single-call step ungrouped", () => {
- const groups = groupRenderedChunks([
- call(1, "a", "s1"),
- call(2, "b", "s1"),
- call(3, "c", "s2"),
- result(4, "a", "s1"),
- result(5, "b", "s1"),
- result(6, "c", "s2"),
- ]);
- expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]);
- const batch = groups[0];
- if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
- expect(batch.entries).toHaveLength(2);
- // the s2 single call + its result remain as separate single groups
- const singles = groups.slice(1);
- expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call");
- expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result");
- });
+ it("batches one step while leaving a different single-call step ungrouped", () => {
+ const groups = groupRenderedChunks([
+ call(1, "a", "s1"),
+ call(2, "b", "s1"),
+ call(3, "c", "s2"),
+ result(4, "a", "s1"),
+ result(5, "b", "s1"),
+ result(6, "c", "s2"),
+ ]);
+ expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]);
+ const batch = groups[0];
+ if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group");
+ expect(batch.entries).toHaveLength(2);
+ // the s2 single call + its result remain as separate single groups
+ const singles = groups.slice(1);
+ expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call");
+ expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result");
+ });
});
diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts
index 6dc7e10..53a2873 100644
--- a/src/core/chunks/groups.ts
+++ b/src/core/chunks/groups.ts
@@ -6,8 +6,8 @@ import type { RenderedChunk } from "./types";
* `result` is null while the call is still pending (no result chunk yet).
*/
export interface ToolBatchEntry {
- readonly call: ToolCallChunk;
- readonly result: ToolResultChunk | null;
+ readonly call: ToolCallChunk;
+ readonly result: ToolResultChunk | null;
}
/**
@@ -16,13 +16,13 @@ export interface ToolBatchEntry {
* rendered as one grouped unit.
*/
export type RenderGroup =
- | { readonly kind: "single"; readonly chunk: RenderedChunk }
- | {
- readonly kind: "tool-batch";
- readonly stepId: string;
- readonly entries: readonly ToolBatchEntry[];
- readonly provisional: boolean;
- };
+ | { readonly kind: "single"; readonly chunk: RenderedChunk }
+ | {
+ readonly kind: "tool-batch";
+ readonly stepId: string;
+ readonly entries: readonly ToolBatchEntry[];
+ readonly provisional: boolean;
+ };
/**
* Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId`
@@ -35,61 +35,61 @@ export type RenderGroup =
* Pure: input → output, no DOM, no Svelte.
*/
export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonly RenderGroup[] {
- // 1. Steps that batched 2+ tool calls.
- const callsPerStep = new Map<string, number>();
- for (const rc of rendered) {
- if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) {
- callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1);
- }
- }
- const batchSteps = new Set<string>();
- for (const [stepId, count] of callsPerStep) {
- if (count >= 2) batchSteps.add(stepId);
- }
+ // 1. Steps that batched 2+ tool calls.
+ const callsPerStep = new Map<string, number>();
+ for (const rc of rendered) {
+ if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) {
+ callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1);
+ }
+ }
+ const batchSteps = new Set<string>();
+ for (const [stepId, count] of callsPerStep) {
+ if (count >= 2) batchSteps.add(stepId);
+ }
- // 2. toolCallIds belonging to a batch (so their results are absorbed), and a
- // lookup of result chunks by toolCallId for pairing.
- const batchCallIds = new Set<string>();
- const resultByCallId = new Map<string, ToolResultChunk>();
- for (const rc of rendered) {
- const chunk = rc.chunk;
- if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
- batchCallIds.add(chunk.toolCallId);
- } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) {
- resultByCallId.set(chunk.toolCallId, chunk);
- }
- }
+ // 2. toolCallIds belonging to a batch (so their results are absorbed), and a
+ // lookup of result chunks by toolCallId for pairing.
+ const batchCallIds = new Set<string>();
+ const resultByCallId = new Map<string, ToolResultChunk>();
+ for (const rc of rendered) {
+ const chunk = rc.chunk;
+ if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
+ batchCallIds.add(chunk.toolCallId);
+ } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) {
+ resultByCallId.set(chunk.toolCallId, chunk);
+ }
+ }
- // 3. Emit groups in stream order; each batch lands at its first call.
- const groups: RenderGroup[] = [];
- const emittedSteps = new Set<string>();
- for (const rc of rendered) {
- const chunk = rc.chunk;
+ // 3. Emit groups in stream order; each batch lands at its first call.
+ const groups: RenderGroup[] = [];
+ const emittedSteps = new Set<string>();
+ for (const rc of rendered) {
+ const chunk = rc.chunk;
- if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
- const stepId = chunk.stepId;
- if (emittedSteps.has(stepId)) continue;
- emittedSteps.add(stepId);
+ if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) {
+ const stepId = chunk.stepId;
+ if (emittedSteps.has(stepId)) continue;
+ emittedSteps.add(stepId);
- const entries: ToolBatchEntry[] = [];
- let provisional = false;
- for (const inner of rendered) {
- if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) {
- const result = resultByCallId.get(inner.chunk.toolCallId) ?? null;
- entries.push({ call: inner.chunk, result });
- if (inner.provisional) provisional = true;
- }
- }
- groups.push({ kind: "tool-batch", stepId, entries, provisional });
- continue;
- }
+ const entries: ToolBatchEntry[] = [];
+ let provisional = false;
+ for (const inner of rendered) {
+ if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) {
+ const result = resultByCallId.get(inner.chunk.toolCallId) ?? null;
+ entries.push({ call: inner.chunk, result });
+ if (inner.provisional) provisional = true;
+ }
+ }
+ groups.push({ kind: "tool-batch", stepId, entries, provisional });
+ continue;
+ }
- if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) {
- continue; // absorbed into its batch
- }
+ if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) {
+ continue; // absorbed into its batch
+ }
- groups.push({ kind: "single", chunk: rc });
- }
+ groups.push({ kind: "single", chunk: rc });
+ }
- return groups;
+ return groups;
}
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts
index 162531d..eea2303 100644
--- a/src/core/chunks/index.ts
+++ b/src/core/chunks/index.ts
@@ -1,30 +1,30 @@
export type { RenderGroup, ToolBatchEntry } from "./groups";
export { groupRenderedChunks } from "./groups";
export {
- appendUserMessage,
- applyHistory,
- clearGenerating,
- foldEvent,
- initialState,
+ appendUserMessage,
+ applyHistory,
+ clearGenerating,
+ foldEvent,
+ initialState,
} from "./reducer";
export type { ProviderRetryView } from "./retry-banner";
export { formatRetryDelay, viewProviderRetry } from "./retry-banner";
export { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors";
export {
- DEFAULT_CHAT_LIMIT,
- initialWindowSize,
- MAX_CHAT_LIMIT,
- MIN_CHAT_LIMIT,
- normalizeChatLimit,
- restoreEarlier,
- selectHasEarlier,
- trimTranscript,
- unloadCount,
- windowTranscript,
+ DEFAULT_CHAT_LIMIT,
+ initialWindowSize,
+ MAX_CHAT_LIMIT,
+ MIN_CHAT_LIMIT,
+ normalizeChatLimit,
+ restoreEarlier,
+ selectHasEarlier,
+ trimTranscript,
+ unloadCount,
+ windowTranscript,
} from "./trim";
export type {
- AccumulatingChunk,
- ProvisionalChunk,
- RenderedChunk,
- TranscriptState,
+ AccumulatingChunk,
+ ProvisionalChunk,
+ RenderedChunk,
+ TranscriptState,
} from "./types";
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts
index ac9b895..8a2e1b7 100644
--- a/src/core/chunks/reducer.test.ts
+++ b/src/core/chunks/reducer.test.ts
@@ -1,871 +1,871 @@
import type {
- StepId,
- StoredChunk,
- TurnDoneEvent,
- TurnErrorEvent,
- TurnInputEvent,
- TurnProviderRetryEvent,
- TurnReasoningDeltaEvent,
- TurnSealedEvent,
- TurnStartEvent,
- TurnSteeringEvent,
- TurnTextDeltaEvent,
- TurnToolCallEvent,
- TurnToolResultEvent,
- TurnUsageEvent,
+ StepId,
+ StoredChunk,
+ TurnDoneEvent,
+ TurnErrorEvent,
+ TurnInputEvent,
+ TurnProviderRetryEvent,
+ TurnReasoningDeltaEvent,
+ TurnSealedEvent,
+ TurnStartEvent,
+ TurnSteeringEvent,
+ TurnTextDeltaEvent,
+ TurnToolCallEvent,
+ TurnToolResultEvent,
+ TurnUsageEvent,
} from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
- appendUserMessage,
- applyHistory,
- clearGenerating,
- foldEvent,
- initialState,
+ appendUserMessage,
+ applyHistory,
+ clearGenerating,
+ foldEvent,
+ initialState,
} from "./reducer";
import { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors";
const turnStart = (turnId: string): TurnStartEvent => ({
- type: "turn-start",
- conversationId: "c1",
- turnId,
+ type: "turn-start",
+ conversationId: "c1",
+ turnId,
});
const textDelta = (turnId: string, delta: string): TurnTextDeltaEvent => ({
- type: "text-delta",
- conversationId: "c1",
- turnId,
- delta,
+ type: "text-delta",
+ conversationId: "c1",
+ turnId,
+ delta,
});
const reasoningDelta = (turnId: string, delta: string): TurnReasoningDeltaEvent => ({
- type: "reasoning-delta",
- conversationId: "c1",
- turnId,
- delta,
+ type: "reasoning-delta",
+ conversationId: "c1",
+ turnId,
+ delta,
});
const toolCall = (
- turnId: string,
- toolCallId: string,
- toolName: string,
- input: unknown,
- stepId = "s0",
+ turnId: string,
+ toolCallId: string,
+ toolName: string,
+ input: unknown,
+ stepId = "s0",
): TurnToolCallEvent => ({
- type: "tool-call",
- conversationId: "c1",
- turnId,
- toolCallId,
- toolName,
- input,
- stepId: stepId as StepId,
+ type: "tool-call",
+ conversationId: "c1",
+ turnId,
+ toolCallId,
+ toolName,
+ input,
+ stepId: stepId as StepId,
});
const toolResult = (
- turnId: string,
- toolCallId: string,
- toolName: string,
- content: string,
- stepId = "s0",
+ turnId: string,
+ toolCallId: string,
+ toolName: string,
+ content: string,
+ stepId = "s0",
): TurnToolResultEvent => ({
- type: "tool-result",
- conversationId: "c1",
- turnId,
- toolCallId,
- toolName,
- content,
- isError: false,
- stepId: stepId as StepId,
+ type: "tool-result",
+ conversationId: "c1",
+ turnId,
+ toolCallId,
+ toolName,
+ content,
+ isError: false,
+ stepId: stepId as StepId,
});
const usageEvent = (turnId: string, inputTokens: number, outputTokens: number): TurnUsageEvent => ({
- type: "usage",
- conversationId: "c1",
- turnId,
- usage: { inputTokens, outputTokens },
+ type: "usage",
+ conversationId: "c1",
+ turnId,
+ usage: { inputTokens, outputTokens },
});
const errorEvent = (turnId: string, message: string, code?: string): TurnErrorEvent =>
- code !== undefined
- ? { type: "error", conversationId: "c1", turnId, message, code }
- : { type: "error", conversationId: "c1", turnId, message };
+ code !== undefined
+ ? { type: "error", conversationId: "c1", turnId, message, code }
+ : { type: "error", conversationId: "c1", turnId, message };
const doneEvent = (turnId: string): TurnDoneEvent => ({
- type: "done",
- conversationId: "c1",
- turnId,
- reason: "stop",
+ type: "done",
+ conversationId: "c1",
+ turnId,
+ reason: "stop",
});
const turnSealed = (turnId: string): TurnSealedEvent => ({
- type: "turn-sealed",
- conversationId: "c1",
- turnId,
+ type: "turn-sealed",
+ conversationId: "c1",
+ turnId,
});
const providerRetry = (
- turnId: string,
- attempt: number,
- delayMs: number,
- message = "HTTP 429: overloaded",
- code?: string,
+ turnId: string,
+ attempt: number,
+ delayMs: number,
+ message = "HTTP 429: overloaded",
+ code?: string,
): TurnProviderRetryEvent =>
- code !== undefined
- ? { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message, code }
- : { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message };
+ code !== undefined
+ ? { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message, code }
+ : { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message };
const storedChunk = (
- seq: number,
- role: "user" | "assistant" | "tool" | "system",
- chunk: StoredChunk["chunk"],
+ seq: number,
+ role: "user" | "assistant" | "tool" | "system",
+ chunk: StoredChunk["chunk"],
): StoredChunk => ({
- seq,
- role,
- chunk,
+ seq,
+ role,
+ chunk,
});
describe("initialState", () => {
- it("initial state is empty", () => {
- const s = initialState();
- expect(s.committed).toEqual([]);
- expect(s.provisional).toEqual([]);
- expect(s.accumulating).toBeNull();
- expect(s.currentTurnId).toBeNull();
- expect(s.latestUsage).toBeNull();
- expect(s.sealedTurnId).toBeNull();
- expect(s.generating).toBe(false);
- });
+ it("initial state is empty", () => {
+ const s = initialState();
+ expect(s.committed).toEqual([]);
+ expect(s.provisional).toEqual([]);
+ expect(s.accumulating).toBeNull();
+ expect(s.currentTurnId).toBeNull();
+ expect(s.latestUsage).toBeNull();
+ expect(s.sealedTurnId).toBeNull();
+ expect(s.generating).toBe(false);
+ });
});
describe("foldEvent — generating (turn-running state)", () => {
- it("turn-start sets generating true", () => {
- let s = initialState();
- expect(selectGenerating(s)).toBe(false);
- s = foldEvent(s, turnStart("t1"));
- expect(s.generating).toBe(true);
- expect(selectGenerating(s)).toBe(true);
- });
-
- it("a content delta sets generating true (e.g. a late-joiner replay missing turn-start)", () => {
- let s = initialState();
- s = foldEvent(s, textDelta("t1", "hi"));
- expect(s.generating).toBe(true);
- s = initialState();
- s = foldEvent(s, reasoningDelta("t1", "hmm"));
- expect(s.generating).toBe(true);
- s = initialState();
- s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
- expect(s.generating).toBe(true);
- });
-
- it("stays generating across the turn's deltas", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "wor"));
- s = foldEvent(s, textDelta("t1", "king"));
- expect(s.generating).toBe(true);
- });
-
- it("done clears generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "answer"));
- s = foldEvent(s, doneEvent("t1"));
- expect(s.generating).toBe(false);
- });
-
- it("turn-sealed clears generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, turnSealed("t1"));
- expect(s.generating).toBe(false);
- });
-
- it("error clears generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, errorEvent("t1", "boom"));
- expect(s.generating).toBe(false);
- });
-
- it("a new turn re-asserts generating after the previous one finished", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, doneEvent("t1"));
- s = foldEvent(s, turnSealed("t1"));
- expect(s.generating).toBe(false);
- s = foldEvent(s, turnStart("t2"));
- expect(s.generating).toBe(true);
- });
-
- it("status does not change generating (free-form string, not inferred)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- const next = foldEvent(s, { type: "status", conversationId: "c1", status: "idle" });
- expect(next.generating).toBe(true);
- });
+ it("turn-start sets generating true", () => {
+ let s = initialState();
+ expect(selectGenerating(s)).toBe(false);
+ s = foldEvent(s, turnStart("t1"));
+ expect(s.generating).toBe(true);
+ expect(selectGenerating(s)).toBe(true);
+ });
+
+ it("a content delta sets generating true (e.g. a late-joiner replay missing turn-start)", () => {
+ let s = initialState();
+ s = foldEvent(s, textDelta("t1", "hi"));
+ expect(s.generating).toBe(true);
+ s = initialState();
+ s = foldEvent(s, reasoningDelta("t1", "hmm"));
+ expect(s.generating).toBe(true);
+ s = initialState();
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
+ expect(s.generating).toBe(true);
+ });
+
+ it("stays generating across the turn's deltas", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "wor"));
+ s = foldEvent(s, textDelta("t1", "king"));
+ expect(s.generating).toBe(true);
+ });
+
+ it("done clears generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "answer"));
+ s = foldEvent(s, doneEvent("t1"));
+ expect(s.generating).toBe(false);
+ });
+
+ it("turn-sealed clears generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(s.generating).toBe(false);
+ });
+
+ it("error clears generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, errorEvent("t1", "boom"));
+ expect(s.generating).toBe(false);
+ });
+
+ it("a new turn re-asserts generating after the previous one finished", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, doneEvent("t1"));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(s.generating).toBe(false);
+ s = foldEvent(s, turnStart("t2"));
+ expect(s.generating).toBe(true);
+ });
+
+ it("status does not change generating (free-form string, not inferred)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ const next = foldEvent(s, { type: "status", conversationId: "c1", status: "idle" });
+ expect(next.generating).toBe(true);
+ });
});
describe("clearGenerating", () => {
- it("clears a set generating flag", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- expect(s.generating).toBe(true);
- const cleared = clearGenerating(s);
- expect(cleared.generating).toBe(false);
- });
-
- it("returns the same object when already not generating (no-op)", () => {
- const s = initialState();
- expect(clearGenerating(s)).toBe(s);
- });
-
- it("preserves transcript content while clearing generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- 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.currentTurnId).toBe("t1");
- });
+ it("clears a set generating flag", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ expect(s.generating).toBe(true);
+ const cleared = clearGenerating(s);
+ expect(cleared.generating).toBe(false);
+ });
+
+ it("returns the same object when already not generating (no-op)", () => {
+ const s = initialState();
+ expect(clearGenerating(s)).toBe(s);
+ });
+
+ it("preserves transcript content while clearing generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ 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.currentTurnId).toBe("t1");
+ });
});
describe("foldEvent — text-delta", () => {
- it("text-delta accumulates into one TextChunk", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello"));
- expect(s.accumulating).toEqual({ kind: "text", text: "hello" });
- expect(s.provisional).toEqual([]);
- });
-
- it("successive text-deltas extend the same provisional chunk", () => {
- let s = initialState();
- 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.provisional).toEqual([]);
- });
-
- it("text-delta after reasoning-delta flushes thinking and starts text", () => {
- let s = initialState();
- 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.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." });
- expect(s.provisional[0]?.role).toBe("assistant");
- });
+ it("text-delta accumulates into one TextChunk", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello"));
+ expect(s.accumulating).toEqual({ kind: "text", text: "hello" });
+ expect(s.provisional).toEqual([]);
+ });
+
+ it("successive text-deltas extend the same provisional chunk", () => {
+ let s = initialState();
+ 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.provisional).toEqual([]);
+ });
+
+ it("text-delta after reasoning-delta flushes thinking and starts text", () => {
+ let s = initialState();
+ 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.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." });
+ expect(s.provisional[0]?.role).toBe("assistant");
+ });
});
describe("foldEvent — reasoning-delta", () => {
- it("reasoning-delta yields a thinking chunk", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, reasoningDelta("t1", "hmm"));
- expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" });
- });
-
- it("successive reasoning-deltas extend the same chunk", () => {
- let s = initialState();
- 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" });
- });
+ it("reasoning-delta yields a thinking chunk", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "hmm"));
+ expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" });
+ });
+
+ it("successive reasoning-deltas extend the same chunk", () => {
+ let s = initialState();
+ 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" });
+ });
});
describe("foldEvent — tool-call then tool-result", () => {
- it("tool-call then tool-result render in order", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0"));
- s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0"));
- expect(s.provisional).toHaveLength(2);
- expect(s.provisional[0]?.role).toBe("assistant");
- // foldEvent copies the event's stepId onto the chunk (grouping key).
- expect(s.provisional[0]?.chunk).toEqual({
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "bash",
- input: { cmd: "ls" },
- stepId: "t1#0",
- });
- expect(s.provisional[1]?.role).toBe("tool");
- expect(s.provisional[1]?.chunk).toEqual({
- type: "tool-result",
- toolCallId: "tc1",
- toolName: "bash",
- content: "file.txt",
- isError: false,
- stepId: "t1#0",
- });
- });
-
- it("tool-call flushes accumulating text", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "let me check"));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
- expect(s.provisional).toHaveLength(2);
- expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "let me check" });
- expect(s.provisional[1]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "tc1" });
- expect(s.accumulating).toBeNull();
- });
+ it("tool-call then tool-result render in order", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0"));
+ s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0"));
+ expect(s.provisional).toHaveLength(2);
+ expect(s.provisional[0]?.role).toBe("assistant");
+ // foldEvent copies the event's stepId onto the chunk (grouping key).
+ expect(s.provisional[0]?.chunk).toEqual({
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "bash",
+ input: { cmd: "ls" },
+ stepId: "t1#0",
+ });
+ expect(s.provisional[1]?.role).toBe("tool");
+ expect(s.provisional[1]?.chunk).toEqual({
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "bash",
+ content: "file.txt",
+ isError: false,
+ stepId: "t1#0",
+ });
+ });
+
+ it("tool-call flushes accumulating text", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "let me check"));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
+ expect(s.provisional).toHaveLength(2);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "let me check" });
+ expect(s.provisional[1]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "tc1" });
+ expect(s.accumulating).toBeNull();
+ });
});
describe("foldEvent — turn-sealed", () => {
- it("turn-sealed sets sealedTurnId", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hi"));
- s = foldEvent(s, turnSealed("t1"));
- expect(s.sealedTurnId).toBe("t1");
- expect(s.accumulating).toBeNull();
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hi" });
- });
+ it("turn-sealed sets sealedTurnId", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hi"));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(s.sealedTurnId).toBe("t1");
+ expect(s.accumulating).toBeNull();
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hi" });
+ });
});
describe("foldEvent — usage", () => {
- it("stores latest usage", () => {
- let s = initialState();
- s = foldEvent(s, usageEvent("t1", 100, 50));
- expect(s.latestUsage).toEqual({ inputTokens: 100, outputTokens: 50 });
- });
-
- it("overwrites previous usage", () => {
- let s = initialState();
- s = foldEvent(s, usageEvent("t1", 100, 50));
- s = foldEvent(s, usageEvent("t1", 200, 80));
- expect(s.latestUsage).toEqual({ inputTokens: 200, outputTokens: 80 });
- });
+ it("stores latest usage", () => {
+ let s = initialState();
+ s = foldEvent(s, usageEvent("t1", 100, 50));
+ expect(s.latestUsage).toEqual({ inputTokens: 100, outputTokens: 50 });
+ });
+
+ it("overwrites previous usage", () => {
+ let s = initialState();
+ s = foldEvent(s, usageEvent("t1", 100, 50));
+ s = foldEvent(s, usageEvent("t1", 200, 80));
+ expect(s.latestUsage).toEqual({ inputTokens: 200, outputTokens: 80 });
+ });
});
describe("foldEvent — error", () => {
- it("creates error chunk with code", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, errorEvent("t1", "bad", "E001"));
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad", code: "E001" });
- expect(s.provisional[0]?.role).toBe("assistant");
- });
-
- it("creates error chunk without code", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, errorEvent("t1", "bad"));
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad" });
- });
+ it("creates error chunk with code", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, errorEvent("t1", "bad", "E001"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad", code: "E001" });
+ expect(s.provisional[0]?.role).toBe("assistant");
+ });
+
+ it("creates error chunk without code", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, errorEvent("t1", "bad"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad" });
+ });
});
describe("foldEvent — done", () => {
- it("flushes accumulating chunk on done", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello"));
- s = foldEvent(s, doneEvent("t1"));
- expect(s.accumulating).toBeNull();
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hello" });
- });
+ it("flushes accumulating chunk on done", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello"));
+ s = foldEvent(s, doneEvent("t1"));
+ expect(s.accumulating).toBeNull();
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hello" });
+ });
});
describe("foldEvent — status and tool-output", () => {
- it("status is a no-op", () => {
- const s = initialState();
- const next = foldEvent(s, { type: "status", conversationId: "c1", status: "running" });
- expect(next).toBe(s);
- });
-
- it("tool-output is a no-op", () => {
- const s = initialState();
- const next = foldEvent(s, {
- type: "tool-output",
- conversationId: "c1",
- turnId: "t1",
- toolCallId: "tc1",
- data: "output",
- stream: "stdout",
- });
- expect(next).toBe(s);
- });
+ it("status is a no-op", () => {
+ const s = initialState();
+ const next = foldEvent(s, { type: "status", conversationId: "c1", status: "running" });
+ expect(next).toBe(s);
+ });
+
+ it("tool-output is a no-op", () => {
+ const s = initialState();
+ const next = foldEvent(s, {
+ type: "tool-output",
+ conversationId: "c1",
+ turnId: "t1",
+ toolCallId: "tc1",
+ data: "output",
+ stream: "stdout",
+ });
+ expect(next).toBe(s);
+ });
});
describe("foldEvent — provider-retry (transient retry banner)", () => {
- it("sets the provider-retry banner on a provider-retry event", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000, "HTTP 429: overloaded", "429"));
- const retry = selectProviderRetry(s);
- expect(retry).not.toBeNull();
- expect(retry?.attempt).toBe(0);
- expect(retry?.delayMs).toBe(5000);
- expect(retry?.code).toBe("429");
- });
-
- it("does NOT add a chunk (never persisted — never pollutes the prompt)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(selectChunks(s)).toHaveLength(0);
- expect(s.provisional).toHaveLength(0);
- expect(s.accumulating).toBeNull();
- });
-
- it("coalesces: the latest attempt + delay replaces the previous", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000, "first", "429"));
- s = foldEvent(s, providerRetry("t1", 1, 10000, "second", "429"));
- const retry = selectProviderRetry(s);
- expect(retry?.attempt).toBe(1);
- expect(retry?.delayMs).toBe(10000);
- expect(retry?.message).toBe("second");
- });
-
- it("keeps generating true (the turn is still in flight, just retrying)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(s.generating).toBe(true);
- });
-
- it("clears when content resumes (text-delta)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(selectProviderRetry(s)).not.toBeNull();
- s = foldEvent(s, textDelta("t1", "here is the reply"));
- expect(selectProviderRetry(s)).toBeNull();
- });
-
- it("clears when content resumes (reasoning-delta / tool-call / tool-result)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, reasoningDelta("t1", "thinking"));
- expect(selectProviderRetry(s)).toBeNull();
-
- s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
- expect(selectProviderRetry(s)).toBeNull();
- });
-
- it("clears when the turn ends (done / turn-sealed / error)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, errorEvent("t1", "exhausted"));
- expect(selectProviderRetry(s)).toBeNull();
-
- s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, doneEvent("t1"));
- expect(selectProviderRetry(s)).toBeNull();
-
- s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, turnSealed("t1"));
- expect(selectProviderRetry(s)).toBeNull();
- });
-
- it("clears on a new turn (turn-start)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(selectProviderRetry(s)).not.toBeNull();
- s = foldEvent(s, turnStart("t2"));
- expect(selectProviderRetry(s)).toBeNull();
- });
-
- it("leaves the banner untouched across metadata events (usage / step-complete / status)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, usageEvent("t1", 10, 20));
- expect(selectProviderRetry(s)).not.toBeNull();
- s = foldEvent(s, {
- type: "step-complete",
- conversationId: "c1",
- turnId: "t1",
- stepId: "t1#0" as StepId,
- ttftMs: 100,
- decodeMs: 200,
- genTotalMs: 300,
- });
- expect(selectProviderRetry(s)).not.toBeNull();
- s = foldEvent(s, { type: "status", conversationId: "c1", status: "running" });
- expect(selectProviderRetry(s)).not.toBeNull();
- });
-
- it("is null in the initial state", () => {
- expect(selectProviderRetry(initialState())).toBeNull();
- });
+ it("sets the provider-retry banner on a provider-retry event", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000, "HTTP 429: overloaded", "429"));
+ const retry = selectProviderRetry(s);
+ expect(retry).not.toBeNull();
+ expect(retry?.attempt).toBe(0);
+ expect(retry?.delayMs).toBe(5000);
+ expect(retry?.code).toBe("429");
+ });
+
+ it("does NOT add a chunk (never persisted — never pollutes the prompt)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(selectChunks(s)).toHaveLength(0);
+ expect(s.provisional).toHaveLength(0);
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("coalesces: the latest attempt + delay replaces the previous", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000, "first", "429"));
+ s = foldEvent(s, providerRetry("t1", 1, 10000, "second", "429"));
+ const retry = selectProviderRetry(s);
+ expect(retry?.attempt).toBe(1);
+ expect(retry?.delayMs).toBe(10000);
+ expect(retry?.message).toBe("second");
+ });
+
+ it("keeps generating true (the turn is still in flight, just retrying)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(s.generating).toBe(true);
+ });
+
+ it("clears when content resumes (text-delta)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, textDelta("t1", "here is the reply"));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+
+ it("clears when content resumes (reasoning-delta / tool-call / tool-result)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, reasoningDelta("t1", "thinking"));
+ expect(selectProviderRetry(s)).toBeNull();
+
+ s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+
+ it("clears when the turn ends (done / turn-sealed / error)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, errorEvent("t1", "exhausted"));
+ expect(selectProviderRetry(s)).toBeNull();
+
+ s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, doneEvent("t1"));
+ expect(selectProviderRetry(s)).toBeNull();
+
+ s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+
+ it("clears on a new turn (turn-start)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, turnStart("t2"));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+
+ it("leaves the banner untouched across metadata events (usage / step-complete / status)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, usageEvent("t1", 10, 20));
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, {
+ type: "step-complete",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 100,
+ decodeMs: 200,
+ genTotalMs: 300,
+ });
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, { type: "status", conversationId: "c1", status: "running" });
+ expect(selectProviderRetry(s)).not.toBeNull();
+ });
+
+ it("is null in the initial state", () => {
+ expect(selectProviderRetry(initialState())).toBeNull();
+ });
});
describe("clearGenerating also clears a stale provider-retry banner (reconnect)", () => {
- it("clears the retry banner alongside generating on reconnect", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(s.generating).toBe(true);
- expect(selectProviderRetry(s)).not.toBeNull();
- const cleared = clearGenerating(s);
- expect(cleared.generating).toBe(false);
- expect(selectProviderRetry(cleared)).toBeNull();
- });
-
- it("preserves transcript content while clearing the banner", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- 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(selectProviderRetry(cleared)).toBeNull();
- });
+ it("clears the retry banner alongside generating on reconnect", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(s.generating).toBe(true);
+ expect(selectProviderRetry(s)).not.toBeNull();
+ const cleared = clearGenerating(s);
+ expect(cleared.generating).toBe(false);
+ expect(selectProviderRetry(cleared)).toBeNull();
+ });
+
+ it("preserves transcript content while clearing the banner", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ 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(selectProviderRetry(cleared)).toBeNull();
+ });
});
describe("foldEvent — user-message (the turn's user prompt; backend CR-3)", () => {
- const userMessage = (text: string): TurnInputEvent => ({
- type: "user-message",
- conversationId: "c1",
- turnId: "t1",
- text,
- });
-
- it("a watcher renders the prompt: appends a provisional user chunk + marks generating", () => {
- let s = initialState();
- s = foldEvent(s, userMessage("what is 2+2?"));
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]?.role).toBe("user");
- expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what is 2+2?" });
- expect(chunks[0]?.provisional).toBe(true);
- expect(s.generating).toBe(true);
- });
-
- it("dedups the SENDER's optimistic echo (no duplicate user bubble)", () => {
- let s = initialState();
- s = appendUserMessage(s, "hi"); // optimistic echo from the sender's send()
- s = foldEvent(s, userMessage("hi")); // server echo for the same turn
- const users = selectChunks(s).filter((c) => c.role === "user");
- expect(users).toHaveLength(1);
- });
-
- it("appends when the trailing provisional differs (no false dedup)", () => {
- let s = initialState();
- s = appendUserMessage(s, "first");
- s = foldEvent(s, userMessage("second"));
- const users = selectChunks(s).filter((c) => c.role === "user");
- expect(users).toHaveLength(2);
- });
-
- it("ignores an empty user-message", () => {
- let s = initialState();
- s = foldEvent(s, userMessage(""));
- expect(selectChunks(s)).toHaveLength(0);
- expect(s.generating).toBe(false);
- });
-
- it("flushes an accumulating chunk before appending the prompt", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "partial"));
- s = foldEvent(s, userMessage("new prompt"));
- // the partial assistant text was flushed to provisional, then the user prompt appended
- expect(s.accumulating).toBeNull();
- const roles = selectChunks(s).map((c) => c.role);
- expect(roles).toEqual(["assistant", "user"]);
- });
+ const userMessage = (text: string): TurnInputEvent => ({
+ type: "user-message",
+ conversationId: "c1",
+ turnId: "t1",
+ text,
+ });
+
+ it("a watcher renders the prompt: appends a provisional user chunk + marks generating", () => {
+ let s = initialState();
+ s = foldEvent(s, userMessage("what is 2+2?"));
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(1);
+ expect(chunks[0]?.role).toBe("user");
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what is 2+2?" });
+ expect(chunks[0]?.provisional).toBe(true);
+ expect(s.generating).toBe(true);
+ });
+
+ it("dedups the SENDER's optimistic echo (no duplicate user bubble)", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hi"); // optimistic echo from the sender's send()
+ s = foldEvent(s, userMessage("hi")); // server echo for the same turn
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users).toHaveLength(1);
+ });
+
+ it("appends when the trailing provisional differs (no false dedup)", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "first");
+ s = foldEvent(s, userMessage("second"));
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users).toHaveLength(2);
+ });
+
+ it("ignores an empty user-message", () => {
+ let s = initialState();
+ s = foldEvent(s, userMessage(""));
+ expect(selectChunks(s)).toHaveLength(0);
+ expect(s.generating).toBe(false);
+ });
+
+ it("flushes an accumulating chunk before appending the prompt", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial"));
+ s = foldEvent(s, userMessage("new prompt"));
+ // the partial assistant text was flushed to provisional, then the user prompt appended
+ expect(s.accumulating).toBeNull();
+ const roles = selectChunks(s).map((c) => c.role);
+ expect(roles).toEqual(["assistant", "user"]);
+ });
});
describe("foldEvent — steering (mid-turn steering injection)", () => {
- const steering = (text: string): TurnSteeringEvent => ({
- type: "steering",
- conversationId: "c1",
- turnId: "t1",
- text,
- });
-
- it("appends a provisional user bubble + keeps generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, toolResult("t1", "tc1", "read", "output"));
- s = foldEvent(s, steering("actually, use a different file"));
- const chunks = selectChunks(s);
- const last = chunks[chunks.length - 1];
- expect(last?.role).toBe("user");
- expect(last?.chunk).toEqual({ type: "text", text: "actually, use a different file" });
- expect(last?.provisional).toBe(true);
- expect(s.generating).toBe(true);
- });
-
- it("does NOT dedup against the sender's queue (unlike user-message)", () => {
- // The sender enqueued the message via `chat.queue` — the queue SURFACE
- // showed it. The `steering` event places it in the transcript; the surface
- // separately clears on drain. No de-dup here (the transcript never showed
- // the queued message).
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, steering("steer once"));
- s = foldEvent(s, steering("steer again"));
- const users = selectChunks(s).filter((c) => c.role === "user");
- expect(users).toHaveLength(2);
- });
-
- it("ignores an empty steering event", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, steering(""));
- expect(selectChunks(s)).toHaveLength(0);
- expect(s.generating).toBe(true); // turn-start already set it
- });
-
- it("flushes an accumulating chunk before appending the steering bubble", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "partial response"));
- s = foldEvent(s, steering("mid-turn correction"));
- expect(s.accumulating).toBeNull();
- const roles = selectChunks(s).map((c) => c.role);
- expect(roles).toEqual(["assistant", "user"]);
- });
+ const steering = (text: string): TurnSteeringEvent => ({
+ type: "steering",
+ conversationId: "c1",
+ turnId: "t1",
+ text,
+ });
+
+ it("appends a provisional user bubble + keeps generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, toolResult("t1", "tc1", "read", "output"));
+ s = foldEvent(s, steering("actually, use a different file"));
+ const chunks = selectChunks(s);
+ const last = chunks[chunks.length - 1];
+ expect(last?.role).toBe("user");
+ expect(last?.chunk).toEqual({ type: "text", text: "actually, use a different file" });
+ expect(last?.provisional).toBe(true);
+ expect(s.generating).toBe(true);
+ });
+
+ it("does NOT dedup against the sender's queue (unlike user-message)", () => {
+ // The sender enqueued the message via `chat.queue` — the queue SURFACE
+ // showed it. The `steering` event places it in the transcript; the surface
+ // separately clears on drain. No de-dup here (the transcript never showed
+ // the queued message).
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, steering("steer once"));
+ s = foldEvent(s, steering("steer again"));
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users).toHaveLength(2);
+ });
+
+ it("ignores an empty steering event", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, steering(""));
+ expect(selectChunks(s)).toHaveLength(0);
+ expect(s.generating).toBe(true); // turn-start already set it
+ });
+
+ it("flushes an accumulating chunk before appending the steering bubble", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial response"));
+ s = foldEvent(s, steering("mid-turn correction"));
+ expect(s.accumulating).toBeNull();
+ const roles = selectChunks(s).map((c) => c.role);
+ expect(roles).toEqual(["assistant", "user"]);
+ });
});
describe("applyHistory", () => {
- it("orders committed chunks by seq", () => {
- const s = initialState();
- const chunks = [
- storedChunk(3, "assistant", { type: "text", text: "c" }),
- storedChunk(1, "user", { type: "text", text: "a" }),
- storedChunk(2, "assistant", { type: "text", text: "b" }),
- ];
- const next = applyHistory(s, chunks);
- expect(next.committed.map((c) => c.seq)).toEqual([1, 2, 3]);
- });
-
- it("is idempotent on duplicate seqs", () => {
- let s = initialState();
- const batch1 = [
- storedChunk(1, "user", { type: "text", text: "a" }),
- storedChunk(2, "assistant", { type: "text", text: "b" }),
- ];
- s = applyHistory(s, batch1);
- const batch2 = [
- storedChunk(2, "assistant", { type: "text", text: "b" }),
- storedChunk(3, "assistant", { type: "text", text: "c" }),
- ];
- s = applyHistory(s, batch2);
- expect(s.committed.map((c) => c.seq)).toEqual([1, 2, 3]);
- expect(s.committed).toHaveLength(3);
- });
-
- it("supersedes & clears provisional once committed", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello"));
- s = foldEvent(s, turnSealed("t1"));
- expect(s.provisional).toHaveLength(1);
- expect(s.sealedTurnId).toBe("t1");
-
- s = applyHistory(s, [storedChunk(1, "assistant", { type: "text", text: "hello" })]);
- expect(s.provisional).toEqual([]);
- expect(s.accumulating).toBeNull();
- expect(s.sealedTurnId).toBeNull();
- expect(s.committed).toHaveLength(1);
- });
-
- it("keeps provisional and accumulating when sealedTurnId is null", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "wip"));
- s = foldEvent(s, doneEvent("t1"));
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
- expect(s.provisional).toHaveLength(1);
- expect(s.committed).toHaveLength(1);
- });
-
- it("merges new history into existing committed", () => {
- let s = initialState();
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "a" })]);
- s = applyHistory(s, [storedChunk(2, "assistant", { type: "text", text: "b" })]);
- expect(s.committed).toHaveLength(2);
- expect(s.committed.map((c) => c.seq)).toEqual([1, 2]);
- });
-
- it("removes provisional duplicate when committed user message arrives during generation", () => {
- // Simulate: send() appends provisional user message, then syncTail
- // fetches the same message as committed (CR-6: persisted at turn start).
- let s = initialState();
- s = appendUserMessage(s, "hello");
- s = foldEvent(s, turnStart("t1"));
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.role).toBe("user");
- expect(s.generating).toBe(true);
-
- // syncTail fetches the persisted user message as committed
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "hello" })]);
-
- // The provisional duplicate is removed — no double render
- expect(s.provisional).toEqual([]);
- expect(s.committed).toHaveLength(1);
- expect(s.committed[0]?.role).toBe("user");
- expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hello" });
- });
+ it("orders committed chunks by seq", () => {
+ const s = initialState();
+ const chunks = [
+ storedChunk(3, "assistant", { type: "text", text: "c" }),
+ storedChunk(1, "user", { type: "text", text: "a" }),
+ storedChunk(2, "assistant", { type: "text", text: "b" }),
+ ];
+ const next = applyHistory(s, chunks);
+ expect(next.committed.map((c) => c.seq)).toEqual([1, 2, 3]);
+ });
+
+ it("is idempotent on duplicate seqs", () => {
+ let s = initialState();
+ const batch1 = [
+ storedChunk(1, "user", { type: "text", text: "a" }),
+ storedChunk(2, "assistant", { type: "text", text: "b" }),
+ ];
+ s = applyHistory(s, batch1);
+ const batch2 = [
+ storedChunk(2, "assistant", { type: "text", text: "b" }),
+ storedChunk(3, "assistant", { type: "text", text: "c" }),
+ ];
+ s = applyHistory(s, batch2);
+ expect(s.committed.map((c) => c.seq)).toEqual([1, 2, 3]);
+ expect(s.committed).toHaveLength(3);
+ });
+
+ it("supersedes & clears provisional once committed", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello"));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.sealedTurnId).toBe("t1");
+
+ s = applyHistory(s, [storedChunk(1, "assistant", { type: "text", text: "hello" })]);
+ expect(s.provisional).toEqual([]);
+ expect(s.accumulating).toBeNull();
+ expect(s.sealedTurnId).toBeNull();
+ expect(s.committed).toHaveLength(1);
+ });
+
+ it("keeps provisional and accumulating when sealedTurnId is null", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "wip"));
+ s = foldEvent(s, doneEvent("t1"));
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
+ expect(s.provisional).toHaveLength(1);
+ expect(s.committed).toHaveLength(1);
+ });
+
+ it("merges new history into existing committed", () => {
+ let s = initialState();
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "a" })]);
+ s = applyHistory(s, [storedChunk(2, "assistant", { type: "text", text: "b" })]);
+ expect(s.committed).toHaveLength(2);
+ expect(s.committed.map((c) => c.seq)).toEqual([1, 2]);
+ });
+
+ it("removes provisional duplicate when committed user message arrives during generation", () => {
+ // Simulate: send() appends provisional user message, then syncTail
+ // fetches the same message as committed (CR-6: persisted at turn start).
+ let s = initialState();
+ s = appendUserMessage(s, "hello");
+ s = foldEvent(s, turnStart("t1"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.role).toBe("user");
+ expect(s.generating).toBe(true);
+
+ // syncTail fetches the persisted user message as committed
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "hello" })]);
+
+ // The provisional duplicate is removed — no double render
+ expect(s.provisional).toEqual([]);
+ expect(s.committed).toHaveLength(1);
+ expect(s.committed[0]?.role).toBe("user");
+ expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hello" });
+ });
});
describe("selectChunks", () => {
- it("selectChunks marks provisional with seq null", () => {
- let s = initialState();
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "wip"));
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(2);
- expect(chunks[0]?.seq).toBe(1);
- expect(chunks[0]?.provisional).toBe(false);
- expect(chunks[1]?.seq).toBeNull();
- expect(chunks[1]?.provisional).toBe(true);
- });
-
- it("returns empty for empty state", () => {
- expect(selectChunks(initialState())).toEqual([]);
- });
-
- it("includes accumulating chunk as provisional", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "building..."));
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]?.seq).toBeNull();
- expect(chunks[0]?.provisional).toBe(true);
- expect(chunks[0]?.chunk).toEqual({ type: "text", text: "building..." });
- });
-
- it("marks ONLY the actively-accumulating chunk as streaming", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- // A flushed-but-still-provisional thinking chunk, then a live accumulating one.
- s = foldEvent(s, reasoningDelta("t1", "first thought"));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); // flushes the thinking
- s = foldEvent(s, textDelta("t1", "now writing"));
- const chunks = selectChunks(s);
- const thinking = chunks.find((c) => c.chunk.type === "thinking");
- const accumulating = chunks.find((c) => c.streaming === true);
- expect(thinking?.streaming).toBeFalsy(); // flushed → not streaming
- expect(accumulating?.chunk).toEqual({ type: "text", text: "now writing" });
- expect(chunks.filter((c) => c.streaming === true)).toHaveLength(1);
- });
+ it("selectChunks marks provisional with seq null", () => {
+ let s = initialState();
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "wip"));
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(2);
+ expect(chunks[0]?.seq).toBe(1);
+ expect(chunks[0]?.provisional).toBe(false);
+ expect(chunks[1]?.seq).toBeNull();
+ expect(chunks[1]?.provisional).toBe(true);
+ });
+
+ it("returns empty for empty state", () => {
+ expect(selectChunks(initialState())).toEqual([]);
+ });
+
+ it("includes accumulating chunk as provisional", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "building..."));
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(1);
+ expect(chunks[0]?.seq).toBeNull();
+ expect(chunks[0]?.provisional).toBe(true);
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "building..." });
+ });
+
+ it("marks ONLY the actively-accumulating chunk as streaming", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ // A flushed-but-still-provisional thinking chunk, then a live accumulating one.
+ s = foldEvent(s, reasoningDelta("t1", "first thought"));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); // flushes the thinking
+ s = foldEvent(s, textDelta("t1", "now writing"));
+ const chunks = selectChunks(s);
+ const thinking = chunks.find((c) => c.chunk.type === "thinking");
+ const accumulating = chunks.find((c) => c.streaming === true);
+ expect(thinking?.streaming).toBeFalsy(); // flushed → not streaming
+ expect(accumulating?.chunk).toEqual({ type: "text", text: "now writing" });
+ expect(chunks.filter((c) => c.streaming === true)).toHaveLength(1);
+ });
});
describe("selectMessages", () => {
- it("selectMessages groups consecutive same-role chunks", () => {
- let s = initialState();
- s = applyHistory(s, [
- storedChunk(1, "user", { type: "text", text: "q1" }),
- storedChunk(2, "user", { type: "text", text: "q2" }),
- storedChunk(3, "assistant", { type: "text", text: "a1" }),
- storedChunk(4, "assistant", { type: "text", text: "a2" }),
- storedChunk(5, "user", { type: "text", text: "q3" }),
- ]);
- const msgs = selectMessages(s);
- expect(msgs).toHaveLength(3);
- expect(msgs[0]?.role).toBe("user");
- expect(msgs[0]?.chunks).toHaveLength(2);
- expect(msgs[1]?.role).toBe("assistant");
- expect(msgs[1]?.chunks).toHaveLength(2);
- expect(msgs[2]?.role).toBe("user");
- expect(msgs[2]?.chunks).toHaveLength(1);
- });
-
- it("returns empty for empty state", () => {
- expect(selectMessages(initialState())).toEqual([]);
- });
-
- it("mixes committed and provisional in messages", () => {
- let s = initialState();
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "a1"));
- s = foldEvent(s, textDelta("t1", "a2"));
- const msgs = selectMessages(s);
- expect(msgs).toHaveLength(2);
- expect(msgs[0]?.role).toBe("user");
- expect(msgs[0]?.chunks).toHaveLength(1);
- expect(msgs[1]?.role).toBe("assistant");
- expect(msgs[1]?.chunks).toHaveLength(1);
- expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" });
- });
+ it("selectMessages groups consecutive same-role chunks", () => {
+ let s = initialState();
+ s = applyHistory(s, [
+ storedChunk(1, "user", { type: "text", text: "q1" }),
+ storedChunk(2, "user", { type: "text", text: "q2" }),
+ storedChunk(3, "assistant", { type: "text", text: "a1" }),
+ storedChunk(4, "assistant", { type: "text", text: "a2" }),
+ storedChunk(5, "user", { type: "text", text: "q3" }),
+ ]);
+ const msgs = selectMessages(s);
+ expect(msgs).toHaveLength(3);
+ expect(msgs[0]?.role).toBe("user");
+ expect(msgs[0]?.chunks).toHaveLength(2);
+ expect(msgs[1]?.role).toBe("assistant");
+ expect(msgs[1]?.chunks).toHaveLength(2);
+ expect(msgs[2]?.role).toBe("user");
+ expect(msgs[2]?.chunks).toHaveLength(1);
+ });
+
+ it("returns empty for empty state", () => {
+ expect(selectMessages(initialState())).toEqual([]);
+ });
+
+ it("mixes committed and provisional in messages", () => {
+ let s = initialState();
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "a1"));
+ s = foldEvent(s, textDelta("t1", "a2"));
+ const msgs = selectMessages(s);
+ expect(msgs).toHaveLength(2);
+ expect(msgs[0]?.role).toBe("user");
+ expect(msgs[0]?.chunks).toHaveLength(1);
+ expect(msgs[1]?.role).toBe("assistant");
+ expect(msgs[1]?.chunks).toHaveLength(1);
+ expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" });
+ });
});
describe("appendUserMessage", () => {
- it("adds a provisional user text chunk", () => {
- let s = initialState();
- s = appendUserMessage(s, "hello from user");
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]?.seq).toBeNull();
- expect(chunks[0]?.role).toBe("user");
- expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" });
- expect(chunks[0]?.provisional).toBe(true);
- });
-
- it("selectMessages includes the optimistic user message", () => {
- let s = initialState();
- s = appendUserMessage(s, "what is 2+2?");
- const msgs = selectMessages(s);
- expect(msgs).toHaveLength(1);
- expect(msgs[0]?.role).toBe("user");
- expect(msgs[0]?.chunks).toHaveLength(1);
- expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" });
- });
-
- it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => {
- let s = initialState();
- s = appendUserMessage(s, "hi");
- expect(selectChunks(s)).toHaveLength(1);
-
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello back"));
- s = foldEvent(s, turnSealed("t1"));
- s = applyHistory(s, [
- storedChunk(1, "user", { type: "text", text: "hi" }),
- storedChunk(2, "assistant", { type: "text", text: "hello back" }),
- ]);
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(2);
- expect(chunks[0]?.seq).toBe(1);
- expect(chunks[0]?.role).toBe("user");
- expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" });
- expect(chunks[0]?.provisional).toBe(false);
- expect(chunks[1]?.seq).toBe(2);
- expect(chunks[1]?.role).toBe("assistant");
- expect(chunks[1]?.provisional).toBe(false);
- });
-
- it("flushes accumulating chunk before appending user message", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "partial"));
- expect(s.accumulating).toEqual({ kind: "text", text: "partial" });
-
- s = appendUserMessage(s, "user msg");
- expect(s.accumulating).toBeNull();
- expect(s.provisional).toHaveLength(2);
- expect(s.provisional[0]?.role).toBe("assistant");
- expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" });
- expect(s.provisional[1]?.role).toBe("user");
- expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" });
- });
+ it("adds a provisional user text chunk", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hello from user");
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(1);
+ expect(chunks[0]?.seq).toBeNull();
+ expect(chunks[0]?.role).toBe("user");
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" });
+ expect(chunks[0]?.provisional).toBe(true);
+ });
+
+ it("selectMessages includes the optimistic user message", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "what is 2+2?");
+ const msgs = selectMessages(s);
+ expect(msgs).toHaveLength(1);
+ expect(msgs[0]?.role).toBe("user");
+ expect(msgs[0]?.chunks).toHaveLength(1);
+ expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" });
+ });
+
+ it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hi");
+ expect(selectChunks(s)).toHaveLength(1);
+
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello back"));
+ s = foldEvent(s, turnSealed("t1"));
+ s = applyHistory(s, [
+ storedChunk(1, "user", { type: "text", text: "hi" }),
+ storedChunk(2, "assistant", { type: "text", text: "hello back" }),
+ ]);
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(2);
+ expect(chunks[0]?.seq).toBe(1);
+ expect(chunks[0]?.role).toBe("user");
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" });
+ expect(chunks[0]?.provisional).toBe(false);
+ expect(chunks[1]?.seq).toBe(2);
+ expect(chunks[1]?.role).toBe("assistant");
+ expect(chunks[1]?.provisional).toBe(false);
+ });
+
+ it("flushes accumulating chunk before appending user message", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial"));
+ expect(s.accumulating).toEqual({ kind: "text", text: "partial" });
+
+ s = appendUserMessage(s, "user msg");
+ expect(s.accumulating).toBeNull();
+ expect(s.provisional).toHaveLength(2);
+ expect(s.provisional[0]?.role).toBe("assistant");
+ expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" });
+ expect(s.provisional[1]?.role).toBe("user");
+ expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" });
+ });
});
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index 1e9c8f5..2152de3 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -3,18 +3,18 @@ import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./typ
/** The initial empty transcript state. */
export function initialState(): TranscriptState {
- return {
- committed: [],
- provisional: [],
- accumulating: null,
- currentTurnId: null,
- latestUsage: null,
- sealedTurnId: null,
- hiddenBeforeSeq: 0,
- hiddenThinkingCount: 0,
- generating: false,
- providerRetry: null,
- };
+ return {
+ committed: [],
+ provisional: [],
+ accumulating: null,
+ currentTurnId: null,
+ latestUsage: null,
+ sealedTurnId: null,
+ hiddenBeforeSeq: 0,
+ hiddenThinkingCount: 0,
+ generating: false,
+ providerRetry: null,
+ };
}
/**
@@ -25,21 +25,21 @@ export function initialState(): TranscriptState {
* server's replay re-asserts `generating` via the replayed `turn-start`.
*/
export function clearGenerating(state: TranscriptState): TranscriptState {
- if (!state.generating) return state;
- // Also drop a stale `provider-retry` banner — a retry pending at disconnect
- // is stale once we re-subscribe (provider-retry events are not replayed), so
- // a finished turn must not keep showing a "retrying…" banner forever.
- return { ...state, generating: false, providerRetry: null };
+ if (!state.generating) return state;
+ // Also drop a stale `provider-retry` banner — a retry pending at disconnect
+ // is stale once we re-subscribe (provider-retry events are not replayed), so
+ // a finished turn must not keep showing a "retrying…" banner forever.
+ return { ...state, generating: false, providerRetry: null };
}
function flushAccumulating(
- provisional: readonly ProvisionalChunk[],
- acc: AccumulatingChunk | null,
+ provisional: readonly ProvisionalChunk[],
+ acc: AccumulatingChunk | null,
): readonly ProvisionalChunk[] {
- if (acc === null) return provisional;
- const chunk: Chunk =
- acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text };
- return [...provisional, { role: "assistant", chunk }];
+ if (acc === null) return provisional;
+ const chunk: Chunk =
+ acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text };
+ return [...provisional, { role: "assistant", chunk }];
}
/**
@@ -53,50 +53,50 @@ function flushAccumulating(
* unloaded. Restoring earlier history goes through `restoreEarlier` instead.
*/
export function applyHistory(
- state: TranscriptState,
- chunks: readonly StoredChunk[],
+ state: TranscriptState,
+ chunks: readonly StoredChunk[],
): 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);
+ 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);
- if (state.sealedTurnId !== null) {
- return {
- ...state,
- committed,
- provisional: [],
- accumulating: null,
- sealedTurnId: null,
- };
- }
+ if (state.sealedTurnId !== null) {
+ return {
+ ...state,
+ committed,
+ provisional: [],
+ accumulating: null,
+ sealedTurnId: null,
+ };
+ }
- // During generation: if new committed chunks arrived, the provisional
- // array may contain duplicates — the optimistic echo from `appendUserMessage`
- // is now backed by a committed chunk (CR-6: user message persisted at turn
- // start). Remove provisional chunks that match the last committed chunk
- // (role + chunk content), keeping only the accumulating (streaming) chunk.
- if (addedNew && state.generating && state.provisional.length > 0) {
- const lastCommitted = committed[committed.length - 1];
- if (lastCommitted !== undefined) {
- const provisional = state.provisional.filter((p) => {
- if (p.role !== lastCommitted.role) return true;
- if (p.chunk.type !== lastCommitted.chunk.type) return true;
- if (p.chunk.type === "text" && lastCommitted.chunk.type === "text") {
- return p.chunk.text !== lastCommitted.chunk.text;
- }
- return true;
- });
- return { ...state, committed, provisional, accumulating: state.accumulating };
- }
- }
+ // During generation: if new committed chunks arrived, the provisional
+ // array may contain duplicates — the optimistic echo from `appendUserMessage`
+ // is now backed by a committed chunk (CR-6: user message persisted at turn
+ // start). Remove provisional chunks that match the last committed chunk
+ // (role + chunk content), keeping only the accumulating (streaming) chunk.
+ if (addedNew && state.generating && state.provisional.length > 0) {
+ const lastCommitted = committed[committed.length - 1];
+ if (lastCommitted !== undefined) {
+ const provisional = state.provisional.filter((p) => {
+ if (p.role !== lastCommitted.role) return true;
+ if (p.chunk.type !== lastCommitted.chunk.type) return true;
+ if (p.chunk.type === "text" && lastCommitted.chunk.type === "text") {
+ return p.chunk.text !== lastCommitted.chunk.text;
+ }
+ return true;
+ });
+ return { ...state, committed, provisional, accumulating: state.accumulating };
+ }
+ }
- return { ...state, committed };
+ return { ...state, committed };
}
/**
@@ -126,179 +126,179 @@ export function applyHistory(
* below, so the clearing logic stays centralized in one place.
*/
function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState {
- switch (event.type) {
- case "status":
- case "tool-output":
- return state;
+ switch (event.type) {
+ case "status":
+ case "tool-output":
+ return state;
- case "turn-start":
- return { ...state, currentTurnId: event.turnId, generating: true };
+ case "turn-start":
+ return { ...state, currentTurnId: event.turnId, generating: true };
- case "user-message": {
- // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a
- // WATCHER/late-joiner renders it mid-turn instead of waiting for seal. The
- // SENDER already echoed its own prompt optimistically (`appendUserMessage`),
- // so DE-DUP: skip if the trailing provisional chunk is already an identical
- // user text chunk. A pure watcher has no such echo → it appends and renders.
- if (event.text.length === 0) return state;
- const last = state.provisional[state.provisional.length - 1];
- if (
- last !== undefined &&
- last.role === "user" &&
- last.chunk.type === "text" &&
- last.chunk.text === event.text
- ) {
- return { ...state, generating: true };
- }
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- return {
- ...state,
- provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }],
- accumulating: null,
- generating: true,
- };
- }
+ case "user-message": {
+ // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a
+ // WATCHER/late-joiner renders it mid-turn instead of waiting for seal. The
+ // SENDER already echoed its own prompt optimistically (`appendUserMessage`),
+ // so DE-DUP: skip if the trailing provisional chunk is already an identical
+ // user text chunk. A pure watcher has no such echo → it appends and renders.
+ if (event.text.length === 0) return state;
+ const last = state.provisional[state.provisional.length - 1];
+ if (
+ last !== undefined &&
+ last.role === "user" &&
+ last.chunk.type === "text" &&
+ last.chunk.text === event.text
+ ) {
+ return { ...state, generating: true };
+ }
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ return {
+ ...state,
+ provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }],
+ accumulating: null,
+ generating: true,
+ };
+ }
- case "text-delta": {
- const acc = state.accumulating;
- if (acc !== null && acc.kind === "text") {
- return {
- ...state,
- accumulating: { kind: "text", text: acc.text + event.delta },
- generating: true,
- };
- }
- const provisional = flushAccumulating(state.provisional, acc);
- return {
- ...state,
- provisional,
- accumulating: { kind: "text", text: event.delta },
- generating: true,
- };
- }
+ case "text-delta": {
+ const acc = state.accumulating;
+ if (acc !== null && acc.kind === "text") {
+ return {
+ ...state,
+ accumulating: { kind: "text", text: acc.text + event.delta },
+ generating: true,
+ };
+ }
+ const provisional = flushAccumulating(state.provisional, acc);
+ return {
+ ...state,
+ provisional,
+ accumulating: { kind: "text", text: event.delta },
+ generating: true,
+ };
+ }
- case "reasoning-delta": {
- const acc = state.accumulating;
- if (acc !== null && acc.kind === "thinking") {
- return {
- ...state,
- accumulating: { kind: "thinking", text: acc.text + event.delta },
- generating: true,
- };
- }
- const provisional = flushAccumulating(state.provisional, acc);
- return {
- ...state,
- provisional,
- accumulating: { kind: "thinking", text: event.delta },
- generating: true,
- };
- }
+ case "reasoning-delta": {
+ const acc = state.accumulating;
+ if (acc !== null && acc.kind === "thinking") {
+ return {
+ ...state,
+ accumulating: { kind: "thinking", text: acc.text + event.delta },
+ generating: true,
+ };
+ }
+ const provisional = flushAccumulating(state.provisional, acc);
+ return {
+ ...state,
+ provisional,
+ accumulating: { kind: "thinking", text: event.delta },
+ generating: true,
+ };
+ }
- case "tool-call": {
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- const chunk: Chunk = {
- type: "tool-call",
- toolCallId: event.toolCallId,
- toolName: event.toolName,
- input: event.input,
- stepId: event.stepId,
- };
- return {
- ...state,
- provisional: [...provisional, { role: "assistant", chunk }],
- accumulating: null,
- generating: true,
- };
- }
+ case "tool-call": {
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ const chunk: Chunk = {
+ type: "tool-call",
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ input: event.input,
+ stepId: event.stepId,
+ };
+ return {
+ ...state,
+ provisional: [...provisional, { role: "assistant", chunk }],
+ accumulating: null,
+ generating: true,
+ };
+ }
- case "tool-result": {
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- const chunk: Chunk = {
- type: "tool-result",
- toolCallId: event.toolCallId,
- toolName: event.toolName,
- content: event.content,
- isError: event.isError,
- stepId: event.stepId,
- };
- return {
- ...state,
- provisional: [...provisional, { role: "tool", chunk }],
- accumulating: null,
- generating: true,
- };
- }
+ case "tool-result": {
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ const chunk: Chunk = {
+ type: "tool-result",
+ toolCallId: event.toolCallId,
+ toolName: event.toolName,
+ content: event.content,
+ isError: event.isError,
+ stepId: event.stepId,
+ };
+ return {
+ ...state,
+ provisional: [...provisional, { role: "tool", chunk }],
+ accumulating: null,
+ generating: true,
+ };
+ }
- case "error": {
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- const chunk: Chunk =
- event.code !== undefined
- ? { type: "error", message: event.message, code: event.code }
- : { type: "error", message: event.message };
- return {
- ...state,
- provisional: [...provisional, { role: "assistant", chunk }],
- accumulating: null,
- generating: false,
- };
- }
+ case "error": {
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ const chunk: Chunk =
+ event.code !== undefined
+ ? { type: "error", message: event.message, code: event.code }
+ : { type: "error", message: event.message };
+ return {
+ ...state,
+ provisional: [...provisional, { role: "assistant", chunk }],
+ accumulating: null,
+ generating: false,
+ };
+ }
- case "usage":
- return { ...state, latestUsage: event.usage };
+ case "usage":
+ return { ...state, latestUsage: event.usage };
- case "step-complete":
- // Timing metadata — no content chunk; handled by the telemetry reducer.
- return state;
+ case "step-complete":
+ // Timing metadata — no content chunk; handled by the telemetry reducer.
+ return state;
- case "done": {
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- return {
- ...state,
- provisional,
- accumulating: null,
- generating: false,
- };
- }
+ case "done": {
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ return {
+ ...state,
+ provisional,
+ accumulating: null,
+ generating: false,
+ };
+ }
- case "turn-sealed": {
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- return {
- ...state,
- provisional,
- accumulating: null,
- sealedTurnId: event.turnId,
- generating: false,
- };
- }
+ case "turn-sealed": {
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ return {
+ ...state,
+ provisional,
+ accumulating: null,
+ sealedTurnId: event.turnId,
+ generating: false,
+ };
+ }
- case "steering": {
- // A steering message drained from the queue at a tool-result boundary
- // (the model sees it alongside the tool results). Append a user bubble
- // to the provisional transcript; the turn is still in flight. The queue
- // surface clears separately on drain (a different channel) — no de-dup
- // here (unlike `user-message`, steering is never optimistically echoed
- // into the transcript by the sender).
- if (event.text.length === 0) return state;
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- return {
- ...state,
- provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }],
- accumulating: null,
- generating: true,
- };
- }
+ case "steering": {
+ // A steering message drained from the queue at a tool-result boundary
+ // (the model sees it alongside the tool results). Append a user bubble
+ // to the provisional transcript; the turn is still in flight. The queue
+ // surface clears separately on drain (a different channel) — no de-dup
+ // here (unlike `user-message`, steering is never optimistically echoed
+ // into the transcript by the sender).
+ if (event.text.length === 0) return state;
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ return {
+ ...state,
+ provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }],
+ accumulating: null,
+ generating: true,
+ };
+ }
- case "provider-retry": {
- // TRANSIENT: a retryable provider error is being retried with backoff.
- // Coalesce — the latest attempt + delay replaces any previous, so a
- // single updating "retrying…" banner shows the newest. NOT a chunk: it
- // never enters provisional/committed, so it can never pollute the prompt
- // or be replayed on a reload. The turn is still in flight, so `generating`
- // (already true from `turn-start`) is left untouched.
- return { ...state, providerRetry: event };
- }
- }
+ case "provider-retry": {
+ // TRANSIENT: a retryable provider error is being retried with backoff.
+ // Coalesce — the latest attempt + delay replaces any previous, so a
+ // single updating "retrying…" banner shows the newest. NOT a chunk: it
+ // never enters provisional/committed, so it can never pollute the prompt
+ // or be replayed on a reload. The turn is still in flight, so `generating`
+ // (already true from `turn-start`) is left untouched.
+ return { ...state, providerRetry: event };
+ }
+ }
}
/**
@@ -313,23 +313,23 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
*/
// Events that clear a showing provider-retry banner (content resumed or turn ended).
const RETRY_CLEARING_EVENTS: ReadonlySet<AgentEvent["type"]> = new Set([
- "turn-start",
- "text-delta",
- "reasoning-delta",
- "tool-call",
- "tool-result",
- "error",
- "done",
- "turn-sealed",
+ "turn-start",
+ "text-delta",
+ "reasoning-delta",
+ "tool-call",
+ "tool-result",
+ "error",
+ "done",
+ "turn-sealed",
]);
export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState {
- const next = reduceEvent(state, event);
- if (event.type === "provider-retry") return next; // set by reduceEvent; not a clearing event
- if (RETRY_CLEARING_EVENTS.has(event.type) && state.providerRetry !== null) {
- return { ...next, providerRetry: null };
- }
- return next;
+ const next = reduceEvent(state, event);
+ if (event.type === "provider-retry") return next; // set by reduceEvent; not a clearing event
+ if (RETRY_CLEARING_EVENTS.has(event.type) && state.providerRetry !== null) {
+ return { ...next, providerRetry: null };
+ }
+ return next;
}
/**
@@ -339,11 +339,11 @@ export function foldEvent(state: TranscriptState, event: AgentEvent): Transcript
* the authoritative committed chunks after a turn seals.
*/
export function appendUserMessage(state: TranscriptState, text: string): TranscriptState {
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- const userChunk: Chunk = { type: "text", text };
- return {
- ...state,
- provisional: [...provisional, { role: "user", chunk: userChunk }],
- accumulating: null,
- };
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ const userChunk: Chunk = { type: "text", text };
+ return {
+ ...state,
+ provisional: [...provisional, { role: "user", chunk: userChunk }],
+ accumulating: null,
+ };
}
diff --git a/src/core/chunks/retry-banner.test.ts b/src/core/chunks/retry-banner.test.ts
index ecc2232..1d8c1cd 100644
--- a/src/core/chunks/retry-banner.test.ts
+++ b/src/core/chunks/retry-banner.test.ts
@@ -3,61 +3,61 @@ import { describe, expect, it } from "vitest";
import { formatRetryDelay, viewProviderRetry } from "./retry-banner";
const retry = (
- attempt: number,
- delayMs: number,
- message = "HTTP 429: overloaded",
- code?: string,
+ attempt: number,
+ delayMs: number,
+ message = "HTTP 429: overloaded",
+ code?: string,
): TurnProviderRetryEvent =>
- code !== undefined
- ? {
- type: "provider-retry",
- conversationId: "c1",
- turnId: "t1",
- attempt,
- delayMs,
- message,
- code,
- }
- : { type: "provider-retry", conversationId: "c1", turnId: "t1", attempt, delayMs, message };
+ code !== undefined
+ ? {
+ type: "provider-retry",
+ conversationId: "c1",
+ turnId: "t1",
+ attempt,
+ delayMs,
+ message,
+ code,
+ }
+ : { type: "provider-retry", conversationId: "c1", turnId: "t1", attempt, delayMs, message };
describe("formatRetryDelay", () => {
- it("formats sub-minute delays as seconds", () => {
- expect(formatRetryDelay(5000)).toBe("5s");
- expect(formatRetryDelay(10000)).toBe("10s");
- expect(formatRetryDelay(30000)).toBe("30s");
- });
-
- it("formats minute+ delays as minutes", () => {
- expect(formatRetryDelay(60000)).toBe("1m");
- expect(formatRetryDelay(300000)).toBe("5m");
- expect(formatRetryDelay(1800000)).toBe("30m");
- });
-
- it("rounds to the nearest whole unit", () => {
- expect(formatRetryDelay(5500)).toBe("6s"); // 5.5s -> 6s
- expect(formatRetryDelay(90000)).toBe("2m"); // 1.5m -> 2m
- });
+ it("formats sub-minute delays as seconds", () => {
+ expect(formatRetryDelay(5000)).toBe("5s");
+ expect(formatRetryDelay(10000)).toBe("10s");
+ expect(formatRetryDelay(30000)).toBe("30s");
+ });
+
+ it("formats minute+ delays as minutes", () => {
+ expect(formatRetryDelay(60000)).toBe("1m");
+ expect(formatRetryDelay(300000)).toBe("5m");
+ expect(formatRetryDelay(1800000)).toBe("30m");
+ });
+
+ it("rounds to the nearest whole unit", () => {
+ expect(formatRetryDelay(5500)).toBe("6s"); // 5.5s -> 6s
+ expect(formatRetryDelay(90000)).toBe("2m"); // 1.5m -> 2m
+ });
});
describe("viewProviderRetry", () => {
- it("labels the attempt 1-based (attempt 0 = Retry #1)", () => {
- expect(viewProviderRetry(retry(0, 5000)).attemptLabel).toBe("Retry #1");
- expect(viewProviderRetry(retry(1, 10000)).attemptLabel).toBe("Retry #2");
- expect(viewProviderRetry(retry(7, 1800000)).attemptLabel).toBe("Retry #8");
- });
-
- it("derives the delay label from delayMs", () => {
- expect(viewProviderRetry(retry(0, 5000)).delayLabel).toBe("5s");
- expect(viewProviderRetry(retry(4, 300000)).delayLabel).toBe("5m");
- });
-
- it("passes the endpoint error verbatim", () => {
- const msg = 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}';
- expect(viewProviderRetry(retry(0, 5000, msg)).message).toBe(msg);
- });
-
- it("surfaces the code when present, null when absent", () => {
- expect(viewProviderRetry(retry(0, 5000, "msg", "429")).code).toBe("429");
- expect(viewProviderRetry(retry(0, 5000, "msg")).code).toBeNull();
- });
+ it("labels the attempt 1-based (attempt 0 = Retry #1)", () => {
+ expect(viewProviderRetry(retry(0, 5000)).attemptLabel).toBe("Retry #1");
+ expect(viewProviderRetry(retry(1, 10000)).attemptLabel).toBe("Retry #2");
+ expect(viewProviderRetry(retry(7, 1800000)).attemptLabel).toBe("Retry #8");
+ });
+
+ it("derives the delay label from delayMs", () => {
+ expect(viewProviderRetry(retry(0, 5000)).delayLabel).toBe("5s");
+ expect(viewProviderRetry(retry(4, 300000)).delayLabel).toBe("5m");
+ });
+
+ it("passes the endpoint error verbatim", () => {
+ const msg = 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}';
+ expect(viewProviderRetry(retry(0, 5000, msg)).message).toBe(msg);
+ });
+
+ it("surfaces the code when present, null when absent", () => {
+ expect(viewProviderRetry(retry(0, 5000, "msg", "429")).code).toBe("429");
+ expect(viewProviderRetry(retry(0, 5000, "msg")).code).toBeNull();
+ });
});
diff --git a/src/core/chunks/retry-banner.ts b/src/core/chunks/retry-banner.ts
index a00748a..afa6a98 100644
--- a/src/core/chunks/retry-banner.ts
+++ b/src/core/chunks/retry-banner.ts
@@ -14,14 +14,14 @@ import type { TurnProviderRetryEvent } from "@dispatch/wire";
/** The display shape for a provider-retry banner. */
export interface ProviderRetryView {
- /** "Retry #N" — `attempt` is 0-based, so +1 (attempt 0 = "Retry #1"). */
- readonly attemptLabel: string;
- /** The scheduled sleep as a short duration: "5s" / "30s" / "1m" / "5m" / "30m". */
- readonly delayLabel: string;
- /** The endpoint's error verbatim, e.g. "HTTP 429: {…overloaded_error…}". */
- readonly message: string;
- /** The HTTP code when known (e.g. "429"), else null. */
- readonly code: string | null;
+ /** "Retry #N" — `attempt` is 0-based, so +1 (attempt 0 = "Retry #1"). */
+ readonly attemptLabel: string;
+ /** The scheduled sleep as a short duration: "5s" / "30s" / "1m" / "5m" / "30m". */
+ readonly delayLabel: string;
+ /** The endpoint's error verbatim, e.g. "HTTP 429: {…overloaded_error…}". */
+ readonly message: string;
+ /** The HTTP code when known (e.g. "429"), else null. */
+ readonly code: string | null;
}
/**
@@ -30,11 +30,11 @@ export interface ProviderRetryView {
* shows seconds, under an hour shows minutes, else hours.
*/
export function formatRetryDelay(ms: number): string {
- const totalSeconds = Math.round(ms / 1000);
- if (totalSeconds < 60) return `${totalSeconds}s`;
- const totalMinutes = Math.round(totalSeconds / 60);
- if (totalMinutes < 60) return `${totalMinutes}m`;
- return `${Math.round(totalMinutes / 60)}h`;
+ const totalSeconds = Math.round(ms / 1000);
+ if (totalSeconds < 60) return `${totalSeconds}s`;
+ const totalMinutes = Math.round(totalSeconds / 60);
+ if (totalMinutes < 60) return `${totalMinutes}m`;
+ return `${Math.round(totalMinutes / 60)}h`;
}
/**
@@ -42,10 +42,10 @@ export function formatRetryDelay(ms: number): string {
* retry about to happen), so the label is 1-based for the user.
*/
export function viewProviderRetry(event: TurnProviderRetryEvent): ProviderRetryView {
- return {
- attemptLabel: `Retry #${event.attempt + 1}`,
- delayLabel: formatRetryDelay(event.delayMs),
- message: event.message,
- code: event.code ?? null,
- };
+ return {
+ attemptLabel: `Retry #${event.attempt + 1}`,
+ delayLabel: formatRetryDelay(event.delayMs),
+ message: event.message,
+ code: event.code ?? null,
+ };
}
diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts
index 3d91559..e46d5f5 100644
--- a/src/core/chunks/selectors.ts
+++ b/src/core/chunks/selectors.ts
@@ -6,21 +6,21 @@ import type { RenderedChunk, TranscriptState } from "./types";
* then provisional (seq: null).
*/
export function selectChunks(state: TranscriptState): readonly RenderedChunk[] {
- const result: RenderedChunk[] = [];
- for (const c of state.committed) {
- result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false });
- }
- for (const p of state.provisional) {
- result.push({ seq: null, role: p.role, chunk: p.chunk, provisional: true });
- }
- if (state.accumulating !== null) {
- const chunk: Chunk =
- state.accumulating.kind === "text"
- ? { type: "text", text: state.accumulating.text }
- : { type: "thinking", text: state.accumulating.text };
- result.push({ seq: null, role: "assistant", chunk, provisional: true, streaming: true });
- }
- return result;
+ const result: RenderedChunk[] = [];
+ for (const c of state.committed) {
+ result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false });
+ }
+ for (const p of state.provisional) {
+ result.push({ seq: null, role: p.role, chunk: p.chunk, provisional: true });
+ }
+ if (state.accumulating !== null) {
+ const chunk: Chunk =
+ state.accumulating.kind === "text"
+ ? { type: "text", text: state.accumulating.text }
+ : { type: "thinking", text: state.accumulating.text };
+ result.push({ seq: null, role: "assistant", chunk, provisional: true, streaming: true });
+ }
+ return result;
}
/**
@@ -29,7 +29,7 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] {
* reconnected client whose in-flight turn was replayed.
*/
export function selectGenerating(state: TranscriptState): boolean {
- return state.generating;
+ return state.generating;
}
/**
@@ -38,32 +38,32 @@ export function selectGenerating(state: TranscriptState): boolean {
* by ChatView). Never persisted — see `TranscriptState.providerRetry`.
*/
export function selectProviderRetry(state: TranscriptState): TurnProviderRetryEvent | null {
- return state.providerRetry;
+ return state.providerRetry;
}
/**
* Group consecutive same-role rendered chunks into ChatMessages.
*/
export function selectMessages(state: TranscriptState): readonly ChatMessage[] {
- const rendered = selectChunks(state);
- const first = rendered[0];
- if (first === undefined) return [];
+ const rendered = selectChunks(state);
+ const first = rendered[0];
+ if (first === undefined) return [];
- const messages: ChatMessage[] = [];
- let role = first.role;
- let chunks: Chunk[] = [first.chunk];
+ const messages: ChatMessage[] = [];
+ let role = first.role;
+ let chunks: Chunk[] = [first.chunk];
- for (let i = 1; i < rendered.length; i++) {
- const rc = rendered[i];
- if (rc === undefined) continue;
- if (rc.role === role) {
- chunks.push(rc.chunk);
- } else {
- messages.push({ role, chunks });
- role = rc.role;
- chunks = [rc.chunk];
- }
- }
- messages.push({ role, chunks });
- return messages;
+ for (let i = 1; i < rendered.length; i++) {
+ const rc = rendered[i];
+ if (rc === undefined) continue;
+ if (rc.role === role) {
+ chunks.push(rc.chunk);
+ } else {
+ messages.push({ role, chunks });
+ role = rc.role;
+ chunks = [rc.chunk];
+ }
+ }
+ messages.push({ role, chunks });
+ return messages;
}
diff --git a/src/core/chunks/trim.test.ts b/src/core/chunks/trim.test.ts
index aa4b0e3..7c4bbef 100644
--- a/src/core/chunks/trim.test.ts
+++ b/src/core/chunks/trim.test.ts
@@ -2,234 +2,234 @@ import type { StoredChunk } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { applyHistory, initialState } from "./reducer";
import {
- DEFAULT_CHAT_LIMIT,
- initialWindowSize,
- MAX_CHAT_LIMIT,
- MIN_CHAT_LIMIT,
- normalizeChatLimit,
- restoreEarlier,
- selectHasEarlier,
- trimTranscript,
- unloadCount,
- windowTranscript,
+ DEFAULT_CHAT_LIMIT,
+ initialWindowSize,
+ MAX_CHAT_LIMIT,
+ MIN_CHAT_LIMIT,
+ normalizeChatLimit,
+ restoreEarlier,
+ selectHasEarlier,
+ trimTranscript,
+ unloadCount,
+ windowTranscript,
} from "./trim";
import type { TranscriptState } from "./types";
function chunk(seq: number, type: "text" | "thinking" = "text"): StoredChunk {
- return { seq, role: "assistant", chunk: { type, text: `c${seq}` } };
+ return { seq, role: "assistant", chunk: { type, text: `c${seq}` } };
}
function chunks(from: number, to: number): StoredChunk[] {
- const out: StoredChunk[] = [];
- for (let seq = from; seq <= to; seq++) out.push(chunk(seq));
- return out;
+ const out: StoredChunk[] = [];
+ for (let seq = from; seq <= to; seq++) out.push(chunk(seq));
+ return out;
}
function stateWith(committed: readonly StoredChunk[]): TranscriptState {
- return { ...initialState(), committed };
+ return { ...initialState(), committed };
}
describe("normalizeChatLimit", () => {
- it("defaults non-numeric / NaN / missing values", () => {
- expect(normalizeChatLimit(undefined)).toBe(DEFAULT_CHAT_LIMIT);
- expect(normalizeChatLimit(null)).toBe(DEFAULT_CHAT_LIMIT);
- expect(normalizeChatLimit("100")).toBe(DEFAULT_CHAT_LIMIT);
- expect(normalizeChatLimit(Number.NaN)).toBe(DEFAULT_CHAT_LIMIT);
- expect(normalizeChatLimit(Number.POSITIVE_INFINITY)).toBe(DEFAULT_CHAT_LIMIT);
- });
-
- it("floors and clamps numeric values", () => {
- expect(normalizeChatLimit(100.9)).toBe(100);
- expect(normalizeChatLimit(0)).toBe(MIN_CHAT_LIMIT);
- expect(normalizeChatLimit(-5)).toBe(MIN_CHAT_LIMIT);
- expect(normalizeChatLimit(10_000_000)).toBe(MAX_CHAT_LIMIT);
- expect(normalizeChatLimit(256)).toBe(256);
- });
+ it("defaults non-numeric / NaN / missing values", () => {
+ expect(normalizeChatLimit(undefined)).toBe(DEFAULT_CHAT_LIMIT);
+ expect(normalizeChatLimit(null)).toBe(DEFAULT_CHAT_LIMIT);
+ expect(normalizeChatLimit("100")).toBe(DEFAULT_CHAT_LIMIT);
+ expect(normalizeChatLimit(Number.NaN)).toBe(DEFAULT_CHAT_LIMIT);
+ expect(normalizeChatLimit(Number.POSITIVE_INFINITY)).toBe(DEFAULT_CHAT_LIMIT);
+ });
+
+ it("floors and clamps numeric values", () => {
+ expect(normalizeChatLimit(100.9)).toBe(100);
+ expect(normalizeChatLimit(0)).toBe(MIN_CHAT_LIMIT);
+ expect(normalizeChatLimit(-5)).toBe(MIN_CHAT_LIMIT);
+ expect(normalizeChatLimit(10_000_000)).toBe(MAX_CHAT_LIMIT);
+ expect(normalizeChatLimit(256)).toBe(256);
+ });
});
describe("unloadCount / initialWindowSize", () => {
- it("unload is a quarter of the limit, rounded up", () => {
- expect(unloadCount(100)).toBe(25);
- expect(unloadCount(256)).toBe(64);
- expect(unloadCount(10)).toBe(3);
- });
-
- it("initial window is 75% of the limit, rounded down", () => {
- expect(initialWindowSize(100)).toBe(75);
- expect(initialWindowSize(256)).toBe(192);
- expect(initialWindowSize(1)).toBe(1); // never below 1
- });
+ it("unload is a quarter of the limit, rounded up", () => {
+ expect(unloadCount(100)).toBe(25);
+ expect(unloadCount(256)).toBe(64);
+ expect(unloadCount(10)).toBe(3);
+ });
+
+ it("initial window is 75% of the limit, rounded down", () => {
+ expect(initialWindowSize(100)).toBe(75);
+ expect(initialWindowSize(256)).toBe(192);
+ expect(initialWindowSize(1)).toBe(1); // never below 1
+ });
});
describe("trimTranscript", () => {
- it("is the identity at or under the limit", () => {
- const at = stateWith(chunks(1, 100));
- expect(trimTranscript(at, 100)).toBe(at);
- const under = stateWith(chunks(1, 99));
- expect(trimTranscript(under, 100)).toBe(under);
- });
-
- it("unloads exactly a quarter when the limit is first exceeded (100 → 101 drops 25)", () => {
- const state = stateWith(chunks(1, 101));
- const next = trimTranscript(state, 100);
- expect(next.committed).toHaveLength(76);
- expect(next.committed[0]?.seq).toBe(26);
- expect(next.hiddenBeforeSeq).toBe(26);
- });
-
- it("unloads multiple quarters when trimming was deferred far past the limit", () => {
- const state = stateWith(chunks(1, 130));
- const next = trimTranscript(state, 100);
- // 130 → needs 2 quarters (25 each) to get to ≤ 100 → 80 remain.
- expect(next.committed).toHaveLength(80);
- expect(next.committed[0]?.seq).toBe(51);
- expect(next.hiddenBeforeSeq).toBe(51);
- });
-
- it("counts provisional + accumulating toward the limit (drops committed first)", () => {
- const base = stateWith(chunks(1, 98));
- const state: TranscriptState = {
- ...base,
- provisional: [
- { role: "user", chunk: { type: "text", text: "q" } },
- { role: "assistant", chunk: { type: "text", text: "a" } },
- ],
- accumulating: { kind: "text", text: "stream" },
- };
- // 98 + 2 + 1 = 101 > 100 → drop 25 committed.
- const next = trimTranscript(state, 100);
- expect(next.committed).toHaveLength(73);
- expect(next.provisional).toHaveLength(2);
- expect(next.accumulating).not.toBeNull();
- });
-
- 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(8);
- expect(next.hiddenBeforeSeq).toBe(3);
- });
-
- it("accumulates the hidden thinking count for stable render keys", () => {
- const committed = [chunk(1, "thinking"), ...chunks(2, 9), chunk(10, "thinking"), chunk(11)];
- const state = stateWith(committed);
- const next = trimTranscript(state, 10); // 11 > 10 → drop ceil(10/4)=3 oldest
- expect(next.committed[0]?.seq).toBe(4);
- expect(next.hiddenThinkingCount).toBe(1);
- });
-
- it("ignores a nonsensical limit", () => {
- const state = stateWith(chunks(1, 50));
- expect(trimTranscript(state, 0)).toBe(state);
- expect(trimTranscript(state, Number.NaN)).toBe(state);
- });
+ it("is the identity at or under the limit", () => {
+ const at = stateWith(chunks(1, 100));
+ expect(trimTranscript(at, 100)).toBe(at);
+ const under = stateWith(chunks(1, 99));
+ expect(trimTranscript(under, 100)).toBe(under);
+ });
+
+ it("unloads exactly a quarter when the limit is first exceeded (100 → 101 drops 25)", () => {
+ const state = stateWith(chunks(1, 101));
+ const next = trimTranscript(state, 100);
+ expect(next.committed).toHaveLength(76);
+ expect(next.committed[0]?.seq).toBe(26);
+ expect(next.hiddenBeforeSeq).toBe(26);
+ });
+
+ it("unloads multiple quarters when trimming was deferred far past the limit", () => {
+ const state = stateWith(chunks(1, 130));
+ const next = trimTranscript(state, 100);
+ // 130 → needs 2 quarters (25 each) to get to ≤ 100 → 80 remain.
+ expect(next.committed).toHaveLength(80);
+ expect(next.committed[0]?.seq).toBe(51);
+ expect(next.hiddenBeforeSeq).toBe(51);
+ });
+
+ it("counts provisional + accumulating toward the limit (drops committed first)", () => {
+ const base = stateWith(chunks(1, 98));
+ const state: TranscriptState = {
+ ...base,
+ provisional: [
+ { role: "user", chunk: { type: "text", text: "q" } },
+ { role: "assistant", chunk: { type: "text", text: "a" } },
+ ],
+ accumulating: { kind: "text", text: "stream" },
+ };
+ // 98 + 2 + 1 = 101 > 100 → drop 25 committed.
+ const next = trimTranscript(state, 100);
+ expect(next.committed).toHaveLength(73);
+ expect(next.provisional).toHaveLength(2);
+ expect(next.accumulating).not.toBeNull();
+ });
+
+ 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(8);
+ expect(next.hiddenBeforeSeq).toBe(3);
+ });
+
+ it("accumulates the hidden thinking count for stable render keys", () => {
+ const committed = [chunk(1, "thinking"), ...chunks(2, 9), chunk(10, "thinking"), chunk(11)];
+ const state = stateWith(committed);
+ const next = trimTranscript(state, 10); // 11 > 10 → drop ceil(10/4)=3 oldest
+ expect(next.committed[0]?.seq).toBe(4);
+ expect(next.hiddenThinkingCount).toBe(1);
+ });
+
+ it("ignores a nonsensical limit", () => {
+ const state = stateWith(chunks(1, 50));
+ expect(trimTranscript(state, 0)).toBe(state);
+ expect(trimTranscript(state, Number.NaN)).toBe(state);
+ });
});
describe("windowTranscript", () => {
- it("keeps only the newest maxCommitted chunks and sets the watermark", () => {
- const state = stateWith(chunks(1, 1000));
- const next = windowTranscript(state, 75);
- expect(next.committed).toHaveLength(75);
- expect(next.committed[0]?.seq).toBe(926);
- expect(next.hiddenBeforeSeq).toBe(926);
- expect(selectHasEarlier(next)).toBe(true);
- });
-
- it("is the identity within the window", () => {
- const state = stateWith(chunks(1, 50));
- expect(windowTranscript(state, 75)).toBe(state);
- expect(selectHasEarlier(state)).toBe(false);
- });
+ it("keeps only the newest maxCommitted chunks and sets the watermark", () => {
+ const state = stateWith(chunks(1, 1000));
+ const next = windowTranscript(state, 75);
+ expect(next.committed).toHaveLength(75);
+ expect(next.committed[0]?.seq).toBe(926);
+ expect(next.hiddenBeforeSeq).toBe(926);
+ expect(selectHasEarlier(next)).toBe(true);
+ });
+
+ it("is the identity within the window", () => {
+ const state = stateWith(chunks(1, 50));
+ expect(windowTranscript(state, 75)).toBe(state);
+ expect(selectHasEarlier(state)).toBe(false);
+ });
});
describe("applyHistory respects the watermark", () => {
- it("does not resurrect chunks below hiddenBeforeSeq on a full-cache merge", () => {
- const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100);
- expect(trimmed.hiddenBeforeSeq).toBe(26);
- // A later sync merges the FULL cache (seqs 1..101) — the unloaded prefix must stay out.
- const merged = applyHistory(trimmed, chunks(1, 101));
- expect(merged.committed[0]?.seq).toBe(26);
- expect(merged.committed).toHaveLength(76);
- });
-
- it("still merges the tail above the watermark", () => {
- const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100);
- const merged = applyHistory(trimmed, chunks(100, 110));
- expect(merged.committed[merged.committed.length - 1]?.seq).toBe(110);
- expect(merged.committed[0]?.seq).toBe(26);
- });
+ it("does not resurrect chunks below hiddenBeforeSeq on a full-cache merge", () => {
+ const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100);
+ expect(trimmed.hiddenBeforeSeq).toBe(26);
+ // A later sync merges the FULL cache (seqs 1..101) — the unloaded prefix must stay out.
+ const merged = applyHistory(trimmed, chunks(1, 101));
+ expect(merged.committed[0]?.seq).toBe(26);
+ expect(merged.committed).toHaveLength(76);
+ });
+
+ it("still merges the tail above the watermark", () => {
+ const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100);
+ const merged = applyHistory(trimmed, chunks(100, 110));
+ expect(merged.committed[merged.committed.length - 1]?.seq).toBe(110);
+ expect(merged.committed[0]?.seq).toBe(26);
+ });
});
describe("restoreEarlier", () => {
- it("pages the newest `count` earlier chunks back in and lowers the watermark", () => {
- const windowed = windowTranscript(stateWith(chunks(1, 1000)), 75); // loaded 926..1000
- const restored = restoreEarlier(windowed, chunks(1, 1000), 64);
- expect(restored.committed[0]?.seq).toBe(862);
- expect(restored.committed).toHaveLength(75 + 64);
- expect(restored.hiddenBeforeSeq).toBe(862);
- expect(selectHasEarlier(restored)).toBe(true);
- });
-
- it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => {
- const windowed = windowTranscript(stateWith(chunks(1, 100)), 75); // hidden: 1..25
- const restored = restoreEarlier(windowed, chunks(1, 100), 64);
- expect(restored.committed).toHaveLength(100);
- expect(restored.committed[0]?.seq).toBe(1);
- expect(restored.hiddenBeforeSeq).toBe(1); // floor at the origin — inert
- expect(restored.hiddenThinkingCount).toBe(0);
- expect(selectHasEarlier(restored)).toBe(false);
- });
-
- it("is the identity when nothing older is known locally (server may still hold more)", () => {
- const windowed = windowTranscript(stateWith(chunks(50, 200)), 75);
- const restored = restoreEarlier(windowed, [], 64);
- expect(restored).toBe(windowed);
- // seqs are 1-based gap-free: window starts at 126 ⇒ older chunks DO exist.
- expect(selectHasEarlier(restored)).toBe(true);
- });
-
- it("is the identity when the window already starts at seq 1", () => {
- const state = stateWith(chunks(1, 10));
- expect(restoreEarlier(state, chunks(1, 10), 5)).toBe(state);
- });
-
- it("works on a server-windowed transcript (no local watermark)", () => {
- // A cold-cache fresh load with `?limit=` commits a suffix (seq 809..1000)
- // with hiddenBeforeSeq still 0 — hasEarlier derives from seq > 1, and a
- // backfilled run merges below it.
- const state = stateWith(chunks(809, 1000));
- expect(state.hiddenBeforeSeq).toBe(0);
- expect(selectHasEarlier(state)).toBe(true);
- const restored = restoreEarlier(state, chunks(745, 808), 64);
- expect(restored.committed[0]?.seq).toBe(745);
- expect(restored.committed).toHaveLength(192 + 64);
- expect(restored.hiddenBeforeSeq).toBe(745);
- expect(selectHasEarlier(restored)).toBe(true);
- });
-
- it("decrements the hidden thinking count by the restored thinking chunks", () => {
- const committed = [chunk(1, "thinking"), chunk(2), chunk(3, "thinking"), ...chunks(4, 12)];
- const trimmed = trimTranscript(stateWith(committed), 10); // drops 3: seqs 1..3 (2 thinking)
- expect(trimmed.hiddenThinkingCount).toBe(2);
- const restored = restoreEarlier(trimmed, committed, 2); // restores seqs 2..3 (1 thinking)
- expect(restored.hiddenBeforeSeq).toBe(2);
- expect(restored.hiddenThinkingCount).toBe(1);
- });
-
- it("round-trips with trim: trim → restore-all yields the original committed list", () => {
- const original = chunks(1, 101);
- const trimmed = trimTranscript(stateWith(original), 100);
- const restored = restoreEarlier(trimmed, original, 1000);
- expect(restored.committed).toEqual(original);
- expect(restored.hiddenBeforeSeq).toBe(1);
- expect(selectHasEarlier(restored)).toBe(false);
- });
+ it("pages the newest `count` earlier chunks back in and lowers the watermark", () => {
+ const windowed = windowTranscript(stateWith(chunks(1, 1000)), 75); // loaded 926..1000
+ const restored = restoreEarlier(windowed, chunks(1, 1000), 64);
+ expect(restored.committed[0]?.seq).toBe(862);
+ expect(restored.committed).toHaveLength(75 + 64);
+ expect(restored.hiddenBeforeSeq).toBe(862);
+ expect(selectHasEarlier(restored)).toBe(true);
+ });
+
+ it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => {
+ const windowed = windowTranscript(stateWith(chunks(1, 100)), 75); // hidden: 1..25
+ const restored = restoreEarlier(windowed, chunks(1, 100), 64);
+ expect(restored.committed).toHaveLength(100);
+ expect(restored.committed[0]?.seq).toBe(1);
+ expect(restored.hiddenBeforeSeq).toBe(1); // floor at the origin — inert
+ expect(restored.hiddenThinkingCount).toBe(0);
+ expect(selectHasEarlier(restored)).toBe(false);
+ });
+
+ it("is the identity when nothing older is known locally (server may still hold more)", () => {
+ const windowed = windowTranscript(stateWith(chunks(50, 200)), 75);
+ const restored = restoreEarlier(windowed, [], 64);
+ expect(restored).toBe(windowed);
+ // seqs are 1-based gap-free: window starts at 126 ⇒ older chunks DO exist.
+ expect(selectHasEarlier(restored)).toBe(true);
+ });
+
+ it("is the identity when the window already starts at seq 1", () => {
+ const state = stateWith(chunks(1, 10));
+ expect(restoreEarlier(state, chunks(1, 10), 5)).toBe(state);
+ });
+
+ it("works on a server-windowed transcript (no local watermark)", () => {
+ // A cold-cache fresh load with `?limit=` commits a suffix (seq 809..1000)
+ // with hiddenBeforeSeq still 0 — hasEarlier derives from seq > 1, and a
+ // backfilled run merges below it.
+ const state = stateWith(chunks(809, 1000));
+ expect(state.hiddenBeforeSeq).toBe(0);
+ expect(selectHasEarlier(state)).toBe(true);
+ const restored = restoreEarlier(state, chunks(745, 808), 64);
+ expect(restored.committed[0]?.seq).toBe(745);
+ expect(restored.committed).toHaveLength(192 + 64);
+ expect(restored.hiddenBeforeSeq).toBe(745);
+ expect(selectHasEarlier(restored)).toBe(true);
+ });
+
+ it("decrements the hidden thinking count by the restored thinking chunks", () => {
+ const committed = [chunk(1, "thinking"), chunk(2), chunk(3, "thinking"), ...chunks(4, 12)];
+ const trimmed = trimTranscript(stateWith(committed), 10); // drops 3: seqs 1..3 (2 thinking)
+ expect(trimmed.hiddenThinkingCount).toBe(2);
+ const restored = restoreEarlier(trimmed, committed, 2); // restores seqs 2..3 (1 thinking)
+ expect(restored.hiddenBeforeSeq).toBe(2);
+ expect(restored.hiddenThinkingCount).toBe(1);
+ });
+
+ it("round-trips with trim: trim → restore-all yields the original committed list", () => {
+ const original = chunks(1, 101);
+ const trimmed = trimTranscript(stateWith(original), 100);
+ const restored = restoreEarlier(trimmed, original, 1000);
+ expect(restored.committed).toEqual(original);
+ expect(restored.hiddenBeforeSeq).toBe(1);
+ expect(selectHasEarlier(restored)).toBe(false);
+ });
});
diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts
index 7791721..9846357 100644
--- a/src/core/chunks/trim.ts
+++ b/src/core/chunks/trim.ts
@@ -29,54 +29,54 @@ export const MAX_CHAT_LIMIT = 100_000;
* [MIN_CHAT_LIMIT, MAX_CHAT_LIMIT].
*/
export function normalizeChatLimit(value: unknown): number {
- if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CHAT_LIMIT;
- const n = Math.floor(value);
- if (n < MIN_CHAT_LIMIT) return MIN_CHAT_LIMIT;
- if (n > MAX_CHAT_LIMIT) return MAX_CHAT_LIMIT;
- return n;
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CHAT_LIMIT;
+ const n = Math.floor(value);
+ if (n < MIN_CHAT_LIMIT) return MIN_CHAT_LIMIT;
+ if (n > MAX_CHAT_LIMIT) return MAX_CHAT_LIMIT;
+ return n;
}
/** The bulk-unload unit: a quarter of the limit, rounded up. */
export function unloadCount(limit: number): number {
- return Math.ceil(limit / 4);
+ return Math.ceil(limit / 4);
}
/** The fresh-load window: 75% of the limit, rounded down (≥ 1). */
export function initialWindowSize(limit: number): number {
- return Math.max(1, Math.floor(limit * 0.75));
+ return Math.max(1, Math.floor(limit * 0.75));
}
/** Total loaded (rendered) chunk count: committed + provisional + accumulating. */
function totalCount(state: TranscriptState): number {
- return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0);
+ return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0);
}
function countThinking(chunks: readonly StoredChunk[]): number {
- let n = 0;
- for (const c of chunks) {
- if (c.chunk.type === "thinking") n++;
- }
- return n;
+ let n = 0;
+ for (const c of chunks) {
+ if (c.chunk.type === "thinking") n++;
+ }
+ return n;
}
/** Drop the `drop` oldest committed chunks, advancing the watermark + thinking base. */
function dropOldest(state: TranscriptState, drop: number): TranscriptState {
- const dropped = state.committed.slice(0, drop);
- const kept = state.committed.slice(drop);
- const first = kept[0];
- const lastDropped = dropped[dropped.length - 1];
- let hiddenBeforeSeq = state.hiddenBeforeSeq;
- if (first !== undefined) {
- hiddenBeforeSeq = first.seq;
- } else if (lastDropped !== undefined) {
- hiddenBeforeSeq = lastDropped.seq + 1;
- }
- return {
- ...state,
- committed: kept,
- hiddenBeforeSeq,
- hiddenThinkingCount: state.hiddenThinkingCount + countThinking(dropped),
- };
+ const dropped = state.committed.slice(0, drop);
+ const kept = state.committed.slice(drop);
+ const first = kept[0];
+ const lastDropped = dropped[dropped.length - 1];
+ let hiddenBeforeSeq = state.hiddenBeforeSeq;
+ if (first !== undefined) {
+ hiddenBeforeSeq = first.seq;
+ } else if (lastDropped !== undefined) {
+ hiddenBeforeSeq = lastDropped.seq + 1;
+ }
+ return {
+ ...state,
+ committed: kept,
+ hiddenBeforeSeq,
+ hiddenThinkingCount: state.hiddenThinkingCount + countThinking(dropped),
+ };
}
/**
@@ -89,36 +89,36 @@ function dropOldest(state: TranscriptState, drop: number): TranscriptState {
* 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;
- const total = totalCount(state);
- if (total <= limit) return state;
- const quarter = unloadCount(limit);
- const passes = Math.ceil((total - limit) / quarter);
-
- // 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;
+ if (!Number.isFinite(limit) || limit <= 0) return state;
+ const total = totalCount(state);
+ if (total <= limit) return state;
+ const quarter = unloadCount(limit);
+ const passes = Math.ceil((total - limit) / quarter);
+
+ // 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;
}
/**
@@ -127,10 +127,10 @@ export function trimTranscript(state: TranscriptState, limit: number): Transcrip
* already within the window.
*/
export function windowTranscript(state: TranscriptState, maxCommitted: number): TranscriptState {
- if (!Number.isFinite(maxCommitted) || maxCommitted < 0) return state;
- const drop = state.committed.length - maxCommitted;
- if (drop <= 0) return state;
- return dropOldest(state, drop);
+ if (!Number.isFinite(maxCommitted) || maxCommitted < 0) return state;
+ const drop = state.committed.length - maxCommitted;
+ if (drop <= 0) return state;
+ return dropOldest(state, drop);
}
/**
@@ -139,7 +139,7 @@ export function windowTranscript(state: TranscriptState, maxCommitted: number):
* committed list (all-provisional overflow). 0 = window start unknown/origin.
*/
function oldestLoadedSeq(state: TranscriptState): number {
- return state.committed[0]?.seq ?? state.hiddenBeforeSeq;
+ return state.committed[0]?.seq ?? state.hiddenBeforeSeq;
}
/**
@@ -154,22 +154,22 @@ function oldestLoadedSeq(state: TranscriptState): number {
* contractual origin) or nothing older is known locally.
*/
export function restoreEarlier(
- state: TranscriptState,
- earlier: readonly StoredChunk[],
- count: number,
+ state: TranscriptState,
+ earlier: readonly StoredChunk[],
+ count: number,
): TranscriptState {
- const oldest = oldestLoadedSeq(state);
- if (oldest <= 1) return state;
- const below = earlier.filter((c) => c.seq < oldest).sort((a, b) => a.seq - b.seq);
- if (below.length === 0) return state;
- const keep = below.slice(-Math.max(1, count));
- const firstKept = keep[0];
- return {
- ...state,
- committed: [...keep, ...state.committed],
- hiddenBeforeSeq: firstKept?.seq ?? state.hiddenBeforeSeq,
- hiddenThinkingCount: Math.max(0, state.hiddenThinkingCount - countThinking(keep)),
- };
+ const oldest = oldestLoadedSeq(state);
+ if (oldest <= 1) return state;
+ const below = earlier.filter((c) => c.seq < oldest).sort((a, b) => a.seq - b.seq);
+ if (below.length === 0) return state;
+ const keep = below.slice(-Math.max(1, count));
+ const firstKept = keep[0];
+ return {
+ ...state,
+ committed: [...keep, ...state.committed],
+ hiddenBeforeSeq: firstKept?.seq ?? state.hiddenBeforeSeq,
+ hiddenThinkingCount: Math.max(0, state.hiddenThinkingCount - countThinking(keep)),
+ };
}
/**
@@ -181,5 +181,5 @@ export function restoreEarlier(
* fresh load.
*/
export function selectHasEarlier(state: TranscriptState): boolean {
- return oldestLoadedSeq(state) > 1;
+ return oldestLoadedSeq(state) > 1;
}
diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts
index 32b9984..2ced736 100644
--- a/src/core/chunks/types.ts
+++ b/src/core/chunks/types.ts
@@ -2,76 +2,76 @@ import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@d
/** A chunk being accumulated from streaming deltas (text or thinking). */
export interface AccumulatingChunk {
- readonly kind: "text" | "thinking";
- readonly text: string;
+ readonly kind: "text" | "thinking";
+ readonly text: string;
}
/** A provisional chunk that has no authoritative seq yet. */
export interface ProvisionalChunk {
- readonly role: Role;
- readonly chunk: Chunk;
+ readonly role: Role;
+ readonly chunk: Chunk;
}
/** The transcript reducer state. Holds committed history + live in-flight turn. */
export interface TranscriptState {
- readonly committed: readonly StoredChunk[];
- readonly provisional: readonly ProvisionalChunk[];
- readonly accumulating: AccumulatingChunk | null;
- readonly currentTurnId: string | null;
- readonly latestUsage: Usage | null;
- readonly sealedTurnId: string | null;
- /**
- * The chat-limit UNLOAD watermark: committed chunks with `seq <` this are
- * unloaded (not in `committed`, not rendered) to keep long transcripts cheap.
- * `0` = nothing unloaded. `applyHistory` refuses chunks below it (a cache/tail
- * merge must not resurrect what the trim dropped); "Show earlier messages"
- * lowers it via `restoreEarlier`. See `trim.ts`.
- */
- readonly hiddenBeforeSeq: number;
- /**
- * How many thinking-type chunks are currently unloaded below the watermark.
- * Pure render-key bookkeeping: the UI keys thinking collapses by ORDINAL (so
- * the key survives the provisional→committed seal transition), and this base
- * keeps those ordinals stable when a trim removes older thinking chunks —
- * otherwise every remaining collapse would shift keys and swap/lose its
- * open state mid-stream.
- */
- readonly hiddenThinkingCount: number;
- /**
- * True while a turn is generating on the server — derived STRUCTURALLY from the
- * event stream: a `turn-start` (or any turn delta) with no matching `done` /
- * `turn-sealed` / `error` yet. A late-joiner that subscribes mid-turn gets the
- * in-flight turn replayed from its `turn-start`, so this lights up for any
- * watching client. NOT inferred from the free-form `status` event string.
- */
- readonly generating: boolean;
- /**
- * The latest `provider-retry` event for the current turn, or `null` when no
- * retry is pending. TRANSIENT UI state (never a Chunk — never committed or
- * provisional, so it can NEVER pollute the model's prompt or be replayed on a
- * reload/replay of past turns: only committed seq'd chunks are history). Set
- * by `foldEvent` on each `provider-retry` (the latest coalesces over previous
- * so a single updating "retrying…" banner shows the newest attempt + delay);
- * cleared when the model's content resumes (`text-delta`/`reasoning-delta`/
- * `tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`),
- * or a new turn starts (`turn-start`). Also cleared on a WS reconnect
- * (`clearGenerating`) — a retry pending at disconnect is stale once we
- * re-subscribe (provider-retry events are not replayed).
- */
- readonly providerRetry: TurnProviderRetryEvent | null;
+ readonly committed: readonly StoredChunk[];
+ readonly provisional: readonly ProvisionalChunk[];
+ readonly accumulating: AccumulatingChunk | null;
+ readonly currentTurnId: string | null;
+ readonly latestUsage: Usage | null;
+ readonly sealedTurnId: string | null;
+ /**
+ * The chat-limit UNLOAD watermark: committed chunks with `seq <` this are
+ * unloaded (not in `committed`, not rendered) to keep long transcripts cheap.
+ * `0` = nothing unloaded. `applyHistory` refuses chunks below it (a cache/tail
+ * merge must not resurrect what the trim dropped); "Show earlier messages"
+ * lowers it via `restoreEarlier`. See `trim.ts`.
+ */
+ readonly hiddenBeforeSeq: number;
+ /**
+ * How many thinking-type chunks are currently unloaded below the watermark.
+ * Pure render-key bookkeeping: the UI keys thinking collapses by ORDINAL (so
+ * the key survives the provisional→committed seal transition), and this base
+ * keeps those ordinals stable when a trim removes older thinking chunks —
+ * otherwise every remaining collapse would shift keys and swap/lose its
+ * open state mid-stream.
+ */
+ readonly hiddenThinkingCount: number;
+ /**
+ * True while a turn is generating on the server — derived STRUCTURALLY from the
+ * event stream: a `turn-start` (or any turn delta) with no matching `done` /
+ * `turn-sealed` / `error` yet. A late-joiner that subscribes mid-turn gets the
+ * in-flight turn replayed from its `turn-start`, so this lights up for any
+ * watching client. NOT inferred from the free-form `status` event string.
+ */
+ readonly generating: boolean;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when no
+ * retry is pending. TRANSIENT UI state (never a Chunk — never committed or
+ * provisional, so it can NEVER pollute the model's prompt or be replayed on a
+ * reload/replay of past turns: only committed seq'd chunks are history). Set
+ * by `foldEvent` on each `provider-retry` (the latest coalesces over previous
+ * so a single updating "retrying…" banner shows the newest attempt + delay);
+ * cleared when the model's content resumes (`text-delta`/`reasoning-delta`/
+ * `tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`),
+ * or a new turn starts (`turn-start`). Also cleared on a WS reconnect
+ * (`clearGenerating`) — a retry pending at disconnect is stale once we
+ * re-subscribe (provider-retry events are not replayed).
+ */
+ readonly providerRetry: TurnProviderRetryEvent | null;
}
/** A chunk ready for rendering: either committed (with seq) or provisional. */
export interface RenderedChunk {
- readonly seq: number | null;
- readonly role: Role;
- readonly chunk: Chunk;
- readonly provisional: boolean;
- /**
- * True only for the single chunk currently being accumulated from live deltas
- * (the in-flight text/thinking the model is actively generating). Absent/false
- * once flushed or committed. Lets the UI show a live indicator (e.g. loading
- * dots on streaming thinking) and drop it the moment generation moves on.
- */
- readonly streaming?: boolean;
+ readonly seq: number | null;
+ readonly role: Role;
+ readonly chunk: Chunk;
+ readonly provisional: boolean;
+ /**
+ * True only for the single chunk currently being accumulated from live deltas
+ * (the in-flight text/thinking the model is actively generating). Absent/false
+ * once flushed or committed. Lets the UI show a live indicator (e.g. loading
+ * dots on streaming thinking) and drop it the moment generation moves on.
+ */
+ readonly streaming?: boolean;
}