This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/components/structured/StructuredPane.test.tsx
T
Miguel Palhas c6af78e9dd
ci / web (push) Failing after 6s
ci / rust (push) Failing after 54s
ci / flake (push) Successful in 2m59s
feat(maestro): concatenate queued structured prompts into one turn
Closes #257
2026-09-12 10:12:36 +01:00

424 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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");
});
});