Expose l'orchestration IdeA comme serveur MCP par-dessus le même OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking). - M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport) - M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface - M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents - M3 câblage par projet (registre mcp_servers jumeau du watcher) - M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3). Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
676 lines
24 KiB
TypeScript
676 lines
24 KiB
TypeScript
/**
|
|
* 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(
|
|
<DIProvider gateways={gateways}>
|
|
<AgentsPanel projectId={PROJECT_ID} projectRoot={projectRoot} />
|
|
</DIProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
/** 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<MockProfileGateway> {
|
|
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(
|
|
<DIProvider gateways={gateways}>
|
|
<AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
|
|
</DIProvider>,
|
|
);
|
|
|
|
// 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(
|
|
<DIProvider gateways={gateways}>
|
|
<AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
|
|
</DIProvider>,
|
|
);
|
|
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(
|
|
<DIProvider gateways={gateways}>
|
|
<AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
|
|
</DIProvider>,
|
|
);
|
|
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();
|
|
});
|
|
});
|