summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks/reducer.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:28 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:31 +0900
commit2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch)
tree94e1923180ae38d571d34b578afecb0a18913c24 /src/core/chunks/reducer.test.ts
parent80f99665034a0e510300793205c162fc7a46769f (diff)
parent08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff)
downloaddispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz
dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip
Merge branch 'dev' into feature/heartbeat
# Conflicts: # src/app/App.svelte # src/app/store.svelte.ts # src/app/store.test.ts # src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/core/chunks/reducer.test.ts')
-rw-r--r--src/core/chunks/reducer.test.ts1546
1 files changed, 773 insertions, 773 deletions
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts
index ac9b895..8a2e1b7 100644
--- a/src/core/chunks/reducer.test.ts
+++ b/src/core/chunks/reducer.test.ts
@@ -1,871 +1,871 @@
import type {
- StepId,
- StoredChunk,
- TurnDoneEvent,
- TurnErrorEvent,
- TurnInputEvent,
- TurnProviderRetryEvent,
- TurnReasoningDeltaEvent,
- TurnSealedEvent,
- TurnStartEvent,
- TurnSteeringEvent,
- TurnTextDeltaEvent,
- TurnToolCallEvent,
- TurnToolResultEvent,
- TurnUsageEvent,
+ StepId,
+ StoredChunk,
+ TurnDoneEvent,
+ TurnErrorEvent,
+ TurnInputEvent,
+ TurnProviderRetryEvent,
+ TurnReasoningDeltaEvent,
+ TurnSealedEvent,
+ TurnStartEvent,
+ TurnSteeringEvent,
+ TurnTextDeltaEvent,
+ TurnToolCallEvent,
+ TurnToolResultEvent,
+ TurnUsageEvent,
} from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
- appendUserMessage,
- applyHistory,
- clearGenerating,
- foldEvent,
- initialState,
+ appendUserMessage,
+ applyHistory,
+ clearGenerating,
+ foldEvent,
+ initialState,
} from "./reducer";
import { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors";
const turnStart = (turnId: string): TurnStartEvent => ({
- type: "turn-start",
- conversationId: "c1",
- turnId,
+ type: "turn-start",
+ conversationId: "c1",
+ turnId,
});
const textDelta = (turnId: string, delta: string): TurnTextDeltaEvent => ({
- type: "text-delta",
- conversationId: "c1",
- turnId,
- delta,
+ type: "text-delta",
+ conversationId: "c1",
+ turnId,
+ delta,
});
const reasoningDelta = (turnId: string, delta: string): TurnReasoningDeltaEvent => ({
- type: "reasoning-delta",
- conversationId: "c1",
- turnId,
- delta,
+ type: "reasoning-delta",
+ conversationId: "c1",
+ turnId,
+ delta,
});
const toolCall = (
- turnId: string,
- toolCallId: string,
- toolName: string,
- input: unknown,
- stepId = "s0",
+ turnId: string,
+ toolCallId: string,
+ toolName: string,
+ input: unknown,
+ stepId = "s0",
): TurnToolCallEvent => ({
- type: "tool-call",
- conversationId: "c1",
- turnId,
- toolCallId,
- toolName,
- input,
- stepId: stepId as StepId,
+ type: "tool-call",
+ conversationId: "c1",
+ turnId,
+ toolCallId,
+ toolName,
+ input,
+ stepId: stepId as StepId,
});
const toolResult = (
- turnId: string,
- toolCallId: string,
- toolName: string,
- content: string,
- stepId = "s0",
+ turnId: string,
+ toolCallId: string,
+ toolName: string,
+ content: string,
+ stepId = "s0",
): TurnToolResultEvent => ({
- type: "tool-result",
- conversationId: "c1",
- turnId,
- toolCallId,
- toolName,
- content,
- isError: false,
- stepId: stepId as StepId,
+ type: "tool-result",
+ conversationId: "c1",
+ turnId,
+ toolCallId,
+ toolName,
+ content,
+ isError: false,
+ stepId: stepId as StepId,
});
const usageEvent = (turnId: string, inputTokens: number, outputTokens: number): TurnUsageEvent => ({
- type: "usage",
- conversationId: "c1",
- turnId,
- usage: { inputTokens, outputTokens },
+ type: "usage",
+ conversationId: "c1",
+ turnId,
+ usage: { inputTokens, outputTokens },
});
const errorEvent = (turnId: string, message: string, code?: string): TurnErrorEvent =>
- code !== undefined
- ? { type: "error", conversationId: "c1", turnId, message, code }
- : { type: "error", conversationId: "c1", turnId, message };
+ code !== undefined
+ ? { type: "error", conversationId: "c1", turnId, message, code }
+ : { type: "error", conversationId: "c1", turnId, message };
const doneEvent = (turnId: string): TurnDoneEvent => ({
- type: "done",
- conversationId: "c1",
- turnId,
- reason: "stop",
+ type: "done",
+ conversationId: "c1",
+ turnId,
+ reason: "stop",
});
const turnSealed = (turnId: string): TurnSealedEvent => ({
- type: "turn-sealed",
- conversationId: "c1",
- turnId,
+ type: "turn-sealed",
+ conversationId: "c1",
+ turnId,
});
const providerRetry = (
- turnId: string,
- attempt: number,
- delayMs: number,
- message = "HTTP 429: overloaded",
- code?: string,
+ turnId: string,
+ attempt: number,
+ delayMs: number,
+ message = "HTTP 429: overloaded",
+ code?: string,
): TurnProviderRetryEvent =>
- code !== undefined
- ? { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message, code }
- : { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message };
+ code !== undefined
+ ? { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message, code }
+ : { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message };
const storedChunk = (
- seq: number,
- role: "user" | "assistant" | "tool" | "system",
- chunk: StoredChunk["chunk"],
+ seq: number,
+ role: "user" | "assistant" | "tool" | "system",
+ chunk: StoredChunk["chunk"],
): StoredChunk => ({
- seq,
- role,
- chunk,
+ seq,
+ role,
+ chunk,
});
describe("initialState", () => {
- it("initial state is empty", () => {
- const s = initialState();
- expect(s.committed).toEqual([]);
- expect(s.provisional).toEqual([]);
- expect(s.accumulating).toBeNull();
- expect(s.currentTurnId).toBeNull();
- expect(s.latestUsage).toBeNull();
- expect(s.sealedTurnId).toBeNull();
- expect(s.generating).toBe(false);
- });
+ it("initial state is empty", () => {
+ const s = initialState();
+ expect(s.committed).toEqual([]);
+ expect(s.provisional).toEqual([]);
+ expect(s.accumulating).toBeNull();
+ expect(s.currentTurnId).toBeNull();
+ expect(s.latestUsage).toBeNull();
+ expect(s.sealedTurnId).toBeNull();
+ expect(s.generating).toBe(false);
+ });
});
describe("foldEvent — generating (turn-running state)", () => {
- it("turn-start sets generating true", () => {
- let s = initialState();
- expect(selectGenerating(s)).toBe(false);
- s = foldEvent(s, turnStart("t1"));
- expect(s.generating).toBe(true);
- expect(selectGenerating(s)).toBe(true);
- });
-
- it("a content delta sets generating true (e.g. a late-joiner replay missing turn-start)", () => {
- let s = initialState();
- s = foldEvent(s, textDelta("t1", "hi"));
- expect(s.generating).toBe(true);
- s = initialState();
- s = foldEvent(s, reasoningDelta("t1", "hmm"));
- expect(s.generating).toBe(true);
- s = initialState();
- s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
- expect(s.generating).toBe(true);
- });
-
- it("stays generating across the turn's deltas", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "wor"));
- s = foldEvent(s, textDelta("t1", "king"));
- expect(s.generating).toBe(true);
- });
-
- it("done clears generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "answer"));
- s = foldEvent(s, doneEvent("t1"));
- expect(s.generating).toBe(false);
- });
-
- it("turn-sealed clears generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, turnSealed("t1"));
- expect(s.generating).toBe(false);
- });
-
- it("error clears generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, errorEvent("t1", "boom"));
- expect(s.generating).toBe(false);
- });
-
- it("a new turn re-asserts generating after the previous one finished", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, doneEvent("t1"));
- s = foldEvent(s, turnSealed("t1"));
- expect(s.generating).toBe(false);
- s = foldEvent(s, turnStart("t2"));
- expect(s.generating).toBe(true);
- });
-
- it("status does not change generating (free-form string, not inferred)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- const next = foldEvent(s, { type: "status", conversationId: "c1", status: "idle" });
- expect(next.generating).toBe(true);
- });
+ it("turn-start sets generating true", () => {
+ let s = initialState();
+ expect(selectGenerating(s)).toBe(false);
+ s = foldEvent(s, turnStart("t1"));
+ expect(s.generating).toBe(true);
+ expect(selectGenerating(s)).toBe(true);
+ });
+
+ it("a content delta sets generating true (e.g. a late-joiner replay missing turn-start)", () => {
+ let s = initialState();
+ s = foldEvent(s, textDelta("t1", "hi"));
+ expect(s.generating).toBe(true);
+ s = initialState();
+ s = foldEvent(s, reasoningDelta("t1", "hmm"));
+ expect(s.generating).toBe(true);
+ s = initialState();
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
+ expect(s.generating).toBe(true);
+ });
+
+ it("stays generating across the turn's deltas", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "wor"));
+ s = foldEvent(s, textDelta("t1", "king"));
+ expect(s.generating).toBe(true);
+ });
+
+ it("done clears generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "answer"));
+ s = foldEvent(s, doneEvent("t1"));
+ expect(s.generating).toBe(false);
+ });
+
+ it("turn-sealed clears generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(s.generating).toBe(false);
+ });
+
+ it("error clears generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, errorEvent("t1", "boom"));
+ expect(s.generating).toBe(false);
+ });
+
+ it("a new turn re-asserts generating after the previous one finished", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, doneEvent("t1"));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(s.generating).toBe(false);
+ s = foldEvent(s, turnStart("t2"));
+ expect(s.generating).toBe(true);
+ });
+
+ it("status does not change generating (free-form string, not inferred)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ const next = foldEvent(s, { type: "status", conversationId: "c1", status: "idle" });
+ expect(next.generating).toBe(true);
+ });
});
describe("clearGenerating", () => {
- it("clears a set generating flag", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- expect(s.generating).toBe(true);
- const cleared = clearGenerating(s);
- expect(cleared.generating).toBe(false);
- });
-
- it("returns the same object when already not generating (no-op)", () => {
- const s = initialState();
- expect(clearGenerating(s)).toBe(s);
- });
-
- it("preserves transcript content while clearing generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "partial"));
- const cleared = clearGenerating(s);
- expect(cleared.generating).toBe(false);
- expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" });
- expect(cleared.currentTurnId).toBe("t1");
- });
+ it("clears a set generating flag", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ expect(s.generating).toBe(true);
+ const cleared = clearGenerating(s);
+ expect(cleared.generating).toBe(false);
+ });
+
+ it("returns the same object when already not generating (no-op)", () => {
+ const s = initialState();
+ expect(clearGenerating(s)).toBe(s);
+ });
+
+ it("preserves transcript content while clearing generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial"));
+ const cleared = clearGenerating(s);
+ expect(cleared.generating).toBe(false);
+ expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" });
+ expect(cleared.currentTurnId).toBe("t1");
+ });
});
describe("foldEvent — text-delta", () => {
- it("text-delta accumulates into one TextChunk", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello"));
- expect(s.accumulating).toEqual({ kind: "text", text: "hello" });
- expect(s.provisional).toEqual([]);
- });
-
- it("successive text-deltas extend the same provisional chunk", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello "));
- s = foldEvent(s, textDelta("t1", "world"));
- expect(s.accumulating).toEqual({ kind: "text", text: "hello world" });
- expect(s.provisional).toEqual([]);
- });
-
- it("text-delta after reasoning-delta flushes thinking and starts text", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, reasoningDelta("t1", "thinking..."));
- s = foldEvent(s, textDelta("t1", "answer"));
- expect(s.accumulating).toEqual({ kind: "text", text: "answer" });
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." });
- expect(s.provisional[0]?.role).toBe("assistant");
- });
+ it("text-delta accumulates into one TextChunk", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello"));
+ expect(s.accumulating).toEqual({ kind: "text", text: "hello" });
+ expect(s.provisional).toEqual([]);
+ });
+
+ it("successive text-deltas extend the same provisional chunk", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello "));
+ s = foldEvent(s, textDelta("t1", "world"));
+ expect(s.accumulating).toEqual({ kind: "text", text: "hello world" });
+ expect(s.provisional).toEqual([]);
+ });
+
+ it("text-delta after reasoning-delta flushes thinking and starts text", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "thinking..."));
+ s = foldEvent(s, textDelta("t1", "answer"));
+ expect(s.accumulating).toEqual({ kind: "text", text: "answer" });
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." });
+ expect(s.provisional[0]?.role).toBe("assistant");
+ });
});
describe("foldEvent — reasoning-delta", () => {
- it("reasoning-delta yields a thinking chunk", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, reasoningDelta("t1", "hmm"));
- expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" });
- });
-
- it("successive reasoning-deltas extend the same chunk", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, reasoningDelta("t1", "hmm "));
- s = foldEvent(s, reasoningDelta("t1", "ok"));
- expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" });
- });
+ it("reasoning-delta yields a thinking chunk", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "hmm"));
+ expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" });
+ });
+
+ it("successive reasoning-deltas extend the same chunk", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "hmm "));
+ s = foldEvent(s, reasoningDelta("t1", "ok"));
+ expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" });
+ });
});
describe("foldEvent — tool-call then tool-result", () => {
- it("tool-call then tool-result render in order", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0"));
- s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0"));
- expect(s.provisional).toHaveLength(2);
- expect(s.provisional[0]?.role).toBe("assistant");
- // foldEvent copies the event's stepId onto the chunk (grouping key).
- expect(s.provisional[0]?.chunk).toEqual({
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "bash",
- input: { cmd: "ls" },
- stepId: "t1#0",
- });
- expect(s.provisional[1]?.role).toBe("tool");
- expect(s.provisional[1]?.chunk).toEqual({
- type: "tool-result",
- toolCallId: "tc1",
- toolName: "bash",
- content: "file.txt",
- isError: false,
- stepId: "t1#0",
- });
- });
-
- it("tool-call flushes accumulating text", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "let me check"));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
- expect(s.provisional).toHaveLength(2);
- expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "let me check" });
- expect(s.provisional[1]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "tc1" });
- expect(s.accumulating).toBeNull();
- });
+ it("tool-call then tool-result render in order", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0"));
+ s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0"));
+ expect(s.provisional).toHaveLength(2);
+ expect(s.provisional[0]?.role).toBe("assistant");
+ // foldEvent copies the event's stepId onto the chunk (grouping key).
+ expect(s.provisional[0]?.chunk).toEqual({
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "bash",
+ input: { cmd: "ls" },
+ stepId: "t1#0",
+ });
+ expect(s.provisional[1]?.role).toBe("tool");
+ expect(s.provisional[1]?.chunk).toEqual({
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "bash",
+ content: "file.txt",
+ isError: false,
+ stepId: "t1#0",
+ });
+ });
+
+ it("tool-call flushes accumulating text", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "let me check"));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
+ expect(s.provisional).toHaveLength(2);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "let me check" });
+ expect(s.provisional[1]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "tc1" });
+ expect(s.accumulating).toBeNull();
+ });
});
describe("foldEvent — turn-sealed", () => {
- it("turn-sealed sets sealedTurnId", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hi"));
- s = foldEvent(s, turnSealed("t1"));
- expect(s.sealedTurnId).toBe("t1");
- expect(s.accumulating).toBeNull();
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hi" });
- });
+ it("turn-sealed sets sealedTurnId", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hi"));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(s.sealedTurnId).toBe("t1");
+ expect(s.accumulating).toBeNull();
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hi" });
+ });
});
describe("foldEvent — usage", () => {
- it("stores latest usage", () => {
- let s = initialState();
- s = foldEvent(s, usageEvent("t1", 100, 50));
- expect(s.latestUsage).toEqual({ inputTokens: 100, outputTokens: 50 });
- });
-
- it("overwrites previous usage", () => {
- let s = initialState();
- s = foldEvent(s, usageEvent("t1", 100, 50));
- s = foldEvent(s, usageEvent("t1", 200, 80));
- expect(s.latestUsage).toEqual({ inputTokens: 200, outputTokens: 80 });
- });
+ it("stores latest usage", () => {
+ let s = initialState();
+ s = foldEvent(s, usageEvent("t1", 100, 50));
+ expect(s.latestUsage).toEqual({ inputTokens: 100, outputTokens: 50 });
+ });
+
+ it("overwrites previous usage", () => {
+ let s = initialState();
+ s = foldEvent(s, usageEvent("t1", 100, 50));
+ s = foldEvent(s, usageEvent("t1", 200, 80));
+ expect(s.latestUsage).toEqual({ inputTokens: 200, outputTokens: 80 });
+ });
});
describe("foldEvent — error", () => {
- it("creates error chunk with code", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, errorEvent("t1", "bad", "E001"));
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad", code: "E001" });
- expect(s.provisional[0]?.role).toBe("assistant");
- });
-
- it("creates error chunk without code", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, errorEvent("t1", "bad"));
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad" });
- });
+ it("creates error chunk with code", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, errorEvent("t1", "bad", "E001"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad", code: "E001" });
+ expect(s.provisional[0]?.role).toBe("assistant");
+ });
+
+ it("creates error chunk without code", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, errorEvent("t1", "bad"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad" });
+ });
});
describe("foldEvent — done", () => {
- it("flushes accumulating chunk on done", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello"));
- s = foldEvent(s, doneEvent("t1"));
- expect(s.accumulating).toBeNull();
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hello" });
- });
+ it("flushes accumulating chunk on done", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello"));
+ s = foldEvent(s, doneEvent("t1"));
+ expect(s.accumulating).toBeNull();
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hello" });
+ });
});
describe("foldEvent — status and tool-output", () => {
- it("status is a no-op", () => {
- const s = initialState();
- const next = foldEvent(s, { type: "status", conversationId: "c1", status: "running" });
- expect(next).toBe(s);
- });
-
- it("tool-output is a no-op", () => {
- const s = initialState();
- const next = foldEvent(s, {
- type: "tool-output",
- conversationId: "c1",
- turnId: "t1",
- toolCallId: "tc1",
- data: "output",
- stream: "stdout",
- });
- expect(next).toBe(s);
- });
+ it("status is a no-op", () => {
+ const s = initialState();
+ const next = foldEvent(s, { type: "status", conversationId: "c1", status: "running" });
+ expect(next).toBe(s);
+ });
+
+ it("tool-output is a no-op", () => {
+ const s = initialState();
+ const next = foldEvent(s, {
+ type: "tool-output",
+ conversationId: "c1",
+ turnId: "t1",
+ toolCallId: "tc1",
+ data: "output",
+ stream: "stdout",
+ });
+ expect(next).toBe(s);
+ });
});
describe("foldEvent — provider-retry (transient retry banner)", () => {
- it("sets the provider-retry banner on a provider-retry event", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000, "HTTP 429: overloaded", "429"));
- const retry = selectProviderRetry(s);
- expect(retry).not.toBeNull();
- expect(retry?.attempt).toBe(0);
- expect(retry?.delayMs).toBe(5000);
- expect(retry?.code).toBe("429");
- });
-
- it("does NOT add a chunk (never persisted — never pollutes the prompt)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(selectChunks(s)).toHaveLength(0);
- expect(s.provisional).toHaveLength(0);
- expect(s.accumulating).toBeNull();
- });
-
- it("coalesces: the latest attempt + delay replaces the previous", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000, "first", "429"));
- s = foldEvent(s, providerRetry("t1", 1, 10000, "second", "429"));
- const retry = selectProviderRetry(s);
- expect(retry?.attempt).toBe(1);
- expect(retry?.delayMs).toBe(10000);
- expect(retry?.message).toBe("second");
- });
-
- it("keeps generating true (the turn is still in flight, just retrying)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(s.generating).toBe(true);
- });
-
- it("clears when content resumes (text-delta)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(selectProviderRetry(s)).not.toBeNull();
- s = foldEvent(s, textDelta("t1", "here is the reply"));
- expect(selectProviderRetry(s)).toBeNull();
- });
-
- it("clears when content resumes (reasoning-delta / tool-call / tool-result)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, reasoningDelta("t1", "thinking"));
- expect(selectProviderRetry(s)).toBeNull();
-
- s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
- expect(selectProviderRetry(s)).toBeNull();
- });
-
- it("clears when the turn ends (done / turn-sealed / error)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, errorEvent("t1", "exhausted"));
- expect(selectProviderRetry(s)).toBeNull();
-
- s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, doneEvent("t1"));
- expect(selectProviderRetry(s)).toBeNull();
-
- s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, turnSealed("t1"));
- expect(selectProviderRetry(s)).toBeNull();
- });
-
- it("clears on a new turn (turn-start)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(selectProviderRetry(s)).not.toBeNull();
- s = foldEvent(s, turnStart("t2"));
- expect(selectProviderRetry(s)).toBeNull();
- });
-
- it("leaves the banner untouched across metadata events (usage / step-complete / status)", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- s = foldEvent(s, usageEvent("t1", 10, 20));
- expect(selectProviderRetry(s)).not.toBeNull();
- s = foldEvent(s, {
- type: "step-complete",
- conversationId: "c1",
- turnId: "t1",
- stepId: "t1#0" as StepId,
- ttftMs: 100,
- decodeMs: 200,
- genTotalMs: 300,
- });
- expect(selectProviderRetry(s)).not.toBeNull();
- s = foldEvent(s, { type: "status", conversationId: "c1", status: "running" });
- expect(selectProviderRetry(s)).not.toBeNull();
- });
-
- it("is null in the initial state", () => {
- expect(selectProviderRetry(initialState())).toBeNull();
- });
+ it("sets the provider-retry banner on a provider-retry event", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000, "HTTP 429: overloaded", "429"));
+ const retry = selectProviderRetry(s);
+ expect(retry).not.toBeNull();
+ expect(retry?.attempt).toBe(0);
+ expect(retry?.delayMs).toBe(5000);
+ expect(retry?.code).toBe("429");
+ });
+
+ it("does NOT add a chunk (never persisted — never pollutes the prompt)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(selectChunks(s)).toHaveLength(0);
+ expect(s.provisional).toHaveLength(0);
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("coalesces: the latest attempt + delay replaces the previous", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000, "first", "429"));
+ s = foldEvent(s, providerRetry("t1", 1, 10000, "second", "429"));
+ const retry = selectProviderRetry(s);
+ expect(retry?.attempt).toBe(1);
+ expect(retry?.delayMs).toBe(10000);
+ expect(retry?.message).toBe("second");
+ });
+
+ it("keeps generating true (the turn is still in flight, just retrying)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(s.generating).toBe(true);
+ });
+
+ it("clears when content resumes (text-delta)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, textDelta("t1", "here is the reply"));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+
+ it("clears when content resumes (reasoning-delta / tool-call / tool-result)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, reasoningDelta("t1", "thinking"));
+ expect(selectProviderRetry(s)).toBeNull();
+
+ s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", {}));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+
+ it("clears when the turn ends (done / turn-sealed / error)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, errorEvent("t1", "exhausted"));
+ expect(selectProviderRetry(s)).toBeNull();
+
+ s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, doneEvent("t1"));
+ expect(selectProviderRetry(s)).toBeNull();
+
+ s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+
+ it("clears on a new turn (turn-start)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, turnStart("t2"));
+ expect(selectProviderRetry(s)).toBeNull();
+ });
+
+ it("leaves the banner untouched across metadata events (usage / step-complete / status)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ s = foldEvent(s, usageEvent("t1", 10, 20));
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, {
+ type: "step-complete",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 100,
+ decodeMs: 200,
+ genTotalMs: 300,
+ });
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, { type: "status", conversationId: "c1", status: "running" });
+ expect(selectProviderRetry(s)).not.toBeNull();
+ });
+
+ it("is null in the initial state", () => {
+ expect(selectProviderRetry(initialState())).toBeNull();
+ });
});
describe("clearGenerating also clears a stale provider-retry banner (reconnect)", () => {
- it("clears the retry banner alongside generating on reconnect", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- expect(s.generating).toBe(true);
- expect(selectProviderRetry(s)).not.toBeNull();
- const cleared = clearGenerating(s);
- expect(cleared.generating).toBe(false);
- expect(selectProviderRetry(cleared)).toBeNull();
- });
-
- it("preserves transcript content while clearing the banner", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "partial"));
- s = foldEvent(s, providerRetry("t1", 0, 5000));
- const cleared = clearGenerating(s);
- expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" });
- expect(selectProviderRetry(cleared)).toBeNull();
- });
+ it("clears the retry banner alongside generating on reconnect", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(s.generating).toBe(true);
+ expect(selectProviderRetry(s)).not.toBeNull();
+ const cleared = clearGenerating(s);
+ expect(cleared.generating).toBe(false);
+ expect(selectProviderRetry(cleared)).toBeNull();
+ });
+
+ it("preserves transcript content while clearing the banner", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ const cleared = clearGenerating(s);
+ expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" });
+ expect(selectProviderRetry(cleared)).toBeNull();
+ });
});
describe("foldEvent — user-message (the turn's user prompt; backend CR-3)", () => {
- const userMessage = (text: string): TurnInputEvent => ({
- type: "user-message",
- conversationId: "c1",
- turnId: "t1",
- text,
- });
-
- it("a watcher renders the prompt: appends a provisional user chunk + marks generating", () => {
- let s = initialState();
- s = foldEvent(s, userMessage("what is 2+2?"));
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]?.role).toBe("user");
- expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what is 2+2?" });
- expect(chunks[0]?.provisional).toBe(true);
- expect(s.generating).toBe(true);
- });
-
- it("dedups the SENDER's optimistic echo (no duplicate user bubble)", () => {
- let s = initialState();
- s = appendUserMessage(s, "hi"); // optimistic echo from the sender's send()
- s = foldEvent(s, userMessage("hi")); // server echo for the same turn
- const users = selectChunks(s).filter((c) => c.role === "user");
- expect(users).toHaveLength(1);
- });
-
- it("appends when the trailing provisional differs (no false dedup)", () => {
- let s = initialState();
- s = appendUserMessage(s, "first");
- s = foldEvent(s, userMessage("second"));
- const users = selectChunks(s).filter((c) => c.role === "user");
- expect(users).toHaveLength(2);
- });
-
- it("ignores an empty user-message", () => {
- let s = initialState();
- s = foldEvent(s, userMessage(""));
- expect(selectChunks(s)).toHaveLength(0);
- expect(s.generating).toBe(false);
- });
-
- it("flushes an accumulating chunk before appending the prompt", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "partial"));
- s = foldEvent(s, userMessage("new prompt"));
- // the partial assistant text was flushed to provisional, then the user prompt appended
- expect(s.accumulating).toBeNull();
- const roles = selectChunks(s).map((c) => c.role);
- expect(roles).toEqual(["assistant", "user"]);
- });
+ const userMessage = (text: string): TurnInputEvent => ({
+ type: "user-message",
+ conversationId: "c1",
+ turnId: "t1",
+ text,
+ });
+
+ it("a watcher renders the prompt: appends a provisional user chunk + marks generating", () => {
+ let s = initialState();
+ s = foldEvent(s, userMessage("what is 2+2?"));
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(1);
+ expect(chunks[0]?.role).toBe("user");
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what is 2+2?" });
+ expect(chunks[0]?.provisional).toBe(true);
+ expect(s.generating).toBe(true);
+ });
+
+ it("dedups the SENDER's optimistic echo (no duplicate user bubble)", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hi"); // optimistic echo from the sender's send()
+ s = foldEvent(s, userMessage("hi")); // server echo for the same turn
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users).toHaveLength(1);
+ });
+
+ it("appends when the trailing provisional differs (no false dedup)", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "first");
+ s = foldEvent(s, userMessage("second"));
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users).toHaveLength(2);
+ });
+
+ it("ignores an empty user-message", () => {
+ let s = initialState();
+ s = foldEvent(s, userMessage(""));
+ expect(selectChunks(s)).toHaveLength(0);
+ expect(s.generating).toBe(false);
+ });
+
+ it("flushes an accumulating chunk before appending the prompt", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial"));
+ s = foldEvent(s, userMessage("new prompt"));
+ // the partial assistant text was flushed to provisional, then the user prompt appended
+ expect(s.accumulating).toBeNull();
+ const roles = selectChunks(s).map((c) => c.role);
+ expect(roles).toEqual(["assistant", "user"]);
+ });
});
describe("foldEvent — steering (mid-turn steering injection)", () => {
- const steering = (text: string): TurnSteeringEvent => ({
- type: "steering",
- conversationId: "c1",
- turnId: "t1",
- text,
- });
-
- it("appends a provisional user bubble + keeps generating", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, toolResult("t1", "tc1", "read", "output"));
- s = foldEvent(s, steering("actually, use a different file"));
- const chunks = selectChunks(s);
- const last = chunks[chunks.length - 1];
- expect(last?.role).toBe("user");
- expect(last?.chunk).toEqual({ type: "text", text: "actually, use a different file" });
- expect(last?.provisional).toBe(true);
- expect(s.generating).toBe(true);
- });
-
- it("does NOT dedup against the sender's queue (unlike user-message)", () => {
- // The sender enqueued the message via `chat.queue` — the queue SURFACE
- // showed it. The `steering` event places it in the transcript; the surface
- // separately clears on drain. No de-dup here (the transcript never showed
- // the queued message).
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, steering("steer once"));
- s = foldEvent(s, steering("steer again"));
- const users = selectChunks(s).filter((c) => c.role === "user");
- expect(users).toHaveLength(2);
- });
-
- it("ignores an empty steering event", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, steering(""));
- expect(selectChunks(s)).toHaveLength(0);
- expect(s.generating).toBe(true); // turn-start already set it
- });
-
- it("flushes an accumulating chunk before appending the steering bubble", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "partial response"));
- s = foldEvent(s, steering("mid-turn correction"));
- expect(s.accumulating).toBeNull();
- const roles = selectChunks(s).map((c) => c.role);
- expect(roles).toEqual(["assistant", "user"]);
- });
+ const steering = (text: string): TurnSteeringEvent => ({
+ type: "steering",
+ conversationId: "c1",
+ turnId: "t1",
+ text,
+ });
+
+ it("appends a provisional user bubble + keeps generating", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, toolResult("t1", "tc1", "read", "output"));
+ s = foldEvent(s, steering("actually, use a different file"));
+ const chunks = selectChunks(s);
+ const last = chunks[chunks.length - 1];
+ expect(last?.role).toBe("user");
+ expect(last?.chunk).toEqual({ type: "text", text: "actually, use a different file" });
+ expect(last?.provisional).toBe(true);
+ expect(s.generating).toBe(true);
+ });
+
+ it("does NOT dedup against the sender's queue (unlike user-message)", () => {
+ // The sender enqueued the message via `chat.queue` — the queue SURFACE
+ // showed it. The `steering` event places it in the transcript; the surface
+ // separately clears on drain. No de-dup here (the transcript never showed
+ // the queued message).
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, steering("steer once"));
+ s = foldEvent(s, steering("steer again"));
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users).toHaveLength(2);
+ });
+
+ it("ignores an empty steering event", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, steering(""));
+ expect(selectChunks(s)).toHaveLength(0);
+ expect(s.generating).toBe(true); // turn-start already set it
+ });
+
+ it("flushes an accumulating chunk before appending the steering bubble", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial response"));
+ s = foldEvent(s, steering("mid-turn correction"));
+ expect(s.accumulating).toBeNull();
+ const roles = selectChunks(s).map((c) => c.role);
+ expect(roles).toEqual(["assistant", "user"]);
+ });
});
describe("applyHistory", () => {
- it("orders committed chunks by seq", () => {
- const s = initialState();
- const chunks = [
- storedChunk(3, "assistant", { type: "text", text: "c" }),
- storedChunk(1, "user", { type: "text", text: "a" }),
- storedChunk(2, "assistant", { type: "text", text: "b" }),
- ];
- const next = applyHistory(s, chunks);
- expect(next.committed.map((c) => c.seq)).toEqual([1, 2, 3]);
- });
-
- it("is idempotent on duplicate seqs", () => {
- let s = initialState();
- const batch1 = [
- storedChunk(1, "user", { type: "text", text: "a" }),
- storedChunk(2, "assistant", { type: "text", text: "b" }),
- ];
- s = applyHistory(s, batch1);
- const batch2 = [
- storedChunk(2, "assistant", { type: "text", text: "b" }),
- storedChunk(3, "assistant", { type: "text", text: "c" }),
- ];
- s = applyHistory(s, batch2);
- expect(s.committed.map((c) => c.seq)).toEqual([1, 2, 3]);
- expect(s.committed).toHaveLength(3);
- });
-
- it("supersedes & clears provisional once committed", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello"));
- s = foldEvent(s, turnSealed("t1"));
- expect(s.provisional).toHaveLength(1);
- expect(s.sealedTurnId).toBe("t1");
-
- s = applyHistory(s, [storedChunk(1, "assistant", { type: "text", text: "hello" })]);
- expect(s.provisional).toEqual([]);
- expect(s.accumulating).toBeNull();
- expect(s.sealedTurnId).toBeNull();
- expect(s.committed).toHaveLength(1);
- });
-
- it("keeps provisional and accumulating when sealedTurnId is null", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "wip"));
- s = foldEvent(s, doneEvent("t1"));
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
- expect(s.provisional).toHaveLength(1);
- expect(s.committed).toHaveLength(1);
- });
-
- it("merges new history into existing committed", () => {
- let s = initialState();
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "a" })]);
- s = applyHistory(s, [storedChunk(2, "assistant", { type: "text", text: "b" })]);
- expect(s.committed).toHaveLength(2);
- expect(s.committed.map((c) => c.seq)).toEqual([1, 2]);
- });
-
- it("removes provisional duplicate when committed user message arrives during generation", () => {
- // Simulate: send() appends provisional user message, then syncTail
- // fetches the same message as committed (CR-6: persisted at turn start).
- let s = initialState();
- s = appendUserMessage(s, "hello");
- s = foldEvent(s, turnStart("t1"));
- expect(s.provisional).toHaveLength(1);
- expect(s.provisional[0]?.role).toBe("user");
- expect(s.generating).toBe(true);
-
- // syncTail fetches the persisted user message as committed
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "hello" })]);
-
- // The provisional duplicate is removed — no double render
- expect(s.provisional).toEqual([]);
- expect(s.committed).toHaveLength(1);
- expect(s.committed[0]?.role).toBe("user");
- expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hello" });
- });
+ it("orders committed chunks by seq", () => {
+ const s = initialState();
+ const chunks = [
+ storedChunk(3, "assistant", { type: "text", text: "c" }),
+ storedChunk(1, "user", { type: "text", text: "a" }),
+ storedChunk(2, "assistant", { type: "text", text: "b" }),
+ ];
+ const next = applyHistory(s, chunks);
+ expect(next.committed.map((c) => c.seq)).toEqual([1, 2, 3]);
+ });
+
+ it("is idempotent on duplicate seqs", () => {
+ let s = initialState();
+ const batch1 = [
+ storedChunk(1, "user", { type: "text", text: "a" }),
+ storedChunk(2, "assistant", { type: "text", text: "b" }),
+ ];
+ s = applyHistory(s, batch1);
+ const batch2 = [
+ storedChunk(2, "assistant", { type: "text", text: "b" }),
+ storedChunk(3, "assistant", { type: "text", text: "c" }),
+ ];
+ s = applyHistory(s, batch2);
+ expect(s.committed.map((c) => c.seq)).toEqual([1, 2, 3]);
+ expect(s.committed).toHaveLength(3);
+ });
+
+ it("supersedes & clears provisional once committed", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello"));
+ s = foldEvent(s, turnSealed("t1"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.sealedTurnId).toBe("t1");
+
+ s = applyHistory(s, [storedChunk(1, "assistant", { type: "text", text: "hello" })]);
+ expect(s.provisional).toEqual([]);
+ expect(s.accumulating).toBeNull();
+ expect(s.sealedTurnId).toBeNull();
+ expect(s.committed).toHaveLength(1);
+ });
+
+ it("keeps provisional and accumulating when sealedTurnId is null", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "wip"));
+ s = foldEvent(s, doneEvent("t1"));
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
+ expect(s.provisional).toHaveLength(1);
+ expect(s.committed).toHaveLength(1);
+ });
+
+ it("merges new history into existing committed", () => {
+ let s = initialState();
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "a" })]);
+ s = applyHistory(s, [storedChunk(2, "assistant", { type: "text", text: "b" })]);
+ expect(s.committed).toHaveLength(2);
+ expect(s.committed.map((c) => c.seq)).toEqual([1, 2]);
+ });
+
+ it("removes provisional duplicate when committed user message arrives during generation", () => {
+ // Simulate: send() appends provisional user message, then syncTail
+ // fetches the same message as committed (CR-6: persisted at turn start).
+ let s = initialState();
+ s = appendUserMessage(s, "hello");
+ s = foldEvent(s, turnStart("t1"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.role).toBe("user");
+ expect(s.generating).toBe(true);
+
+ // syncTail fetches the persisted user message as committed
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "hello" })]);
+
+ // The provisional duplicate is removed — no double render
+ expect(s.provisional).toEqual([]);
+ expect(s.committed).toHaveLength(1);
+ expect(s.committed[0]?.role).toBe("user");
+ expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hello" });
+ });
});
describe("selectChunks", () => {
- it("selectChunks marks provisional with seq null", () => {
- let s = initialState();
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "wip"));
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(2);
- expect(chunks[0]?.seq).toBe(1);
- expect(chunks[0]?.provisional).toBe(false);
- expect(chunks[1]?.seq).toBeNull();
- expect(chunks[1]?.provisional).toBe(true);
- });
-
- it("returns empty for empty state", () => {
- expect(selectChunks(initialState())).toEqual([]);
- });
-
- it("includes accumulating chunk as provisional", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "building..."));
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]?.seq).toBeNull();
- expect(chunks[0]?.provisional).toBe(true);
- expect(chunks[0]?.chunk).toEqual({ type: "text", text: "building..." });
- });
-
- it("marks ONLY the actively-accumulating chunk as streaming", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- // A flushed-but-still-provisional thinking chunk, then a live accumulating one.
- s = foldEvent(s, reasoningDelta("t1", "first thought"));
- s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); // flushes the thinking
- s = foldEvent(s, textDelta("t1", "now writing"));
- const chunks = selectChunks(s);
- const thinking = chunks.find((c) => c.chunk.type === "thinking");
- const accumulating = chunks.find((c) => c.streaming === true);
- expect(thinking?.streaming).toBeFalsy(); // flushed → not streaming
- expect(accumulating?.chunk).toEqual({ type: "text", text: "now writing" });
- expect(chunks.filter((c) => c.streaming === true)).toHaveLength(1);
- });
+ it("selectChunks marks provisional with seq null", () => {
+ let s = initialState();
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "wip"));
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(2);
+ expect(chunks[0]?.seq).toBe(1);
+ expect(chunks[0]?.provisional).toBe(false);
+ expect(chunks[1]?.seq).toBeNull();
+ expect(chunks[1]?.provisional).toBe(true);
+ });
+
+ it("returns empty for empty state", () => {
+ expect(selectChunks(initialState())).toEqual([]);
+ });
+
+ it("includes accumulating chunk as provisional", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "building..."));
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(1);
+ expect(chunks[0]?.seq).toBeNull();
+ expect(chunks[0]?.provisional).toBe(true);
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "building..." });
+ });
+
+ it("marks ONLY the actively-accumulating chunk as streaming", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ // A flushed-but-still-provisional thinking chunk, then a live accumulating one.
+ s = foldEvent(s, reasoningDelta("t1", "first thought"));
+ s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); // flushes the thinking
+ s = foldEvent(s, textDelta("t1", "now writing"));
+ const chunks = selectChunks(s);
+ const thinking = chunks.find((c) => c.chunk.type === "thinking");
+ const accumulating = chunks.find((c) => c.streaming === true);
+ expect(thinking?.streaming).toBeFalsy(); // flushed → not streaming
+ expect(accumulating?.chunk).toEqual({ type: "text", text: "now writing" });
+ expect(chunks.filter((c) => c.streaming === true)).toHaveLength(1);
+ });
});
describe("selectMessages", () => {
- it("selectMessages groups consecutive same-role chunks", () => {
- let s = initialState();
- s = applyHistory(s, [
- storedChunk(1, "user", { type: "text", text: "q1" }),
- storedChunk(2, "user", { type: "text", text: "q2" }),
- storedChunk(3, "assistant", { type: "text", text: "a1" }),
- storedChunk(4, "assistant", { type: "text", text: "a2" }),
- storedChunk(5, "user", { type: "text", text: "q3" }),
- ]);
- const msgs = selectMessages(s);
- expect(msgs).toHaveLength(3);
- expect(msgs[0]?.role).toBe("user");
- expect(msgs[0]?.chunks).toHaveLength(2);
- expect(msgs[1]?.role).toBe("assistant");
- expect(msgs[1]?.chunks).toHaveLength(2);
- expect(msgs[2]?.role).toBe("user");
- expect(msgs[2]?.chunks).toHaveLength(1);
- });
-
- it("returns empty for empty state", () => {
- expect(selectMessages(initialState())).toEqual([]);
- });
-
- it("mixes committed and provisional in messages", () => {
- let s = initialState();
- s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "a1"));
- s = foldEvent(s, textDelta("t1", "a2"));
- const msgs = selectMessages(s);
- expect(msgs).toHaveLength(2);
- expect(msgs[0]?.role).toBe("user");
- expect(msgs[0]?.chunks).toHaveLength(1);
- expect(msgs[1]?.role).toBe("assistant");
- expect(msgs[1]?.chunks).toHaveLength(1);
- expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" });
- });
+ it("selectMessages groups consecutive same-role chunks", () => {
+ let s = initialState();
+ s = applyHistory(s, [
+ storedChunk(1, "user", { type: "text", text: "q1" }),
+ storedChunk(2, "user", { type: "text", text: "q2" }),
+ storedChunk(3, "assistant", { type: "text", text: "a1" }),
+ storedChunk(4, "assistant", { type: "text", text: "a2" }),
+ storedChunk(5, "user", { type: "text", text: "q3" }),
+ ]);
+ const msgs = selectMessages(s);
+ expect(msgs).toHaveLength(3);
+ expect(msgs[0]?.role).toBe("user");
+ expect(msgs[0]?.chunks).toHaveLength(2);
+ expect(msgs[1]?.role).toBe("assistant");
+ expect(msgs[1]?.chunks).toHaveLength(2);
+ expect(msgs[2]?.role).toBe("user");
+ expect(msgs[2]?.chunks).toHaveLength(1);
+ });
+
+ it("returns empty for empty state", () => {
+ expect(selectMessages(initialState())).toEqual([]);
+ });
+
+ it("mixes committed and provisional in messages", () => {
+ let s = initialState();
+ s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]);
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "a1"));
+ s = foldEvent(s, textDelta("t1", "a2"));
+ const msgs = selectMessages(s);
+ expect(msgs).toHaveLength(2);
+ expect(msgs[0]?.role).toBe("user");
+ expect(msgs[0]?.chunks).toHaveLength(1);
+ expect(msgs[1]?.role).toBe("assistant");
+ expect(msgs[1]?.chunks).toHaveLength(1);
+ expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" });
+ });
});
describe("appendUserMessage", () => {
- it("adds a provisional user text chunk", () => {
- let s = initialState();
- s = appendUserMessage(s, "hello from user");
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]?.seq).toBeNull();
- expect(chunks[0]?.role).toBe("user");
- expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" });
- expect(chunks[0]?.provisional).toBe(true);
- });
-
- it("selectMessages includes the optimistic user message", () => {
- let s = initialState();
- s = appendUserMessage(s, "what is 2+2?");
- const msgs = selectMessages(s);
- expect(msgs).toHaveLength(1);
- expect(msgs[0]?.role).toBe("user");
- expect(msgs[0]?.chunks).toHaveLength(1);
- expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" });
- });
-
- it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => {
- let s = initialState();
- s = appendUserMessage(s, "hi");
- expect(selectChunks(s)).toHaveLength(1);
-
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "hello back"));
- s = foldEvent(s, turnSealed("t1"));
- s = applyHistory(s, [
- storedChunk(1, "user", { type: "text", text: "hi" }),
- storedChunk(2, "assistant", { type: "text", text: "hello back" }),
- ]);
- const chunks = selectChunks(s);
- expect(chunks).toHaveLength(2);
- expect(chunks[0]?.seq).toBe(1);
- expect(chunks[0]?.role).toBe("user");
- expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" });
- expect(chunks[0]?.provisional).toBe(false);
- expect(chunks[1]?.seq).toBe(2);
- expect(chunks[1]?.role).toBe("assistant");
- expect(chunks[1]?.provisional).toBe(false);
- });
-
- it("flushes accumulating chunk before appending user message", () => {
- let s = initialState();
- s = foldEvent(s, turnStart("t1"));
- s = foldEvent(s, textDelta("t1", "partial"));
- expect(s.accumulating).toEqual({ kind: "text", text: "partial" });
-
- s = appendUserMessage(s, "user msg");
- expect(s.accumulating).toBeNull();
- expect(s.provisional).toHaveLength(2);
- expect(s.provisional[0]?.role).toBe("assistant");
- expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" });
- expect(s.provisional[1]?.role).toBe("user");
- expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" });
- });
+ it("adds a provisional user text chunk", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hello from user");
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(1);
+ expect(chunks[0]?.seq).toBeNull();
+ expect(chunks[0]?.role).toBe("user");
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" });
+ expect(chunks[0]?.provisional).toBe(true);
+ });
+
+ it("selectMessages includes the optimistic user message", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "what is 2+2?");
+ const msgs = selectMessages(s);
+ expect(msgs).toHaveLength(1);
+ expect(msgs[0]?.role).toBe("user");
+ expect(msgs[0]?.chunks).toHaveLength(1);
+ expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" });
+ });
+
+ it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hi");
+ expect(selectChunks(s)).toHaveLength(1);
+
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "hello back"));
+ s = foldEvent(s, turnSealed("t1"));
+ s = applyHistory(s, [
+ storedChunk(1, "user", { type: "text", text: "hi" }),
+ storedChunk(2, "assistant", { type: "text", text: "hello back" }),
+ ]);
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(2);
+ expect(chunks[0]?.seq).toBe(1);
+ expect(chunks[0]?.role).toBe("user");
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" });
+ expect(chunks[0]?.provisional).toBe(false);
+ expect(chunks[1]?.seq).toBe(2);
+ expect(chunks[1]?.role).toBe("assistant");
+ expect(chunks[1]?.provisional).toBe(false);
+ });
+
+ it("flushes accumulating chunk before appending user message", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial"));
+ expect(s.accumulating).toEqual({ kind: "text", text: "partial" });
+
+ s = appendUserMessage(s, "user msg");
+ expect(s.accumulating).toBeNull();
+ expect(s.provisional).toHaveLength(2);
+ expect(s.provisional[0]?.role).toBe("assistant");
+ expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" });
+ expect(s.provisional[1]?.role).toBe("user");
+ expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" });
+ });
});