feat(cli): dispatch du callback plugin slash-command vers le runtime registry — #166 (QA verte)

Boucle le gap end-to-end de #165 : à l'exécution d'une commande slash plugin
depuis le composer custom, l'UI traite l'effet pluginCallback et le dispatche
au PluginRuntimeRegistry pour que la callback du plugin s'exécute réellement.

- runtime/registry: runCommandStrict (échec explicite si handler absent) +
  propagation de la valeur de retour ; élargit les types de retour (unknown).
- runtime/loader: chargement de contributes.slashCommands au registre +
  propagation du retour du handler.
- features/plugins/usePluginMenus: runCommand retourne unknown.
- features/agents/CustomAgentChatView: handler de l'effet pluginCallback ->
  pluginRuntime.registry.runCommandStrict, avec retour utilisateur en cas
  d'échec (commande indisponible / callback absente).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:47:04 +02:00
parent fbaca9d5cc
commit b0be5f04e4
6 changed files with 341 additions and 10 deletions

View File

@ -4,6 +4,13 @@ import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import type { AgentProfile } from "@/domain";
import { PluginRuntimeProvider } from "@/features/plugins";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
} from "@/plugins/runtime";
import type { Gateways } from "@/ports";
import { CustomAgentChatView } from "./CustomAgentChatView";
@ -18,6 +25,27 @@ const profile: AgentProfile = {
structuredAdapter: "codex",
};
function addPluginCommand(
registry: PluginRuntimeRegistry,
pluginId: string,
commandId: string,
handler: (...args: unknown[]) => unknown | Promise<unknown>,
) {
const commands = new PluginCommandRegistry(pluginId, new Set([commandId]));
commands.register(commandId, handler);
registry.add({
pluginId,
displayName: "Acme Plugin",
contributes: {
commands: [{ id: commandId, title: "Explain", shortDescription: "Explain selection" }],
},
commands,
layouts: new PluginLayoutRegistry(pluginId, new Set()),
menu: new PluginMenuRegistry(pluginId),
dispose: async () => {},
});
}
describe("CustomAgentChatView", () => {
it("cancels only the current turn and keeps the structured session alive", async () => {
const agent = {
@ -522,6 +550,173 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
});
it("dispatches plugin slash-command callback effects through the plugin runtime", async () => {
const commandHandler = vi.fn(async () => {});
const registry = new PluginRuntimeRegistry();
addPluginCommand(
registry,
"dev.acme.explain",
"dev.acme.explain.run",
commandHandler,
);
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async (_name: string, options) => ({
command: {
name: "/explain",
shortDescription: "Explain selection",
requiresConfirmation: false,
availability: { status: "available" as const },
source: { plugin: { pluginId: "dev.acme.explain" } },
plugin: {
pluginId: "dev.acme.explain",
commandId: "dev.acme.explain.run",
},
},
effect: {
kind: "pluginCallback" as const,
pluginId: "dev.acme.explain",
commandId: "dev.acme.explain.run",
sessionId: options.sessionId,
arguments: options.arguments ?? [],
},
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<PluginRuntimeProvider
value={{ registry, failures: [], pending: [], loading: false }}
>
<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()}
/>
</PluginRuntimeProvider>
</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: "/explain focus this" } });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/explain", {
sessionId: "chat-session-1",
arguments: ["focus this"],
}),
);
await waitFor(() => expect(commandHandler).toHaveBeenCalledWith("focus this"));
expect(agent.sendAgentChat).not.toHaveBeenCalled();
expect(
await screen.findByText("Commande plugin /explain exécutée."),
).toBeTruthy();
expect(composer.value).toBe("");
});
it("shows feedback when a plugin slash-command callback cannot be dispatched", async () => {
const registry = new PluginRuntimeRegistry();
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/explain",
shortDescription: "Explain selection",
requiresConfirmation: false,
availability: { status: "available" as const },
source: { plugin: { pluginId: "dev.acme.missing" } },
plugin: {
pluginId: "dev.acme.missing",
commandId: "dev.acme.missing.run",
},
},
effect: {
kind: "pluginCallback" as const,
pluginId: "dev.acme.missing",
commandId: "dev.acme.missing.run",
arguments: [],
},
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<PluginRuntimeProvider
value={{ registry, failures: [], pending: [], loading: false }}
>
<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()}
/>
</PluginRuntimeProvider>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/);
fireEvent.change(composer, { target: { value: "/explain" } });
fireEvent.keyDown(composer, { key: "Enter" });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("Commande /explain échouée");
expect(alert.textContent).toContain('plugin "dev.acme.missing" is not loaded');
expect(screen.getAllByText(/Commande \/explain échouée/)).toHaveLength(2);
expect(agent.sendAgentChat).not.toHaveBeenCalled();
});
it("executes /profile as a confirmed profile-switch flow that resets the current session", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({