diff options
Diffstat (limited to 'src/core/chunks')
| -rw-r--r-- | src/core/chunks/groups.test.ts | 204 | ||||
| -rw-r--r-- | src/core/chunks/groups.ts | 118 | ||||
| -rw-r--r-- | src/core/chunks/image-url.test.ts | 46 | ||||
| -rw-r--r-- | src/core/chunks/image-url.ts | 35 | ||||
| -rw-r--r-- | src/core/chunks/index.ts | 43 | ||||
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 1548 | ||||
| -rw-r--r-- | src/core/chunks/reducer.ts | 568 | ||||
| -rw-r--r-- | src/core/chunks/retry-banner.test.ts | 63 | ||||
| -rw-r--r-- | src/core/chunks/retry-banner.ts | 51 | ||||
| -rw-r--r-- | src/core/chunks/selectors.ts | 81 | ||||
| -rw-r--r-- | src/core/chunks/trim.test.ts | 404 | ||||
| -rw-r--r-- | src/core/chunks/trim.ts | 160 | ||||
| -rw-r--r-- | src/core/chunks/types.ts | 108 |
13 files changed, 2071 insertions, 1358 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/image-url.test.ts b/src/core/chunks/image-url.test.ts new file mode 100644 index 0000000..8a79c09 --- /dev/null +++ b/src/core/chunks/image-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { resolveImageUrl } from "./image-url"; + +const BASE = "http://localhost:24203"; + +describe("resolveImageUrl", () => { + it("returns a data URL as-is (the optimistic echo / a pasted image)", () => { + const dataUrl = "data:image/png;base64,iVBORw0KGgo="; + expect(resolveImageUrl(dataUrl, BASE)).toBe(dataUrl); + }); + + it("returns an absolute http URL as-is", () => { + const abs = "https://example.com/img.png"; + expect(resolveImageUrl(abs, BASE)).toBe(abs); + }); + + it("prepends the api base to a relative /images/ path", () => { + expect(resolveImageUrl("/images/conv-123/abc-456.png", BASE)).toBe( + "http://localhost:24203/images/conv-123/abc-456.png", + ); + }); + + it("does not double the slash when the base has a trailing slash", () => { + expect(resolveImageUrl("/images/c/x.png", "http://localhost:24203/")).toBe( + "http://localhost:24203/images/c/x.png", + ); + }); + + it("adds a leading slash to a path-relative url without one", () => { + expect(resolveImageUrl("images/c/x.png", BASE)).toBe("http://localhost:24203/images/c/x.png"); + }); + + it("returns the relative path as-is when apiBase is empty (root-relative)", () => { + // A browser resolves a root-relative `/images/…` against the document origin. + expect(resolveImageUrl("/images/c/x.png", "")).toBe("/images/c/x.png"); + }); + + it("handles a relative path with an empty apiBase (path-relative without slash)", () => { + expect(resolveImageUrl("images/c/x.png", "")).toBe("/images/c/x.png"); + }); + + it("returns a data URL as-is even with an empty apiBase", () => { + const dataUrl = "data:image/jpeg;base64,AAAA"; + expect(resolveImageUrl(dataUrl, "")).toBe(dataUrl); + }); +}); diff --git a/src/core/chunks/image-url.ts b/src/core/chunks/image-url.ts new file mode 100644 index 0000000..e5ec756 --- /dev/null +++ b/src/core/chunks/image-url.ts @@ -0,0 +1,35 @@ +/** + * Resolve an `ImageChunk.url` into a renderable `<img src>` value. + * + * Persisted image chunks now carry a COMPACT HTTP path + * (`/images/<conversationId>/<uuid>.png`) served by the backend — NOT a base64 + * data URL (images are stored on disk under tmp, not in the conversation store, + * to keep SQLite payloads small). The optimistic echo (what the FE just sent in + * `ChatRequest.images`) still carries a data URL, and a chunk could also carry + * an absolute `http(s)://` URL, so the resolution is format-aware: + * + * - `data:` URL → returned as-is (the optimistic echo / a pasted data URL). + * - `http(s)://` → returned as-is (an absolute URL already). + * - anything else (a relative path like `/images/…`) → `apiBase` is prepended + * (with no double slash). An empty `apiBase` leaves a root-relative path, + * which a browser resolves against the document origin. + * + * Pure: input → output, zero DOM, zero Svelte. + * + * @param url The chunk's `url` (data URL, absolute, or relative path). + * @param apiBase The HTTP API base URL (e.g. `http://localhost:24203`). + */ +export function resolveImageUrl(url: string, apiBase: string): string { + if (url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + // A relative path (e.g. `/images/…`) — normalize to a leading slash and + // prepend the api base. With an empty base this yields a root-relative path + // (a browser resolves `/images/…` against the document origin). + const path = url.startsWith("/") ? url : `/${url}`; + if (apiBase.length === 0) return path; + // Join without a double slash: strip a trailing slash from the base, then + // append the (leading-slash) path verbatim. + const base = apiBase.endsWith("/") ? apiBase.slice(0, -1) : apiBase; + return `${base}${path}`; +} diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index 6ab0f35..bdd6ce4 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,28 +1,31 @@ export type { RenderGroup, ToolBatchEntry } from "./groups"; export { groupRenderedChunks } from "./groups"; +export { resolveImageUrl } from "./image-url"; export { - appendUserMessage, - applyHistory, - clearGenerating, - foldEvent, - initialState, + appendUserMessage, + applyHistory, + clearGenerating, + foldEvent, + initialState, } from "./reducer"; -export { selectChunks, selectGenerating, selectMessages } from "./selectors"; +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 a346545..058552e 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -1,700 +1,1024 @@ import type { - StepId, - StoredChunk, - TurnDoneEvent, - TurnErrorEvent, - TurnInputEvent, - TurnReasoningDeltaEvent, - TurnSealedEvent, - TurnStartEvent, - TurnSteeringEvent, - TurnTextDeltaEvent, - TurnToolCallEvent, - TurnToolResultEvent, - TurnUsageEvent, + ImageInput, + 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 } from "./selectors"; +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, +): TurnProviderRetryEvent => + 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(); + }); +}); + +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(); + }); }); 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("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" }); + }); +}); + +const PNG = "data:image/png;base64,AAAA"; +const JPG = "data:image/jpeg;base64,BBBB"; + +describe("appendUserMessage — images (vision handoff)", () => { + it("echoes text then image chunks in order", () => { + const images: ImageInput[] = [ + { url: PNG, mimeType: "image/png" }, + { url: JPG, mimeType: "image/jpeg" }, + ]; + let s = initialState(); + s = appendUserMessage(s, "what's this?", images); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(3); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what's this?" }); + expect(chunks[1]?.chunk).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); + expect(chunks[2]?.chunk).toEqual({ type: "image", url: JPG, mimeType: "image/jpeg" }); + expect(chunks.every((c) => c.provisional && c.seq === null)).toBe(true); + }); + + it("omits mimeType when not provided", () => { + let s = initialState(); + s = appendUserMessage(s, "look", [{ url: PNG }]); + const img = selectChunks(s)[1]?.chunk; + expect(img).toEqual({ type: "image", url: PNG }); + expect(img).not.toHaveProperty("mimeType"); + }); + + it("echoes images-only when text is empty (no text chunk)", () => { + let s = initialState(); + s = appendUserMessage(s, "", [{ url: PNG, mimeType: "image/png" }]); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.chunk.type).toBe("image"); + }); + + it("echoes nothing for empty text + empty images", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + s = appendUserMessage(s, "", []); + // Defensive flush still happened; no user chunk added. + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(0); + expect(s.accumulating).toBeNull(); + }); + + it("skips images with an empty url", () => { + let s = initialState(); + s = appendUserMessage(s, "hi", [ + { url: "", mimeType: "image/png" }, + { url: PNG, mimeType: "image/png" }, + ]); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(2); // text + the one valid image + expect(chunks[1]?.chunk.type).toBe("image"); + }); + + it("groups text + images into one user ChatMessage", () => { + let s = initialState(); + s = appendUserMessage(s, "see this", [{ url: PNG }]); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(1); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(2); + }); +}); + +describe("foldEvent — user-message dedups against a text+image echo", () => { + const userMessage = (text: string): TurnInputEvent => ({ + type: "user-message", + conversationId: "c1", + turnId: "t1", + text, + }); + + it("does not duplicate the text when images follow it in the echo", () => { + let s = initialState(); + s = appendUserMessage(s, "hi", [{ url: PNG, mimeType: "image/png" }]); + expect(selectChunks(s)).toHaveLength(2); // text + image + s = foldEvent(s, userMessage("hi")); + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(2); // unchanged — no duplicate text + expect(users[0]?.chunk.type).toBe("text"); + expect(users[1]?.chunk.type).toBe("image"); + expect(s.generating).toBe(true); + }); + + it("still appends when the echoed text differs", () => { + let s = initialState(); + s = appendUserMessage(s, "first", [{ url: PNG }]); + s = foldEvent(s, userMessage("second")); + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users.filter((c) => c.chunk.type === "text")).toHaveLength(2); + }); +}); + +describe("applyHistory — multi-chunk image echo is superseded by committed", () => { + it("drops the provisional [text, image] echo when committed arrives during generation", () => { + let s = initialState(); + s = appendUserMessage(s, "hi", [{ url: PNG, mimeType: "image/png" }]); + s = foldEvent(s, turnStart("t1")); + expect(s.provisional).toHaveLength(2); + + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "hi" }), + storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }), + ]); + expect(s.provisional).toEqual([]); + expect(s.committed).toHaveLength(2); + expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hi" }); + expect(s.committed[1]?.chunk).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); + }); + + it("keeps the echo when committed is only a partial match (img not yet persisted)", () => { + let s = initialState(); + s = appendUserMessage(s, "hi", [ + { url: PNG, mimeType: "image/png" }, + { url: JPG, mimeType: "image/jpeg" }, + ]); + s = foldEvent(s, turnStart("t1")); + // Only the text + first image have been persisted so far. + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "hi" }), + storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }), + ]); + // Echo is [text, img1, img2]; committed run is [text, img1] — not a full + // match (echo is longer) → keep the echo (turn-seal will drop it wholesale). + expect(s.provisional.length).toBeGreaterThan(0); + }); + + it("renders committed image chunks from history (a non-vision transcription turn)", () => { + // The server persists the original image chunk AND the transcription text + // in the SAME user message: render both (image, then analysis text). + let s = initialState(); + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "describe this" }), + storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }), + storedChunk(3, "user", { + type: "text", + text: "[Image analysis (via kimi/k2)]: a red square", + }), + storedChunk(4, "assistant", { type: "text", text: "the square is red" }), + ]); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(2); // one user message (3 chunks) + one assistant + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(3); + expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); + }); }); diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 035846c..64e41b9 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -1,19 +1,21 @@ -import type { AgentEvent, Chunk, StoredChunk } from "@dispatch/wire"; +import type { AgentEvent, Chunk, ImageInput, Role, StoredChunk } from "@dispatch/wire"; +import { assertChunkExhaustive } from "../wire/conformance"; import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./types"; /** 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, - }; + return { + committed: [], + provisional: [], + accumulating: null, + currentTurnId: null, + latestUsage: null, + sealedTurnId: null, + hiddenBeforeSeq: 0, + hiddenThinkingCount: 0, + generating: false, + providerRetry: null, + }; } /** @@ -24,18 +26,78 @@ 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; - return { ...state, generating: false }; + 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 }]; +} + +/** + * Content equality for two chunks of the SAME role (used to de-dup an + * optimistic echo against the authoritative committed version). Compares the + * discriminating payload only — `stepId` (generation provenance) is ignored + * since it is absent on provisional echoes but present on committed tool chunks. + */ +function chunkContentEquals(a: Chunk, b: Chunk): boolean { + if (a.type !== b.type) return false; + switch (a.type) { + case "text": { + const o = b as Extract<Chunk, { type: "text" }>; + return a.text === o.text; + } + case "thinking": { + const o = b as Extract<Chunk, { type: "thinking" }>; + return a.text === o.text; + } + case "image": { + const o = b as Extract<Chunk, { type: "image" }>; + return a.url === o.url; + } + case "error": { + const o = b as Extract<Chunk, { type: "error" }>; + return a.message === o.message && a.code === o.code; + } + case "system": { + const o = b as Extract<Chunk, { type: "system" }>; + return a.text === o.text; + } + case "tool-call": { + const o = b as Extract<Chunk, { type: "tool-call" }>; + return a.toolCallId === o.toolCallId && a.toolName === o.toolName; + } + case "tool-result": { + const o = b as Extract<Chunk, { type: "tool-result" }>; + return a.toolCallId === o.toolCallId && a.toolName === o.toolName && a.isError === o.isError; + } + default: + return assertChunkExhaustive(a) === assertChunkExhaustive(b); + } +} + +/** + * The trailing run of consecutive same-role provisional chunks at the end of + * `provisional` (the optimistic echo of one message). Returns the start/end + * indices `[start, end)` into `provisional` (empty if none). + */ +function trailingRun( + provisional: readonly ProvisionalChunk[], + role: Role, +): readonly ProvisionalChunk[] { + const end = provisional.length; + let start = end; + while (start > 0 && provisional[start - 1]?.role === role) start--; + return provisional.slice(start, end); } /** @@ -49,28 +111,59 @@ 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); - for (const c of chunks) { - if (c.seq < state.hiddenBeforeSeq) continue; - 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, + }; + } - return { ...state, committed }; + // During generation: if new committed chunks arrived, the provisional + // array may contain duplicates — the optimistic echo from `appendUserMessage` + // is now backed by committed chunks (CR-6: user message persisted at turn + // start). A user message may be multi-chunk (`[text, image, image, …]`), so + // match the trailing provisional user-run against the trailing committed + // user-run by content equality and drop the whole echo when it is fully + // backed. Leaves the accumulating (streaming) chunk untouched. + if (addedNew && state.generating && state.provisional.length > 0) { + const provRun = trailingRun(state.provisional, "user"); + if (provRun.length > 0) { + const commRun = trailingRun(committed, "user"); + // Drop the provisional user echo iff every echoed chunk is content-equal + // to the corresponding committed chunk (the server persisted the same + // message). A partial match (echo longer than committed) keeps the echo + // — the not-yet-committed tail stays until the turn seals. + const fullyBacked = + commRun.length >= provRun.length && + provRun.every((p, i) => { + const c = commRun[i]; + return c !== undefined && chunkContentEquals(p.chunk, c.chunk); + }); + if (fullyBacked) { + const dropStart = state.provisional.length - provRun.length; + const provisional = state.provisional.slice(0, dropStart); + return { ...state, committed, provisional, accumulating: state.accumulating }; + } + } + } + + return { ...state, committed }; } /** @@ -94,185 +187,260 @@ export function applyHistory( * it true; `done` / `turn-sealed` / `error` clear it. This is what a watching * (or reconnected) client renders as "generating…", with no dependence on the * free-form `status` event string. + * + * NOTE: this is the inner reducer. The transient `provider-retry` banner is SET + * here (on a `provider-retry` event) but CLEARED by the `foldEvent` wrapper + * below, so the clearing logic stays centralized in one place. */ -export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState { - switch (event.type) { - case "status": - case "tool-output": - return state; +function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState { + switch (event.type) { + case "status": + case "tool-output": + return state; + + 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 USER run already contains an + // identical user text chunk. The echo may be multi-chunk (`[text, image, + // image, …]` — `appendUserMessage` appends the text first, then images), so + // we scan the whole trailing user run, not just the last chunk (which would + // be an image when images were pasted). A pure watcher has no such echo → + // it appends and renders. The `user-message` event carries ONLY text (never + // images — images arrive via history/loadSince); an images-only send (empty + // text) emits no `user-message` and is not de-duped here. + if (event.text.length === 0) return state; + const run = trailingRun(state.provisional, "user"); + const alreadyEchoed = run.some((p) => p.chunk.type === "text" && p.chunk.text === event.text); + if (alreadyEchoed) { + 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 "turn-start": - return { ...state, currentTurnId: event.turnId, 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 "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 "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 "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 "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 "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-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-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 "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 "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 "usage": + return { ...state, latestUsage: event.usage }; - 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 "step-complete": + // Timing metadata — no content chunk; handled by the telemetry reducer. + return state; - case "usage": - return { ...state, latestUsage: event.usage }; + case "done": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional, + accumulating: null, + generating: false, + }; + } - case "step-complete": - // Timing metadata — no content chunk; handled by the telemetry reducer. - return state; + case "turn-sealed": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional, + accumulating: null, + sealedTurnId: event.turnId, + generating: false, + }; + } - case "done": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional, - accumulating: null, - 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 "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 "turn-sealed": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional, - accumulating: null, - sealedTurnId: event.turnId, - generating: false, - }; - } +/** + * Fold one live AgentEvent into the transcript state. Wraps `reduceEvent` to + * centralize the TRANSIENT `provider-retry` banner's clearing: the banner is + * SET by `reduceEvent` on a `provider-retry` event (coalescing), and CLEARED + * here when the model's content resumes (the retry succeeded) or the turn ends + * (done/sealed/error) or a new turn starts. Metadata/no-op events + * (`status`/`tool-output`/`usage`/`step-complete`) leave a showing banner + * untouched. The `state.providerRetry !== null` guard keeps the common path + * (no banner pending) identity-stable — no needless new object. + */ +// 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", +]); - 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, - }; - } - } +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; } /** * Optimistically append a user message to the provisional list. * Flushes any in-progress accumulating chunk first (defensively). - * The provisional user chunk is superseded when applyHistory receives + * The provisional user chunks are superseded when applyHistory receives * the authoritative committed chunks after a turn seals. + * + * When `images` are provided, they are appended AFTER the text chunk (in + * order) as `image` chunks — matching the server's persisted layout + * (`[text, image, image, …]` in one user message). A text chunk is only + * appended when `text` is non-empty (an images-only send echoes just the + * images). The `user-message` event carries only text (never images), so its + * de-dup scans the trailing user run for the echoed text rather than just the + * last chunk. */ -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, - }; +export function appendUserMessage( + state: TranscriptState, + text: string, + images?: readonly ImageInput[], +): TranscriptState { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const userChunks: Chunk[] = []; + if (text.length > 0) userChunks.push({ type: "text", text }); + if (images !== undefined) { + for (const img of images) { + if (img.url.length === 0) continue; + const chunk: Chunk = + img.mimeType !== undefined + ? { type: "image", url: img.url, mimeType: img.mimeType } + : { type: "image", url: img.url }; + userChunks.push(chunk); + } + } + if (userChunks.length === 0) { + // Nothing to echo (empty text + no images) — leave state unchanged but + // still flush any accumulating chunk defensively. + return { ...state, provisional, accumulating: null }; + } + return { + ...state, + provisional: [...provisional, ...userChunks.map((chunk) => ({ role: "user", chunk }) as const)], + accumulating: null, + }; } diff --git a/src/core/chunks/retry-banner.test.ts b/src/core/chunks/retry-banner.test.ts new file mode 100644 index 0000000..1d8c1cd --- /dev/null +++ b/src/core/chunks/retry-banner.test.ts @@ -0,0 +1,63 @@ +import type { TurnProviderRetryEvent } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { formatRetryDelay, viewProviderRetry } from "./retry-banner"; + +const retry = ( + 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 }; + +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 + }); +}); + +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(); + }); +}); diff --git a/src/core/chunks/retry-banner.ts b/src/core/chunks/retry-banner.ts new file mode 100644 index 0000000..afa6a98 --- /dev/null +++ b/src/core/chunks/retry-banner.ts @@ -0,0 +1,51 @@ +import type { TurnProviderRetryEvent } from "@dispatch/wire"; + +/** + * Pure view-model for the transient `provider-retry` warning banner. Zero DOM, + * zero effects, zero Svelte — mirrors the `core/metrics` view-models the chat UI + * already imports. The banner state itself lives in `TranscriptState.providerRetry` + * (set/cleared by `foldEvent`); this module only formats an event into render data. + * + * The "countdown" is a STATIC label derived from `delayMs` (e.g. "retrying in + * 5s…"), matching the backend's examples — NOT a live ticking timer (which would + * be a component effect and re-render churn). Each new `provider-retry` event + * coalesces over the previous, so the banner always shows the newest attempt + delay. + */ + +/** 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; +} + +/** + * Format a millisecond delay as a short, human-friendly duration. Matches the + * backend's backoff schedule (5s→10s→30s→60s→5m→10m→15m→30m): under a minute + * 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`; +} + +/** + * Map a `provider-retry` event to its banner view. `attempt` is 0-based (the Nth + * 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, + }; +} diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts index 6929de2..e46d5f5 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, Chunk } from "@dispatch/wire"; +import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire"; import type { RenderedChunk, TranscriptState } from "./types"; /** @@ -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,32 +29,41 @@ 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; +} + +/** + * The latest `provider-retry` event for the current turn, or `null` when no retry + * is pending. Drives the transient yellow "retrying…" warning banner (rendered + * by ChatView). Never persisted — see `TranscriptState.providerRetry`. + */ +export function selectProviderRetry(state: TranscriptState): TurnProviderRetryEvent | null { + 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 14619bd..2ced736 100644 --- a/src/core/chunks/types.ts +++ b/src/core/chunks/types.ts @@ -1,63 +1,77 @@ -import type { Chunk, Role, StoredChunk, Usage } from "@dispatch/wire"; +import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@dispatch/wire"; /** 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; + 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; } |
