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