From b0be5f04e44816574a7f47da70ee145888cdfd8d Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 6 Aug 2026 15:47:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20dispatch=20du=20callback=20plugin?= =?UTF-8?q?=20slash-command=20vers=20le=20runtime=20registry=20=E2=80=94?= =?UTF-8?q?=20#166=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boucle le gap end-to-end de #165 : à l'exécution d'une commande slash plugin depuis le composer custom, l'UI traite l'effet pluginCallback et le dispatche au PluginRuntimeRegistry pour que la callback du plugin s'exécute réellement. - runtime/registry: runCommandStrict (échec explicite si handler absent) + propagation de la valeur de retour ; élargit les types de retour (unknown). - runtime/loader: chargement de contributes.slashCommands au registre + propagation du retour du handler. - features/plugins/usePluginMenus: runCommand retourne unknown. - features/agents/CustomAgentChatView: handler de l'effet pluginCallback -> pluginRuntime.registry.runCommandStrict, avec retour utilisateur en cas d'échec (commande indisponible / callback absente). Co-Authored-By: Claude Opus 4.8 --- .../agents/CustomAgentChatView.test.tsx | 195 ++++++++++++++++++ .../features/agents/CustomAgentChatView.tsx | 62 ++++++ .../src/features/plugins/usePluginMenus.ts | 2 +- frontend/src/plugins/runtime/loader.test.ts | 49 ++++- frontend/src/plugins/runtime/loader.ts | 8 +- frontend/src/plugins/runtime/registry.ts | 35 +++- 6 files changed, 341 insertions(+), 10 deletions(-) diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index dc61888..97a4ba7 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -4,6 +4,13 @@ import { describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; import type { AgentProfile } from "@/domain"; +import { PluginRuntimeProvider } from "@/features/plugins"; +import { + PluginCommandRegistry, + PluginLayoutRegistry, + PluginMenuRegistry, + PluginRuntimeRegistry, +} from "@/plugins/runtime"; import type { Gateways } from "@/ports"; import { CustomAgentChatView } from "./CustomAgentChatView"; @@ -18,6 +25,27 @@ const profile: AgentProfile = { structuredAdapter: "codex", }; +function addPluginCommand( + registry: PluginRuntimeRegistry, + pluginId: string, + commandId: string, + handler: (...args: unknown[]) => unknown | Promise, +) { + const commands = new PluginCommandRegistry(pluginId, new Set([commandId])); + commands.register(commandId, handler); + registry.add({ + pluginId, + displayName: "Acme Plugin", + contributes: { + commands: [{ id: commandId, title: "Explain", shortDescription: "Explain selection" }], + }, + commands, + layouts: new PluginLayoutRegistry(pluginId, new Set()), + menu: new PluginMenuRegistry(pluginId), + dispose: async () => {}, + }); +} + describe("CustomAgentChatView", () => { it("cancels only the current turn and keeps the structured session alive", async () => { const agent = { @@ -522,6 +550,173 @@ describe("CustomAgentChatView", () => { expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull(); }); + it("dispatches plugin slash-command callback effects through the plugin runtime", async () => { + const commandHandler = vi.fn(async () => {}); + const registry = new PluginRuntimeRegistry(); + addPluginCommand( + registry, + "dev.acme.explain", + "dev.acme.explain.run", + commandHandler, + ); + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + executeSlashCommand: vi.fn(async (_name: string, options) => ({ + command: { + name: "/explain", + shortDescription: "Explain selection", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: { plugin: { pluginId: "dev.acme.explain" } }, + plugin: { + pluginId: "dev.acme.explain", + commandId: "dev.acme.explain.run", + }, + }, + effect: { + kind: "pluginCallback" as const, + pluginId: "dev.acme.explain", + commandId: "dev.acme.explain.run", + sessionId: options.sessionId, + arguments: options.arguments ?? [], + }, + })), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; + fireEvent.change(composer, { target: { value: "/explain focus this" } }); + fireEvent.keyDown(composer, { key: "Enter" }); + + await waitFor(() => + expect(agent.executeSlashCommand).toHaveBeenCalledWith("/explain", { + sessionId: "chat-session-1", + arguments: ["focus this"], + }), + ); + await waitFor(() => expect(commandHandler).toHaveBeenCalledWith("focus this")); + expect(agent.sendAgentChat).not.toHaveBeenCalled(); + expect( + await screen.findByText("Commande plugin /explain exécutée."), + ).toBeTruthy(); + expect(composer.value).toBe(""); + }); + + it("shows feedback when a plugin slash-command callback cannot be dispatched", async () => { + const registry = new PluginRuntimeRegistry(); + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + executeSlashCommand: vi.fn(async () => ({ + command: { + name: "/explain", + shortDescription: "Explain selection", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: { plugin: { pluginId: "dev.acme.missing" } }, + plugin: { + pluginId: "dev.acme.missing", + commandId: "dev.acme.missing.run", + }, + }, + effect: { + kind: "pluginCallback" as const, + pluginId: "dev.acme.missing", + commandId: "dev.acme.missing.run", + arguments: [], + }, + })), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + + + , + ); + + await waitFor(() => + expect(agent.reattachAgentChat).toHaveBeenCalledWith( + "chat-session-1", + expect.any(Function), + ), + ); + + const composer = screen.getByLabelText(/message CLI custom/); + fireEvent.change(composer, { target: { value: "/explain" } }); + fireEvent.keyDown(composer, { key: "Enter" }); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Commande /explain échouée"); + expect(alert.textContent).toContain('plugin "dev.acme.missing" is not loaded'); + expect(screen.getAllByText(/Commande \/explain échouée/)).toHaveLength(2); + expect(agent.sendAgentChat).not.toHaveBeenCalled(); + }); + it("executes /profile as a confirmed profile-switch flow that resets the current session", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 9406eb7..c5d7797 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -26,6 +26,7 @@ import type { SlashCommand, } from "@/domain"; import { useGateways } from "@/app/di"; +import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { Button, Spinner, cn } from "@/shared"; import type { ChatAttachmentInput } from "@/ports"; @@ -66,6 +67,11 @@ interface ProfileCommandDialogState { error: string | null; } +interface SlashCommandInvocation { + name: string; + arguments: unknown[]; +} + function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); @@ -274,6 +280,17 @@ function slashCommandPrefix(draft: string): string | null { return draft; } +function slashCommandInvocation(text: string): SlashCommandInvocation | null { + if (!text.startsWith("/")) return null; + const [name, ...argumentParts] = text.split(/\s+/); + if (!name) return null; + const rawArguments = argumentParts.join(" ").trim(); + return { + name, + arguments: rawArguments ? [rawArguments] : [], + }; +} + function isSlashCommandAvailable(command: SlashCommand): boolean { return command.availability.status === "available"; } @@ -328,6 +345,7 @@ export function CustomAgentChatView({ onConversationId, }: CustomAgentChatViewProps) { const { agent, profile: profileGateway, system } = useGateways(); + const pluginRuntime = usePluginRuntime(); const [turns, setTurns] = useState([]); const [currentSession, setCurrentSession] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId); @@ -756,6 +774,45 @@ export function CustomAgentChatView({ } } + async function executeSlashCommand(invocation: SlashCommandInvocation) { + if (!agent.executeSlashCommand) { + setError(`Commande ${invocation.name} indisponible dans ce runtime.`); + return; + } + setDraft(""); + setError(null); + try { + const sid = + currentSession ?? + (await recoverStructuredSession({ + applyScrollback: false, + retryAttachNotFound: true, + })); + const result = await agent.executeSlashCommand(invocation.name, { + sessionId: sid, + arguments: invocation.arguments, + }); + if (result.effect.kind !== "pluginCallback") { + throw new Error( + `La commande ${invocation.name} n'a pas renvoyé d'effet pluginCallback.`, + ); + } + await pluginRuntime.registry.runCommandStrict( + result.effect.pluginId, + result.effect.commandId, + ...result.effect.arguments, + ); + setTurns((prev) => [ + ...prev, + { role: "tool", label: `Commande plugin ${result.command.name} exécutée.` }, + ]); + } catch (e) { + const message = `Commande ${invocation.name} échouée: ${describe(e)}`; + setError(message); + setTurns((prev) => [...prev, { role: "error", text: message }]); + } + } + async function pickAttachment() { const path = await system.pickFile(); if (path) { @@ -804,6 +861,11 @@ export function CustomAgentChatView({ await openProfileCommandFlow(); return; } + const slashInvocation = slashCommandInvocation(text); + if (slashInvocation) { + await executeSlashCommand(slashInvocation); + return; + } const outgoingAttachments = attachments; const attachmentInputs = outgoingAttachments.map((item) => item.input); const attachmentLabels = outgoingAttachments.map((item) => item.label); diff --git a/frontend/src/features/plugins/usePluginMenus.ts b/frontend/src/features/plugins/usePluginMenus.ts index 86b3440..e667d37 100644 --- a/frontend/src/features/plugins/usePluginMenus.ts +++ b/frontend/src/features/plugins/usePluginMenus.ts @@ -23,7 +23,7 @@ export interface UsePluginMenusResult { topLevelMenus: MenuBarMenu[]; /** Resolved+ordered `MenuBarItem`s to append to a native menu's items. */ itemsFor: (targetMenuId: MenuTargetId) => MenuBarItem[]; - runCommand: (pluginId: string, commandId: string) => Promise; + runCommand: (pluginId: string, commandId: string) => Promise; } function describeError(e: unknown): string { diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index ea956a1..90a2382 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -98,7 +98,7 @@ describe("loadPlugins", () => { ); expect(failures).toEqual([]); expect((globalThis as Record).__registerError).toMatch( - /not declared by any menu item/, + /not declared by any command contribution/, ); }); @@ -591,6 +591,13 @@ describe("loadPlugins", () => { command: "hello-plugin", }, ], + slashCommands: [ + { + name: "/hello", + shortDescription: "Run the hello-plugin callback", + command: "hello-plugin", + }, + ], layouts: [ { type: "hello-plugin.hello-world", @@ -613,6 +620,13 @@ describe("loadPlugins", () => { }, ]); expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]); + expect(registry.get("com.example.hello-plugin")?.contributes.slashCommands).toEqual([ + { + name: "/hello", + shortDescription: "Run the hello-plugin callback", + command: "hello-plugin", + }, + ]); await registry.runCommand("com.example.hello-plugin", "hello-plugin"); expect((globalThis as Record).__helloArchiveCommandRan).toBe(true); const Layout = registry.layoutComponent( @@ -623,6 +637,39 @@ describe("loadPlugins", () => { expect((Layout as unknown as () => string)()).toBe("hello-world"); }); + it("allows command callbacks declared only by slashCommands", async () => { + const bundle = dataUrl(` + export function activate(ctx) { + ctx.commands.registerCommand("dev.acme.explain", () => { + globalThis.__slashOnlyCommandRan = true; + }); + } + `); + const { registry, failures } = await loadPlugins( + [ + entry({ + id: "dev.acme.slash", + displayName: "Slash", + bundleUrl: bundle, + contributes: { + slashCommands: [ + { + name: "/explain", + shortDescription: "Explain selection", + command: "dev.acme.explain", + }, + ], + } as unknown as PluginContributionDto, + }), + ], + gateways, + ); + + expect(failures).toEqual([]); + await registry.runCommand("dev.acme.slash", "dev.acme.explain"); + expect((globalThis as Record).__slashOnlyCommandRan).toBe(true); + }); + it("confines a malformed runtime catalog entry and still loads healthy plugins", async () => { const bundle = dataUrl(` export function activate(ctx) { diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index a8ba952..50ec18f 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -151,7 +151,7 @@ function createCommandContext(commands: PluginCommandRegistry): PluginCommandCon register: (commandId, handler) => commands.register(commandId, handler), registerCommand: (commandId, handler) => commands.register(commandId, async (...args) => { - await handler(...args); + return await handler(...args); }), }; } @@ -195,7 +195,10 @@ function safePluginId(entry: unknown): string { function commandIdsFromContributes(contributes: PluginContributionDto): Set { return new Set( - contributes.menuItems.flatMap((item) => { + [ + ...arrayOrEmpty(contributes.menuItems), + ...arrayOrEmpty(contributes.slashCommands), + ].flatMap((item) => { const command = nonEmptyString(objectOrEmpty(item).command); return command ? [command] : []; }), @@ -216,6 +219,7 @@ function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto return { menus: arrayOrEmpty(contributes?.menus), menuItems: arrayOrEmpty(contributes?.menuItems), + slashCommands: arrayOrEmpty(contributes?.slashCommands), layouts: arrayOrEmpty(contributes?.layouts), mcpServers: arrayOrEmpty(contributes?.mcpServers), }; diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts index fe996ac..f51fb92 100644 --- a/frontend/src/plugins/runtime/registry.ts +++ b/frontend/src/plugins/runtime/registry.ts @@ -59,7 +59,7 @@ export interface Disposable { dispose(): void; } -export type PluginCommandHandler = (...args: unknown[]) => void | Promise; +export type PluginCommandHandler = (...args: unknown[]) => unknown | Promise; /** Commands a plugin registers in `activate(ctx)`, dispatched by menu items. */ export class PluginCommandRegistry { @@ -74,7 +74,7 @@ export class PluginCommandRegistry { if (!this.declaredCommandIds.has(commandId)) { throw new Error( `plugin "${this.pluginId}" tried to register command "${commandId}" ` + - "which is not declared by any menu item in its manifest", + "which is not declared by any command contribution in its manifest", ); } this.handlers.set(commandId, handler); @@ -86,11 +86,11 @@ export class PluginCommandRegistry { } /** Runs a registered command; a no-op (never throws) if none is registered. */ - async run(commandId: string, ...args: unknown[]): Promise { + async run(commandId: string, ...args: unknown[]): Promise { const handler = this.handlers.get(commandId); if (!handler) return; try { - await handler(...args); + return await handler(...args); } catch (e) { console.error( `[plugin:${this.pluginId}] command "${commandId}" failed`, @@ -99,6 +99,17 @@ export class PluginCommandRegistry { } } + /** Runs a registered command and surfaces failures to the caller. */ + async runStrict(commandId: string, ...args: unknown[]): Promise { + const handler = this.handlers.get(commandId); + if (!handler) { + throw new Error( + `plugin "${this.pluginId}" has no registered command handler "${commandId}"`, + ); + } + return await handler(...args); + } + has(commandId: string): boolean { return this.handlers.has(commandId); } @@ -256,7 +267,19 @@ export class PluginRuntimeRegistry { ); } - async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise { - await this.loaded.get(pluginId)?.commands.run(commandId, ...args); + async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise { + return await this.loaded.get(pluginId)?.commands.run(commandId, ...args); + } + + async runCommandStrict( + pluginId: string, + commandId: string, + ...args: unknown[] + ): Promise { + const plugin = this.loaded.get(pluginId); + if (!plugin) { + throw new Error(`plugin "${pluginId}" is not loaded`); + } + return await plugin.commands.runStrict(commandId, ...args); } }