Intermediate narration was hidden inside the collapsed tool group while the agent was still working. The last AgentText/AgentThought now stays visible as current status, absorbed only when the turn ends or newer text replaces it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1837 lines
69 KiB
TypeScript
1837 lines
69 KiB
TypeScript
// @vitest-environment happy-dom
|
|
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,
|
|
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<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
|
expect(remove).not.toBeNull();
|
|
remove.click();
|
|
expect(conn.el.querySelector<HTMLElement>(".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<HTMLSelectElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".st-tool-group");
|
|
expect(groups).toHaveLength(1);
|
|
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
|
|
expect(groups[0].querySelector<HTMLElement>(".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<HTMLElement>(".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 });
|
|
// While the turn is active, the last intermediate text (agent:2) stays
|
|
// outside the collapsed group so it's visible as current narration.
|
|
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).toBeNull();
|
|
// Earlier intermediate text (agent:1) is still inside the group.
|
|
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).not.toBeNull();
|
|
// When the turn ends, all intermediate text is absorbed.
|
|
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("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<HTMLButtonElement>(".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<HTMLButtonElement>(".st-tool-head")!;
|
|
expect(patchedHead).not.toBe(originalHead);
|
|
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
|
|
expect(conn.el.querySelector<HTMLElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLTextAreaElement>(".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: "<milestone>" },
|
|
]),
|
|
],
|
|
},
|
|
});
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
|
const menu = conn.el.querySelector<HTMLElement>(".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("<milestone>");
|
|
|
|
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<HTMLTextAreaElement>(".structured-input")!;
|
|
input.value = "/hum";
|
|
input.dispatchEvent(new Event("input"));
|
|
const options = conn.el.querySelectorAll<HTMLElement>(".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<HTMLTextAreaElement>(".structured-input")!;
|
|
input.value = "asd /s dsa";
|
|
input.setSelectionRange(6, 6);
|
|
input.dispatchEvent(new Event("input"));
|
|
|
|
const options = conn.el.querySelectorAll<HTMLElement>(".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<HTMLTextAreaElement>(".structured-input")!;
|
|
input.value = "before /stale after";
|
|
input.setSelectionRange(9, 9);
|
|
input.dispatchEvent(new Event("input"));
|
|
conn.el.querySelector<HTMLElement>(".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<HTMLElement>(".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");
|
|
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
|
|
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.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
|
|
|
|
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.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
|
|
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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<number, FrameRequestCallback>;
|
|
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<typeof mount>, height: number, clientHeight: number, top = 0) {
|
|
const scroll = conn.el.querySelector<HTMLElement>(".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<HTMLTextAreaElement>(".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<HTMLButtonElement>(".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<HTMLTextAreaElement>(".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();
|
|
});
|
|
});
|