/** * R0d — launch-path mapping of the backend singleton refusal. * * When `launch_agent` is refused with `AGENT_ALREADY_RUNNING` (the agent is a * singleton already alive in another cell), the hosting leaf must surface a * clear notice — never a raw error — together with an "aller à la cellule" * action that focuses the live host. The host cell is resolved from the live * agents set (the R0b source of truth: PTY *and* chat), not by parsing the * error message. * * The terminal opener that drives the launch only runs once xterm's `open` * succeeds; under jsdom the real xterm bails, so — exactly like * `LayoutGrid.chat.test.tsx` — we stub *only* xterm (the headless obstacle), * leaving the leaf's launch + error-mapping logic untouched. */ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; // Minimal xterm stub so `term.open` succeeds and the opener (→ launchAgent) runs. vi.mock("@xterm/xterm", () => ({ Terminal: class { loadAddon() {} open() {} onData() { return { dispose() {} }; } onResize() { return { dispose() {} }; } write() {} dispose() {} get cols() { return 80; } get rows() { return 24; } }, })); vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { fit() {} }, })); vi.mock("@xterm/xterm/css/xterm.css", () => ({})); if (typeof globalThis.ResizeObserver === "undefined") { globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; } import type { Gateways } from "@/ports"; import { MockAgentGateway, MockLayoutGateway, MockSystemGateway, MockTerminalGateway, } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { leaves } from "./layout"; import { LayoutGrid } from "./LayoutGrid"; function renderGrid(gateways: Gateways) { return render( , ); } describe("R0d — launch refused with AGENT_ALREADY_RUNNING", () => { it("maps the refusal to a clear notice + 'aller à la cellule' on the host", async () => { const layout = new MockLayoutGateway(); const agentGateway = new MockAgentGateway(); const terminal = new MockTerminalGateway(); const system = new MockSystemGateway(); const agent = await agentGateway.createAgent("p1", { name: "Solo", profileId: "claude", }); // Two visible leaves; pin the agent on cell A. const initial = await layout.loadLayout("p1"); const aId = leaves(initial)[0].id; await layout.mutateLayout("p1", { type: "split", target: aId, direction: "row", newLeaf: "b", container: "c", }); const after = leaves(await layout.loadLayout("p1")); const bId = after.find((l) => l.id !== aId)!.id; await layout.mutateLayout("p1", { type: "setCellAgent", target: aId, agent: agent.id, }); // The live set reports the agent already hosted in cell B (the host). vi.spyOn(agentGateway, "listLiveAgents").mockResolvedValue([ { agentId: agent.id, nodeId: bId, sessionId: "s-1", kind: "pty" }, ]); // The launch from cell A is refused by the backend (singleton invariant). vi.spyOn(agentGateway, "launchAgent").mockRejectedValue({ code: "AGENT_ALREADY_RUNNING", message: `agent ${agent.id} is already running in cell ${bId}`, }); const gateways = { layout, agent: agentGateway, terminal, system, } as unknown as Gateways; renderGrid(gateways); // The leaf's terminal opener runs the launch → refused → mapped to a notice. const status = await screen.findByRole("status"); expect(status.textContent).toMatch(/déjà actif dans une autre cellule/); // And NOT a raw error dump. expect(status.textContent).not.toMatch(/AGENT_ALREADY_RUNNING/); // The go-to action focuses the live host cell (B): it gets an outline flash. const goTo = await screen.findByRole("button", { name: "aller à la cellule", }); fireEvent.click(goTo); await waitFor(() => { const host = document.querySelector( `[data-node-id="${bId}"]`, )!; expect(host.style.outline).not.toBe(""); }); }); });