Files
IdeA/frontend/src/features/agents/useAgents.ts
Blomios 37e72747d3 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>
2026-06-10 12:53:31 +02:00

358 lines
11 KiB
TypeScript

/**
* `useAgents` — view-model hook for the agents feature (L6).
*
* Owns the agents feature state for a given project and exposes the actions the
* UI triggers. Consumes {@link AgentGateway} and {@link ProfileGateway}
* exclusively; never touches `invoke()` or `@tauri-apps/api`, keeping the
* component layer testable with mock gateways (ARCHITECTURE §1.3).
*/
import { useCallback, useEffect, useState } from "react";
import type {
Agent,
AgentProfile,
GatewayError,
TerminalSession,
} from "@/domain";
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
import { useGateways } from "@/app/di";
/** What the agents UI needs from this hook. */
export interface AgentsViewModel {
/** All agents known to the project. */
agents: Agent[];
/** Id of the currently selected agent, or `null`. */
selectedAgentId: string | null;
/** The `.md` context of the selected agent (loaded on select). */
context: string;
/** Profiles available for assigning to a new agent. */
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. */
busy: boolean;
/**
* Id of the agent whose terminal is currently running, or `null`.
* Set when the user clicks Launch; cleared when the terminal is stopped.
*/
runningAgentId: string | null;
/** Reloads the agent list. */
refresh: () => Promise<void>;
/** Reloads the currently-live agent sessions. */
refreshLiveAgents: () => Promise<void>;
/** Creates a new agent and refreshes the list. */
createAgent: (name: string, profileId: string, initialContent?: string) => Promise<void>;
/** Selects an agent and loads its context. */
selectAgent: (agentId: string) => Promise<void>;
/** Saves the context for the currently selected agent. */
saveContext: (content: string) => Promise<void>;
/**
* Hot-swaps an agent's runtime AI profile. The caller is expected to have
* confirmed with the user first (the conversation history is abandoned). On a
* live agent the backend relaunches the session and returns it so the cell can
* rebind; the agent list is refreshed locally. Returns the relaunched session,
* if any, so the panel can rebind the hosting cell.
*/
changeAgentProfile: (
agentId: string,
profileId: string,
rows: number,
cols: number,
) => Promise<TerminalSession | undefined>;
/** Deletes an agent; deselects if it was selected. */
deleteAgent: (agentId: string) => Promise<void>;
/**
* Returns an opener compatible with `TerminalView`'s `open` prop for the
* given agent. Calling it sets `runningAgentId`. The component unmounting
* will call `handle.close()` automatically via the TerminalView cleanup.
*/
launchAgent: (
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
) => Promise<TerminalHandle>;
/** Stops an agent PTY explicitly by closing its live session id. */
stopAgent: (agentId?: string) => Promise<void>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function useAgents(projectId: string): AgentsViewModel {
const { agent, profile, system, terminal } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [context, setContext] = useState<string>("");
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [liveAgents, setLiveAgents] = useState<LiveAgent[]>([]);
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);
setError(null);
try {
const [agentList, profileList] = await Promise.all([
agent.listAgents(projectId),
profile.listProfiles(),
]);
setAgents(agentList);
setProfiles(profileList);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [agent, profile, projectId]);
const refreshLiveAgents = useCallback(async () => {
try {
setLiveAgents(await agent.listLiveAgents(projectId));
} catch {
setLiveAgents([]);
}
}, [agent, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
void refreshLiveAgents();
}, [refreshLiveAgents]);
// Live refresh: the agents list is otherwise only reloaded on mount or after a
// UI-driven CRUD. An agent created/launched/stopped *out of band* — most
// notably by the orchestrator (`spawn_agent`, ARCHITECTURE §14.3) when an AI
// delegates agent creation to IdeA — emits `agentLaunched` / `agentExited`.
// Subscribing here makes the tab reflect those changes without a manual reload.
// `system` may be absent in unit tests that inject a partial gateway set; guard.
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (
event.type === "agentLaunched" ||
event.type === "agentExited" ||
event.type === "agentProfileChanged"
) {
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();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system, refresh, refreshLiveAgents]);
const createAgent = useCallback(
async (name: string, profileId: string, initialContent?: string) => {
setBusy(true);
setError(null);
try {
const created = await agent.createAgent(projectId, {
name,
profileId,
initialContent,
});
setAgents((prev) => [...prev, created]);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId],
);
const selectAgent = useCallback(
async (agentId: string) => {
setBusy(true);
setError(null);
try {
const ctx = await agent.readContext(projectId, agentId);
setSelectedAgentId(agentId);
setContext(ctx);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId],
);
const saveContext = useCallback(
async (content: string) => {
if (!selectedAgentId) return;
setBusy(true);
setError(null);
try {
await agent.updateContext(projectId, selectedAgentId, content);
setContext(content);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId, selectedAgentId],
);
const changeAgentProfile = useCallback(
async (
agentId: string,
profileId: string,
rows: number,
cols: number,
): Promise<TerminalSession | undefined> => {
setBusy(true);
setError(null);
try {
const { agent: updated, relaunchedSession } =
await agent.changeAgentProfile(projectId, agentId, profileId, rows, cols);
// Reflect the new profile locally; the `agentProfileChanged` event (when
// wired) also triggers a full refresh, but updating here keeps the UI
// responsive offline / in tests.
setAgents((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a)),
);
// A hot relaunch produces a fresh live session id — keep the live list
// in sync so the cell can rebind.
void refreshLiveAgents();
return relaunchedSession;
} catch (e) {
setError(describe(e));
return undefined;
} finally {
setBusy(false);
}
},
[agent, projectId, refreshLiveAgents],
);
const deleteAgent = useCallback(
async (agentId: string) => {
setBusy(true);
setError(null);
try {
await agent.deleteAgent(projectId, agentId);
setAgents((prev) => prev.filter((a) => a.id !== agentId));
setSelectedAgentId((current) => (current === agentId ? null : current));
if (selectedAgentId === agentId) setContext("");
// Also stop the terminal if the running agent was just deleted.
setRunningAgentId((current) => (current === agentId ? null : current));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId, selectedAgentId],
);
const launchAgent = useCallback(
async (
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> => {
setError(null);
try {
const handle = await agent.launchAgent(projectId, agentId, options, onData);
setRunningAgentId(agentId);
void refreshLiveAgents();
return handle;
} catch (e) {
setError(describe(e));
throw e;
}
},
[agent, projectId, refreshLiveAgents],
);
const stopAgent = useCallback(
async (agentId?: string) => {
const targetAgentId = agentId ?? runningAgentId;
const live = targetAgentId
? liveAgents.find((candidate) => candidate.agentId === targetAgentId)
: null;
setRunningAgentId((current) =>
current === targetAgentId || !targetAgentId ? null : current,
);
if (live?.sessionId) {
try {
await terminal?.closeTerminal(live.sessionId);
setLiveAgents((current) =>
current.filter((candidate) => candidate.agentId !== targetAgentId),
);
} catch (e) {
setError(describe(e));
await refreshLiveAgents();
}
}
},
[liveAgents, refreshLiveAgents, runningAgentId, terminal],
);
return {
agents,
selectedAgentId,
context,
profiles,
liveAgents,
delegationSourceByRequester,
error,
busy,
runningAgentId,
refresh,
refreshLiveAgents,
createAgent,
selectAgent,
saveContext,
changeAgentProfile,
deleteAgent,
launchAgent,
stopAgent,
};
}