424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
// @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<typeof import("../../api.js")>("../../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(
|
||
<StructuredPane
|
||
sessionId="s1"
|
||
socketFactory={() => {
|
||
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<HTMLTextAreaElement>(".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<HTMLButtonElement>(".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<HTMLTextAreaElement>(".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<HTMLInputElement>(".structured-file-input")!, {
|
||
target: { files: [file] },
|
||
});
|
||
expect(screen.getByText("notes.txt")).toBeTruthy();
|
||
|
||
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".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<HTMLTextAreaElement>(".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<HTMLTextAreaElement>(".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<HTMLTextAreaElement>(".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<HTMLButtonElement>(".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<HTMLElement>(".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(
|
||
<StructuredPane
|
||
sessionId="s1"
|
||
onStatusChange={status}
|
||
socketFactory={() => {
|
||
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");
|
||
});
|
||
});
|