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:
@ -323,6 +323,9 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
vm.profiles.find((p) => p.id === a.profileId)?.name ??
|
||||
a.profileId;
|
||||
const agentDrift = drift.driftByAgentId.get(a.id);
|
||||
// Source of this agent's last orchestration delegation (mcp vs
|
||||
// file), if any has been observed. Absent ⇒ no badge.
|
||||
const delegationSource = vm.delegationSourceByRequester[a.id];
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
@ -347,6 +350,19 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
update available
|
||||
</span>
|
||||
)}
|
||||
{delegationSource && (
|
||||
<span
|
||||
aria-label={`delegation source ${delegationSource}`}
|
||||
title={
|
||||
delegationSource === "mcp"
|
||||
? "Last delegation arrived via the MCP server"
|
||||
: "Last delegation arrived via .ideai/requests"
|
||||
}
|
||||
className="rounded-full bg-raised px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted"
|
||||
>
|
||||
{delegationSource}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{profileName}</span>
|
||||
{live && (
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -30,6 +30,13 @@ export interface AgentsViewModel {
|
||||
profiles: AgentProfile[];
|
||||
/** Agents that currently own a live PTY session, as tracked by IdeA. */
|
||||
liveAgents: LiveAgent[];
|
||||
/**
|
||||
* The entry door (`"mcp"` | `"file"`) the *last* orchestration delegation from
|
||||
* each requester agent arrived through, keyed by requester id. Populated from
|
||||
* `orchestratorRequestProcessed` events; a requester absent here (or an event
|
||||
* without `source`) yields no badge.
|
||||
*/
|
||||
delegationSourceByRequester: Record<string, "mcp" | "file">;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
@ -96,6 +103,9 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [runningAgentId, setRunningAgentId] = useState<string | null>(null);
|
||||
const [delegationSourceByRequester, setDelegationSourceByRequester] = useState<
|
||||
Record<string, "mcp" | "file">
|
||||
>({});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
@ -150,6 +160,19 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
void refresh();
|
||||
void refreshLiveAgents();
|
||||
}
|
||||
// Record which door a delegation came through (mcp vs file). Absent
|
||||
// `source` (older backend) leaves the map untouched → no badge.
|
||||
if (
|
||||
event.type === "orchestratorRequestProcessed" &&
|
||||
event.source !== undefined
|
||||
) {
|
||||
const { requesterId, source } = event;
|
||||
setDelegationSourceByRequester((prev) =>
|
||||
prev[requesterId] === source
|
||||
? prev
|
||||
: { ...prev, [requesterId]: source },
|
||||
);
|
||||
}
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
@ -317,6 +340,7 @@ export function useAgents(projectId: string): AgentsViewModel {
|
||||
context,
|
||||
profiles,
|
||||
liveAgents,
|
||||
delegationSourceByRequester,
|
||||
error,
|
||||
busy,
|
||||
runningAgentId,
|
||||
|
||||
Reference in New Issue
Block a user