feat(skills,mcp): menus Skills et MCP unifies avec selection cochable des tools #297 (SkillsPanel devient la surface unique d'assignation cochable des skills IdeA + plugins — cible agent, recherche, groupes IdeA/Plugins, tombstones retirables, CRUD catalogue natif conserve, stores separes SkillGateway/PluginGateway; nouveau panneau MCP dedie McpPanel desktop+web — tools MCP IdeA via PermissionGateway et tools MCP plugins catalogues via PluginGateway groupes par plugin/serveur, selection cochable, fin de la saisie manuelle du nom exact, serveurs sans catalogue en tombstones retirables uniquement; retrait complet de la surface Capacites de la fiche agent et suppression d'AgentPluginCapabilities.tsx; PermissionsPanel reserve aux permissions systeme/sandbox, onglet MCP retire; raccourci Parametres > Plugins ouvre Skills avec renvois vers Skills/MCP; integration ProjectsView/ViewPanelBody/viewPlacement/WebWorkspace/webSurfaceCatalog; QA verte: tsc+vite build OK, 28+24 tests cibles OK, suite complete 1223 OK / 32 echecs sur 6 fichiers web hors perimetre classes non bloquants; scripts/ et sync tickets #296 hors perimetre; aucun push)

This commit is contained in:
2026-09-09 20:38:39 +02:00
parent f78f0093e8
commit e6ce7d6e74
17 changed files with 452 additions and 631 deletions

View File

@ -4,11 +4,11 @@ import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import {
createMockGateways,
MockPermissionGateway,
MockPluginGateway,
} from "@/adapters/mock";
import type { ProjectPluginManifest } from "@/domain";
import { AgentPluginCapabilities } from "./AgentPluginCapabilities";
import { SkillsPanel } from "@/features/skills";
import { McpPanel } from "@/features/permissions";
import { ProjectPluginsPanel } from "./ProjectPluginsPanel";
function manifest(): ProjectPluginManifest {
@ -87,11 +87,11 @@ describe("project plugin settings #282", () => {
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "Capacités des agents" }));
fireEvent.click(await screen.findByRole("button", { name: "Gérer les skills" }));
expect(onAssignAgents).toHaveBeenCalledOnce();
});
it("assigns plugin skills and MCP tools to an agent and keeps stale references removable", async () => {
it("assigns plugin skills from the unified Skills panel and keeps tombstones removable", async () => {
const gateways = gatewaysWithPlugin();
const agent = await gateways.agent.createAgent("project-1", {
name: "Dev",
@ -108,91 +108,48 @@ describe("project plugin settings #282", () => {
});
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active />
<SkillsPanel projectId="project-1" />
</DIProvider>,
);
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/).closest("div")?.textContent).toContain(
"Indisponible",
);
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(/legacy_ping/)).toBeTruthy();
expect(screen.getByRole("status").textContent).toContain("Relance requise");
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
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 () => {
it("lists catalogued plugin tools in MCP and removes free-form assignment", 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.plugin._seedProjectPluginSettings("project-1", {
version: 1,
plugins: [{ pluginId: "dev.acme.unity", enabled: true }],
agents: [{ agentId: agent.id, skills: [], tools: [] }],
});
(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 />
<McpPanel projectId="project-1" />
</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");
fireEvent.click(await screen.findByRole("button", { name: /Main/ }));
const staticTool = await screen.findByRole("checkbox", { name: /unity_build/ });
fireEvent.click(staticTool);
await waitFor(() => expect((staticTool as HTMLInputElement).checked).toBe(true));
expect(screen.queryByPlaceholderText("Nom exact du tool")).toBeNull();
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
expect(settings.agents.find((entry) => entry.agentId === agent.id)?.tools).toContainEqual({
pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build",
});
});
it("keeps disabled and missing plugin assignments removable", async () => {
@ -209,6 +166,7 @@ describe("project plugin settings #282", () => {
skills: [{ pluginId: "missing.plugin", skillId: "gone-skill" }],
tools: [
{ pluginId: "dev.acme.unity", serverId: "unity-tools", toolName: "unity_build" },
{ pluginId: "dev.acme.unity", serverId: "legacy-tools", toolName: "legacy_ping" },
{ pluginId: "missing.plugin", serverId: "gone-server", toolName: "gone_tool" },
],
}],
@ -216,16 +174,28 @@ describe("project plugin settings #282", () => {
render(
<DIProvider gateways={gateways}>
<AgentPluginCapabilities projectId="project-1" agent={agent} active={false} />
<McpPanel projectId="project-1" />
</DIProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: /QA/ }));
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 runtimeOnly = screen.getByText(/legacy_ping/).closest("div")!;
expect(runtimeOnly.textContent).toContain("Indisponible");
expect(screen.queryByRole("checkbox", { name: /legacy_ping/ })).toBeNull();
fireEvent.click(runtimeOnly.querySelector("button")!);
await waitFor(async () => {
const settings = (await gateways.plugin.listProjectSettings("project-1")).settings;
expect(settings.agents[0]?.tools).not.toContainEqual(
expect.objectContaining({ toolName: "legacy_ping" }),
);
});
const stale = screen.getByText(/gone_tool/).closest("div")!;
fireEvent.click(stale.querySelector("button")!);
await waitFor(async () => {