Câble les actions contrôlées dans ProjectWorkStatePanel : port et adaptateur agent étendus aux nouvelles commandes, adaptateur mock aligné. Tests verts : workstate + projects (25), agent (8), singletonAgent + agentAlreadyRunning (9), tsc --noEmit OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
9.7 KiB
TypeScript
296 lines
9.7 KiB
TypeScript
/**
|
|
* "One live session per agent" invariant (UI side).
|
|
*
|
|
* - The mock agent gateway mirrors the backend guard: launching an agent already
|
|
* live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same
|
|
* node is idempotent; `listLiveAgents` reports who runs where (T5).
|
|
* - The per-cell dropdown blocks an agent already visible in another cell, but
|
|
* can rebind a background live session when the backend exposes its session id.
|
|
*/
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
|
|
import type { Gateways, LiveAgent } from "@/ports";
|
|
import {
|
|
MockLayoutGateway,
|
|
MockAgentGateway,
|
|
type GatewayError,
|
|
} from "@/adapters/mock";
|
|
import { DIProvider } from "@/app/di";
|
|
import { LayoutGrid } from "./LayoutGrid";
|
|
import { leaves } from "./layout";
|
|
|
|
const noop = () => {};
|
|
|
|
describe("singleton agent — mock gateway guard (T5)", () => {
|
|
it("refuses launching an agent already running in another cell", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
|
|
|
|
// Launch in cell A.
|
|
await agent.launchAgent(
|
|
"p1",
|
|
a.id,
|
|
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
|
|
noop,
|
|
);
|
|
|
|
// Launching the SAME agent in cell B is refused.
|
|
let caught: GatewayError | null = null;
|
|
await agent
|
|
.launchAgent(
|
|
"p1",
|
|
a.id,
|
|
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-B" },
|
|
noop,
|
|
)
|
|
.catch((e) => {
|
|
caught = e as GatewayError;
|
|
});
|
|
expect(caught).not.toBeNull();
|
|
expect(caught!.code).toBe("AGENT_ALREADY_RUNNING");
|
|
});
|
|
|
|
it("listLiveAgents reports the cell hosting each live agent", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
|
|
|
|
expect(await agent.listLiveAgents("p1")).toEqual([]);
|
|
|
|
await agent.launchAgent(
|
|
"p1",
|
|
a.id,
|
|
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
|
|
noop,
|
|
);
|
|
|
|
const live: LiveAgent[] = await agent.listLiveAgents("p1");
|
|
expect(live).toEqual([
|
|
{
|
|
agentId: a.id,
|
|
nodeId: "node-A",
|
|
sessionId: "mock-agent-session-1",
|
|
kind: "pty",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("relaunching in the SAME node is allowed (idempotent), not refused", async () => {
|
|
const agent = new MockAgentGateway();
|
|
const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" });
|
|
|
|
await agent.launchAgent(
|
|
"p1",
|
|
a.id,
|
|
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
|
|
noop,
|
|
);
|
|
// Same node again must NOT throw.
|
|
await expect(
|
|
agent.launchAgent(
|
|
"p1",
|
|
a.id,
|
|
{ cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" },
|
|
noop,
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("singleton agent — dropdown guard (T6)", () => {
|
|
function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
|
|
// A system gateway stub so LeafView's event subscription is a no-op.
|
|
const system = {
|
|
onDomainEvent: vi.fn().mockResolvedValue(() => {}),
|
|
};
|
|
const gateways = { layout, agent, system } as unknown as Gateways;
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
it("opens an agent already running elsewhere by rebinding its live session", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
|
await agent.launchAgent(
|
|
"p1",
|
|
a.id,
|
|
{ cwd: "/x", rows: 24, cols: 80, nodeId: "old-node" },
|
|
noop,
|
|
);
|
|
|
|
const tree = await layout.loadLayout("p1");
|
|
const leafId = leaves(tree)[0].id;
|
|
|
|
renderGrid(layout, agent);
|
|
|
|
// The option is selectable: choosing it rebinds the running session here.
|
|
const option = await screen.findByRole("option", {
|
|
name: /Busy/,
|
|
});
|
|
expect((option as HTMLOptionElement).disabled).toBe(false);
|
|
|
|
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
fireEvent.change(select, { target: { value: a.id } });
|
|
|
|
await waitFor(async () => {
|
|
const updated = await layout.loadLayout("p1");
|
|
const leaf = leaves(updated).find((l) => l.id === leafId)!;
|
|
expect(leaf.agent).toBe(a.id);
|
|
expect(leaf.session).toBe("mock-agent-session-1");
|
|
});
|
|
expect(await agent.listLiveAgents("p1")).toEqual([
|
|
{
|
|
agentId: a.id,
|
|
nodeId: leafId,
|
|
sessionId: "mock-agent-session-1",
|
|
kind: "pty",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("explains the missing backend contract when a live agent has no attachable session", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
|
|
|
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
|
{ agentId: a.id, nodeId: "some-other-node" } as LiveAgent,
|
|
]);
|
|
|
|
renderGrid(layout, agent);
|
|
|
|
const option = await screen.findByRole("option", {
|
|
name: /Busy/,
|
|
});
|
|
expect((option as HTMLOptionElement).disabled).toBe(false);
|
|
|
|
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
fireEvent.change(select, { target: { value: a.id } });
|
|
|
|
expect((await screen.findByRole("status")).textContent).toMatch(
|
|
/Session active introuvable/,
|
|
);
|
|
});
|
|
|
|
it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const a = await agent.createAgent("p1", { name: "Mine", profileId: "p" });
|
|
|
|
// Pin the agent on the single leaf.
|
|
const tree = await layout.loadLayout("p1");
|
|
const leafId = leaves(tree)[0].id;
|
|
await layout.mutateLayout("p1", {
|
|
type: "setCellAgent",
|
|
target: leafId,
|
|
agent: a.id,
|
|
});
|
|
|
|
// The agent is live in THIS very cell (same node id as the leaf).
|
|
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
|
{ agentId: a.id, nodeId: leafId, sessionId: "s-own", kind: "pty" },
|
|
]);
|
|
|
|
renderGrid(layout, agent);
|
|
|
|
const option = await screen.findByRole("option", { name: "Mine" });
|
|
// Live on its own cell ⇒ NOT disabled (it can keep running here).
|
|
expect((option as HTMLOptionElement).disabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
|
|
function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) {
|
|
const system = {
|
|
onDomainEvent: vi.fn().mockResolvedValue(() => {}),
|
|
};
|
|
const gateways = { layout, agent, system } as unknown as Gateways;
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
/** Splits the project's single leaf into two visible leaves; returns both ids. */
|
|
async function splitInTwo(
|
|
layout: MockLayoutGateway,
|
|
): Promise<{ a: string; b: string }> {
|
|
const initial = await layout.loadLayout("p1");
|
|
const a = leaves(initial)[0].id;
|
|
await layout.mutateLayout("p1", {
|
|
type: "split",
|
|
target: a,
|
|
direction: "row",
|
|
newLeaf: "b",
|
|
container: "c",
|
|
});
|
|
const after = leaves(await layout.loadLayout("p1"));
|
|
return { a, b: after.find((l) => l.id !== a)!.id };
|
|
}
|
|
|
|
it("an agent live in another VISIBLE cell ⇒ option disabled + 'aller à la cellule'", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const ag = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
|
const { a, b } = await splitInTwo(layout);
|
|
|
|
// The agent is live in cell B (a currently-visible leaf).
|
|
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
|
{ agentId: ag.id, nodeId: b, sessionId: "s-1", kind: "pty" },
|
|
]);
|
|
|
|
renderGrid(layout, agent);
|
|
|
|
// In cell A's dropdown the option is DISABLED (cannot run in two cells).
|
|
const selectA = (await screen.findByLabelText(
|
|
`agent selector ${a}`,
|
|
)) as HTMLSelectElement;
|
|
await waitFor(() => {
|
|
const opt = Array.from(selectA.options).find((o) => o.value === ag.id)!;
|
|
expect(opt.disabled).toBe(true);
|
|
expect(opt.textContent).toMatch(/visible ailleurs/);
|
|
});
|
|
|
|
// Selecting it surfaces a clear notice + an "aller à la cellule" action.
|
|
fireEvent.change(selectA, { target: { value: ag.id } });
|
|
expect((await screen.findByRole("status")).textContent).toMatch(
|
|
/déjà actif dans une autre cellule/,
|
|
);
|
|
expect(
|
|
screen.getByRole("button", { name: "aller à la cellule" }),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("an agent NOT live anywhere stays selectable (no regression)", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const ag = await agent.createAgent("p1", { name: "Free", profileId: "p" });
|
|
|
|
// No live agents at all.
|
|
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([]);
|
|
|
|
renderGrid(layout, agent);
|
|
|
|
const option = await screen.findByRole("option", { name: "Free" });
|
|
expect((option as HTMLOptionElement).disabled).toBe(false);
|
|
|
|
// And selecting it pins the agent on the cell (no notice, no block).
|
|
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
|
|
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
fireEvent.change(select, { target: { value: ag.id } });
|
|
await waitFor(async () => {
|
|
const leaf = leaves(await layout.loadLayout("p1")).find(
|
|
(l) => l.id === leafId,
|
|
)!;
|
|
expect(leaf.agent).toBe(ag.id);
|
|
});
|
|
expect(screen.queryByRole("status")).toBeNull();
|
|
});
|
|
|
|
});
|