Files
IdeA/frontend/src/features/layout/singletonAgent.test.tsx
Blomios 785e9935fd feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:24:51 +02:00

203 lines
6.3 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",
},
]);
});
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",
},
]);
});
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" },
]);
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 },
]);
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);
});
});