// Covers plugin CLI command behavior and output paths. import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { listRegisteredPluginAgentPromptGuidance } from "./command-registry-state.js"; import { testing, clearPluginCommands, executePluginCommand, getPluginCommandSpecs, listProviderPluginCommandSpecs, listPluginCommands, matchPluginCommand, registerPluginCommand, } from "./commands.js"; import { createPluginRegistry, type PluginRecord } from "./registry.js"; import { setActivePluginRegistry } from "./runtime.js"; import type { PluginRuntime } from "./runtime/types.js"; const completionMocks = vi.hoisted(() => ({ prepareSimpleCompletionModelForAgent: vi.fn(), completeWithPreparedSimpleCompletionModel: vi.fn(), resolveSimpleCompletionSelectionForAgent: vi.fn(), })); vi.mock("../agents/simple-completion-runtime.js", () => ({ prepareSimpleCompletionModelForAgent: completionMocks.prepareSimpleCompletionModelForAgent, completeWithPreparedSimpleCompletionModel: completionMocks.completeWithPreparedSimpleCompletionModel, resolveSimpleCompletionSelectionForAgent: completionMocks.resolveSimpleCompletionSelectionForAgent, })); type CommandsModule = typeof import("./commands.js"); const commandsModuleUrl = new URL("./commands.ts", import.meta.url).href; async function importCommandsModule(cacheBust: string): Promise { return (await import(`${commandsModuleUrl}?t=${cacheBust}`)) as CommandsModule; } function createVoiceCommand(overrides: Partial[1]> = {}) { return { name: "voice", description: "Voice command", handler: async () => ({ text: "ok" }), ...overrides, }; } function createBundledPluginRecord(id: string): PluginRecord { return { id, name: id, source: `bundled:${id}`, rootDir: `/bundled/${id}`, origin: "bundled", enabled: true, status: "loaded", toolNames: [], hookNames: [], channelIds: [], cliBackendIds: [], providerIds: [], embeddingProviderIds: [], speechProviderIds: [], realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], mediaUnderstandingProviderIds: [], transcriptSourceProviderIds: [], imageGenerationProviderIds: [], videoGenerationProviderIds: [], musicGenerationProviderIds: [], webFetchProviderIds: [], webSearchProviderIds: [], migrationProviderIds: [], memoryEmbeddingProviderIds: [], agentHarnessIds: [], cliCommands: [], services: [], gatewayDiscoveryServiceIds: [], commands: [], httpRoutes: 0, hookCount: 0, configSchema: false, } as PluginRecord; } function registerHostTrustedReservedCommandForTest( command: Parameters[1], ) { const pluginRegistry = createPluginRegistry({ logger: { info() {}, warn() {}, error() {}, debug() {}, }, runtime: {} as PluginRuntime, activateGlobalSideEffects: true, }); pluginRegistry.registerCommand(createBundledPluginRecord(command.name), command); } function registerVoiceCommandForTest( overrides: Partial[1]> = {}, ) { return registerPluginCommand("demo-plugin", createVoiceCommand(overrides)); } function resolveBindingConversationFromCommand( params: Parameters[0], ) { return testing.resolveBindingConversationFromCommand(params); } function expectCommandMatch( commandBody: string, params: { name: string; pluginId: string; args: string }, ) { const match = requirePluginCommandMatch(commandBody); expect(match.command.name).toBe(params.name); expect(match.command.pluginId).toBe(params.pluginId); expect(match.args).toBe(params.args); } function requirePluginCommandMatch(commandBody: string) { const match = matchPluginCommand(commandBody); if (!match) { throw new Error(`expected plugin command match for ${commandBody}`); } return match; } function expectProviderCommandSpecs( provider: Parameters[0], expectedNames: readonly string[], ) { expect(getPluginCommandSpecs(provider)).toEqual( expectedNames.map((name) => ({ name, description: "Demo command", acceptsArgs: false, })), ); } function expectProviderCommandSpecCases( cases: ReadonlyArray<{ provider: Parameters[0]; expectedNames: readonly string[]; }>, ) { cases.forEach(({ provider, expectedNames }) => { expectProviderCommandSpecs(provider, expectedNames); }); } function expectUnsupportedBindingApiResult(result: { text?: string }) { expect(result.text).toBe( JSON.stringify({ requested: { status: "error", message: "This command cannot bind the current conversation.", }, current: null, detached: { removed: false }, }), ); } function expectBindingConversationCase( params: Parameters[0], expected: ReturnType, ) { expect(resolveBindingConversationFromCommand(params)).toEqual(expected); } beforeEach(() => { completionMocks.prepareSimpleCompletionModelForAgent.mockReset(); completionMocks.prepareSimpleCompletionModelForAgent.mockResolvedValue({ selection: { provider: "openai", modelId: "gpt-5.5", agentDir: "/tmp/openclaw-agent", }, model: { provider: "openai", id: "gpt-5.5", name: "GPT-5.5", api: "openai", input: ["text"], reasoning: false, contextWindow: 128_000, maxTokens: 4096, cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, }, auth: { apiKey: "test-api-key", source: "test", mode: "api-key", }, }); completionMocks.completeWithPreparedSimpleCompletionModel.mockReset(); completionMocks.completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [{ type: "text", text: "done" }], usage: {}, }); completionMocks.resolveSimpleCompletionSelectionForAgent.mockReset(); completionMocks.resolveSimpleCompletionSelectionForAgent.mockReturnValue({ provider: "openai", modelId: "gpt-5.5", agentDir: "/tmp/openclaw-agent", }); setActivePluginRegistry( createTestRegistry([ { pluginId: "telegram", source: "test", plugin: { ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), commands: { nativeCommandsAutoEnabled: true, }, bindings: { selfParentConversationByDefault: true, resolveCommandConversation: ({ threadId, originatingTo, commandTo, fallbackTo, }: { threadId?: string; originatingTo?: string; commandTo?: string; fallbackTo?: string; }) => { const rawTarget = [commandTo, originatingTo, fallbackTo].find(Boolean)?.trim(); if (!rawTarget || rawTarget.startsWith("slash:")) { return null; } const normalized = rawTarget.replace(/^telegram:/i, ""); const topicMatch = /^(.*?):topic:(\d+)$/i.exec(normalized); if (topicMatch?.[1]) { return { conversationId: `${topicMatch[1]}:topic:${threadId ?? topicMatch[2]}`, parentConversationId: topicMatch[1], }; } return { conversationId: normalized }; }, }, }, }, { pluginId: "discord", source: "test", plugin: { ...createChannelTestPluginBase({ id: "discord", label: "Discord" }), commands: { nativeCommandsAutoEnabled: true, }, bindings: { resolveCommandConversation: ({ threadId, threadParentId, originatingTo, commandTo, fallbackTo, }: { threadId?: string; threadParentId?: string; originatingTo?: string; commandTo?: string; fallbackTo?: string; }) => { const rawTarget = [originatingTo, commandTo, fallbackTo].find(Boolean)?.trim(); if (!rawTarget || rawTarget.startsWith("slash:")) { return null; } const normalized = rawTarget.replace(/^discord:/i, ""); if (/^\d+$/.test(normalized)) { return { conversationId: `user:${normalized}` }; } if (threadId) { const baseConversationId = originatingTo?.trim()?.replace(/^discord:/i, "") || commandTo?.trim()?.replace(/^discord:/i, "") || fallbackTo?.trim()?.replace(/^discord:/i, ""); return { conversationId: baseConversationId || threadId, ...(threadParentId ? { parentConversationId: threadParentId } : {}), }; } if (normalized.startsWith("channel:") || normalized.startsWith("user:")) { return { conversationId: normalized }; } return null; }, }, }, }, { pluginId: "signal", source: "test", plugin: { ...createChannelTestPluginBase({ id: "signal", label: "Signal" }), commands: { nativeCommandsAutoEnabled: true, }, bindings: { resolveCommandConversation: ({ senderId }: { senderId?: string }) => { const normalizedSenderId = senderId?.trim(); return normalizedSenderId ? { conversationId: `dm:${normalizedSenderId}` } : null; }, }, }, }, { pluginId: "slack", source: "test", plugin: { ...createChannelTestPluginBase({ id: "slack", label: "Slack", capabilities: { nativeCommands: true, chatTypes: ["direct", "group"] }, }), }, }, ]), ); }); afterEach(() => { clearPluginCommands(); }); describe("registerPluginCommand", () => { it.each([ { name: "rejects invalid command names", command: { // Runtime plugin payloads are untyped; guard at boundary. name: undefined as unknown as string, description: "Demo", handler: async () => ({ text: "ok" }), }, expected: { ok: false, error: "Command name must be a string", }, }, { name: "rejects invalid command descriptions", command: { name: "demo", description: undefined as unknown as string, handler: async () => ({ text: "ok" }), }, expected: { ok: false, error: "Command description must be a string", }, }, { name: "rejects invalid agent prompt guidance", command: { name: "demo", description: "Demo", agentPromptGuidance: "use /demo" as unknown as string[], handler: async () => ({ text: "ok" }), }, expected: { ok: false, error: "Agent prompt guidance must be an array of strings or objects", }, }, { name: "rejects invalid structured agent prompt guidance", command: { name: "demo", description: "Demo", agentPromptGuidance: [{ text: "Use /demo.", surfaces: ["nope"] }] as never, handler: async () => ({ text: "ok" }), }, expected: { ok: false, error: "Agent prompt guidance 1 surface 1 must be one of: openclaw_main, pi_main, codex_app_server, cli_backend, acp_backend, subagent", }, }, { name: "rejects empty structured agent prompt guidance surfaces", command: { name: "demo", description: "Demo", agentPromptGuidance: [{ text: "Use /demo.", surfaces: [] }] as never, handler: async () => ({ text: "ok" }), }, expected: { ok: false, error: "Agent prompt guidance 1 surfaces cannot be empty", }, }, { name: "rejects invalid channel scopes", command: { name: "demo", description: "Demo", channels: ["telegram", " "], handler: async () => ({ text: "ok" }), }, expected: { ok: false, error: "Command channel 2 cannot be empty", }, }, { name: "rejects primitive native command metadata", command: { name: "demo", description: "Demo", nativeNames: "demo-native", handler: async () => ({ text: "ok" }), }, expected: { ok: false, error: "Command nativeNames must be an object", }, }, ] as const)("$name", ({ command, expected }) => { expect(registerPluginCommand("demo-plugin", command as never)).toEqual(expected); }); it("normalizes command metadata for downstream consumers", () => { const result = registerPluginCommand("demo-plugin", { name: " demo_cmd ", description: " Demo command ", agentPromptGuidance: [" Use /demo_cmd for demo routing. "], handler: async () => ({ text: "ok" }), }); expect(result).toEqual({ ok: true }); expect(listPluginCommands()).toEqual([ { name: "demo_cmd", description: "Demo command", pluginId: "demo-plugin", acceptsArgs: false, }, ]); expect(getPluginCommandSpecs()).toEqual([ { name: "demo_cmd", description: "Demo command", acceptsArgs: false, }, ]); expect(listRegisteredPluginAgentPromptGuidance()).toEqual(["Use /demo_cmd for demo routing."]); }); it("normalizes and filters structured agent prompt guidance by surface", () => { const result = registerPluginCommand("demo-plugin", { name: "demo_cmd", description: "Demo command", agentPromptGuidance: [ " Use /demo_cmd everywhere. ", { text: " Use /demo_cmd for main agent routing. ", surfaces: ["openclaw_main"], }, { text: "Use /demo_cmd for subagents.", surfaces: ["subagent"], }, ], handler: async () => ({ text: "ok" }), }); expect(result).toEqual({ ok: true }); expect(listRegisteredPluginAgentPromptGuidance()).toEqual([ "Use /demo_cmd everywhere.", "Use /demo_cmd for main agent routing.", "Use /demo_cmd for subagents.", ]); expect(listRegisteredPluginAgentPromptGuidance({ surface: "openclaw_main" })).toEqual([ "Use /demo_cmd everywhere.", "Use /demo_cmd for main agent routing.", ]); expect(listRegisteredPluginAgentPromptGuidance({ surface: "pi_main" })).toEqual([ "Use /demo_cmd everywhere.", "Use /demo_cmd for main agent routing.", ]); expect(listRegisteredPluginAgentPromptGuidance({ surface: "subagent" })).toEqual([ "Use /demo_cmd everywhere.", "Use /demo_cmd for subagents.", ]); expect( listRegisteredPluginAgentPromptGuidance({ surface: "subagent", includeLegacyGlobalGuidance: false, }), ).toEqual(["Use /demo_cmd for subagents."]); }); it("matches underscore aliases for hyphenated command names", () => { registerPluginCommand("demo-plugin", { name: "active-memory", description: "Active Memory command", acceptsArgs: true, handler: async () => ({ text: "ok" }), }); expectCommandMatch("/active_memory status", { name: "active-memory", pluginId: "demo-plugin", args: "status", }); }); it("supports provider-specific native command aliases", () => { const result = registerVoiceCommandForTest({ nativeNames: { default: "talkvoice", discord: "discordvoice", }, description: "Demo command", }); expect(result).toEqual({ ok: true }); expectProviderCommandSpecCases([ { provider: undefined, expectedNames: ["talkvoice"] }, { provider: "discord", expectedNames: ["discordvoice"] }, { provider: "telegram", expectedNames: ["talkvoice"] }, { provider: "slack", expectedNames: [] }, ]); }); it("scopes plugin command matches and native specs to configured channels", () => { const result = registerVoiceCommandForTest({ channels: [" Telegram "], description: "Demo command", }); expect(result).toEqual({ ok: true }); const telegramMatch = matchPluginCommand("/voice", { channel: "telegram" }); expect(telegramMatch?.command.name).toBe("voice"); expect(telegramMatch?.command.channels).toEqual(["telegram"]); expect(matchPluginCommand("/voice", { channel: "discord" })).toBeNull(); expect(matchPluginCommand("/voice")?.command.name).toBe("voice"); expectProviderCommandSpecCases([ { provider: undefined, expectedNames: ["voice"] }, { provider: "telegram", expectedNames: ["voice"] }, { provider: "discord", expectedNames: [] }, ]); expect(listProviderPluginCommandSpecs("discord")).toStrictEqual([]); }); it("allows Slack to resolve provider-native plugin specs without changing shared native gating", () => { const result = registerVoiceCommandForTest({ nativeNames: { default: "talkvoice", discord: "discordvoice", }, description: "Demo command", }); expect(result).toEqual({ ok: true }); expect(listProviderPluginCommandSpecs("slack")).toEqual([ { name: "talkvoice", description: "Demo command", acceptsArgs: false, }, ]); }); it("requires config before using read-only manifest command defaults", () => { setActivePluginRegistry(createTestRegistry([])); registerVoiceCommandForTest({ nativeNames: { discord: "discordvoice", }, description: "Demo command", }); const env = { ...process.env, OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", }; expect(getPluginCommandSpecs("discord", { env })).toStrictEqual([]); expect( getPluginCommandSpecs("discord", { env, config: { plugins: { entries: { discord: { enabled: true, }, }, }, }, }), ).toEqual([ { name: "discordvoice", description: "Demo command", acceptsArgs: false, }, ]); }); it("accepts native progress metadata on plugin commands", () => { const result = registerVoiceCommandForTest({ nativeProgressMessages: { telegram: "Running voice command..." }, description: "Demo command", }); expect(result).toEqual({ ok: true }); expect(matchPluginCommand("/voice")?.command.nativeProgressMessages).toEqual({ telegram: "Running voice command...", }); }); it("exposes native description localizations on plugin command specs", () => { const result = registerVoiceCommandForTest({ description: "Demo command", descriptionLocalizations: { ko: "데모 명령" }, }); expect(result).toEqual({ ok: true }); expect(listProviderPluginCommandSpecs("discord")).toEqual([ { name: "voice", description: "Demo command", descriptionLocalizations: { ko: "데모 명령" }, acceptsArgs: false, }, ]); }); it("rejects empty native description localizations", () => { const result = registerVoiceCommandForTest({ description: "Demo command", descriptionLocalizations: { ko: " " }, }); expect(result).toEqual({ ok: false, error: 'Description localization "ko" cannot be empty', }); }); it("rejects empty native progress metadata", () => { const result = registerVoiceCommandForTest({ nativeProgressMessages: { telegram: " " }, description: "Demo command", }); expect(result).toEqual({ ok: false, error: 'Native progress message "telegram" cannot be empty', }); }); it("keeps reserved command bypass scoped to the primary command name", () => { const result = registerPluginCommand( "status", createVoiceCommand({ name: "status", nativeNames: { telegram: "help", }, }), { allowReservedCommandNames: true }, ); expect(result).toEqual({ ok: false, error: 'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command', }); }); it("reserves the bundled Codex command name", () => { const result = registerPluginCommand("demo-plugin", { name: "codex", description: "Fake Codex command", handler: async () => ({ text: "ok" }), }); expect(result).toEqual({ ok: false, error: 'Command name "codex" is reserved by a built-in command', }); }); it("rejects reserved ownership on non-reserved direct command registrations", () => { const result = registerPluginCommand( "demo-plugin", { name: "voice", description: "Voice command", ownership: "reserved", handler: async () => ({ text: "ok" }), }, { allowReservedCommandNames: true }, ); expect(result).toEqual({ ok: false, error: "Reserved command ownership is only available to bundled reserved commands", }); }); it("does not expose owner status to normal plugin commands", async () => { let observedOwnerStatus: boolean | undefined; registerPluginCommand("demo-plugin", { name: "voice", description: "Voice command", handler: async (ctx) => { observedOwnerStatus = ctx.senderIsOwner; return { text: "ok" }; }, }); const match = requirePluginCommandMatch("/voice"); await executePluginCommand({ command: match.command, channel: "telegram", isAuthorizedSender: true, senderIsOwner: true, commandBody: "/voice", config: {}, }); expect(observedOwnerStatus).toBeUndefined(); }); it("ignores owner status opt-in from direct plugin command registration", async () => { let observedOwnerStatus: boolean | undefined; registerPluginCommand("demo-plugin", { name: "voice", description: "Voice command", exposeSenderIsOwner: true, handler: async (ctx) => { observedOwnerStatus = ctx.senderIsOwner; return { text: "ok" }; }, }); const match = requirePluginCommandMatch("/voice"); await executePluginCommand({ command: match.command, channel: "telegram", isAuthorizedSender: true, senderIsOwner: true, commandBody: "/voice", config: {}, }); expect(observedOwnerStatus).toBeUndefined(); }); it("ignores owner status opt-in from external plugin registry commands", async () => { const pluginRegistry = createPluginRegistry({ logger: { info() {}, warn() {}, error() {}, debug() {}, }, runtime: {} as PluginRuntime, activateGlobalSideEffects: true, }); let observedOwnerStatus: boolean | undefined; pluginRegistry.registerCommand( { ...createBundledPluginRecord("external-plugin"), origin: "workspace", source: "/workspace/external-plugin/index.ts", rootDir: "/workspace/external-plugin", }, { name: "external", description: "External command", exposeSenderIsOwner: true, handler: async (ctx) => { observedOwnerStatus = ctx.senderIsOwner; return { text: "ok" }; }, }, ); const match = requirePluginCommandMatch("/external"); await executePluginCommand({ command: match.command, channel: "telegram", isAuthorizedSender: true, senderIsOwner: true, commandBody: "/external", config: {}, }); expect(observedOwnerStatus).toBeUndefined(); }); it("exposes owner status to trusted bundled plugin commands that opt in", async () => { const pluginRegistry = createPluginRegistry({ logger: { info() {}, warn() {}, error() {}, debug() {}, }, runtime: {} as PluginRuntime, activateGlobalSideEffects: true, }); let observedOwnerStatus: boolean | undefined; pluginRegistry.registerCommand(createBundledPluginRecord("phone-control"), { name: "phone", description: "Phone command", exposeSenderIsOwner: true, handler: async (ctx) => { observedOwnerStatus = ctx.senderIsOwner; return { text: "ok" }; }, }); const match = requirePluginCommandMatch("/phone"); await executePluginCommand({ command: match.command, channel: "telegram", isAuthorizedSender: true, senderIsOwner: true, commandBody: "/phone", config: {}, }); expect(observedOwnerStatus).toBe(true); }); it("allows command owners to run scoped plugin commands without gateway scopes", async () => { let observedOwnerStatus: boolean | undefined; const handler = vi.fn(async (ctx: { senderIsOwner?: boolean }) => { observedOwnerStatus = ctx.senderIsOwner; return { text: "ok" }; }); registerPluginCommand("demo-plugin", { name: "pairlike", description: "Scoped command", requiredScopes: ["operator.pairing"], handler, }); const match = requirePluginCommandMatch("/pairlike"); const result = await executePluginCommand({ command: match.command, channel: "telegram", isAuthorizedSender: true, senderIsOwner: true, commandBody: "/pairlike", config: {}, }); expect(result).toEqual({ text: "ok" }); expect(handler).toHaveBeenCalledTimes(1); expect(observedOwnerStatus).toBe(true); }); it("rejects command owners when explicit gateway scopes miss the required scope", async () => { const handler = vi.fn(async () => ({ text: "ok" })); registerPluginCommand("demo-plugin", { name: "pairlike", description: "Scoped command", requiredScopes: ["operator.pairing"], handler, }); const match = requirePluginCommandMatch("/pairlike"); const result = await executePluginCommand({ command: match.command, channel: "webchat", isAuthorizedSender: true, senderIsOwner: true, commandBody: "/pairlike", gatewayClientScopes: ["operator.write"], config: {}, }); expect(result).toEqual({ text: "⚠️ This command requires gateway scope: operator.pairing." }); expect(handler).not.toHaveBeenCalled(); }); it("rejects non-owner scoped plugin commands without gateway scopes", async () => { const handler = vi.fn(async () => ({ text: "ok" })); registerPluginCommand("demo-plugin", { name: "pairlike", description: "Scoped command", requiredScopes: ["operator.pairing"], handler, }); const match = requirePluginCommandMatch("/pairlike"); const result = await executePluginCommand({ command: match.command, channel: "telegram", isAuthorizedSender: true, senderIsOwner: false, commandBody: "/pairlike", config: {}, }); expect(result).toEqual({ text: "⚠️ This command requires gateway scope: operator.pairing." }); expect(handler).not.toHaveBeenCalled(); }); it("skips direct plugin command execution on unsupported channels", async () => { let handlerCalled = false; const handler = async () => { handlerCalled = true; return { text: "ok" }; }; const result = await executePluginCommand({ command: { name: "voice", description: "Voice command", channels: ["qqbot"], handler, pluginId: "demo-plugin", }, channel: "discord", isAuthorizedSender: true, commandBody: "/voice", config: {}, }); expect(result).toEqual({ continueAgent: true }); expect(handlerCalled).toBe(false); }); it("does not allow direct reserved command registrations to claim owner status", () => { const result = registerPluginCommand( "codex", { name: "codex", description: "Codex command", ownership: "reserved", handler: async () => ({ text: "ok" }), }, { allowReservedCommandNames: true }, ); expect(result).toEqual({ ok: false, error: "Reserved command ownership is only available to bundled reserved commands", }); expect(matchPluginCommand("/codex")).toBeNull(); }); it("exposes owner status only to host-trusted reserved command owners", async () => { let observedOwnerStatus: boolean | undefined; registerHostTrustedReservedCommandForTest({ name: "codex", description: "Codex command", ownership: "reserved", handler: async (ctx) => { observedOwnerStatus = ctx.senderIsOwner; return { text: "ok" }; }, }); const match = requirePluginCommandMatch("/codex"); await executePluginCommand({ command: match.command, channel: "telegram", isAuthorizedSender: true, senderIsOwner: true, commandBody: "/codex", config: {}, }); expect(observedOwnerStatus).toBe(true); }); it("rejects mismatched reserved command owners", () => { const pluginRegistry = createPluginRegistry({ logger: { info() {}, warn() {}, error() {}, debug() {}, }, runtime: {} as PluginRuntime, activateGlobalSideEffects: true, }); pluginRegistry.registerCommand(createBundledPluginRecord("bundled-plugin"), { name: "codex", description: "Codex command", ownership: "reserved", handler: async () => ({ text: "ok" }), }); const diagnostic = pluginRegistry.registry.diagnostics.find( (entry) => entry.pluginId === "bundled-plugin", ); expect(diagnostic?.level).toBe("error"); expect(diagnostic?.message).toBe( 'command registration failed: Reserved command ownership requires plugin id "bundled-plugin" to match reserved command name "codex"', ); }); it("shares plugin commands across duplicate module instances", async () => { const first = await importCommandsModule(`first-${Date.now()}`); const second = await importCommandsModule(`second-${Date.now()}`); first.clearPluginCommands(); expect( first.registerPluginCommand( "demo-plugin", createVoiceCommand({ nativeNames: { telegram: "voice", }, }), ), ).toEqual({ ok: true }); expect(second.getPluginCommandSpecs("telegram")).toEqual([ { name: "voice", description: "Voice command", acceptsArgs: false, }, ]); const secondMatch = second.matchPluginCommand("/voice"); expect(secondMatch?.command.name).toBe("voice"); expect(secondMatch?.command.pluginId).toBe("demo-plugin"); second.clearPluginCommands(); }); it.each(["/talkvoice now", "/discordvoice now"] as const)( "matches provider-specific native alias %s back to the canonical command", (commandBody) => { const result = registerVoiceCommandForTest({ nativeNames: { default: "talkvoice", discord: "discordvoice", }, description: "Demo command", acceptsArgs: true, }); expect(result).toEqual({ ok: true }); expectCommandMatch(commandBody, { name: "voice", pluginId: "demo-plugin", args: "now", }); }, ); it.each([ { name: "rejects provider aliases that collide with another registered command", setup: () => registerPluginCommand( "demo-plugin", createVoiceCommand({ nativeNames: { telegram: "pair_device", }, }), ), candidate: { name: "pair", nativeNames: { telegram: "pair_device", }, description: "Pair command", handler: async () => ({ text: "ok" }), }, expected: { ok: false, error: 'Command "pair_device" already registered by plugin "demo-plugin"', }, }, { name: "rejects reserved provider aliases", candidate: createVoiceCommand({ nativeNames: { telegram: "help", }, }), expected: { ok: false, error: 'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command', }, }, ] as const)("$name", ({ setup, candidate, expected }) => { setup?.(); expect(registerPluginCommand("other-plugin", candidate)).toEqual(expected); }); it.each([ { name: "resolves Discord DM command bindings with the user target prefix intact", params: { channel: "discord", from: "discord:1177378744822943744", to: "slash:1177378744822943744", accountId: "default", }, expected: { channel: "discord", accountId: "default", conversationId: "user:1177378744822943744", }, }, { name: "resolves Discord guild command bindings with the channel target prefix intact", params: { channel: "discord", from: "discord:channel:1480554272859881494", accountId: "default", }, expected: { channel: "discord", accountId: "default", conversationId: "channel:1480554272859881494", }, }, { name: "resolves Discord thread command bindings with parent channel context intact", params: { channel: "discord", from: "discord:channel:1480554272859881494", accountId: "default", messageThreadId: "thread-42", threadParentId: "channel-parent-7", }, expected: { channel: "discord", accountId: "default", conversationId: "channel:1480554272859881494", parentConversationId: "channel-parent-7", threadId: "thread-42", }, }, { name: "does not resolve binding conversations for unsupported command channels", params: { channel: "slack", from: "slack:U123", to: "C456", accountId: "default", }, expected: null, }, { name: "resolves sender-keyed command bindings when only senderId is available", params: { channel: "signal", senderId: "signal-user-42", accountId: "default", }, expected: { channel: "signal", accountId: "default", conversationId: "dm:signal-user-42", }, }, ] as const)("$name", ({ params, expected }) => { expectBindingConversationCase(params, expected); }); it("does not expose binding APIs to plugin commands on unsupported channels", async () => { const handler = async (ctx: { requestConversationBinding: (params: { summary: string }) => Promise; getCurrentConversationBinding: () => Promise; detachConversationBinding: () => Promise; }) => { const requested = await ctx.requestConversationBinding({ summary: "Bind this conversation.", }); const current = await ctx.getCurrentConversationBinding(); const detached = await ctx.detachConversationBinding(); return { text: JSON.stringify({ requested, current, detached, }), }; }; registerPluginCommand( "demo-plugin", { name: "bindcheck", description: "Demo command", acceptsArgs: false, handler, }, { pluginRoot: "/plugins/demo-plugin" }, ); const result = await executePluginCommand({ command: { name: "bindcheck", description: "Demo command", acceptsArgs: false, handler, pluginId: "demo-plugin", pluginRoot: "/plugins/demo-plugin", }, channel: "slack", senderId: "U123", isAuthorizedSender: true, commandBody: "/bindcheck", config: {} as never, from: "slack:U123", to: "C456", accountId: "default", }); expectUnsupportedBindingApiResult(result); }); it("passes host session identity through to the plugin command context", async () => { let receivedCtx: | { sessionKey?: string; sessionId?: string; } | undefined; const handler = async (ctx: { sessionKey?: string; sessionId?: string }) => { receivedCtx = ctx; return { text: "ok" }; }; const result = await executePluginCommand({ command: { name: "sessioncheck", description: "Demo command", acceptsArgs: false, handler, pluginId: "demo-plugin", }, channel: "whatsapp", senderId: "U123", isAuthorizedSender: true, sessionKey: "agent:main:whatsapp:direct:123", sessionId: "session-123", commandBody: "/sessioncheck", config: {} as never, }); expect(result).toEqual({ text: "ok" }); expect(receivedCtx?.sessionKey).toBe("agent:main:whatsapp:direct:123"); expect(receivedCtx?.sessionId).toBe("session-123"); }); it("passes a host-bound llm runtime through to plugin command handlers", async () => { let receivedCtx: | { runtimeContext?: { llm?: { complete?: unknown; }; }; } | undefined; const handler = async (ctx: typeof receivedCtx) => { receivedCtx = ctx; return { text: "ok" }; }; const result = await executePluginCommand({ command: { name: "runtimecheck", description: "Demo command", acceptsArgs: false, handler, pluginId: "demo-plugin", }, channel: "telegram", senderId: "U123", isAuthorizedSender: true, sessionKey: "agent:main:telegram:direct:runtimecheck", authProfileId: "openai:claude@example.com", commandBody: "/runtimecheck", config: {} as never, }); expect(result).toEqual({ text: "ok" }); expect(receivedCtx?.runtimeContext?.llm?.complete).toEqual(expect.any(Function)); }); it("binds legacy main session plugin llm runtime to the default agent", async () => { const handler = async (ctx: { runtimeContext?: { llm?: { complete: (params: { messages: Array<{ role: "user"; content: string }>; }) => Promise; }; }; }) => { await ctx.runtimeContext?.llm?.complete({ messages: [{ role: "user", content: "draft" }], }); return { text: "ok" }; }; await executePluginCommand({ command: { name: "runtimecheck", description: "Demo command", acceptsArgs: false, handler, pluginId: "demo-plugin", }, channel: "telegram", senderId: "U123", isAuthorizedSender: true, sessionKey: "main", commandBody: "/runtimecheck", config: { agents: { list: [{ id: "ops", default: true }], defaults: { model: "openai/gpt-5.5", }, }, session: { mainKey: "main", }, } as never, }); expect(completionMocks.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( expect.objectContaining({ agentId: "ops", }), ); }); it("binds plugin-owned command sessions to the host-resolved agent", async () => { const handler = async (ctx: { runtimeContext?: { llm?: { complete: (params: { messages: Array<{ role: "user"; content: string }>; }) => Promise; }; }; }) => { await ctx.runtimeContext?.llm?.complete({ messages: [{ role: "user", content: "summarize" }], }); return { text: "ok" }; }; await executePluginCommand({ command: { name: "runtimecheck", description: "Demo command", acceptsArgs: false, handler, pluginId: "demo-plugin", }, channel: "discord", senderId: "U123", isAuthorizedSender: true, agentId: "codex", sessionKey: "plugin-binding:openclaw-codex-app-server:dm", authProfileId: "openai:owner@example.com", commandBody: "/runtimecheck", config: {} as never, }); expect(completionMocks.prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith( expect.objectContaining({ agentId: "codex", preferredProfile: "openai:owner@example.com", }), ); }); it("normalizes undefined plugin command handler results to an empty reply payload", async () => { const handler = async () => undefined as never; const result = await executePluginCommand({ command: { name: "silentcheck", description: "Demo command", acceptsArgs: false, handler, pluginId: "demo-plugin", }, channel: "telegram", senderId: "U123", isAuthorizedSender: true, commandBody: "/silentcheck", config: {} as never, }); expect(result).toStrictEqual({}); }); it("passes the effective default account to plugin command handlers when accountId is omitted", async () => { setActivePluginRegistry( createTestRegistry([ { pluginId: "line", source: "test", plugin: { ...createChannelTestPluginBase({ id: "line", label: "LINE", config: { listAccountIds: () => ["default", "work"], defaultAccountId: () => "work", resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "work" }), }, }), bindings: { resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo, }: { originatingTo?: string; commandTo?: string; fallbackTo?: string; }) => { const rawTarget = [originatingTo, commandTo, fallbackTo].find(Boolean)?.trim(); if (!rawTarget) { return null; } return { conversationId: rawTarget.replace(/^line:/i, "").replace(/^user:/i, ""), }; }, }, }, }, ]), ); let receivedCtx: | { accountId?: string; } | undefined; const handler = async (ctx: { accountId?: string }) => { receivedCtx = ctx; return { text: "ok" }; }; const result = await executePluginCommand({ command: { name: "accountcheck", description: "Demo command", acceptsArgs: false, handler, pluginId: "demo-plugin", }, channel: "line", senderId: "U123", isAuthorizedSender: true, commandBody: "/accountcheck", config: {} as never, from: "line:user:U1234567890abcdef1234567890abcdef", }); expect(result).toEqual({ text: "ok" }); expect(receivedCtx?.accountId).toBe("work"); }); });