feat(agents,plugins): surface unifiée Capacités de l'agent — skills et tools MCP natifs + plugins #296 (domain: PluginMcpToolMetadata dans PluginMcpServerContribution, validation hôte alignée SDK — name requis non vide, description optionnelle non vide, doublons rejetés; application: McpToolPermissionCatalogue.vue structurée additive tools {name, description, access read|writeAction} enrichie des descriptions canoniques infrastructure orchestrator mcp tools, use case SetAgentMcpToolAllowed — toggle atomique idempotente d'un tool natif dans l'allowlist explicite d'un agent, refus unknown tool sans mutation, overrides vides retirés; backend DTO: catalogue.tools additive + SetAgentMcpToolAllowedRequestDto, wire camelCase; app-tauri + web-server: commande set_agent_mcp_tool_allowed; frontend: AgentPluginCapabilities devient la surface unifiée — skills natifs assign/unassign, tools natifs via setAgentMcpToolAllowed (catalogue structuré avec fallback legacy), skills/tools plugins avec metadata statiques mcpServers[].tools et saisie manuelle fallback pour serveurs sans catalogue, tombstones indisponibles, bannière redémarrage requis si agent actif; AgentsPanel retire l'ancienne section Skills séparée au profit de la surface unifiée; ProjectPluginsPanel recentré enable/disable avec renvoi Capacités des agents; adapters Tauri/HTTP/mock + permission.test.ts; QA verte reconfirmée par Git: application mcp_tool_permissions 5/5 + plugin::tests 37/37, backend ciblés 3/3 + checks domain/application/backend, app-tauri dto 33/33 + dto_agents 30/30 + dto_plugins 2/2, check web-server, npm test 35/35 + 65/65, npm run build; suites complètes rouges classées non bloquantes par QA: web-server parallèle too many open files + 7 PTY série passant individuellement, frontend 32 échecs sur 6 fichiers web non modifiés reproduits isolément)

This commit is contained in:
2026-09-09 19:52:38 +02:00
parent 6e4d9eb47e
commit e283f4a9e4
22 changed files with 1152 additions and 301 deletions

View File

