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>
)}