From 5609c6a7a87bd647eb6a57a5b5e8f93ba169165e Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 11 Aug 2026 15:46:59 +0200 Subject: [PATCH] fix(slash-commands): route /clean et /help en commandes natives IdeA avant dispatch plugin (#183) --- crates/application/src/slash_command.rs | 31 ++++ .../agents/CustomAgentChatView.test.tsx | 163 ++++++++++++++++++ .../features/agents/CustomAgentChatView.tsx | 105 ++++++----- 3 files changed, 246 insertions(+), 53 deletions(-) diff --git a/crates/application/src/slash_command.rs b/crates/application/src/slash_command.rs index 41f1c909..90e5cd5d 100644 --- a/crates/application/src/slash_command.rs +++ b/crates/application/src/slash_command.rs @@ -386,6 +386,37 @@ mod tests { assert_eq!(arguments, vec![serde_json::json!({ "selection": "abc" })]); } + #[test] + fn native_command_names_are_routed_before_plugin_commands() { + let colliding_plugin = plugin_slash_command( + "dev.acme.plugin", + "dev.acme.plugin.clean", + "/clean", + "Plugin clean", + false, + SlashCommandAvailability::Available, + ) + .unwrap(); + let sid = session(); + + let out = ExecuteSlashCommand::new(SlashCommandRegistry::new()) + .execute_with_plugins( + ExecuteSlashCommandInput { + name: "/clean".to_owned(), + session_id: Some(sid), + arguments: Vec::new(), + }, + vec![colliding_plugin], + ) + .unwrap(); + + assert_eq!(out.command.source, SlashCommandSource::Native); + assert_eq!( + out.effect, + SlashCommandEffect::CleanConversation { session_id: sid } + ); + } + #[test] fn runtime_catalog_plugin_slash_commands_feed_unified_registry() { let catalog = PluginRuntimeCatalog { diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 8b9ba2ee..3061c125 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -972,6 +972,169 @@ describe("CustomAgentChatView", () => { expect(composer.value).toBe(""); }); + it("executes /clean as a native conversation clear effect", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [{ kind: "userPrompt", text: "old conversation" }], + })), + sendAgentChat: vi.fn(async () => {}), + executeSlashCommand: vi.fn(async () => ({ + command: { + name: "/clean", + shortDescription: "Nettoyer la conversation courante", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: "native" as const, + native: "clean" as const, + }, + effect: { + kind: "cleanConversation" as const, + sessionId: "chat-session-1", + clearedChunks: 1, + }, + })), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + expect(await screen.findByText("old conversation")).toBeTruthy(); + + const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; + fireEvent.change(composer, { target: { value: "/clean" } }); + fireEvent.keyDown(composer, { key: "Enter" }); + + await waitFor(() => + expect(agent.executeSlashCommand).toHaveBeenCalledWith("/clean", { + sessionId: "chat-session-1", + }), + ); + await waitFor(() => expect(screen.queryByText("old conversation")).toBeNull()); + expect(screen.queryByRole("alert")).toBeNull(); + expect(agent.sendAgentChat).not.toHaveBeenCalled(); + expect(composer.value).toBe(""); + }); + + it("executes /help as a native help effect", async () => { + const agent = { + launchAgentChat: vi.fn(), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + executeSlashCommand: vi.fn(async () => ({ + command: { + name: "/help", + shortDescription: "Afficher les commandes disponibles", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: "native" as const, + native: "help" as const, + }, + effect: { + kind: "help" as const, + commands: [ + { + name: "/help", + shortDescription: "Afficher les commandes disponibles", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: "native" as const, + native: "help" as const, + }, + { + name: "/clean", + shortDescription: "Nettoyer la conversation courante", + requiresConfirmation: false, + availability: { status: "available" as const }, + source: "native" as const, + native: "clean" as const, + }, + { + name: "/profile", + shortDescription: "Changer le profil de l'agent", + requiresConfirmation: true, + availability: { status: "available" as const }, + source: "native" as const, + native: "profile" as const, + }, + ], + }, + })), + 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: "/help" } }); + fireEvent.keyDown(composer, { key: "Enter" }); + + await waitFor(() => + expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", { + sessionId: "chat-session-1", + }), + ); + expect( + await screen.findByText(/\/clean - Nettoyer la conversation courante/), + ).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(agent.sendAgentChat).not.toHaveBeenCalled(); + expect(composer.value).toBe(""); + }); + it("shows feedback when a plugin slash-command callback cannot be dispatched", async () => { const registry = new PluginRuntimeRegistry(); const agent = { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 797abc13..ec51113e 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -401,6 +401,12 @@ function isSlashCommandAvailable(command: SlashCommand): boolean { return command.availability.status === "available"; } +function formatSlashCommandHelp(commands: SlashCommand[]): string { + return commands + .map((command) => `${command.name} - ${command.shortDescription}`) + .join("\n"); +} + function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] { if (!isReplyRecord(raw)) { return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; @@ -792,42 +798,23 @@ export function CustomAgentChatView({ [supported, draft, attachments.length, busy, opening], ); - async function openProfileCommandFlow() { - if (!agent.executeSlashCommand) { - setError("Commande /profile indisponible dans ce runtime."); - return; - } - setError(null); - try { - const sid = - currentSession ?? - (await recoverStructuredSession({ - applyScrollback: false, - retryAttachNotFound: true, - })); - const result = await agent.executeSlashCommand("/profile", { sessionId: sid }); - if (result.effect.kind !== "profileSwitch") { - throw new Error("La commande /profile n'a pas renvoyé le flow profil."); - } - const availableProfiles = (await profileGateway.listProfiles()).filter( - (candidate) => Boolean(candidate.structuredAdapter), - ); - if (availableProfiles.length === 0) { - throw new Error("Aucun profil compatible avec la CLI custom n'est configuré."); - } - setDraft(""); - setProfileDialog({ - profiles: availableProfiles, - selectedProfileId: - availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ?? - availableProfiles[0].id, - confirmedReset: false, - busy: false, - error: null, - }); - } catch (e) { - setError(describe(e)); + async function openProfileDialog() { + const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) => + Boolean(candidate.structuredAdapter), + ); + if (availableProfiles.length === 0) { + throw new Error("Aucun profil compatible avec la CLI custom n'est configuré."); } + setDraft(""); + setProfileDialog({ + profiles: availableProfiles, + selectedProfileId: + availableProfiles.find((candidate) => candidate.id !== profile.id)?.id ?? + availableProfiles[0].id, + confirmedReset: false, + busy: false, + error: null, + }); } async function confirmProfileCommandChange() { @@ -904,22 +891,38 @@ export function CustomAgentChatView({ })); const result = await agent.executeSlashCommand(invocation.name, { sessionId: sid, - arguments: invocation.arguments, + ...(invocation.arguments.length > 0 + ? { arguments: invocation.arguments } + : {}), }); - if (result.effect.kind !== "pluginCallback") { - throw new Error( - `La commande ${invocation.name} n'a pas renvoyé d'effet pluginCallback.`, - ); + if (result.effect.kind === "help") { + const helpText = formatSlashCommandHelp(result.effect.commands); + setTurns((prev) => [ + ...prev, + { role: "agent", text: helpText }, + ]); + return; + } + if (result.effect.kind === "cleanConversation") { + setTurns([]); + return; + } + if (result.effect.kind === "profileSwitch") { + await openProfileDialog(); + return; + } + if (result.effect.kind === "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.` }, + ]); + return; } - 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); @@ -971,10 +974,6 @@ export function CustomAgentChatView({ async function send() { const text = draft.trim(); if (!canSend || !agent.sendAgentChat) return; - if (text === "/profile") { - await openProfileCommandFlow(); - return; - } const slashInvocation = slashCommandInvocation(text); if (slashInvocation) { await executeSlashCommand(slashInvocation);