From 5609c6a7a87bd647eb6a57a5b5e8f93ba169165e Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 11 Aug 2026 15:46:59 +0200 Subject: [PATCH 1/2] 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); From 06bc8bdf1b3995cd6ca2875155922813b9dad3a5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 11 Aug 2026 15:46:59 +0200 Subject: [PATCH 2/2] chore(tickets): sync miroir #183 (QA verte) + maj carnets #182 --- .ideai/tickets/182/carnet.md | 7 ++++--- .ideai/tickets/182/issue.md | 4 ++-- .ideai/tickets/183/carnet.md | 8 +++++--- .ideai/tickets/183/issue.md | 6 +++--- .ideai/tickets/index.json | 6 +++--- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.ideai/tickets/182/carnet.md b/.ideai/tickets/182/carnet.md index 469aa85e..3d67f5ea 100644 --- a/.ideai/tickets/182/carnet.md +++ b/.ideai/tickets/182/carnet.md @@ -1,10 +1,11 @@ --- issueRef: "#182" -version: 2 +version: 3 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1786455261558 +updatedAt: 1786455395341 --- 2026-08-11 - Ticket ouvert suite au signalement utilisateur. - Cadrage UX: restauration transparente du brouillon au retour sur la cellule/layout; pas de persistance des attachments; TTL court acceptable. -- Cadrage architecture: persistance bornée par projet/layout/node, à trancher entre stockage frontend léger et store backend dédié selon l'implémentation existante. \ No newline at end of file +- Arbitrage architecture validé: store backend JSON, scoped par projet/layout/node; pas de solution frontend-only. +- L'implémentation devra nettoyer le draft à l'envoi et après /clean si la conversation est vidée. \ No newline at end of file diff --git a/.ideai/tickets/182/issue.md b/.ideai/tickets/182/issue.md index 79c0103e..4752901c 100644 --- a/.ideai/tickets/182/issue.md +++ b/.ideai/tickets/182/issue.md @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1786454719252 -updatedAt: 1786455261558 -version: 2 +updatedAt: 1786455395341 +version: 3 --- Quand l'utilisateur tape un message dans le chat puis change de layout ou de projet, le texte saisi doit rester dans le chat quand il revient sur ce layout. \ No newline at end of file diff --git a/.ideai/tickets/183/carnet.md b/.ideai/tickets/183/carnet.md index 4c49b34f..bc6a64f0 100644 --- a/.ideai/tickets/183/carnet.md +++ b/.ideai/tickets/183/carnet.md @@ -1,11 +1,13 @@ --- issueRef: "#183" -version: 2 +version: 4 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1786455261582 +updatedAt: 1786455949201 --- 2026-08-11 - Ticket ouvert suite au signalement utilisateur. - Symptôme confirmé: /clean et /help échouent faute d'effet pluginCallback, alors que /profile fonctionne. - Cadrage UX: /help et /clean sont des commandes natives IdeA; erreur inconnue doit pointer vers /help. -- Cadrage architecture: router explicitement les commandes natives séparément des commandes plugin et préserver /profile comme natif. \ No newline at end of file +- Cadrage architecture: router explicitement les commandes natives séparément des commandes plugin et préserver /profile comme natif. +- Implémentation dev reçue sur fix/183-native-slash-commands-routing: dispatch frontend explicite des effets natifs help/cleanConversation/profileSwitch/pluginCallback, tests frontend ajoutés; garde backend que les commandes natives priment sur les plugins. +- QA 2026-08-11: VERT. Vérifications exécutées avec succès: cargo test -p application slash_command ; npm test -- --run src/features/agents/CustomAgentChatView.test.tsx. \ No newline at end of file diff --git a/.ideai/tickets/183/issue.md b/.ideai/tickets/183/issue.md index 100b1982..e7fb5ef2 100644 --- a/.ideai/tickets/183/issue.md +++ b/.ideai/tickets/183/issue.md @@ -2,7 +2,7 @@ id: "a48be9fa-1b0c-4109-ba1d-c5ad0919d113" number: 183 title: "Réparer les commandes /clean et /help comme commandes natives IdeA" -status: "inProgress" +status: "qa" priority: "critical" sprint: null links: [] @@ -11,7 +11,7 @@ attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1786454719274 -updatedAt: 1786455261582 -version: 2 +updatedAt: 1786455949201 +version: 4 --- Les commandes /clean et /help échouent avec l'erreur: "La commande /clean n'a pas renvoyé d'effet pluginCallback." L'utilisateur précise que /clean, /help et /profile sont des commandes fournies nativement par IdeA. /profile fonctionne, /clean et /help non. Il faut lever la confusion entre commandes natives IdeA et futures commandes extensibles par plugins. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 0687889e..965128d9 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -2449,13 +2449,13 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1786455261558 + "updatedAt": 1786455395341 }, { "issueRef": "#183", "path": "183", "title": "Réparer les commandes /clean et /help comme commandes natives IdeA", - "status": "inProgress", + "status": "qa", "priority": "critical", "sprint": null, "assignedAgentIds": [], @@ -2463,7 +2463,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1786455261582 + "updatedAt": 1786455949201 } ] } \ No newline at end of file