diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 3feedf8ccc6..775dd52ebf6 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -72,18 +72,19 @@ const mockChatCommands: ChatCommandDefinition[] = [ ]; const mockPluginSpecs = [{ name: "tts", description: "Text to speech", acceptsArgs: false }]; +type RuntimeCommandRegistration = { + pluginId: string; + command: { + name: string; + description: string; + acceptsArgs?: boolean; + nativeNames?: Record; + channels?: string[]; + }; +}; const runtimeMocks = vi.hoisted(() => ({ gatewayRegistry: null as null | { - commands: Array<{ - pluginId: string; - command: { - name: string; - description: string; - acceptsArgs?: boolean; - nativeNames?: Record; - channels?: string[]; - }; - }>; + commands: RuntimeCommandRegistration[]; }, })); @@ -177,46 +178,10 @@ vi.mock("../../agents/agent-scope.js", () => ({ })); vi.mock("../../channels/plugins/index.js", () => ({ getLoadedChannelPlugin: vi.fn((provider: string) => { - if (provider === "discord") { - return { - commands: { - resolveNativeCommandName: ({ - commandKey, - defaultName, - }: { - commandKey: string; - defaultName: string; - }) => { - if (commandKey === "model") { - return "set_model"; - } - return defaultName; - }, - }, - }; - } - return undefined; + return provider === "discord" ? createDiscordChannelPlugin() : undefined; }), getChannelPlugin: vi.fn((provider: string) => { - if (provider === "discord") { - return { - commands: { - resolveNativeCommandName: ({ - commandKey, - defaultName, - }: { - commandKey: string; - defaultName: string; - }) => { - if (commandKey === "model") { - return "set_model"; - } - return defaultName; - }, - }, - }; - } - return undefined; + return provider === "discord" ? createDiscordChannelPlugin() : undefined; }), })); @@ -231,6 +196,20 @@ import { } from "../../../packages/gateway-protocol/src/schema.js"; import { commandsHandlers, buildCommandsListResult } from "./commands.js"; +function createDiscordChannelPlugin() { + return { + commands: { + resolveNativeCommandName: ({ + commandKey, + defaultName, + }: { + commandKey: string; + defaultName: string; + }) => (commandKey === "model" ? "set_model" : defaultName), + }, + }; +} + function callHandler(params: Record = {}) { let result: { ok: boolean; payload?: unknown; error?: unknown } | undefined; const respond = (ok: boolean, payload?: unknown, error?: unknown) => { @@ -250,6 +229,20 @@ function callHandler(params: Record = {}) { return result; } +type ListedCommand = Record & { + name: string; + source: string; + scope?: string; + textAliases?: string[]; + nativeName?: string; + args?: Array>; +}; + +function listCommands(params: Record = {}): ListedCommand[] { + const { payload } = callHandler(params); + return (payload as { commands: ListedCommand[] }).commands; +} + function requireCommand(commands: T[], name: string): T { const command = commands.find((entry) => entry.name === name); if (!command) { @@ -268,6 +261,36 @@ function collectBuiltinNames(commands: readonly { name: string; source: string } return names; } +function pluginCommand(params: Record = {}): ListedCommand | undefined { + return listCommands(params).find((command) => command.source === "plugin"); +} + +function setGatewayRegistry(commands: RuntimeCommandRegistration[]): void { + runtimeMocks.gatewayRegistry = { commands }; +} + +function providerFilteredPluginRegistrations(params: { nativeName?: string } = {}) { + return [ + { + pluginId: "android-only", + command: { + name: "android_only", + description: "Android-only command", + channels: ["android"], + }, + }, + { + pluginId: "phone-control", + command: { + name: "phone", + description: "Control paired phones", + ...(params.nativeName ? { nativeNames: { discord: params.nativeName } } : {}), + channels: ["discord"], + }, + }, + ]; +} + describe("commands.list handler", () => { beforeEach(() => { runtimeMocks.gatewayRegistry = null; @@ -283,12 +306,7 @@ describe("commands.list handler", () => { }); it("maps native commands with category, scope, and args", () => { - const { payload } = callHandler(); - const { commands } = payload as { - commands: Array< - Record & { name: string; args?: Array> } - >; - }; + const commands = listCommands(); const model = requireCommand(commands, "model"); expect(model.name).toBe("model"); expect(model.nativeName).toBe("model"); @@ -307,8 +325,7 @@ describe("commands.list handler", () => { }); it("exposes per-command scope", () => { - const { payload } = callHandler(); - const { commands } = payload as { commands: Array<{ name: string; scope: string }> }; + const commands = listCommands(); expect(requireCommand(commands, "model").scope).toBe("both"); expect(requireCommand(commands, "commands").scope).toBe("text"); expect(requireCommand(commands, "debug_prompt").scope).toBe("native"); @@ -316,12 +333,7 @@ describe("commands.list handler", () => { }); it("skips args when acceptsArgs is false", () => { - const { payload } = callHandler(); - const { commands } = payload as { commands: Array> }; - const debug = requireCommand( - commands as Array & { name: string }>, - "debug_prompt", - ); + const debug = requireCommand(listCommands(), "debug_prompt"); expect(debug.args).toBeUndefined(); }); @@ -330,12 +342,7 @@ describe("commands.list handler", () => { const saved = debugCmd.acceptsArgs; debugCmd.acceptsArgs = true; try { - const { payload } = callHandler(); - const { commands } = payload as { commands: Array> }; - const debug = requireCommand( - commands as Array & { name: string }>, - "debug_prompt", - ); + const debug = requireCommand(listCommands(), "debug_prompt"); const args = debug.args as Array>; expect(args[0].dynamic).toBe(true); expect(args[0].choices).toBeUndefined(); @@ -345,8 +352,7 @@ describe("commands.list handler", () => { }); it("identifies skill commands by source", () => { - const { payload } = callHandler(); - const { commands } = payload as { commands: Array> }; + const commands = listCommands(); const skill = commands.find((c) => c.name === "code_review"); expect(skill?.source).toBe("skill"); expect(skill?.category).toBe("tools"); @@ -354,16 +360,14 @@ describe("commands.list handler", () => { it("always includes plugin commands regardless of scope filter", () => { for (const scope of ["native", "text", "both"] as const) { - const { payload } = callHandler({ scope }); - const { commands } = payload as { commands: Array<{ name: string; source: string }> }; + const commands = listCommands({ scope }); const sources = commands.map((command) => command.source); expect(sources).toContain("plugin"); } }); it("filters built-in commands by scope=native (excludes text-only)", () => { - const { payload } = callHandler({ scope: "native" }); - const { commands } = payload as { commands: Array<{ name: string; source: string }> }; + const commands = listCommands({ scope: "native" }); const builtinNames = collectBuiltinNames(commands); expect(builtinNames).not.toContain("commands"); expect(builtinNames).toContain("model"); @@ -371,51 +375,38 @@ describe("commands.list handler", () => { }); it("filters built-in commands by scope=text (excludes native-only)", () => { - const { payload } = callHandler({ scope: "text" }); - const { commands } = payload as { commands: Array<{ name: string; source: string }> }; + const commands = listCommands({ scope: "text" }); const builtinNames = collectBuiltinNames(commands); expect(builtinNames).toContain("commands"); expect(builtinNames).not.toContain("debug_prompt"); }); it("resolves provider-specific native names", () => { - const { payload } = callHandler({ provider: "discord" }); - const { commands } = payload as { commands: Array<{ name: string }> }; + const commands = listCommands({ provider: "discord" }); expect(requireCommand(commands, "set_model").name).toBe("set_model"); expect(commands.find((c) => c.name === "model")).toBeUndefined(); }); it("normalizes mixed-case provider", () => { - const { payload } = callHandler({ provider: "Discord" }); - const { commands } = payload as { commands: Array<{ name: string; source: string }> }; + const commands = listCommands({ provider: "Discord" }); expect(requireCommand(commands, "set_model").name).toBe("set_model"); const plugin = commands.find((c) => c.source === "plugin"); expect(plugin?.name).toBe("discord_tts"); }); it("uses default names without provider", () => { - const { payload } = callHandler(); - const { commands } = payload as { commands: Array<{ name: string }> }; + const commands = listCommands(); expect(requireCommand(commands, "model").name).toBe("model"); expect(commands.find((c) => c.name === "set_model")).toBeUndefined(); }); it("omits plugin commands when provider lacks nativeCommandsAutoEnabled", () => { - const { payload } = callHandler({ provider: "whatsapp" }); - const { commands } = payload as { commands: Array<{ name: string; source: string }> }; + const commands = listCommands({ provider: "whatsapp" }); expect(commands.some((c) => c.source === "plugin")).toBe(false); }); it("uses text-surface names when scope=text even with provider-native aliases", () => { - const { payload } = callHandler({ provider: "discord", scope: "text" }); - const { commands } = payload as { - commands: Array<{ - name: string; - nativeName?: string; - textAliases?: string[]; - source: string; - }>; - }; + const commands = listCommands({ provider: "discord", scope: "text" }); const model = commands.find((c) => c.source === "native" && c.name === "model"); expect(model?.name).toBe("model"); expect(model?.nativeName).toBe("set_model"); @@ -424,61 +415,32 @@ describe("commands.list handler", () => { }); it("keeps plugin text commands visible for scope=text even without native provider support", () => { - const { payload } = callHandler({ provider: "whatsapp", scope: "text" }); - const { commands } = payload as { - commands: Array<{ - name: string; - source: string; - textAliases?: string[]; - nativeName?: string; - }>; - }; - const plugin = commands.find((c) => c.source === "plugin"); + const plugin = pluginCommand({ provider: "whatsapp", scope: "text" }); expect(plugin?.name).toBe("tts"); expect(plugin?.textAliases).toEqual(["/tts"]); expect(plugin?.nativeName).toBeUndefined(); }); it("keeps plugin text names while exposing provider-native aliases for scope=text", () => { - const { payload } = callHandler({ provider: "discord", scope: "text" }); - const { commands } = payload as { - commands: Array<{ - name: string; - source: string; - textAliases?: string[]; - nativeName?: string; - }>; - }; - const plugin = commands.find((c) => c.source === "plugin"); + const plugin = pluginCommand({ provider: "discord", scope: "text" }); expect(plugin?.name).toBe("tts"); expect(plugin?.nativeName).toBe("discord_tts"); expect(plugin?.textAliases).toEqual(["/tts"]); }); it("reads plugin commands from the gateway registry before the global command table", () => { - runtimeMocks.gatewayRegistry = { - commands: [ - { - pluginId: "phone-control", - command: { - name: " phone ", - description: " Control paired phones ", - acceptsArgs: true, - }, + setGatewayRegistry([ + { + pluginId: "phone-control", + command: { + name: " phone ", + description: " Control paired phones ", + acceptsArgs: true, }, - ], - }; + }, + ]); - const { payload } = callHandler(); - const { commands } = payload as { - commands: Array<{ - name: string; - description: string; - source: string; - textAliases?: string[]; - acceptsArgs?: boolean; - }>; - }; + const commands = listCommands(); const phone = commands.find((c) => c.source === "plugin"); expect(phone?.name).toBe("phone"); @@ -489,39 +451,10 @@ describe("commands.list handler", () => { }); it("keeps provider-filtered native plugin names paired with their text aliases", () => { - runtimeMocks.gatewayRegistry = { - commands: [ - { - pluginId: "android-only", - command: { - name: "android_only", - description: "Android-only command", - channels: ["android"], - }, - }, - { - pluginId: "phone-control", - command: { - name: "phone", - description: "Control paired phones", - acceptsArgs: true, - channels: ["discord"], - nativeNames: { discord: "discord_phone" }, - }, - }, - ], - }; + setGatewayRegistry(providerFilteredPluginRegistrations({ nativeName: "discord_phone" })); - const { payload } = callHandler({ provider: "discord" }); - const { commands } = payload as { - commands: Array<{ - name: string; - source: string; - textAliases?: string[]; - nativeName?: string; - }>; - }; - const plugin = commands.find((c) => c.source === "plugin"); + const commands = listCommands({ provider: "discord" }); + const plugin = pluginCommand({ provider: "discord" }); expect(plugin?.name).toBe("discord_phone"); expect(plugin?.nativeName).toBe("discord_phone"); @@ -532,35 +465,9 @@ describe("commands.list handler", () => { }); it("filters provider-incompatible plugin commands from the text surface", () => { - runtimeMocks.gatewayRegistry = { - commands: [ - { - pluginId: "android-only", - command: { - name: "android_only", - description: "Android-only command", - channels: ["android"], - }, - }, - { - pluginId: "phone-control", - command: { - name: "phone", - description: "Control paired phones", - channels: ["discord"], - }, - }, - ], - }; + setGatewayRegistry(providerFilteredPluginRegistrations()); - const { payload } = callHandler({ provider: "discord", scope: "text" }); - const { commands } = payload as { - commands: Array<{ - name: string; - source: string; - textAliases?: string[]; - }>; - }; + const commands = listCommands({ provider: "discord", scope: "text" }); expect( commands.find((c) => c.source === "plugin" && c.name === "android_only"), @@ -569,19 +476,12 @@ describe("commands.list handler", () => { }); it("returns provider-specific plugin command names", () => { - const { payload } = callHandler({ provider: "discord" }); - const { commands } = payload as { commands: Array<{ name: string; source: string }> }; - const plugin = commands.find((c) => c.source === "plugin"); + const plugin = pluginCommand({ provider: "discord" }); expect(plugin?.name).toBe("discord_tts"); }); it("excludes args when includeArgs=false", () => { - const { payload } = callHandler({ includeArgs: false }); - const { commands } = payload as { commands: Array> }; - const model = requireCommand( - commands as Array & { name: string }>, - "model", - ); + const model = requireCommand(listCommands({ includeArgs: false }), "model"); expect(model.args).toBeUndefined(); }); @@ -619,11 +519,10 @@ describe("commands.list handler", () => { }); } - const { payload } = callHandler(); - const { commands } = payload as { commands: Array> }; + const commands = listCommands(); expect(commands).toHaveLength(COMMAND_LIST_MAX_ITEMS); const first = commands[0]; - expect((first.name as string).length).toBeLessThanOrEqual(COMMAND_NAME_MAX_LENGTH); + expect(first.name.length).toBeLessThanOrEqual(COMMAND_NAME_MAX_LENGTH); expect((first.description as string).length).toBeLessThanOrEqual( COMMAND_DESCRIPTION_MAX_LENGTH, );