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:
@ -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 () => ({
|
||||
|
||||
Reference in New Issue
Block a user