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