/**
* 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("");
});
});
});