// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("./api.js", async () => { const actual = await vi.importActual("./api.js"); return { ...actual, sendStructuredPrompt: vi.fn().mockResolvedValue(undefined), uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }), cancelStructuredTurn: vi.fn().mockResolvedValue(undefined), resolveStructuredApproval: vi.fn().mockResolvedValue(undefined), setStructuredConfig: vi.fn().mockResolvedValue(undefined), }; }); import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt, uploadStructuredFile, setStructuredConfig, } from "./api.js"; import { TOGGLE_DRAWER_EVENT } from "./drawer.js"; import type { CatalogStore } from "./events.js"; import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js"; import type { ReducedState, StructuredEvent, TranscriptRow } from "./types.js"; /** Just enough of a WebSocket for the connection to drive (mirrors * events.test.ts's FakeSocket, adapted for JSON structured events). */ class FakeSocket { onopen: (() => void) | null = null; onmessage: ((ev: { data: string }) => void) | null = null; onerror: (() => void) | null = null; onclose: ((ev: { code: number; reason: string }) => void) | null = null; closed = false; open(): void { this.onopen?.(); } emit(event: StructuredEvent): void { this.onmessage?.({ data: JSON.stringify(event) }); } drop(code = 1006, reason = ""): void { this.onclose?.({ code, reason }); } close(): void { this.closed = true; } } function row(id: string, kind: TranscriptRow["kind"], data: unknown): TranscriptRow { return { id, kind, data }; } function toolRow(id: string, text: string): TranscriptRow { return row(id, "tool_call", { title: `Read(${id})`, status: "completed", content: [{ type: "content", content: { type: "text", text } }], }); } function diffRow(id: string, newText: string): TranscriptRow { return row(id, "tool_diff", { title: `Edit(${id})`, status: "completed", content: [{ type: "diff", path: "file.ts", oldText: "before", newText }], }); } const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } }; const allApprovalOptions = [ { optionId: "a1", name: "Allow once", kind: "allow_once" as const }, { optionId: "a2", name: "Allow always", kind: "allow_always" as const }, { optionId: "r1", name: "Reject once", kind: "reject_once" as const }, ]; function mount(opts: { sockets: FakeSocket[]; shouldReconnect?: (id: string, code: number) => boolean; store?: CatalogStore; }) { let i = 0; const factory: StructuredSocketFactory = () => opts.sockets[i++] as unknown as WebSocket; const conn = createStructuredConnection( "s1", { shouldReconnect: opts.shouldReconnect }, { name: "s1", command: "claude", view: "Structured" }, opts.store, factory, ); document.body.appendChild(conn.el); return conn; } describe("structured connection: data flow", () => { beforeEach(() => vi.clearAllMocks()); it("converts long pasted text into a removable file and sends it without reply text", async () => { const conn = mount({ sockets: [new FakeSocket()] }); const input = conn.el.querySelector("textarea")!; const paste = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "a".repeat(4000), } }); input.dispatchEvent(paste); expect(paste.defaultPrevented).toBe(true); expect(input.value).toBe(""); expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt"); conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true })); await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt"))); expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 })); conn.dispose(); }); it("leaves short pastes alone", () => { const conn = mount({ sockets: [new FakeSocket()] }); const paste = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } }); conn.el.querySelector("textarea")!.dispatchEvent(paste); expect(paste.defaultPrevented).toBe(false); conn.dispose(); }); it("retains failed uploads for retry and lets the operator remove them", async () => { vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed")); const conn = mount({ sockets: [new FakeSocket()] }); const input = conn.el.querySelector("textarea")!; input.value = "Read this"; const paste = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(paste, "clipboardData", { value: { files: [new File(["content"], "notes.txt")], getData: () => "", } }); input.dispatchEvent(paste); conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true })); await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed")); expect(input.value).toBe("Read this"); expect(sendStructuredPrompt).not.toHaveBeenCalled(); const remove = conn.el.querySelector('[aria-label="Remove notes.txt"]')!; expect(remove).not.toBeNull(); remove.click(); expect(conn.el.querySelector(".structured-attachments")!.hidden).toBe(true); conn.dispose(); }); it("renders every row from the initial snapshot", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 2, rows: [ row("user:1", "user", { content: { type: "text", text: "hello" } }), row("agent:1", "agent_text", { content: { type: "text", text: "hi there" } }), ], }, }); ws.emit({ type: "reduced_state", state: idleState }); expect(conn.el.textContent).toContain("hello"); expect(conn.el.textContent).toContain("hi there"); conn.dispose(); }); it("shows turn details only while that footer is the conversation's final item", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); const turnMeta = (id: string, elapsedMs: number) => row(id, "notice", { maestroTurnMeta: { elapsedMs, contextTokens: 12_000, outcome: "completed", model: "codex" }, }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 4, rows: [ row("user:1", "user", { content: { type: "text", text: "first" } }), turnMeta("turn:1", 1_000), row("user:2", "user", { content: { type: "text", text: "second" } }), turnMeta("turn:2", 2_000), ], }, }); expect(conn.el.querySelectorAll(".st-turn-meta")).toHaveLength(1); expect(conn.el.querySelector(".st-turn-meta")?.textContent).toContain("2s"); ws.emit({ type: "projection_update", transcript: { type: "append", row: row("user:3", "user", { content: { type: "text", text: "third" } }), }, state: idleState, }); expect(conn.el.querySelector(".st-turn-meta")).toBeNull(); conn.dispose(); }); it("keeps state-only plan rows out of the transcript", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 3, rows: [ row("user:1", "user", { content: { type: "text", text: "before plan" } }), row("plan", "plan", { entries: [{ content: "ship it", status: "pending" }] }), row("agent:1", "agent_text", { content: { type: "text", text: "after plan" } }), ], }, }); ws.emit({ type: "reduced_state", state: { ...idleState, plan: { entries: [{ content: "ship it", priority: "high", status: "pending" }] }, }, }); const transcript = conn.el.querySelector(".structured-rows")!; expect(transcript.querySelectorAll(".st-row")).toHaveLength(2); expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull(); expect(transcript.textContent).toContain("before plan"); expect(transcript.textContent).toContain("after plan"); expect(transcript.textContent).not.toContain("[plan]"); expect(conn.el.querySelector(".structured-rail")).toBeNull(); conn.dispose(); }); it("renders advertised session settings and sends changed values", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "reduced_state", state: { ...idleState, config_options: { configOptions: [ { id: "model", name: "Model", category: "model", type: "select", currentValue: "opus", options: [ { value: "opus", name: "Opus" }, { value: "sonnet", name: "Sonnet" }, ], }, { id: "effort", name: "Effort", category: "thought_level", type: "select", currentValue: "high", options: [{ value: "high", name: "High" }], }, ], }, }, }); expect(conn.el.querySelector(".structured-config-trigger")?.getAttribute("aria-label")).toBe( "Session settings. Model: Opus. Effort: High", ); (conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click(); const selects = conn.el.querySelectorAll(".structured-config-select"); expect(selects).toHaveLength(2); selects[0].value = "sonnet"; selects[0].dispatchEvent(new Event("change")); expect(setStructuredConfig).toHaveBeenCalledWith("s1", { config_id: "model", value: "sonnet", }); conn.dispose(); }); it("applies plan append, patch, and remove without disturbing surrounding rows", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [row("user:1", "user", { content: { type: "text", text: "keep me" } })], }, }); ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "projection_update", transcript: { type: "append", row: row("plan", "plan", { entries: [{ content: "first", status: "pending" }] }), }, state: { ...idleState, plan: { entries: [{ content: "first", priority: "high", status: "pending" }] }, }, }); ws.emit({ type: "projection_update", transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "still here" } }), }, state: { ...idleState, plan: { entries: [{ content: "first", priority: "high", status: "pending" }] }, }, }); ws.emit({ type: "projection_update", transcript: { type: "patch", row: row("plan", "plan", { entries: [{ content: "updated", status: "completed" }] }), }, state: { ...idleState, plan: { entries: [{ content: "updated", priority: "high", status: "completed" }] }, }, }); const transcript = conn.el.querySelector(".structured-rows")!; expect([...transcript.querySelectorAll(".st-row")].map((el) => el.dataset.rowId)).toEqual([ "user:1", "agent:1", ]); expect(transcript.textContent).not.toContain("updated"); ws.emit({ type: "projection_update", transcript: { type: "remove", id: "plan" }, state: idleState, }); ws.emit({ type: "projection_update", transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "patched neighbor" } }), }, state: idleState, }); ws.emit({ type: "projection_update", transcript: { type: "remove", id: "user:1" }, state: idleState, }); expect(conn.el.querySelector(".structured-rail")).toBeNull(); expect([...transcript.querySelectorAll(".st-row")].map((el) => el.dataset.rowId)).toEqual([ "agent:1", ]); expect(transcript.textContent).toContain("patched neighbor"); conn.dispose(); }); it("appends and patches rows via projection_update without duplicating them", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "projection_update", transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) }, state: idleState, }); expect(conn.el.querySelector('[data-row-id="agent:1"]')?.classList).toContain("st-row-enter"); ws.emit({ type: "projection_update", transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) }, state: idleState, }); const rows = conn.el.querySelectorAll(".st-row-agent"); expect(rows).toHaveLength(1); expect(rows[0].classList).not.toContain("st-row-enter"); expect(conn.el.textContent).toContain("partial done"); expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it conn.dispose(); }); it("groups multiple tools in one user turn but keeps separate turns separate", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 6, rows: [ row("user:1", "user", { content: { type: "text", text: "first" } }), toolRow("tool:1", "one"), toolRow("tool:2", "two"), row("user:2", "user", { content: { type: "text", text: "second" } }), toolRow("tool:3", "three"), ], }, }); const groups = conn.el.querySelectorAll(".st-tool-group"); expect(groups).toHaveLength(1); expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls"); expect(groups[0].querySelector(".st-tool-group-body")!.getAttribute("aria-hidden")).toBe("true"); expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull(); conn.dispose(); }); it("absorbs intermediate agent text into tool group, keeps final text outside", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 8, rows: [ row("user:1", "user", { content: { type: "text", text: "do it" } }), row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }), toolRow("tool:1", "one"), toolRow("tool:2", "two"), row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }), toolRow("tool:3", "three"), toolRow("tool:4", "four"), row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }), ], }, }); const groups = conn.el.querySelectorAll(".st-tool-group"); expect(groups).toHaveLength(1); // Header counts only tool rows, not intermediate text. expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls"); // Intermediate text (agent:2) is inside the group. expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull(); // First text (agent:1) is outside — it precedes all tools. expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull(); // Final text (agent:3) is outside — no tool follows it. expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull(); conn.dispose(); }); it("keeps last intermediate text outside group while turn is active", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); const respondingState: ReducedState = { turn: { responding: true, thinking: false, active_tool_calls: [] }, }; ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 6, rows: [ row("user:1", "user", { content: { type: "text", text: "do it" } }), toolRow("tool:1", "one"), row("agent:1", "agent_text", { content: { type: "text", text: "Now fixing" } }), toolRow("tool:2", "two"), row("agent:2", "agent_text", { content: { type: "text", text: "Found more" } }), toolRow("tool:3", "three"), ], }, }); ws.emit({ type: "reduced_state", state: respondingState }); // Intermediate updates stay inside the collapsed activity disclosure, // rather than becoming a second visible transcript alongside its summary. expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull(); expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull(); // It remains collapsed when the turn ends. ws.emit({ type: "reduced_state", state: idleState }); expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull(); expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull(); conn.dispose(); }); it("collapses trailing thought commentary with a turn's tool activity", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 5, rows: [ row("user:1", "user", { content: { type: "text", text: "do it" } }), toolRow("tool:1", "one"), toolRow("tool:2", "two"), row("thought:1", "agent_thought", { content: { type: "text", text: "Checking the next step" } }), row("thought:2", "agent_thought", { content: { type: "text", text: "Preparing the change" } }), ], }, }); for (const id of ["thought:1", "thought:2"]) { expect(conn.el.querySelector(`[data-row-id="${id}"]`)?.closest(".st-tool-group")).not.toBeNull(); } conn.dispose(); }); it("keeps an expanded running tool open without emitting a click when patched", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [ row("tool:1", "tool_call", { title: "Bash(pnpm test)", status: "in_progress", content: [{ type: "content", content: { type: "text", text: "starting" } }], }), ], }, }); ws.emit({ type: "reduced_state", state: idleState }); const originalHead = conn.el.querySelector(".st-tool-head")!; originalHead.click(); expect(originalHead.getAttribute("aria-expanded")).toBe("true"); const clickListener = vi.fn(); window.addEventListener("click", clickListener, { capture: true, once: true }); ws.emit({ type: "projection_update", transcript: { type: "patch", row: row("tool:1", "tool_call", { title: "Bash(pnpm test)", status: "in_progress", content: [{ type: "content", content: { type: "text", text: "still running" } }], }), }, state: idleState, }); expect(clickListener).not.toHaveBeenCalled(); window.removeEventListener("click", clickListener, true); const patchedHead = conn.el.querySelector(".st-tool-head")!; expect(patchedHead).not.toBe(originalHead); expect(patchedHead.getAttribute("aria-expanded")).toBe("true"); expect(conn.el.querySelector(".st-tool-out")!.hidden).toBe(false); expect(conn.el.textContent).toContain("still running"); conn.dispose(); }); it("restores focus to a patched tool header with the same stable row id", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })], }, }); ws.emit({ type: "reduced_state", state: idleState }); const originalHead = conn.el.querySelector(".st-tool-head")!; originalHead.focus(); expect(document.activeElement).toBe(originalHead); ws.emit({ type: "projection_update", transcript: { type: "patch", row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }), }, state: idleState, }); const patchedHead = conn.el.querySelector(".st-tool-head")!; expect(patchedHead).not.toBe(originalHead); expect(document.activeElement).toBe(patchedHead); conn.dispose(); }); it("does not move unrelated focus when a tool row is patched", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })], }, }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input")!; input.focus(); ws.emit({ type: "projection_update", transcript: { type: "patch", row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }), }, state: idleState, }); expect(document.activeElement).toBe(input); conn.dispose(); }); }); describe("structured connection: turn/composer gating", () => { beforeEach(() => vi.clearAllMocks()); const commandNotice = (commands: Array<{ name: string; description: string; hint?: string }>) => row("commands", "notice", { sessionUpdate: "available_commands_update", availableCommands: commands.map(({ name, description, hint }) => ({ name, description, ...(hint ? { input: { hint } } : {}), })), }); it("autocompletes the live ACP command catalog with keyboard navigation", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [ commandNotice([ { name: "yolo", description: "Implement directly" }, { name: "blitz", description: "Complete a milestone", hint: "" }, ]), ], }, }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input")!; const menu = conn.el.querySelector(".structured-command-menu")!; input.value = "/"; input.dispatchEvent(new Event("input")); expect(menu.hidden).toBe(false); expect([...menu.querySelectorAll(".structured-command-name")].map((node) => node.textContent)).toEqual([ "/blitz", "/yolo", ]); expect(menu.querySelector(".structured-command-hint")!.textContent).toBe(""); input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); expect(input.value).toBe("/yolo "); expect(menu.hidden).toBe(true); expect(sendStructuredPrompt).not.toHaveBeenCalled(); input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "/yolo "); conn.dispose(); }); it("filters commands as the operator types and accepts a touch selection", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [ commandNotice([ { name: "hourlog", description: "Reconcile hours" }, { name: "humanizer", description: "Rewrite prose" }, { name: "blitz", description: "Complete a milestone" }, ]), ], }, }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input")!; input.value = "/hum"; input.dispatchEvent(new Event("input")); const options = conn.el.querySelectorAll(".structured-command-option"); expect(options).toHaveLength(1); expect(options[0].textContent).toContain("/humanizer"); options[0].click(); expect(input.value).toBe("/humanizer "); expect(document.activeElement).toBe(input); conn.dispose(); }); it("completes the slash token under the caret without replacing surrounding text", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [ commandNotice([ { name: "security-review", description: "Review pending changes" }, { name: "status", description: "Show status" }, { name: "blitz", description: "Complete a milestone" }, ]), ], }, }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input")!; input.value = "asd /s dsa"; input.setSelectionRange(6, 6); input.dispatchEvent(new Event("input")); const options = conn.el.querySelectorAll(".structured-command-option"); expect([...options].map((option) => option.querySelector(".structured-command-name")!.textContent)).toEqual([ "/security-review", "/status", ]); options[0].click(); expect(input.value).toBe("asd /security-review dsa"); expect(input.selectionStart).toBe(input.value.indexOf("dsa")); expect(input.selectionEnd).toBe(input.selectionStart); conn.dispose(); }); it("replaces the rest of a partially edited slash token after the caret", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [commandNotice([{ name: "status", description: "Show status" }])], }, }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input")!; input.value = "before /stale after"; input.setSelectionRange(9, 9); input.dispatchEvent(new Event("input")); conn.el.querySelector(".structured-command-option")!.click(); expect(input.value).toBe("before /status after"); conn.dispose(); }); it("keeps the composer available while a turn is active", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement; ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); expect(input.disabled).toBe(false); expect(cancel.hidden).toBe(false); expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send"); ws.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); expect(cancel.hidden).toBe(true); expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send"); conn.dispose(); }); it("shows live thinking time, tool activity, and context usage", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); const activity = conn.el.querySelector(".structured-activity")!; ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [], started_at: new Date().toISOString(), started_context_tokens: 10_000, }, usage: { used: 12_400, size: 200_000 }, }, }); expect(activity.hidden).toBe(false); expect(activity.textContent).toContain("Thinking · 0s · 12.4k context / 200k context"); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: ["bash-1", "read-1"] }, usage: { used: 12_800, size: 200_000 }, }, }); expect(activity.textContent).toContain("Running 2 tools"); ws.emit({ type: "reduced_state", state: idleState }); expect(activity.hidden).toBe(true); conn.dispose(); }); it("sends a prompt on Enter, clears the draft, and restores it on failure", async () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "do the thing"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"); expect(input.value).toBe(""); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: idleState }); vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom")); input.value = "retry me"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); await Promise.resolve(); await Promise.resolve(); expect(input.value).toBe("retry me"); expect(conn.el.textContent).toContain("boom"); conn.dispose(); }); it("queues a second message before begin-turn is projected", () => { vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; const send = conn.el.querySelector(".structured-send") as HTMLButtonElement; const form = conn.el.querySelector(".structured-composer") as HTMLFormElement; input.value = "only once"; form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); input.value = "queued next"; form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); expect(input.disabled).toBe(false); expect(send.disabled).toBe(false); expect(sendStructuredPrompt).toHaveBeenCalledTimes(1); expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once"); const queued = conn.el.querySelectorAll(".st-row-queued"); expect(queued).toHaveLength(1); expect(queued[0].textContent).toBe("queued next"); conn.dispose(); }); it("dispatches queued messages in order as turns become idle", async () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; const submit = (text: string) => { input.value = text; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); }; submit("first"); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); submit("second"); submit("third"); expect(sendStructuredPrompt).toHaveBeenCalledTimes(1); expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(2); await Promise.resolve().then(() => Promise.resolve()); ws.emit({ type: "reduced_state", state: idleState }); expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second"); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); await Promise.resolve().then(() => Promise.resolve()); ws.emit({ type: "reduced_state", state: idleState }); expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third"); expect(conn.el.querySelectorAll(".st-row-queued")).toHaveLength(0); conn.dispose(); }); it("does not overwrite a newer operator draft when the matching request fails", async () => { let reject!: (error: unknown) => void; vi.mocked(sendStructuredPrompt).mockImplementationOnce( () => new Promise((_, rejectPromise) => { reject = rejectPromise; }), ); const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "first prompt"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: idleState }); input.value = "newer draft"; reject(new Error("late failure")); await Promise.resolve().then(() => Promise.resolve()); expect(input.value).toBe("newer draft"); expect(conn.el.textContent).toContain("late failure"); conn.dispose(); }); it("ignores an older failure after a newer submission generation starts", async () => { let rejectFirst!: (error: unknown) => void; vi.mocked(sendStructuredPrompt).mockImplementationOnce( () => new Promise((_, rejectPromise) => { rejectFirst = rejectPromise; }), ); const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "first prompt"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: idleState }); input.value = "second prompt"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); rejectFirst(new Error("stale failure")); await Promise.resolve().then(() => Promise.resolve()); expect(sendStructuredPrompt).toHaveBeenCalledTimes(2); expect(input.value).toBe(""); expect(input.disabled).toBe(false); expect(conn.el.textContent).not.toContain("stale failure"); conn.dispose(); }); it("ignores a submission failure after disposal", async () => { let reject!: (error: unknown) => void; vi.mocked(sendStructuredPrompt).mockImplementationOnce( () => new Promise((_, rejectPromise) => { reject = rejectPromise; }), ); const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "do not restore"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); conn.dispose(); input.value = "detached draft"; reject(new Error("late failure")); await Promise.resolve().then(() => Promise.resolve()); expect(input.value).toBe("detached draft"); expect(conn.el.querySelector(".structured-error")).toBeNull(); }); it("cancels the active turn via the cancel button", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [] } } }); (conn.el.querySelector(".structured-cancel") as HTMLButtonElement).click(); expect(cancelStructuredTurn).toHaveBeenCalledWith("s1"); conn.dispose(); }); }); describe("structured connection: approvals", () => { beforeEach(() => vi.clearAllMocks()); it("shows decision buttons only for the currently pending approval, and answers with its nonce", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "projection_update", transcript: { type: "append", row: row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions }), }, state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, }); const buttons = conn.el.querySelectorAll(".st-approval-acts button"); expect(buttons).toHaveLength(3); buttons[1].click(); // "Allow for session" expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_for_session"); conn.dispose(); }); it("hides a standalone resolved approval entirely when no tool group absorbs it", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions })] }, }); ws.emit({ type: "reduced_state", state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, }); expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3); // Resolved on another client: state clears with no transcript delta at all. ws.emit({ type: "reduced_state", state: idleState }); expect(conn.el.querySelector(".st-approval-acts")).toBeNull(); // Standalone resolved approval is hidden — no card, no compact line. expect(conn.el.querySelector(".st-row-approval")).toBeNull(); conn.dispose(); }); it("absorbs a resolved approval into the tool group as a compact line", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [ row("user:1", "user", { content: { text: "go" } }), toolRow("tool:1", "first"), row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }), toolRow("tool:2", "second"), ], }, }); ws.emit({ type: "reduced_state", state: idleState }); // 2+ tools in the turn → tool group exists; resolved approval is inside it. const group = conn.el.querySelector(".st-tool-group"); expect(group).not.toBeNull(); const compact = group!.querySelector(".st-approval-compact"); expect(compact).not.toBeNull(); expect(compact!.textContent).toContain("Write file.ts"); expect(compact!.textContent).toContain("approved"); // No full-card approval anywhere on the transcript. expect(conn.el.querySelector(".st-approval")).toBeNull(); conn.dispose(); }); it("keeps a pending approval standalone even when a tool group exists in the same turn", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [ row("user:1", "user", { content: { text: "go" } }), toolRow("tool:1", "first"), row("approval:1", "approval", { toolCall: { title: "Write file.ts" }, options: allApprovalOptions }), toolRow("tool:2", "second"), ], }, }); ws.emit({ type: "reduced_state", state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, }); // Pending approval stays outside the tool group as a full card. const approvalCard = conn.el.querySelector(".st-approval"); expect(approvalCard).not.toBeNull(); expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0); // It should not be inside the tool group. const group = conn.el.querySelector(".st-tool-group"); expect(group).not.toBeNull(); expect(group!.querySelector(".st-approval")).toBeNull(); conn.dispose(); }); it("does not act on a stale click after the approval already moved on", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] }, }); ws.emit({ type: "reduced_state", state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, }); const buttons = conn.el.querySelectorAll(".st-approval-acts button"); // Approval is superseded before the click is handled (buttons already // disabled by the click handler is not the path under test here — this // simulates the row having already flipped to "answered" underneath it). ws.emit({ type: "reduced_state", state: idleState }); buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(resolveStructuredApproval).not.toHaveBeenCalled(); conn.dispose(); }); it("restores the buttons and surfaces an error when the POST rejects while still pending", async () => { vi.mocked(resolveStructuredApproval).mockRejectedValueOnce(new Error("network down")); const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] }, }); ws.emit({ type: "reduced_state", state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, }); const buttons = conn.el.querySelectorAll(".st-approval-acts button"); buttons[0].click(); expect(buttons[0].disabled).toBe(true); await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve()); expect(buttons[0].disabled).toBe(false); expect(buttons[1].disabled).toBe(false); expect(conn.el.textContent).toContain("network down"); conn.dispose(); }); it("does not resurrect buttons or show an error for a decision the approval already moved past", async () => { let reject!: (e: unknown) => void; vi.mocked(resolveStructuredApproval).mockImplementationOnce( () => new Promise((_, r) => { reject = r; }), ); const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] }, }); ws.emit({ type: "reduced_state", state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, }); const buttons = conn.el.querySelectorAll(".st-approval-acts button"); buttons[0].click(); expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once"); // Superseded by a new approval before the in-flight POST settles. ws.emit({ type: "projection_update", transcript: { type: "append", row: row("approval:2", "approval", { toolCall: {}, options: allApprovalOptions }) }, state: { ...idleState, pending_approval: { nonce: "n2", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } }, }); reject(new Error("too late")); await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve()); expect(conn.el.textContent).not.toContain("too late"); const newButtons = conn.el.querySelectorAll(".st-approval-acts button"); expect(newButtons).toHaveLength(3); expect(newButtons[0].disabled).toBe(false); conn.dispose(); }); }); describe("structured connection: reconnection/error/loading", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); }); afterEach(() => vi.useRealTimers()); it("shows connecting, then connected, then reconnecting with backoff, and rebuilds from the fresh snapshot", () => { const first = new FakeSocket(); const second = new FakeSocket(); const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); const state = () => (conn.el.querySelector(".attach-state") as HTMLElement).dataset.state; expect(state()).toBe("connecting"); first.open(); expect(state()).toBe("open"); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [row("user:1", "user", { content: { type: "text", text: "before" } })] }, }); expect(conn.el.textContent).toContain("before"); first.drop(1006, ""); expect(state()).toBe("connecting"); // "reconnecting…" vi.advanceTimersByTime(1000); second.open(); expect(state()).toBe("open"); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); expect(conn.el.textContent).not.toContain("before"); // rebuilt wholesale, not merged conn.dispose(); }); it("does not mount plan rows from a reconnect snapshot", () => { const first = new FakeSocket(); const second = new FakeSocket(); const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); first.open(); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [row("agent:old", "agent_text", { content: { type: "text", text: "old" } })], }, }); first.drop(); vi.advanceTimersByTime(1000); second.open(); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 4, rows: [ row("user:new", "user", { content: { type: "text", text: "new" } }), row("plan", "plan", { entries: [{ content: "reconnected", status: "in_progress" }] }), ], }, }); second.emit({ type: "reduced_state", state: { ...idleState, plan: { entries: [{ content: "reconnected", priority: "high", status: "in_progress" }] }, }, }); const transcript = conn.el.querySelector(".structured-rows")!; expect(transcript.textContent).toBe("new"); expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull(); expect(conn.el.querySelector(".structured-rail")).toBeNull(); conn.dispose(); }); it("keeps surviving expanded rows open across a reconnect snapshot and leaves fresh rows collapsed", () => { const first = new FakeSocket(); const second = new FakeSocket(); const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); first.open(); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] }, }); for (const head of conn.el.querySelectorAll(".st-tool-head")) head.click(); first.drop(); vi.advanceTimersByTime(1000); second.open(); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 3, rows: [toolRow("tool:1", "after"), diffRow("diff:1", "second"), toolRow("tool:2", "new")], }, }); expect( conn.el.querySelector('[data-row-id="tool:1"] .st-tool-head')?.getAttribute("aria-expanded"), ).toBe("true"); expect( conn.el.querySelector('[data-row-id="diff:1"] .st-tool-head')?.getAttribute("aria-expanded"), ).toBe("true"); expect( conn.el.querySelector('[data-row-id="tool:2"] .st-tool-head')?.getAttribute("aria-expanded"), ).toBe("false"); expect(conn.el.querySelector('[data-row-id="tool:1"] .st-tool-out')?.textContent).toContain("after"); conn.dispose(); }); it("prunes expanded IDs removed or made non-expandable by a snapshot", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] }, }); for (const head of conn.el.querySelectorAll(".st-tool-head")) head.click(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 3, rows: [row("tool:plain", "agent_text", { content: { type: "text", text: "not a tool" } })], }, }); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 5, rows: [toolRow("tool:gone", "returned"), toolRow("tool:plain", "returned")] }, }); for (const head of conn.el.querySelectorAll(".st-tool-head")) { expect(head.getAttribute("aria-expanded")).toBe("false"); } conn.dispose(); }); it("keeps a local submission gated across a reconnect until begin-turn arrives", () => { vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); const first = new FakeSocket(); const second = new FakeSocket(); const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); first.open(); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); first.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "survive reconnect"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); first.drop(); vi.advanceTimersByTime(1000); second.open(); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); second.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); conn.dispose(); }); it("releases an accepted submission from a post-acceptance idle snapshot", async () => { vi.mocked(sendStructuredPrompt).mockResolvedValueOnce(undefined); const first = new FakeSocket(); const second = new FakeSocket(); const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); first.open(); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); first.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "finishes while disconnected"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); await Promise.resolve().then(() => Promise.resolve()); first.drop(); vi.advanceTimersByTime(1000); second.open(); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); conn.dispose(); }); it("does not release from reconnect idle cached before submission acceptance", async () => { let accept!: () => void; vi.mocked(sendStructuredPrompt).mockImplementationOnce( () => new Promise((resolve) => { accept = resolve; }), ); const first = new FakeSocket(); const second = new FakeSocket(); const third = new FakeSocket(); const conn = mount({ sockets: [first, second, third], shouldReconnect: () => true }); first.open(); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); first.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "accepted after reconnect"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); first.drop(); vi.advanceTimersByTime(1000); second.open(); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); accept(); await Promise.resolve().then(() => Promise.resolve()); expect(input.disabled).toBe(false); expect(second.closed).toBe(true); third.open(); third.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); third.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); conn.dispose(); }); it("cancels pending backoff when acceptance forces reconciliation", async () => { let accept!: () => void; vi.mocked(sendStructuredPrompt).mockImplementationOnce( () => new Promise((resolve) => { accept = resolve; }), ); const first = new FakeSocket(); const second = new FakeSocket(); const reconciliation = new FakeSocket(); const duplicate = new FakeSocket(); const conn = mount({ sockets: [first, second, reconciliation, duplicate], shouldReconnect: () => true, }); first.open(); first.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "accepted during backoff"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); first.drop(); vi.advanceTimersByTime(1000); second.drop(); accept(); await Promise.resolve().then(() => Promise.resolve()); expect(reconciliation.onopen).not.toBeNull(); vi.advanceTimersByTime(60_000); expect(duplicate.onopen).toBeNull(); reconciliation.open(); reconciliation.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); conn.dispose(); expect(reconciliation.closed).toBe(true); }); it("does not reconcile when submission acceptance arrives after disposal", async () => { let accept!: () => void; vi.mocked(sendStructuredPrompt).mockImplementationOnce( () => new Promise((resolve) => { accept = resolve; }), ); const first = new FakeSocket(); const second = new FakeSocket(); const late = new FakeSocket(); const conn = mount({ sockets: [first, second, late], shouldReconnect: () => true }); first.open(); first.emit({ type: "reduced_state", state: idleState }); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; input.value = "accepted after disposal"; input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); first.drop(); vi.advanceTimersByTime(1000); second.open(); second.emit({ type: "reduced_state", state: idleState }); conn.dispose(); accept(); await Promise.resolve().then(() => Promise.resolve()); vi.advanceTimersByTime(60_000); expect(second.closed).toBe(true); expect(late.onopen).toBeNull(); }); it("reports a daemon rejection reason and does not schedule a retry", () => { const only = new FakeSocket(); const conn = mount({ sockets: [only], shouldReconnect: () => true }); only.open(); only.drop(4004, "session is not a live structured session"); const stateEl = conn.el.querySelector(".attach-state") as HTMLElement; expect(stateEl.dataset.state).toBe("error"); expect(stateEl.textContent).toBe("session is not a live structured session"); vi.advanceTimersByTime(60_000); // No second socket was ever asked for — only one factory call happened. conn.dispose(); }); }); describe("structured connection: transcript scrolling", () => { let frames: Map; let nextFrame: number; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); frames = new Map(); nextFrame = 1; vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { const id = nextFrame++; frames.set(id, cb); return id; }); vi.stubGlobal("cancelAnimationFrame", (id: number) => { frames.delete(id); }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); function metrics(conn: ReturnType, height: number, clientHeight: number, top = 0) { const scroll = conn.el.querySelector(".structured-scroll")!; const values = { height, clientHeight, top }; Object.defineProperties(scroll, { scrollHeight: { configurable: true, get: () => values.height }, clientHeight: { configurable: true, get: () => values.clientHeight }, scrollTop: { configurable: true, get: () => values.top, set: (value: number) => { values.top = value; }, }, }); return { scroll, values }; } function runFrames(): void { const pending = [...frames.entries()]; frames.clear(); for (const [, cb] of pending) cb(0); } it("shows the bottom after the initial transcript snapshot is laid out", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); const { values } = metrics(conn, 900, 300, 40); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [ row("agent:1", "agent_text", { content: { type: "text", text: "latest" } }), ], }, }); expect(values.top).toBe(40); runFrames(); expect(values.top).toBe(600); conn.dispose(); }); it("keeps pinned appends and patches at the bottom after layout", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); const { scroll, values } = metrics(conn, 600, 200); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); runFrames(); values.height = 720; ws.emit({ type: "projection_update", transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }), }, state: idleState, }); runFrames(); expect(values.top).toBe(520); expect(scroll.style.scrollBehavior).toBe("smooth"); values.height = 780; ws.emit({ type: "projection_update", transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "a longer completion" }, }), }, state: idleState, }); runFrames(); expect(values.top).toBe(580); expect(scroll.style.scrollBehavior).toBe("smooth"); conn.dispose(); }); it("keeps follow-to-bottom immediate when reduced motion is requested", () => { vi.stubGlobal("matchMedia", () => ({ matches: true })); const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); const { scroll, values } = metrics(conn, 600, 200); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); runFrames(); values.height = 720; ws.emit({ type: "projection_update", transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "quietly" } }), }, state: idleState, }); runFrames(); expect(values.top).toBe(520); expect(scroll.style.scrollBehavior).toBe(""); conn.dispose(); }); it.each([ { position: "pinned", top: 800, expected: 880, manuallyScrolled: false }, { position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true }, ])("preserves the $position position when the composer grows", ({ top, expected, manuallyScrolled }) => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); const { scroll, values } = metrics(conn, 1000, 200); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); runFrames(); values.top = top; if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll")); const input = conn.el.querySelector(".structured-input")!; Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 }); input.value = "one\ntwo\nthree"; input.dispatchEvent(new Event("input")); values.height = 1080; runFrames(); expect(input.style.height).toBe("120px"); expect(values.top).toBe(expected); conn.dispose(); }); it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); const { scroll, values } = metrics(conn, 1000, 200); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [toolRow("tool:1", "output")] } }); runFrames(); values.top = 180; scroll.dispatchEvent(new Event("scroll")); values.height = 1120; ws.emit({ type: "projection_update", transcript: { type: "append", row: row("agent:2", "agent_text", { content: { type: "text", text: "new" } }) }, state: idleState, }); runFrames(); expect(values.top).toBe(180); values.height = 1180; ws.emit({ type: "projection_update", transcript: { type: "append", row: row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions }), }, state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions }, }, }, }); runFrames(); expect(values.top).toBe(180); conn.el.querySelector(".st-tool-head")!.click(); values.height = 1260; runFrames(); expect(values.top).toBe(180); conn.dispose(); }); it("resumes following when the user scrolls back near the bottom", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); const { scroll, values } = metrics(conn, 1000, 200); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); runFrames(); values.top = 750; scroll.dispatchEvent(new Event("scroll")); values.height = 1100; ws.emit({ type: "projection_update", transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "follow" } }), }, state: idleState, }); runFrames(); expect(values.top).toBe(900); conn.dispose(); }); it("preserves a user-scrolled position across a reconnect snapshot", () => { const first = new FakeSocket(); const second = new FakeSocket(); const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); const { scroll, values } = metrics(conn, 1000, 200); first.open(); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); runFrames(); values.top = 160; scroll.dispatchEvent(new Event("scroll")); first.drop(); vi.advanceTimersByTime(1000); second.open(); values.height = 1200; second.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [ row("agent:1", "agent_text", { content: { type: "text", text: "reconnected" }, }), ], }, }); runFrames(); expect(values.top).toBe(160); conn.dispose(); }); it("cancels a pending transcript frame when disposed or parked", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); const { values } = metrics(conn, 900, 300, 25); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); expect(frames.size).toBe(1); conn.onUnmount(); expect(frames.size).toBe(0); const input = conn.el.querySelector(".structured-input")!; Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 }); input.dispatchEvent(new Event("input")); expect(frames.size).toBe(0); conn.onMount({ focus: false }); expect(frames.size).toBe(2); conn.dispose(); expect(frames.size).toBe(0); runFrames(); expect(values.top).toBe(25); }); }); describe("structured connection: pane lifecycle", () => { beforeEach(() => vi.clearAllMocks()); it("keeps the composer outside the transcript scroll region", () => { const conn = mount({ sockets: [new FakeSocket()] }); const main = conn.el.querySelector(".structured-main")!; const scroll = conn.el.querySelector(".structured-scroll")!; const composer = conn.el.querySelector(".structured-composer")!; expect(main.contains(scroll)).toBe(true); expect(main.contains(composer)).toBe(true); expect(scroll.contains(composer)).toBe(false); conn.dispose(); }); it("sendInput is a no-op — there is no PTY behind a structured session", () => { const conn = mount({ sockets: [new FakeSocket()] }); expect(() => conn.sendInput("\x0b")).not.toThrow(); conn.dispose(); }); it("carries the same off-canvas sessions button as the terminal attach header", () => { const conn = mount({ sockets: [new FakeSocket()] }); const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement; expect(btn).not.toBeNull(); expect(btn.getAttribute("aria-label")).toBe("Show sessions"); expect(btn.type).toBe("button"); let toggled = 0; const onToggle = () => toggled++; document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle); btn.click(); document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle); expect(toggled).toBe(1); conn.dispose(); }); it("focusTerminal keeps the composer reachable during an active turn", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); conn.focusTerminal(); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; expect(document.activeElement).toBe(input); ws.emit({ type: "reduced_state", state: idleState }); conn.focusTerminal(); expect(document.activeElement).toBe(input); conn.dispose(); }); it("dispose closes the socket", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); conn.dispose(); expect(ws.closed).toBe(true); }); }); describe("structured connection: deferred mount focus", () => { let frames: FrameRequestCallback[]; beforeEach(() => { vi.clearAllMocks(); frames = []; vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { frames.push(cb); return frames.length; }); }); afterEach(() => vi.unstubAllGlobals()); function runFrames(): void { const pending = frames; frames = []; for (const cb of pending) cb(0); } it("does not focus the composer if the pane was parked before its deferred frame ran", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: idleState }); conn.onMount({ focus: true }); conn.onUnmount(); // parked again before the frame below runs runFrames(); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; expect(document.activeElement).not.toBe(input); conn.dispose(); }); it("does not focus the composer if it was re-mounted into a different pane before the stale frame ran", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: idleState }); conn.onMount({ focus: true }); // stale frame #1 conn.onUnmount(); conn.onMount({ focus: false }); // the pane that actually won focus, without it runFrames(); // both frames fire; only the latest should have any say const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; expect(document.activeElement).not.toBe(input); conn.dispose(); }); it("still focuses normally when nothing raced the frame", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); ws.emit({ type: "reduced_state", state: idleState }); conn.onMount({ focus: true }); runFrames(); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; expect(document.activeElement).toBe(input); conn.dispose(); }); });