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

@ -98,7 +98,7 @@ describe("loadPlugins", () => {
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__registerError).toMatch(
/not declared by any menu item/,
/not declared by any command contribution/,
);
});
@ -591,6 +591,13 @@ describe("loadPlugins", () => {
command: "hello-plugin",
},
],
slashCommands: [
{
name: "/hello",
shortDescription: "Run the hello-plugin callback",
command: "hello-plugin",
},
],
layouts: [
{
type: "hello-plugin.hello-world",
@ -613,6 +620,13 @@ describe("loadPlugins", () => {
},
]);
expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]);
expect(registry.get("com.example.hello-plugin")?.contributes.slashCommands).toEqual([
{
name: "/hello",
shortDescription: "Run the hello-plugin callback",
command: "hello-plugin",
},
]);
await registry.runCommand("com.example.hello-plugin", "hello-plugin");
expect((globalThis as Record<string, unknown>).__helloArchiveCommandRan).toBe(true);
const Layout = registry.layoutComponent(
@ -623,6 +637,39 @@ describe("loadPlugins", () => {
expect((Layout as unknown as () => string)()).toBe("hello-world");
});
it("allows command callbacks declared only by slashCommands", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.commands.registerCommand("dev.acme.explain", () => {
globalThis.__slashOnlyCommandRan = true;
});
}
`);
const { registry, failures } = await loadPlugins(
[
entry({
id: "dev.acme.slash",
displayName: "Slash",
bundleUrl: bundle,
contributes: {
slashCommands: [
{
name: "/explain",
shortDescription: "Explain selection",
command: "dev.acme.explain",
},
],
} as unknown as PluginContributionDto,
}),
],
gateways,
);
expect(failures).toEqual([]);
await registry.runCommand("dev.acme.slash", "dev.acme.explain");
expect((globalThis as Record<string, unknown>).__slashOnlyCommandRan).toBe(true);
});
it("confines a malformed runtime catalog entry and still loads healthy plugins", async () => {
const bundle = dataUrl(`
export function activate(ctx) {

View File

@ -151,7 +151,7 @@ function createCommandContext(commands: PluginCommandRegistry): PluginCommandCon
register: (commandId, handler) => commands.register(commandId, handler),
registerCommand: (commandId, handler) =>
commands.register(commandId, async (...args) => {
await handler(...args);
return await handler(...args);
}),
};
}
@ -195,7 +195,10 @@ function safePluginId(entry: unknown): string {
function commandIdsFromContributes(contributes: PluginContributionDto): Set<string> {
return new Set<string>(
contributes.menuItems.flatMap<string>((item) => {
[
...arrayOrEmpty(contributes.menuItems),
...arrayOrEmpty(contributes.slashCommands),
].flatMap<string>((item) => {
const command = nonEmptyString(objectOrEmpty(item).command);
return command ? [command] : [];
}),
@ -216,6 +219,7 @@ function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto
return {
menus: arrayOrEmpty(contributes?.menus),
menuItems: arrayOrEmpty(contributes?.menuItems),
slashCommands: arrayOrEmpty(contributes?.slashCommands),
layouts: arrayOrEmpty(contributes?.layouts),
mcpServers: arrayOrEmpty(contributes?.mcpServers),
};

View File

@ -59,7 +59,7 @@ export interface Disposable {
dispose(): void;
}
export type PluginCommandHandler = (...args: unknown[]) => void | Promise<void>;
export type PluginCommandHandler = (...args: unknown[]) => unknown | Promise<unknown>;
/** Commands a plugin registers in `activate(ctx)`, dispatched by menu items. */
export class PluginCommandRegistry {
@ -74,7 +74,7 @@ export class PluginCommandRegistry {
if (!this.declaredCommandIds.has(commandId)) {
throw new Error(
`plugin "${this.pluginId}" tried to register command "${commandId}" ` +
"which is not declared by any menu item in its manifest",
"which is not declared by any command contribution in its manifest",
);
}
this.handlers.set(commandId, handler);
@ -86,11 +86,11 @@ export class PluginCommandRegistry {
}
/** Runs a registered command; a no-op (never throws) if none is registered. */
async run(commandId: string, ...args: unknown[]): Promise<void> {
async run(commandId: string, ...args: unknown[]): Promise<unknown> {
const handler = this.handlers.get(commandId);
if (!handler) return;
try {
await handler(...args);
return await handler(...args);
} catch (e) {
console.error(
`[plugin:${this.pluginId}] command "${commandId}" failed`,
@ -99,6 +99,17 @@ export class PluginCommandRegistry {
}
}
/** Runs a registered command and surfaces failures to the caller. */
async runStrict(commandId: string, ...args: unknown[]): Promise<unknown> {
const handler = this.handlers.get(commandId);
if (!handler) {
throw new Error(
`plugin "${this.pluginId}" has no registered command handler "${commandId}"`,
);
}
return await handler(...args);
}
has(commandId: string): boolean {
return this.handlers.has(commandId);
}
@ -256,7 +267,19 @@ export class PluginRuntimeRegistry {
);
}
async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise<void> {
await this.loaded.get(pluginId)?.commands.run(commandId, ...args);
async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise<unknown> {
return await this.loaded.get(pluginId)?.commands.run(commandId, ...args);
}
async runCommandStrict(
pluginId: string,
commandId: string,
...args: unknown[]
): Promise<unknown> {
const plugin = this.loaded.get(pluginId);
if (!plugin) {
throw new Error(`plugin "${pluginId}" is not loaded`);
}
return await plugin.commands.runStrict(commandId, ...args);
}
}