/** * L6 — agents feature wired to the stateful `MockAgentGateway` (and a seeded * `MockProfileGateway`) via the real `DIProvider`. * * Covers: * - create → agent appears in list * - select → context is shown; edit + save → re-reading shows new content * - delete → agent removed from list * - launch → `launchAgent` is called and mounts a terminal view; stop unmounts it * - empty name → Create button disabled * - MockAgentGateway unit behaviour: slug deduplication, NOT_FOUND errors */ import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor, fireEvent, } from "@testing-library/react"; import { MockAgentGateway, MockProfileGateway, MockSystemGateway, MockTemplateGateway, } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { AgentsPanel } from "./AgentsPanel"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const PROJECT_ID = "proj-test-001"; /** * Renders `AgentsPanel` behind a `DIProvider` with an isolated * `MockAgentGateway` (and optionally a seeded `MockProfileGateway`). */ function renderPanel( agent: MockAgentGateway = new MockAgentGateway(), profile: MockProfileGateway = new MockProfileGateway(), projectRoot = "/home/me/proj", template?: MockTemplateGateway, ) { const tmpl = template ?? new MockTemplateGateway(agent); const gateways = { agent, profile, template: tmpl } as unknown as Gateways; return { agent, profile, template: tmpl, ...render( , ), }; } /** Waits until no Spinner is visible (busy → idle). */ async function waitForIdle() { // The Create button is enabled when not busy and name is non-empty. // We wait for the panel to stabilise by waiting for "No agents yet." or // for the agent list to be rendered. await waitFor(() => { // Panel has finished its initial load when the form is accessible. expect(screen.getByLabelText("agent name")).toBeTruthy(); }); } /** Types a name into the agent name field, optionally selects a profile, and clicks Create. */ async function createAgent(name: string, profileId?: string) { await waitForIdle(); fireEvent.change(screen.getByLabelText("agent name"), { target: { value: name }, }); if (profileId) { const profileInput = screen.queryByLabelText("agent profile"); if (profileInput) { fireEvent.change(profileInput, { target: { value: profileId } }); } } const createBtn = screen.getByRole("button", { name: "create agent" }); expect((createBtn as HTMLButtonElement).disabled).toBe(false); fireEvent.click(createBtn); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("AgentsPanel (with MockAgentGateway)", () => { it("shows 'No agents yet.' when the project has no agents", async () => { renderPanel(); await waitForIdle(); expect(screen.getByText("No agents yet.")).toBeTruthy(); }); it("creating an agent adds it to the list", async () => { renderPanel(); await createAgent("My Worker"); const agent = await screen.findByText("My Worker"); expect(agent).toBeTruthy(); }); it("the Create button is disabled when the name is empty", async () => { renderPanel(); await waitForIdle(); // Name field is empty by default → disabled const btn = screen.getByRole("button", { name: "create agent" }); expect((btn as HTMLButtonElement).disabled).toBe(true); }); it("selecting an agent displays its context", async () => { const agent = new MockAgentGateway(); // Pre-seed an agent with initial content. await agent.createAgent(PROJECT_ID, { name: "Alpha", profileId: "p1", initialContent: "## Alpha context", }); renderPanel(agent); await waitForIdle(); // Click on the agent row button (aria-pressed) to select it — not the // "launch Alpha" action button. const buttons = screen.getAllByRole("button", { name: /alpha/i }); const rowBtn = buttons.find( (b) => b.hasAttribute("aria-pressed"), )!; fireEvent.click(rowBtn); const textarea = await screen.findByLabelText("agent context"); expect((textarea as HTMLTextAreaElement).value).toBe("## Alpha context"); }); it("editing and saving context persists the new content", async () => { const agent = new MockAgentGateway(); await agent.createAgent(PROJECT_ID, { name: "Beta", profileId: "p1", initialContent: "initial", }); renderPanel(agent); await waitForIdle(); // Select the agent row button (aria-pressed), not the action buttons. const buttons = screen.getAllByRole("button", { name: /beta/i }); const rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!; fireEvent.click(rowBtn); const textarea = await screen.findByLabelText("agent context"); fireEvent.change(textarea, { target: { value: "updated content" } }); fireEvent.click(screen.getByRole("button", { name: "Save" })); // After save the gateway should hold the new content. await waitFor(async () => { const agents = await agent.listAgents(PROJECT_ID); const ctx = await agent.readContext(PROJECT_ID, agents[0].id); expect(ctx).toBe("updated content"); }); }); it("deleting an agent removes it from the list", async () => { const agent = new MockAgentGateway(); await agent.createAgent(PROJECT_ID, { name: "Gamma", profileId: "p1", }); renderPanel(agent); await waitForIdle(); await screen.findByText("Gamma"); fireEvent.click(screen.getByRole("button", { name: "delete Gamma" })); await waitFor(() => { expect(screen.queryByText("Gamma")).toBeNull(); }); // Gateway-level check. const remaining = await agent.listAgents(PROJECT_ID); expect(remaining).toHaveLength(0); }); it("launching an agent calls launchAgent and mounts the terminal view", async () => { const agent = new MockAgentGateway(); const createdAgent = await agent.createAgent(PROJECT_ID, { name: "Delta", profileId: "p1", }); // Spy on launchAgent. const launchSpy = vi.spyOn(agent, "launchAgent"); renderPanel(agent); await waitForIdle(); fireEvent.click(screen.getByRole("button", { name: "launch Delta" })); // The terminal container div (data-testid="terminal-view") should be mounted. await waitFor(() => { expect(screen.getByTestId("terminal-view")).toBeTruthy(); }); // launchAgent should have been called with the correct projectId and agentId. await waitFor(() => { if (launchSpy.mock.calls.length > 0) { expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID); expect(launchSpy.mock.calls[0][1]).toBe(createdAgent.id); // options (rows/cols) and onData callback expect(typeof launchSpy.mock.calls[0][2]).toBe("object"); expect(typeof launchSpy.mock.calls[0][3]).toBe("function"); } }); }); it("stopping an agent unmounts the terminal view", async () => { const agent = new MockAgentGateway(); await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" }); renderPanel(agent); await waitForIdle(); // Launch fireEvent.click(screen.getByRole("button", { name: "launch Echo" })); await waitFor(() => { expect(screen.getByTestId("terminal-view")).toBeTruthy(); }); // Stop fireEvent.click(screen.getByRole("button", { name: "stop Echo" })); await waitFor(() => { expect(screen.queryByTestId("terminal-view")).toBeNull(); }); }); it("selecting a template calls createAgentFromTemplate instead of createAgent", async () => { const agent = new MockAgentGateway(); const tmpl = new MockTemplateGateway(agent); // Seed a template so the selector has something to choose const t = await tmpl.createTemplate({ name: "My Template", content: "# ctx", defaultProfileId: "p1", }); const createFromTemplateSpy = vi.spyOn(tmpl, "createAgentFromTemplate"); renderPanel(agent, new MockProfileGateway(), "/home/me/proj", tmpl); await waitForIdle(); // Select the template in the dropdown const templateSelect = screen.getByLabelText("agent template") as HTMLSelectElement; fireEvent.change(templateSelect, { target: { value: t.id } }); // Fill agent name fireEvent.change(screen.getByLabelText("agent name"), { target: { value: "From Template" }, }); // Click Create fireEvent.click(screen.getByRole("button", { name: "create agent" })); // createAgentFromTemplate should have been called with the correct args await waitFor(() => { expect(createFromTemplateSpy).toHaveBeenCalledWith( PROJECT_ID, t.id, { name: "From Template", synchronized: true }, ); }); // Agent should appear in the list await screen.findByText("From Template"); }); it("shows profiles in the dropdown when profiles are configured", async () => { const profile = new MockProfileGateway(); await profile.configureProfiles([ { id: "prof-1", name: "Claude Code", command: "claude", args: [], contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" }, detect: null, cwdTemplate: "{projectRoot}", }, ]); renderPanel(new MockAgentGateway(), profile); await waitForIdle(); // The select element should contain the profile option. const select = screen.getByLabelText("agent profile") as HTMLSelectElement; const options = Array.from(select.options).map((o) => o.text); expect(options).toContain("Claude Code"); }); }); // --------------------------------------------------------------------------- // MockAgentGateway unit tests // --------------------------------------------------------------------------- /** Helper: minimal valid options for launchAgent. */ const LAUNCH_OPTS = { cwd: "/tmp", rows: 24, cols: 80 }; const NOOP_ON_DATA = (_bytes: Uint8Array) => {}; describe("MockAgentGateway (unit)", () => { it("creates agents with unique slugified contextPaths", async () => { const gw = new MockAgentGateway(); const a1 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" }); const a2 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" }); const a3 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" }); expect(a1.contextPath).toBe("agents/my-agent.md"); expect(a2.contextPath).toBe("agents/my-agent-2.md"); expect(a3.contextPath).toBe("agents/my-agent-3.md"); }); it("readContext throws NOT_FOUND for unknown agent", async () => { const gw = new MockAgentGateway(); await expect(gw.readContext("proj", "ghost-id")).rejects.toMatchObject({ code: "NOT_FOUND", }); }); it("deleteAgent throws NOT_FOUND for unknown agent", async () => { const gw = new MockAgentGateway(); await expect(gw.deleteAgent("proj", "ghost-id")).rejects.toMatchObject({ code: "NOT_FOUND", }); }); it("launchAgent returns a TerminalHandle with sequential session ids", async () => { const gw = new MockAgentGateway(); const a1 = await gw.createAgent("proj", { name: "A1", profileId: "p" }); const a2 = await gw.createAgent("proj", { name: "A2", profileId: "p" }); const h1 = await gw.launchAgent("proj", a1.id, LAUNCH_OPTS, NOOP_ON_DATA); const h2 = await gw.launchAgent("proj", a2.id, LAUNCH_OPTS, NOOP_ON_DATA); expect(h1.sessionId).toBe("mock-agent-session-1"); expect(h2.sessionId).toBe("mock-agent-session-2"); }); it("launchAgent throws NOT_FOUND for an agent not in the project", async () => { const gw = new MockAgentGateway(); await expect( gw.launchAgent("proj", "unknown-id", LAUNCH_OPTS, NOOP_ON_DATA), ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); it("launchAgent greeting is delivered via onData", async () => { const gw = new MockAgentGateway(); const a = await gw.createAgent("proj", { name: "Greeter", profileId: "p" }); const received: Uint8Array[] = []; await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) => received.push(bytes)); // Wait for the queueMicrotask greeting await new Promise((r) => setTimeout(r, 0)); const dec = new TextDecoder(); const msg = received.map((b) => dec.decode(b)).join(""); expect(msg).toContain(`agent ${a.id}`); }); it("launchAgent handle write echoes bytes back via onData", async () => { const gw = new MockAgentGateway(); const a = await gw.createAgent("proj", { name: "Echo", profileId: "p" }); const received: Uint8Array[] = []; const handle = await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) => received.push(bytes), ); await handle.write(new TextEncoder().encode("hi")); const dec = new TextDecoder(); const echoed = received.map((b) => dec.decode(b)).join(""); // The greeting (via queueMicrotask) and the echo both arrive; just check echo is there expect(echoed).toContain("hi"); }); it("projects are isolated — agents in one project don't appear in another", async () => { const gw = new MockAgentGateway(); await gw.createAgent("proj-A", { name: "A Agent", profileId: "p" }); const listB = await gw.listAgents("proj-B"); expect(listB).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // A2 — profile hot-swap selector + confirmation dialog // --------------------------------------------------------------------------- /** Seeds two profiles so an agent's engine can be swapped to a different one. */ async function seededProfiles(): Promise { const profile = new MockProfileGateway(); await profile.configureProfiles([ { id: "prof-1", name: "Claude Code", command: "claude", args: [], contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" }, detect: null, cwdTemplate: "{projectRoot}", }, { id: "prof-2", name: "Codex CLI", command: "codex", args: [], contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, detect: null, cwdTemplate: "{projectRoot}", }, ]); return profile; } describe("AgentsPanel profile hot-swap (A2)", () => { it("changing the profile select does NOT call changeAgentProfile immediately — it opens a confirmation dialog", async () => { const agent = new MockAgentGateway(); await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" }); const swapSpy = vi.spyOn(agent, "changeAgentProfile"); renderPanel(agent, await seededProfiles()); await waitForIdle(); await screen.findByText("Swap"); const select = screen.getByLabelText("profile for Swap") as HTMLSelectElement; fireEvent.change(select, { target: { value: "prof-2" } }); // No gateway call yet — only the dialog appears. expect(swapSpy).not.toHaveBeenCalled(); expect(screen.getByRole("dialog")).toBeTruthy(); }); it("Cancel closes the dialog without calling changeAgentProfile", async () => { const agent = new MockAgentGateway(); await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" }); const swapSpy = vi.spyOn(agent, "changeAgentProfile"); renderPanel(agent, await seededProfiles()); await waitForIdle(); await screen.findByText("Swap"); fireEvent.change(screen.getByLabelText("profile for Swap"), { target: { value: "prof-2" }, }); fireEvent.click( screen.getByRole("button", { name: "cancel profile change" }), ); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(swapSpy).not.toHaveBeenCalled(); }); it("Continue confirms and calls changeAgentProfile with the chosen profile", async () => { const agent = new MockAgentGateway(); const created = await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1", }); const swapSpy = vi.spyOn(agent, "changeAgentProfile"); renderPanel(agent, await seededProfiles()); await waitForIdle(); await screen.findByText("Swap"); fireEvent.change(screen.getByLabelText("profile for Swap"), { target: { value: "prof-2" }, }); fireEvent.click( screen.getByRole("button", { name: "confirm profile change" }), ); await waitFor(() => { expect(swapSpy).toHaveBeenCalled(); expect(swapSpy.mock.calls[0][0]).toBe(PROJECT_ID); expect(swapSpy.mock.calls[0][1]).toBe(created.id); expect(swapSpy.mock.calls[0][2]).toBe("prof-2"); }); // Dialog closes after confirmation. await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); it("the confirmation dialog message is in FRENCH and conveys the spec §15.1 wording", async () => { const agent = new MockAgentGateway(); await agent.createAgent(PROJECT_ID, { name: "Swap", profileId: "prof-1" }); renderPanel(agent, await seededProfiles()); await waitForIdle(); await screen.findByText("Swap"); fireEvent.change(screen.getByLabelText("profile for Swap"), { target: { value: "prof-2" }, }); const dialog = screen.getByRole("dialog"); const text = dialog.textContent ?? ""; // Spec §15.1: « Changer le moteur abandonne l'historique de conversation // (le contexte et la mémoire sont conservés). Continuer ? » — must be FRENCH. expect(text).toMatch(/abandonne l'historique de conversation/i); expect(text).toMatch(/contexte et la mémoire sont conservés/i); expect(text).toMatch(/Continuer\s*\?/); }); }); describe("AgentsPanel live refresh on domain events", () => { it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => { const agent = new MockAgentGateway(); const profile = new MockProfileGateway(); const system = new MockSystemGateway(); const template = new MockTemplateGateway(agent); const gateways = { agent, profile, system, template } as unknown as Gateways; render( , ); // Initially empty. await waitFor(() => expect(screen.getByText("No agents yet.")).toBeTruthy()); // Simulate the orchestrator creating an agent directly in the store // (bypassing the UI's createAgent path), then emitting `agentLaunched`. const created = await agent.createAgent(PROJECT_ID, { name: "Orchestrated", profileId: "p1", }); system.emit({ type: "agentLaunched", agentId: created.id, sessionId: "sess-1", }); // The panel must reflect the new agent without any manual reload. expect(await screen.findByText("Orchestrated")).toBeTruthy(); }); it("refreshes the list when an `agentProfileChanged` event fires (A2 hot-swap)", async () => { const agent = new MockAgentGateway(); const profile = new MockProfileGateway(); const system = new MockSystemGateway(); const template = new MockTemplateGateway(agent); const gateways = { agent, profile, system, template } as unknown as Gateways; const created = await agent.createAgent(PROJECT_ID, { name: "Swapper", profileId: "p1", }); render( , ); await waitFor(() => expect(screen.getByText("Swapper")).toBeTruthy()); // Mutate the profile out of band, then emit the event the backend relays. await agent.changeAgentProfile(PROJECT_ID, created.id, "p2", 24, 80); const refreshSpy = vi.spyOn(agent, "listAgents"); system.emit({ type: "agentProfileChanged", agentId: created.id, profileId: "p2", }); // The hook re-fetches the list on the event. await waitFor(() => expect(refreshSpy).toHaveBeenCalled()); }); it("does not crash when the system gateway is absent", async () => { // The existing renderPanel helper injects no `system` gateway; the // subscription effect must no-op rather than throw. renderPanel(); await waitForIdle(); expect(screen.getByText("No agents yet.")).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // M4 — orchestration source badge (mcp / file) on `orchestratorRequestProcessed` // --------------------------------------------------------------------------- describe("AgentsPanel orchestration source badge (M4)", () => { /** Mounts the panel with a live `MockSystemGateway`, returning all the pieces. */ async function renderWithSystem() { const agent = new MockAgentGateway(); const profile = new MockProfileGateway(); const system = new MockSystemGateway(); const template = new MockTemplateGateway(agent); const gateways = { agent, profile, system, template } as unknown as Gateways; // The badge keys on `a.id === requesterId`, so create the agent first and use // its id as the requester in the emitted event. const created = await agent.createAgent(PROJECT_ID, { name: "Requester", profileId: "p1", }); render( , ); await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy()); return { agent, system, created }; } it("shows an `mcp` badge when a delegation arrives via the MCP door", async () => { const { system, created } = await renderWithSystem(); system.emit({ type: "orchestratorRequestProcessed", requesterId: created.id, action: "idea_ask_agent", ok: true, source: "mcp", }); const badge = await screen.findByLabelText("delegation source mcp"); expect(badge.textContent?.toLowerCase()).toContain("mcp"); }); it("shows a `file` badge when a delegation arrives via the .ideai/requests door", async () => { const { system, created } = await renderWithSystem(); system.emit({ type: "orchestratorRequestProcessed", requesterId: created.id, action: "spawn_agent", ok: true, source: "file", }); const badge = await screen.findByLabelText("delegation source file"); expect(badge.textContent?.toLowerCase()).toContain("file"); }); it("renders NO badge when the event carries no `source` (older backend)", async () => { const { system, created } = await renderWithSystem(); // Same requester, but the event omits `source` entirely. system.emit({ type: "orchestratorRequestProcessed", requesterId: created.id, action: "spawn_agent", ok: true, }); // Give the effect a tick; the map must stay empty → no badge for any source. await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy()); expect(screen.queryByLabelText("delegation source mcp")).toBeNull(); expect(screen.queryByLabelText("delegation source file")).toBeNull(); }); it("does not badge an agent whose id never matched a processed requester", async () => { const { system } = await renderWithSystem(); // A processed event for a *different* requester id leaves our agent un-badged. system.emit({ type: "orchestratorRequestProcessed", requesterId: "some-other-requester", action: "idea_ask_agent", ok: true, source: "mcp", }); await waitFor(() => expect(screen.getByText("Requester")).toBeTruthy()); expect(screen.queryByLabelText("delegation source mcp")).toBeNull(); }); });