@ -68,50 +68,6 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
void templateGw.listTemplates().then(setTemplates).catch(() => {});
}
// Skills available for assignment (global + project), loaded lazily once.
const skillGw = gateways.skill ?? null;
const [skills, setSkills] = useState<import("@/domain").Skill[]>([]);
const [skillsLoaded, setSkillsLoaded] = useState(false);
const [skillToAssign, setSkillToAssign] = useState("");
async function refreshSkills() {
if (!skillGw) return;
try {
const [globals, projects] = await Promise.all([
skillGw.listSkills(projectId, "global"),
skillGw.listSkills(projectId, "project"),
]);
setSkills([...projects, ...globals]);
} catch {
// Skills are optional — leave the list empty on failure.
}
}
if (!skillsLoaded && skillGw) {
setSkillsLoaded(true);
void refreshSkills();
}
async function handleAssignSkill() {
if (!skillGw || !vm.selectedAgentId || !skillToAssign) return;
const target = skills.find((s) => s.id === skillToAssign);
if (!target) return;
await skillGw.assignSkill(
projectId,
vm.selectedAgentId,
target.id,
target.scope,
);
setSkillToAssign("");
await vm.refresh();
}
async function handleUnassignSkill(skillId: string) {
if (!skillGw || !vm.selectedAgentId) return;
await skillGw.unassignSkill(projectId, vm.selectedAgentId, skillId);
await vm.refresh();
}
// Context editor state — local copy before Save
const [editedContext, setEditedContext] = useState(vm.context);
@ -668,82 +624,16 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
</div>
)}
{/* ── Skill assignment ── */}
{selectedAgent && skillGw && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<h4 className="text-sm font-semibold text-content">
Skills — {selectedAgent.name}
</h4>
{/* Assigned skills */}
{selectedAgent.skills.length === 0 ? (
<p className="text-xs text-muted">No skills assigned.</p>
) : (
<ul className="flex flex-wrap gap-1.5">
{selectedAgent.skills.map((ref) => {
const name =
skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId;
return (
<li
key={ref.skillId}
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
>
<span>{name}</span>
<button
type="button"
aria-label={`unassign ${name}`}
disabled={vm.busy}
onClick={() => void handleUnassignSkill(ref.skillId)}
className="text-faint hover:text-danger"
>
✕
</button>
</li>
);
})}
</ul>
)}
{/* Assign selector */}
<div className="flex items-end gap-2">
<SmallDropdown
aria-label="skill to assign"
value={skillToAssign}
onChange={setSkillToAssign}
disabled={vm.busy}
className="min-w-0 flex-1"
size="md"
options={[
{ value: "", label: "— assign a skill —" },
...skills
.filter(
(s) =>
!selectedAgent.skills.some((r) => r.skillId === s.id),
)
.map((s) => ({
value: s.id,
label: `${s.name} (${s.scope})`,
})),
]}
/>
<Button
variant="primary"
aria-label="assign skill"
disabled={vm.busy || !skillToAssign}
onClick={() => void handleAssignSkill()}
>
Assign
</Button>
</div>
</div>
)}
{selectedAgent && (
{selectedAgent && gateways.plugin && (
<div className="border-t border-border p-4">
<AgentPluginCapabilities
projectId={projectId}
agentId={selectedAgent.id}
agentName={selectedAgent.name}
agent={selectedAgent}
active={
activeAgentId === selectedAgent.id ||
vm.liveAgents.some((live) => live.agentId === selectedAgent.id)
}
onAgentChanged={vm.refresh}
/>
</div>
)}

View File

@ -1,158 +1,411 @@
import { useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { Button, Input, Panel, SmallDropdown, Spinner } from "@/shared";
import type { PluginSkillRef, PluginToolRef } from "@/domain";
import { useGateways } from "@/app/di";
import type {
Agent,
McpToolCatalogueEntry,
PluginSkillRef,
PluginToolRef,
ProjectMcpToolPermissions,
Skill,
SkillRef,
} from "@/domain";
import { Button, Input, SmallDropdown, Spinner } from "@/shared";
import { useProjectPlugins } from "./useProjectPlugins";
interface AgentPluginCapabilitiesProps {
projectId: string;
agentId: string;
agentName: string;
agent: Agent;
active: boolean;
onAgentChanged?: () => Promise<void> | void;
}
function skillKey(ref: PluginSkillRef): string {
return `${ref.pluginId}:${ref.skillId}`;
const pluginSkillKey = (ref: PluginSkillRef) => `${ref.pluginId}:${ref.skillId}`;
const pluginToolKey = (ref: PluginToolRef) =>
`${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
const nativeSkillKey = (ref: SkillRef) => `${ref.scope}:${ref.skillId}`;
function nativeCatalogue(doc: ProjectMcpToolPermissions | null): McpToolCatalogueEntry[] {
if (!doc) return [];
if (doc.catalogue.tools?.length) return doc.catalogue.tools;
return [
...doc.catalogue.readOnlyTools.map((name) => ({
name,
description: "",
access: "read" as const,
})),
...doc.catalogue.writeActionTools.map((name) => ({
name,
description: "",
access: "writeAction" as const,
})),
];
}
function toolKey(ref: PluginToolRef): string {
return `${ref.pluginId}:${ref.serverId}:${ref.toolName}`;
function Origin({ children }: { children: ReactNode }) {
return <span className="shrink-0 text-[0.65rem] text-faint">{children}</span>;
}
function Tombstone({
label,
origin,
onRemove,
disabled,
}: {
label: string;
origin: string;
onRemove: () => void;
disabled: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span className="min-w-0">
<span className="font-medium">{label}</span>{" "}
<span className="text-warning">— Indisponible</span>{" "}
<Origin>{origin}</Origin>
</span>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onRemove}>
Retirer
</Button>
</div>
);
}
export function AgentPluginCapabilities({
projectId,
agentId,
agentName,
agent,
active,
onAgentChanged,
}: AgentPluginCapabilitiesProps) {
const vm = useProjectPlugins(projectId);
const { skill: skillGateway, permission: permissionGateway } = useGateways();
const plugins = useProjectPlugins(projectId);
const [nativeSkills, setNativeSkills] = useState<Skill[]>([]);
const [nativeAssignments, setNativeAssignments] = useState<SkillRef[]>(agent.skills);
const [permissions, setPermissions] = useState<ProjectMcpToolPermissions | null>(null);
const [loadingNative, setLoadingNative] = useState(true);
const [nativeBusy, setNativeBusy] = useState(false);
const [nativeError, setNativeError] = useState<string | null>(null);
const [serverChoice, setServerChoice] = useState("");
const [toolName, setToolName] = useState("");
const assignments = vm.settings?.agents.find((entry) => entry.agentId === agentId);
const assignedSkills = new Set((assignments?.skills ?? []).map(skillKey));
const assignedTools = assignments?.tools ?? [];
const enabledIds = new Set(
(vm.settings?.plugins ?? []).filter((entry) => entry.enabled).map((entry) => entry.pluginId),
const [restartRequired, setRestartRequired] = useState(false);
useEffect(() => setNativeAssignments(agent.skills), [agent.skills]);
const loadNative = useCallback(async () => {
setLoadingNative(true);
setNativeError(null);
try {
const [globals, projects, permissionDoc] = await Promise.all([
skillGateway?.listSkills(projectId, "global") ?? Promise.resolve([]),
skillGateway?.listSkills(projectId, "project") ?? Promise.resolve([]),
permissionGateway?.getMcpToolPermissions(projectId) ?? Promise.resolve(null),
]);
setNativeSkills([...projects, ...globals]);
setPermissions(permissionDoc);
} catch (cause) {
setNativeError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoadingNative(false);
}
}, [permissionGateway, projectId, skillGateway]);
useEffect(() => {
void loadNative();
}, [loadNative]);
function markRestartRequired() {
if (active) setRestartRequired(true);
}
async function setNativeSkill(skill: Skill, assigned: boolean) {
if (!skillGateway) return;
setNativeBusy(true);
setNativeError(null);
try {
if (assigned) {
await skillGateway.assignSkill(projectId, agent.id, skill.id, skill.scope);
setNativeAssignments((current) => [
...current.filter((ref) => ref.skillId !== skill.id),
{ skillId: skill.id, scope: skill.scope },
]);
} else {
await skillGateway.unassignSkill(projectId, agent.id, skill.id);
setNativeAssignments((current) =>
current.filter((ref) => ref.skillId !== skill.id),
);
}
await onAgentChanged?.();
markRestartRequired();
} catch (cause) {
setNativeError(cause instanceof Error ? cause.message : String(cause));
} finally {
setNativeBusy(false);
}
}
async function removeMissingNativeSkill(ref: SkillRef) {
if (!skillGateway) return;
setNativeBusy(true);
try {
await skillGateway.unassignSkill(projectId, agent.id, ref.skillId);
setNativeAssignments((current) =>
current.filter((candidate) => nativeSkillKey(candidate) !== nativeSkillKey(ref)),
);
await onAgentChanged?.();
markRestartRequired();
} finally {
setNativeBusy(false);
}
}
async function setNativeTool(name: string, allowed: boolean) {
if (!permissionGateway) return;
setNativeBusy(true);
setNativeError(null);
try {
setPermissions(
await permissionGateway.setAgentMcpToolAllowed(
projectId,
agent.id,
name,
allowed,
),
);
markRestartRequired();
} catch (cause) {
setNativeError(cause instanceof Error ? cause.message : String(cause));
} finally {
setNativeBusy(false);
}
}
const assignments = plugins.settings?.agents.find((entry) => entry.agentId === agent.id);
const assignedPluginSkills = new Set((assignments?.skills ?? []).map(pluginSkillKey));
const assignedPluginTools = assignments?.tools ?? [];
const enabledPluginIds = new Set(
(plugins.settings?.plugins ?? [])
.filter((entry) => entry.enabled)
.map((entry) => entry.pluginId),
);
const enabledPlugins = vm.installed.filter((plugin) => enabledIds.has(plugin.id));
const serverOptions = useMemo(
() => enabledPlugins.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).map((server) => ({
value: `${plugin.id}\u0000${server.id}`,
label: `${plugin.displayName} — ${server.displayName}`,
})),
const knownPluginSkillKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
),
[enabledPlugins],
);
const knownServerKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`),
),
);
const staticPluginToolKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
(server.tools ?? []).map((tool) => `${plugin.id}:${server.id}:${tool.name}`),
),
),
);
const noCatalogueServerKeys = new Set(
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? [])
.filter((server) => (server.tools ?? []).length === 0)
.map((server) => `${plugin.id}:${server.id}`),
),
);
const stalePluginSkills = (assignments?.skills ?? []).filter(
(ref) => !knownPluginSkillKeys.has(pluginSkillKey(ref)),
);
const stalePluginTools = assignedPluginTools.filter(
(ref) => {
const serverKey = `${ref.pluginId}:${ref.serverId}`;
return (
!knownServerKeys.has(serverKey) ||
(!noCatalogueServerKeys.has(serverKey) &&
!staticPluginToolKeys.has(pluginToolKey(ref)))
);
},
);
const advancedAssignedTools = assignedPluginTools.filter(
(ref) =>
noCatalogueServerKeys.has(`${ref.pluginId}:${ref.serverId}`),
);
const advancedServers = useMemo(
() =>
plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? [])
.filter((server) => (server.tools ?? []).length === 0)
.map((server) => ({
value: `${plugin.id}\u0000${server.id}`,
label: `${plugin.displayName} — ${server.displayName}`,
enabled: enabledPluginIds.has(plugin.id),
})),
),
[plugins.installed, plugins.settings],
);
const knownSkillKeys = new Set(vm.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => `${plugin.id}:${skill.id}`),
));
const knownServerKeys = new Set(vm.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).map((server) => `${plugin.id}:${server.id}`),
));
const staleSkills = (assignments?.skills ?? []).filter((ref) => !knownSkillKeys.has(skillKey(ref)));
const staleTools = assignedTools.filter(
(ref) => !knownServerKeys.has(`${ref.pluginId}:${ref.serverId}`),
);
async function setPluginSkill(pluginId: string, skillId: string, assigned: boolean) {
await plugins.setSkillAssigned(agent.id, pluginId, skillId, assigned);
markRestartRequired();
}
async function assignTool() {
async function setPluginTool(ref: PluginToolRef, assigned: boolean) {
await plugins.setToolAssigned({ agentId: agent.id, ...ref }, assigned);
markRestartRequired();
}
async function assignAdvancedTool() {
const [pluginId, serverId] = serverChoice.split("\u0000");
const name = toolName.trim();
if (!pluginId || !serverId || !name) return;
await vm.setToolAssigned({ agentId, pluginId, serverId, toolName: name }, true);
await setPluginTool({ pluginId, serverId, toolName: name }, true);
setToolName("");
}
if (vm.loading && !vm.settings) {
return <div className="flex items-center gap-2 text-xs text-muted"><Spinner size={12} /> Chargement des capabilities de plugins…</div>;
const nativeAssignedKeys = new Set(nativeAssignments.map(nativeSkillKey));
const knownNativeSkillKeys = new Set(
nativeSkills.map((skill) => `${skill.scope}:${skill.id}`),
);
const staleNativeSkills = nativeAssignments.filter(
(ref) => !knownNativeSkillKeys.has(nativeSkillKey(ref)),
);
const nativeTools = nativeCatalogue(permissions);
const explicitNativeTools = new Set(
permissions?.agents.find((entry) => entry.agentId === agent.id)?.policy.allowedTools ?? [],
);
const knownNativeTools = new Set(nativeTools.map((tool) => tool.name));
const staleNativeTools = [...explicitNativeTools].filter(
(name) => !knownNativeTools.has(name),
);
const busy = nativeBusy || plugins.busy;
if ((loadingNative || plugins.loading) && !permissions && !plugins.settings) {
return (
<div className="flex items-center gap-2 text-xs text-muted">
<Spinner size={12} /> Chargement des capacités de l’agent…
</div>
);
}
return (
<div className="flex flex-col gap-4">
<section aria-label={`Capacités de l'agent — ${agent.name}`} className="flex flex-col gap-4">
<div>
<h4 className="text-sm font-semibold text-content">
Capabilities de plugins — {agentName}
Capacités de l&apos;agent — {agent.name}
</h4>
<p className="mt-1 text-xs text-muted">
Attribuez à cet agent les skills et tools MCP fournis par les plugins activés
dans les paramètres du projet.
Attribuez les skills et tools MCP disponibles pour cet agent.
</p>
</div>
{vm.error && <p role="alert" className="text-xs text-danger">{vm.error}</p>}
{restartRequired && (
<p role="status" className="rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
Relance requise pour appliquer ces changements à l’agent actif.
</p>
)}
{(nativeError || plugins.error) && (
<p role="alert" className="text-xs text-danger">{nativeError ?? plugins.error}</p>
)}
<section aria-label={`Skills de plugins — ${agentName}`} className="flex flex-col gap-2">
<h5 className="text-sm font-semibold text-content">Skills de plugins</h5>
{enabledPlugins.every((plugin) => (plugin.contributes.skills ?? []).length === 0) ? (
<p className="text-xs text-muted">Aucun skill fourni par les plugins activés pour ce projet.</p>
) : (
enabledPlugins.map((plugin) => {
const skills = plugin.contributes.skills ?? [];
if (skills.length === 0) return null;
<section aria-label={`Skills — ${agent.name}`} className="flex flex-col gap-2">
<h5 className="text-sm font-semibold text-content">Skills</h5>
{nativeSkills.length === 0 &&
plugins.installed.every(
(plugin) => (plugin.contributes.skills ?? []).length === 0,
) &&
staleNativeSkills.length === 0 &&
stalePluginSkills.length === 0 && (
<p className="text-xs text-muted">Aucun skill disponible.</p>
)}
{nativeSkills.map((skill) => {
const assigned = nativeAssignedKeys.has(`${skill.scope}:${skill.id}`);
return (
<label key={`native:${skill.scope}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={busy || !skillGateway} onChange={(event) => void setNativeSkill(skill, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
<Origin>IdeA</Origin>
</label>
);
})}
{staleNativeSkills.map((ref) => (
<Tombstone key={`stale-native:${nativeSkillKey(ref)}`} label={ref.skillId} origin="IdeA" disabled={busy} onRemove={() => void removeMissingNativeSkill(ref)} />
))}
{plugins.installed.flatMap((plugin) =>
(plugin.contributes.skills ?? []).map((skill) => {
const assigned = assignedPluginSkills.has(`${plugin.id}:${skill.id}`);
const enabled = enabledPluginIds.has(plugin.id);
return (
<Panel key={plugin.id} className="flex flex-col gap-2">
<h6 className="text-xs font-semibold text-content">{plugin.displayName}</h6>
{skills.map((skill) => {
const assigned = assignedSkills.has(`${plugin.id}:${skill.id}`);
return (
<label key={skill.id} className="flex items-start gap-2 text-xs text-content">
<input
type="checkbox"
checked={assigned}
disabled={vm.busy}
onChange={(event) => void vm.setSkillAssigned(
agentId, plugin.id, skill.id, event.target.checked,
)}
/>
<span><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}</span>
</label>
);
})}
</Panel>
<label key={`plugin:${plugin.id}:${skill.id}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={busy || (!enabled && !assigned)} onChange={(event) => void setPluginSkill(plugin.id, skill.id, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{skill.name}</span>{skill.description ? ` — ${skill.description}` : ""}{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
<Origin>Plugin: {plugin.displayName}</Origin>
</label>
);
})
}),
)}
{staleSkills.map((ref) => (
<div key={skillKey(ref)} className="flex items-center justify-between gap-2 rounded border border-warning/40 px-3 py-2 text-xs">
<span>{ref.pluginId} · {ref.skillId} <span className="text-warning">— Obsolète ou manquant</span></span>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setSkillAssigned(agentId, ref.pluginId, ref.skillId, false)}>Retirer</Button>
</div>
{stalePluginSkills.map((ref) => (
<Tombstone key={`stale-plugin:${pluginSkillKey(ref)}`} label={ref.skillId} origin={`Plugin: ${ref.pluginId}`} disabled={busy} onRemove={() => void setPluginSkill(ref.pluginId, ref.skillId, false)} />
))}
</section>
<section aria-label={`Tools MCP de plugins — ${agentName}`} className="flex flex-col gap-2">
<h5 className="text-sm font-semibold text-content">Tools MCP de plugins</h5>
{assignedTools.length === 0 ? (
<p className="text-xs text-muted">Aucun tool MCP de plugin attribué à l’agent.</p>
) : (
<ul className="flex flex-col gap-1">
{assignedTools.map((ref) => {
const stale = staleTools.some((candidate) => toolKey(candidate) === toolKey(ref));
<section aria-label={`Tools MCP — ${agent.name}`} className="flex flex-col gap-2">
<h5 className="text-sm font-semibold text-content">Tools MCP</h5>
{nativeTools.length === 0 &&
staticPluginToolKeys.size === 0 &&
advancedAssignedTools.length === 0 &&
stalePluginTools.length === 0 &&
advancedServers.length === 0 && (
<p className="text-xs text-muted">Aucun tool MCP disponible.</p>
)}
{nativeTools.map((tool) => (
<label key={`native-tool:${tool.name}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={explicitNativeTools.has(tool.name)} disabled={busy || !permissionGateway} onChange={(event) => void setNativeTool(tool.name, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="ml-1 text-faint">({tool.access === "read" ? "lecture" : "action"})</span></span>
<Origin>IdeA</Origin>
</label>
))}
{staleNativeTools.map((name) => (
<Tombstone key={`stale-native-tool:${name}`} label={name} origin="IdeA" disabled={busy} onRemove={() => void setNativeTool(name, false)} />
))}
{plugins.installed.flatMap((plugin) =>
(plugin.contributes.mcpServers ?? []).flatMap((server) =>
(server.tools ?? []).map((tool) => {
const ref = { pluginId: plugin.id, serverId: server.id, toolName: tool.name };
const assigned = assignedPluginTools.some((candidate) => pluginToolKey(candidate) === pluginToolKey(ref));
const enabled = enabledPluginIds.has(plugin.id);
return (
<li key={toolKey(ref)} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<span>{ref.pluginId} · {ref.serverId} · <span className="font-medium">{ref.toolName}</span>{stale && <span className="text-warning"> — Obsolète ou manquant</span>}</span>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => void vm.setToolAssigned({ agentId, ...ref }, false)}>Retirer</Button>
</li>
<label key={`plugin-tool:${pluginToolKey(ref)}`} className="flex items-start gap-2 rounded bg-raised px-3 py-2 text-xs text-content">
<input type="checkbox" checked={assigned} disabled={busy || (!enabled && !assigned)} onChange={(event) => void setPluginTool(ref, event.target.checked)} />
<span className="min-w-0 flex-1"><span className="font-medium">{tool.name}</span>{tool.description ? ` — ${tool.description}` : ""}<span className="text-faint"> · {server.displayName}</span>{!enabled && <span className="text-warning"> — Plugin désactivé</span>}</span>
<Origin>Plugin: {plugin.displayName}</Origin>
</label>
);
})}
</ul>
}),
),
)}
{serverOptions.length > 0 && (
<div className="flex items-end gap-2">
<SmallDropdown
aria-label="serveur MCP de plugin"
value={serverChoice}
onChange={setServerChoice}
disabled={vm.busy}
className="min-w-0 flex-1"
size="md"
options={[{ value: "", label: "— choisir un serveur MCP —" }, ...serverOptions]}
/>
<Input aria-label="nom du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom du tool" disabled={vm.busy} />
<Button variant="primary" disabled={vm.busy || !serverChoice || !toolName.trim()} onClick={() => void assignTool()}>Attribuer</Button>
</div>
{advancedAssignedTools.map((ref) => {
const plugin = plugins.installed.find((candidate) => candidate.id === ref.pluginId);
const enabled = enabledPluginIds.has(ref.pluginId);
return (
<div key={`advanced:${pluginToolKey(ref)}`} className="flex items-center justify-between gap-2 rounded bg-raised px-3 py-2 text-xs">
<span><span className="font-medium">{ref.toolName}</span> · {ref.serverId}{!enabled && <span className="text-warning"> — Plugin désactivé</span>} <Origin>Plugin: {plugin?.displayName ?? ref.pluginId}</Origin></span>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void setPluginTool(ref, false)}>Retirer</Button>
</div>
);
})}
{stalePluginTools.map((ref) => (
<Tombstone key={`stale-tool:${pluginToolKey(ref)}`} label={`${ref.serverId} · ${ref.toolName}`} origin={`Plugin: ${ref.pluginId}`} disabled={busy} onRemove={() => void setPluginTool(ref, false)} />
))}
{advancedServers.length > 0 && (
<details>
<summary className="cursor-pointer text-xs font-medium text-primary">Ajout avancé d’un tool MCP</summary>
<div className="mt-2 flex items-end gap-2">
<SmallDropdown aria-label="serveur MCP sans catalogue statique" value={serverChoice} onChange={setServerChoice} disabled={busy} className="min-w-0 flex-1" size="md" options={[{ value: "", label: "— choisir un serveur MCP —" }, ...advancedServers.map((server) => ({ value: server.value, label: `${server.label}${server.enabled ? "" : " — désactivé"}`, disabled: !server.enabled }))]} />
<Input aria-label="nom avancé du tool MCP" value={toolName} onChange={(event) => setToolName(event.target.value)} placeholder="Nom exact du tool" disabled={busy} />
<Button variant="primary" disabled={busy || !serverChoice || !toolName.trim()} onClick={() => void assignAdvancedTool()}>Attribuer</Button>
</div>
</details>
)}
</section>
</div>
</section>
);
}

View File

@ -2,7 +2,11 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockPluginGateway } from "@/adapters/mock";
import {
createMockGateways,
MockPermissionGateway,
MockPluginGateway,
} from "@/adapters/mock";
import type { ProjectPluginManifest } from "@/domain";
import { AgentPluginCapabilities } from "./AgentPluginCapabilities";
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
@ -29,7 +33,15 @@ function manifest(): ProjectPluginManifest {
menus: [],
menuItems: [],
layouts: [],
mcpServers: [{ id: "unity-tools", displayName: "Unity Tools", transport: "stdio" }],
mcpServers: [
{
id: "unity-tools",
displayName: "Unity Tools",
transport: "stdio",
tools: [{ name: "unity_build", description: "Build the Unity project." }],
},
{ id: "legacy-tools", displayName: "Legacy Tools", transport: "stdio" },
],
},
};
}
@ -75,48 +87,152 @@ describe("project plugin settings #282", () => {
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Attribuer aux agents" }));
fireEvent.click(await screen.findByRole("button", { name: "Capacités des agents" }));
expect(onAssignAgents).toHaveBeenCalledOnce();
});
it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => {
const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", {
name: "Dev",
profileId: "profile-1",
});
gateways.plugin._seedProjectPluginSettings("project-1", {
version: 1,
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
agents: [{
agentId: "agent-1",
agentId: agent.id,
skills: [{ pluginId: "missing.plugin", skillId: "removed-skill" }],
tools: [],
}],
});
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agentId="agent-1" agentName="Dev" />
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
</DIProvider>,
);
expect(await screen.findByText("Capabilities de plugins — Dev")).toBeTruthy();
expect(await screen.findByText("Capacités de l'agent — Dev")).toBeTruthy();
const skill = await screen.findByRole("checkbox", { name: /unity-debug/ });
fireEvent.click(skill);
await waitFor(() => expect((skill as HTMLInputElement).checked).toBe(true));
expect(screen.getByText(/removed-skill/).textContent).toContain("Obsolète ou manquant");
expect(screen.getByText(/removed-skill/).closest("div")?.textContent).toContain(
"Indisponible",
);
fireEvent.click(screen.getByLabelText("serveur MCP de plugin"));
fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Unity Tools" }));
fireEvent.change(screen.getByLabelText("nom du tool MCP"), {
target: { value: "unity_build" },
const staticTool = screen.getByRole("checkbox", { name: /unity_build/ });
fireEvent.click(staticTool);
await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true));
fireEvent.click(screen.getByText("Ajout avancé d’un tool MCP"));
fireEvent.click(screen.getByLabelText("serveur MCP sans catalogue statique"));
fireEvent.click(screen.getByRole("option", { name: "Unity Plugin — Legacy Tools" }));
fireEvent.change(screen.getByLabelText("nom avancé du tool MCP"), {
target: { value: "legacy_ping" },
});
fireEvent.click(screen.getByRole("button", { name: "Attribuer" }));
expect(await screen.findByText("unity_build")).toBeTruthy();
expect(await screen.findByText(/legacy_ping/)).toBeTruthy();
expect(screen.getByRole("status").textContent).toContain("Relance requise");
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
const agent = settings.agents.find((entry) => entry.agentId === "agent-1");
expect(agent?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
expect(agent?.tools).toContainEqual({
const assigned = settings.agents.find((entry) => entry.agentId === agent.id);
expect(assigned?.skills).toContainEqual({ pluginId: "dev.acme.unity", skillId: "unity.debug" });
expect(assigned?.tools).toContainEqual({
pluginId: "dev.acme.unity",
serverId: "unity-tools",
toolName: "unity_build",
});
expect(assigned?.tools).toContainEqual({
pluginId: "dev.acme.unity",
serverId: "legacy-tools",
toolName: "legacy_ping",
});
});
it("aggregates native capabilities and uses the granular MCP tool toggle", async () => {
const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", {
name: "Main",
profileId: "profile-1",
});
await gateways.skill.createSkill({
projectId: "project-1",
scope: "project",
name: "release-check",
description: "Vérifier une release.",
content: "# Release",
});
(gateways.permission as MockPermissionGateway).setMcpToolCatalogue({
readOnlyTools: ["idea_ticket_read"],
writeActionTools: ["idea_ticket_update"],
tools: [
{ name: "idea_ticket_read", description: "Lire un ticket.", access: "read" },
{ name: "idea_ticket_update", description: "Modifier un ticket.", access: "writeAction" },
],
});
const toggleSpy = vi.spyOn(gateways.permission, "setAgentMcpToolAllowed");
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
</DIProvider>,
);
const nativeSkill = await screen.findByRole("checkbox", { name: /release-check/ });
const nativeTool = screen.getByRole("checkbox", { name: /idea_ticket_update/ });
expect(screen.getAllByText("IdeA").length).toBeGreaterThanOrEqual(2);
fireEvent.click(nativeSkill);
fireEvent.click(nativeTool);
await waitFor(() =>
expect(toggleSpy).toHaveBeenCalledWith(
"project-1",
agent.id,
"idea_ticket_update",
true,
),
);
expect(screen.getByRole("status").textContent).toContain("Relance requise");
});
it("keeps disabled and missing plugin assignments removable", async () => {
const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", {
name: "QA",
profileId: "profile-1",
});
gateways.plugin._seedProjectPluginSettings("project-1", {
version: 1,
plugins: [{ pluginId: "dev.acme.unity", enabled: false }],
agents: [{
agentId: agent.id,
skills: [{ pluginId: "missing.plugin", skillId: "gone-skill" }],
tools: [
{ pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build" },
{ pluginId: "missing.plugin", serverId: "gone-server", toolName: "gone_tool" },
],
}],
});
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active={false} />
</DIProvider>,
);
const disabledAssigned = await screen.findByRole("checkbox", { name: /unity_build/ });
expect((disabledAssigned as HTMLInputElement).checked).toBe(true);
expect((disabledAssigned as HTMLInputElement).disabled).toBe(false);
fireEvent.click(disabledAssigned);
await waitFor(() => expect((disabledAssigned as HTMLInputElement).checked).toBe(false));
const stale = screen.getByText(/gone_tool/).closest("div")!;
fireEvent.click(stale.querySelector("button")!);
await waitFor(async () => {
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
expect(settings.agents[0]?.tools).not.toContainEqual(
expect.objectContaining({ toolName: "gone_tool" }),
);
});
});
});

View File

@ -36,13 +36,13 @@ export function ProjectPluginsPanel({
<div>
<h2 className="text-sm font-semibold text-content">Plugins du projet</h2>
<p className="mt-1 text-xs text-muted">
Activez les plugins utilisés par ce projet, puis attribuez leurs skills et
tools MCP à chaque agent.
Activez les plugins utilisés par ce projet. Les attributions se gèrent
depuis la fiche de chaque agent.
</p>
</div>
{onAssignAgents && (
<Button size="sm" variant="primary" onClick={onAssignAgents}>
Attribuer aux agents
<Button size="sm" variant="ghost" onClick={onAssignAgents}>
Capacités des agents
</Button>
)}
</div>

View File

@ -17,6 +17,8 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import {
MockAgentGateway,
MockPermissionGateway,
MockPluginGateway,
MockProfileGateway,
MockSkillGateway,
} from "@/adapters/mock";
@ -44,7 +46,13 @@ function renderSkillsPanel(agent?: MockAgentGateway, skill?: MockSkillGateway) {
function renderAgentsPanel(agent: MockAgentGateway, skill: MockSkillGateway) {
const profile = new MockProfileGateway();
const gateways = { agent, profile, skill } as unknown as Gateways;
const gateways = {
agent,
profile,
skill,
permission: new MockPermissionGateway(),
plugin: new MockPluginGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
@ -58,11 +66,6 @@ async function waitForSkillsIdle() {
});
}
function chooseDropdownOption(label: string | RegExp, optionName: string | RegExp) {
fireEvent.click(screen.getByLabelText(label));
fireEvent.click(screen.getByRole("option", { name: optionName }));
}
/** Opens the SkillEditor, fills it, and saves. */
async function createSkill(
name: string,
@ -172,7 +175,7 @@ describe("SkillsPanel (with MockSkillGateway)", () => {
// ---------------------------------------------------------------------------
describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
it("assigns a skill to the selected agent and shows it as a chip", async () => {
it("assigns a skill to the selected agent from the unified capabilities", async () => {
const agent = new MockAgentGateway();
const skill = new MockSkillGateway(agent);
const created = await agent.createAgent(PROJECT_ID, {
@ -191,14 +194,9 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
// Select the agent
fireEvent.click(screen.getByText("Worker"));
await waitFor(() =>
expect(screen.getByLabelText("skill to assign")).toBeTruthy(),
);
// Choose the skill and assign
const toggle = await screen.findByRole("checkbox", { name: /Deploy/ });
const sk = (await skill.listSkills(PROJECT_ID, "project"))[0];
chooseDropdownOption("skill to assign", "Deploy (project)");
fireEvent.click(screen.getByRole("button", { name: "assign skill" }));
fireEvent.click(toggle);
// The agent record now carries the skill ref
await waitFor(async () => {
@ -206,8 +204,7 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
const a = agents.find((x) => x.id === created.id)!;
expect(a.skills.map((s) => s.skillId)).toContain(sk.id);
});
// Chip with an unassign control appears
expect(screen.getByRole("button", { name: "unassign Deploy" })).toBeTruthy();
expect((toggle as HTMLInputElement).checked).toBe(true);
});
it("unassigns a skill from the agent", async () => {
@ -229,12 +226,9 @@ describe("Skill assignment (AgentsPanel + MockSkillGateway)", () => {
await waitFor(() => expect(screen.getByLabelText("agent name")).toBeTruthy());
fireEvent.click(screen.getByText("Worker2"));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "unassign Cleanup" }),
).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: "unassign Cleanup" }));
const toggle = await screen.findByRole("checkbox", { name: /Cleanup/ });
expect((toggle as HTMLInputElement).checked).toBe(true);
fireEvent.click(toggle);
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);