fix(slash-commands): route /clean et /help en commandes natives IdeA avant dispatch plugin (#183)
This commit is contained in:
@ -386,6 +386,37 @@ mod tests {
|
|||||||
assert_eq!(arguments, vec![serde_json::json!({ "selection": "abc" })]);
|
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]
|
#[test]
|
||||||
fn runtime_catalog_plugin_slash_commands_feed_unified_registry() {
|
fn runtime_catalog_plugin_slash_commands_feed_unified_registry() {
|
||||||
let catalog = PluginRuntimeCatalog {
|
let catalog = PluginRuntimeCatalog {
|
||||||
|
|||||||
@ -972,6 +972,169 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(composer.value).toBe("");
|
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 () => {
|
it("shows feedback when a plugin slash-command callback cannot be dispatched", async () => {
|
||||||
const registry = new PluginRuntimeRegistry();
|
const registry = new PluginRuntimeRegistry();
|
||||||
const agent = {
|
const agent = {
|
||||||
|
|||||||
@ -401,6 +401,12 @@ function isSlashCommandAvailable(command: SlashCommand): boolean {
|
|||||||
return command.availability.status === "available";
|
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[] {
|
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
||||||
if (!isReplyRecord(raw)) {
|
if (!isReplyRecord(raw)) {
|
||||||
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
||||||
@ -792,42 +798,23 @@ export function CustomAgentChatView({
|
|||||||
[supported, draft, attachments.length, busy, opening],
|
[supported, draft, attachments.length, busy, opening],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function openProfileCommandFlow() {
|
async function openProfileDialog() {
|
||||||
if (!agent.executeSlashCommand) {
|
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
||||||
setError("Commande /profile indisponible dans ce runtime.");
|
Boolean(candidate.structuredAdapter),
|
||||||
return;
|
);
|
||||||
}
|
if (availableProfiles.length === 0) {
|
||||||
setError(null);
|
throw new Error("Aucun profil compatible avec la CLI custom n'est configuré.");
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
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() {
|
async function confirmProfileCommandChange() {
|
||||||
@ -904,22 +891,38 @@ export function CustomAgentChatView({
|
|||||||
}));
|
}));
|
||||||
const result = await agent.executeSlashCommand(invocation.name, {
|
const result = await agent.executeSlashCommand(invocation.name, {
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
arguments: invocation.arguments,
|
...(invocation.arguments.length > 0
|
||||||
|
? { arguments: invocation.arguments }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
if (result.effect.kind !== "pluginCallback") {
|
if (result.effect.kind === "help") {
|
||||||
throw new Error(
|
const helpText = formatSlashCommandHelp(result.effect.commands);
|
||||||
`La commande ${invocation.name} n'a pas renvoyé d'effet pluginCallback.`,
|
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) {
|
} catch (e) {
|
||||||
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
||||||
setError(message);
|
setError(message);
|
||||||
@ -971,10 +974,6 @@ export function CustomAgentChatView({
|
|||||||
async function send() {
|
async function send() {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!canSend || !agent.sendAgentChat) return;
|
if (!canSend || !agent.sendAgentChat) return;
|
||||||
if (text === "/profile") {
|
|
||||||
await openProfileCommandFlow();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const slashInvocation = slashCommandInvocation(text);
|
const slashInvocation = slashCommandInvocation(text);
|
||||||
if (slashInvocation) {
|
if (slashInvocation) {
|
||||||
await executeSlashCommand(slashInvocation);
|
await executeSlashCommand(slashInvocation);
|
||||||
|
|||||||
Reference in New Issue
Block a user