feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3

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>
This commit is contained in:
2026-06-10 12:53:31 +02:00
parent 97daf3fae5
commit 37e72747d3
30 changed files with 3646 additions and 27 deletions

View File

@ -582,3 +582,94 @@ describe("AgentsPanel live refresh on domain events", () => {
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();
});
});