merge(slash-commands): route /clean et /help en commandes natives IdeA (#183, QA verte)

This commit is contained in:
2026-08-11 15:46:59 +02:00
8 changed files with 263 additions and 67 deletions

View File

@ -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.
- 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.

View File

@ -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.

View File

@ -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.
- 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.

View File

@ -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.

View File

@ -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
}
]
}

View File

@ -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 {

View File

@ -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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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 = {

View File

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