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:
@ -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) {
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user