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

@ -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" }),
);
});
});
});