// @vitest-environment happy-dom import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; 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, } from "../../api.js"; import { INITIAL_RECONNECT_MS } from "../../reconnect.js"; import type { ReducedState, StructuredEvent, TranscriptRow } from "../../types.js"; import { StructuredPane } from "./StructuredPane.js"; /** Just enough of a WebSocket for the pane to drive (mirrors structured.test.ts's * own fake, which the pre-React connection used). */ 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 { act(() => this.onopen?.()); } emit(event: StructuredEvent): void { act(() => this.onmessage?.({ data: JSON.stringify(event) })); } drop(code = 1006, reason = ""): void { act(() => this.onclose?.({ code, reason })); } close(): void { this.closed = true; } } const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } }; const busy: ReducedState = { turn: { responding: true, thinking: false, active_tool_calls: [] } }; function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow { return { id, kind, data }; } function chunk(id: string, kind: "user" | "agent_text" | "agent_thought", text: string): TranscriptRow { return row(id, kind, { content: { type: "text", text } }); } const APPROVAL_REQUEST = { toolCall: { title: "Write(secrets.txt)", kind: "edit", locations: [{ path: "/repo/secrets.txt" }] }, options: [ { optionId: "a1", name: "Allow once", kind: "allow_once" }, { optionId: "a2", name: "Allow always", kind: "allow_always" }, { optionId: "r1", name: "Reject", kind: "reject_once" }, ], }; function mount(): { socket: FakeSocket; sockets: FakeSocket[] } { const sockets: FakeSocket[] = []; render( { const socket = new FakeSocket(); sockets.push(socket); return socket as unknown as WebSocket; }} />, ); sockets[0].open(); return { socket: sockets[0], sockets }; } function snapshot(socket: FakeSocket, rows: TranscriptRow[], state: ReducedState = idle): void { socket.emit({ type: "transcript_snapshot", snapshot: { cursor: rows.length, rows } }); socket.emit({ type: "reduced_state", state }); } beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { cleanup(); }); describe("the eight row kinds", () => { it("renders user, agent, thought, tool, diff, approval and notice rows; never a plan", () => { const { socket } = mount(); snapshot( socket, [ chunk("user:1", "user", "please edit it"), chunk("agent:1", "agent_text", "**bold** answer"), chunk("thought:1", "agent_thought", "considering"), row("tool:1", "tool_call", { title: "Read(src/main.ts)", kind: "read", status: "completed" }), row("tool:2", "tool_diff", { title: "Edit(a.ts)", kind: "edit", status: "completed", content: [{ type: "diff", path: "a.ts", oldText: "one\n", newText: "one\ntwo\n" }], }), row("approval:1", "approval", APPROVAL_REQUEST), row("plan", "plan", { entries: [{ content: "step", priority: "high", status: "pending" }] }), row("notice:1", "notice", { sessionUpdate: "mystery_update" }), ], { ...idle, pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never } }, ); expect(screen.getByText("please edit it")).toBeTruthy(); expect(document.querySelector(".st-row-agent strong")?.textContent).toBe("bold"); expect(document.querySelector(".st-row-thought")?.textContent).toContain("considering"); expect(screen.getByText("src/main.ts")).toBeTruthy(); // The diff row's head carries the line counts, not a fabricated duration. expect(document.querySelector('[data-row-id="tool:2"] .st-tool-meta')?.textContent).toBe("+1 −0"); expect(screen.getByText("permission requested")).toBeTruthy(); expect(screen.getByText("[notice]")).toBeTruthy(); expect(document.querySelector('[data-row-id="plan"]')).toBeNull(); }); it("omits the state-only notices it consumes for `/` autocomplete", () => { const { socket } = mount(); snapshot(socket, [ row("notice:1", "notice", { sessionUpdate: "available_commands_update", availableCommands: [{ name: "compact", description: "Compact the conversation" }], }), row("notice:2", "notice", { sessionUpdate: "session_info_update" }), ]); expect(screen.queryByText("[notice]")).toBeNull(); // …and offers them in the composer instead. const input = document.querySelector(".structured-input")!; fireEvent.change(input, { target: { value: "/comp" } }); expect(screen.getByText("/compact")).toBeTruthy(); }); it("renders a completed turn's footer inline, and retires it once the next turn starts", () => { const { socket } = mount(); const footer = row("turn_meta:1", "notice", { maestroTurnMeta: { elapsedMs: 65_000, outcome: "completed", model: "sonnet", contextDelta: 1200 }, }); snapshot(socket, [chunk("user:1", "user", "go"), footer]); expect(screen.getByText("1m 5s · +1.2k context tokens · sonnet")).toBeTruthy(); socket.emit({ type: "projection_update", transcript: { type: "append", row: chunk("user:2", "user", "again") }, state: idle, }); expect(screen.queryByText(/1m 5s/)).toBeNull(); }); }); describe("tool rows and activity groups", () => { it("collapses two or more tools in a turn into one group, opened on click", () => { const { socket } = mount(); snapshot(socket, [ chunk("user:1", "user", "go"), row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }), chunk("agent:1", "agent_text", "still working"), row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }), chunk("agent:2", "agent_text", "the answer"), ]); const head = screen.getByText(/^2 tool calls/).closest("button")!; expect(head.getAttribute("aria-expanded")).toBe("false"); // The final answer stays outside the group; the intermediate text is in it. expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false); expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true); fireEvent.click(head); expect(head.getAttribute("aria-expanded")).toBe("true"); }); it("discloses a tool's output only once its head is opened", () => { const { socket } = mount(); snapshot(socket, [ row("tool:1", "tool_call", { title: "Execute(ls)", kind: "execute", status: "completed", content: [{ type: "content", content: { type: "text", text: "a.ts\nb.ts" } }], rawInput: { command: "ls" }, rawOutput: { exit_code: 0 }, }), ]); expect(document.querySelector(".st-tool-text")).toBeNull(); fireEvent.click(document.querySelector(".st-tool-head")!); expect(document.querySelector(".st-tool-text")?.textContent).toBe("a.ts\nb.ts"); expect(screen.getByText("Exit code")).toBeTruthy(); expect(document.querySelector(".st-tool-exit-ok")?.textContent).toBe("0"); }); it("patches a tool row in place rather than appending a second one", () => { const { socket } = mount(); snapshot(socket, [row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "in_progress" })]); expect(document.querySelector(".st-tool-meta")?.textContent).toBe("running"); socket.emit({ type: "projection_update", transcript: { type: "patch", row: row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }), }, state: idle, }); expect(document.querySelectorAll('[data-row-id="tool:1"]')).toHaveLength(1); expect(document.querySelector(".st-tool-meta")).toBeNull(); }); }); describe("approvals", () => { it("answers with the live nonce, offering only the decisions the request backs", async () => { const { socket } = mount(); snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], { ...idle, pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never }, }); expect(screen.getByText("/repo/secrets.txt")).toBeTruthy(); fireEvent.click(screen.getByText("Allow for session")); await waitFor(() => expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "nonce-1", "allow_for_session"), ); }); it("hides a decision the request offers no ACP option for", () => { const { socket } = mount(); const request = { toolCall: { title: "Read(a.ts)" }, options: [{ optionId: "r", name: "No", kind: "reject_once" }] }; snapshot(socket, [row("approval:1", "approval", request)], { ...idle, pending_approval: { nonce: "nonce-2", request: request as never }, }); expect(screen.getByText("Deny")).toBeTruthy(); expect(screen.queryByText("Allow once")).toBeNull(); }); it("treats only the newest approval as pending, and hides resolved standalone ones", () => { const { socket } = mount(); snapshot( socket, [row("approval:1", "approval", APPROVAL_REQUEST), row("approval:2", "approval", APPROVAL_REQUEST)], { ...idle, pending_approval: { nonce: "nonce-3", request: APPROVAL_REQUEST as never } }, ); expect(screen.getAllByText("permission requested")).toHaveLength(1); expect(screen.queryByText(/approved/)).toBeNull(); }); it("drops the controls once the approval is no longer pending", () => { const { socket } = mount(); snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], { ...idle, pending_approval: { nonce: "nonce-4", request: APPROVAL_REQUEST as never }, }); expect(screen.getByText("Allow once")).toBeTruthy(); socket.emit({ type: "reduced_state", state: idle }); expect(screen.queryByText("Allow once")).toBeNull(); }); }); describe("prompt and cancel", () => { it("sends a draft, clearing the composer", async () => { const { socket } = mount(); snapshot(socket, []); const input = document.querySelector(".structured-input")!; fireEvent.change(input, { target: { value: "do the thing" } }); fireEvent.submit(document.querySelector("form.structured-composer")!); await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing")); expect(input.value).toBe(""); }); it("uploads attachments and names them in the prompt", async () => { const { socket } = mount(); snapshot(socket, []); const file = new File(["hello"], "notes.txt", { type: "text/plain" }); fireEvent.change(document.querySelector(".structured-file-input")!, { target: { files: [file] }, }); expect(screen.getByText("notes.txt")).toBeTruthy(); fireEvent.change(document.querySelector(".structured-input")!, { target: { value: "read this" }, }); fireEvent.submit(document.querySelector("form.structured-composer")!); await waitFor(() => expect(uploadStructuredFile).toHaveBeenCalled()); await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith( "s1", 'read this\n\nAttached file "notes.txt": "/uploads/pasted.txt"', ), ); }); it("queues a second message while a turn is in flight, and sends it when the turn ends", async () => { const { socket } = mount(); snapshot(socket, [], busy); fireEvent.change(document.querySelector(".structured-input")!, { target: { value: "next up" }, }); fireEvent.submit(document.querySelector("form.structured-composer")!); expect(sendStructuredPrompt).not.toHaveBeenCalled(); expect(document.querySelector(".st-row-queued")?.textContent).toBe("next up"); socket.emit({ type: "reduced_state", state: idle }); await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up")); }); it("concatenates multiple queued messages into a single prompt", async () => { const { socket } = mount(); snapshot(socket, [], busy); const input = document.querySelector(".structured-input")!; const form = document.querySelector("form.structured-composer")!; fireEvent.change(input, { target: { value: "first" } }); fireEvent.submit(form); fireEvent.change(input, { target: { value: "second" } }); fireEvent.submit(form); expect(sendStructuredPrompt).not.toHaveBeenCalled(); const queued = document.querySelectorAll(".st-row-queued"); expect(queued).toHaveLength(2); socket.emit({ type: "reduced_state", state: idle }); await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "first\n\nsecond")); expect(sendStructuredPrompt).toHaveBeenCalledTimes(1); }); it("hands a failed send's draft back to the operator", async () => { vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom")); const { socket } = mount(); snapshot(socket, []); const input = document.querySelector(".structured-input")!; fireEvent.change(input, { target: { value: "keep me" } }); fireEvent.submit(document.querySelector("form.structured-composer")!); await waitFor(() => expect(document.querySelector(".structured-error")?.textContent).toContain("boom")); expect(input.value).toBe("keep me"); }); it("shows the cancel control only while a turn is live", () => { const { socket } = mount(); snapshot(socket, []); expect(document.querySelector(".structured-cancel")).toBeNull(); socket.emit({ type: "reduced_state", state: busy }); fireEvent.click(document.querySelector(".structured-cancel")!); expect(cancelStructuredTurn).toHaveBeenCalledWith("s1"); }); it("reports live turn activity above the composer", () => { const { socket } = mount(); snapshot(socket, [], { turn: { responding: true, thinking: false, active_tool_calls: ["t1", "t2"], started_at: new Date(Date.now() - 5_000).toISOString(), }, usage: { used: 12_000, size: 200_000 }, }); const activity = document.querySelector(".structured-activity")!; expect(activity.hidden).toBe(false); expect(activity.textContent).toContain("Running 2 tools"); expect(activity.textContent).toContain("12k context / 200k context"); }); }); describe("the socket", () => { it("replaces the transcript wholesale on every snapshot, never merging", async () => { const { socket, sockets } = mount(); snapshot(socket, [chunk("user:1", "user", "first")]); socket.drop(1006); // The retry is on reconnect.ts's backoff schedule, not immediate. await act(async () => { await new Promise((resolve) => setTimeout(resolve, INITIAL_RECONNECT_MS + 20)); }); const replacement = sockets[1]; expect(replacement).toBeDefined(); replacement.open(); snapshot(replacement, [chunk("user:2", "user", "second")]); expect(screen.queryByText("first")).toBeNull(); expect(screen.getByText("second")).toBeTruthy(); }); it("reports a daemon rejection instead of retrying it", () => { const status = vi.fn(); const sockets: FakeSocket[] = []; render( { const socket = new FakeSocket(); sockets.push(socket); return socket as unknown as WebSocket; }} />, ); sockets[0].open(); sockets[0].drop(4004, "not a structured session"); expect(sockets).toHaveLength(1); expect(status).toHaveBeenCalledWith("error", "not a structured session"); }); });