From 6805a80da24530d14aef11fd1f59727881160e71 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 18:30:30 -0700 Subject: [PATCH] Tests: lock plugin slash commands to one runtime graph --- .../native-command.plugin-dispatch.test.ts | 50 ++++++ .../src/bot-native-commands.registry.test.ts | 143 ++++++++++++++++++ .../stage-bundled-plugin-runtime.test.ts | 109 +++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 extensions/telegram/src/bot-native-commands.registry.test.ts diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 4ac49c92119..08f5d6151f1 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -4,6 +4,7 @@ import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-regi import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js"; import type { OpenClawConfig } from "../../../../src/config/config.js"; import * as pluginCommandsModule from "../../../../src/plugins/commands.js"; +import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; import { createMockCommandInteraction, @@ -153,6 +154,7 @@ async function expectBoundStatusCommandDispatch(params: { describe("Discord native plugin command dispatch", () => { beforeEach(() => { vi.restoreAllMocks(); + clearPluginCommands(); persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset(); persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(null); persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset(); @@ -162,6 +164,54 @@ describe("Discord native plugin command dispatch", () => { }); }); + it("executes plugin commands from the real registry through the native Discord command path", async () => { + const cfg = createConfig(); + const commandSpec: NativeCommandSpec = { + name: "pair", + description: "Pair", + acceptsArgs: true, + }; + const command = createDiscordNativeCommand({ + command: commandSpec, + cfg, + discordConfig: cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); + const interaction = createInteraction(); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({} as never); + + await (command as { run: (interaction: unknown) => Promise }).run( + Object.assign(interaction, { + options: { + getString: () => "now", + getBoolean: () => null, + getFocused: () => "", + }, + }) as unknown, + ); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: "paired:now" }), + ); + }); + it("executes matched plugin commands directly without invoking the agent dispatcher", async () => { const cfg = createConfig(); const commandSpec: NativeCommandSpec = { diff --git a/extensions/telegram/src/bot-native-commands.registry.test.ts b/extensions/telegram/src/bot-native-commands.registry.test.ts new file mode 100644 index 00000000000..5ebf92e1300 --- /dev/null +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { TelegramAccountConfig } from "../../../src/config/types.js"; +import { clearPluginCommands, registerPluginCommand } from "../../../src/plugins/commands.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; +import { registerTelegramNativeCommands } from "./bot-native-commands.js"; + +const { listSkillCommandsForAgents } = vi.hoisted(() => ({ + listSkillCommandsForAgents: vi.fn(() => []), +})); +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), +})); + +vi.mock("../../../src/auto-reply/skill-commands.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listSkillCommandsForAgents, + }; +}); + +vi.mock("./bot/delivery.js", () => ({ + deliverReplies: deliveryMocks.deliverReplies, +})); + +describe("registerTelegramNativeCommands real plugin registry", () => { + type RegisteredCommand = { + command: string; + description: string; + }; + + async function waitForRegisteredCommands( + setMyCommands: ReturnType, + ): Promise { + await vi.waitFor(() => { + expect(setMyCommands).toHaveBeenCalled(); + }); + return setMyCommands.mock.calls[0]?.[0] as RegisteredCommand[]; + } + + const buildParams = (cfg: OpenClawConfig, accountId = "default") => + ({ + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as Parameters[0]["bot"], + cfg, + runtime: {} as RuntimeEnv, + accountId, + telegramCfg: {} as TelegramAccountConfig, + allowFrom: [], + groupAllowFrom: [], + replyToMode: "off", + textLimit: 4000, + useAccessGroups: false, + nativeEnabled: true, + nativeSkillsEnabled: true, + nativeDisabledExplicit: false, + resolveGroupPolicy: () => + ({ + allowlistEnabled: false, + allowed: true, + }) as ReturnType< + Parameters[0]["resolveGroupPolicy"] + >, + resolveTelegramGroupConfig: () => ({ + groupConfig: undefined, + topicConfig: undefined, + }), + shouldSkipUpdate: () => false, + opts: { token: "token" }, + }) satisfies Parameters[0]; + + beforeEach(() => { + clearPluginCommands(); + deliveryMocks.deliverReplies.mockClear(); + deliveryMocks.deliverReplies.mockResolvedValue({ delivered: true }); + listSkillCommandsForAgents.mockClear(); + listSkillCommandsForAgents.mockReturnValue([]); + }); + + afterEach(() => { + clearPluginCommands(); + }); + + it("registers and executes plugin commands through the real plugin registry", async () => { + const commandHandlers = new Map Promise>(); + const sendMessage = vi.fn().mockResolvedValue(undefined); + const setMyCommands = vi.fn().mockResolvedValue(undefined); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + registerTelegramNativeCommands({ + ...buildParams({}), + bot: { + api: { + setMyCommands, + sendMessage, + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"], + }); + + const registeredCommands = await waitForRegisteredCommands(setMyCommands); + expect(registeredCommands).toEqual( + expect.arrayContaining([{ command: "pair", description: "Pair device" }]), + ); + + const handler = commandHandlers.get("pair"); + expect(handler).toBeTruthy(); + + await handler?.({ + match: "now", + message: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { id: 123, type: "private" }, + from: { id: 456, username: "alice" }, + }, + }); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "paired:now" })], + }), + ); + expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); + }); +}); diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 6d91ab90323..419192b06fd 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -77,6 +77,115 @@ describe("stageBundledPluginRuntime", () => { expect(runtimeModule.value).toBe(1); }); + it("keeps plugin command registration on the canonical dist graph when loaded from dist-runtime", async () => { + const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-commands-"); + const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo"); + const distCommandsDir = path.join(repoRoot, "dist", "plugins"); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.mkdirSync(distCommandsDir, { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "package.json"), '{ "type": "module" }\n', "utf8"); + fs.writeFileSync( + path.join(distCommandsDir, "commands.js"), + [ + "const registry = globalThis.__openclawTestPluginCommands ??= new Map();", + "export function registerPluginCommand(pluginId, command) {", + " registry.set(`/${command.name.toLowerCase()}`, { ...command, pluginId });", + "}", + "export function clearPluginCommands() {", + " registry.clear();", + "}", + "export function getPluginCommandSpecs(provider) {", + " if (provider && provider !== 'telegram' && provider !== 'discord') return [];", + " return Array.from(registry.values()).map((command) => ({", + " name: command.nativeNames?.[provider] ?? command.nativeNames?.default ?? command.name,", + " description: command.description,", + " acceptsArgs: command.acceptsArgs ?? false,", + " }));", + "}", + "export function matchPluginCommand(commandBody) {", + " const [commandName, ...rest] = commandBody.trim().split(/\\s+/u);", + " const command = registry.get(commandName.toLowerCase());", + " if (!command) return null;", + " return { command, args: rest.length > 0 ? rest.join(' ') : undefined };", + "}", + "export async function executePluginCommand(params) {", + " return params.command.handler({ args: params.args });", + "}", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(distPluginDir, "index.js"), + [ + "import { registerPluginCommand } from '../../plugins/commands.js';", + "", + "export function registerDemoCommand() {", + " registerPluginCommand('demo-plugin', {", + " name: 'pair',", + " description: 'Pair a device',", + " acceptsArgs: true,", + " nativeNames: { telegram: 'pair', discord: 'pair' },", + " handler: async ({ args }) => ({ text: `paired:${args ?? ''}` }),", + " });", + "}", + "", + ].join("\n"), + "utf8", + ); + + stageBundledPluginRuntime({ repoRoot }); + + const runtimeEntryPath = path.join(repoRoot, "dist-runtime", "extensions", "demo", "index.js"); + const canonicalCommandsPath = path.join(repoRoot, "dist", "plugins", "commands.js"); + + expect(fs.existsSync(path.join(repoRoot, "dist-runtime", "plugins", "commands.js"))).toBe( + false, + ); + + const runtimeModule = await import(`${pathToFileURL(runtimeEntryPath).href}?t=${Date.now()}`); + const commandsModule = (await import( + `${pathToFileURL(canonicalCommandsPath).href}?t=${Date.now()}` + )) as { + clearPluginCommands: () => void; + getPluginCommandSpecs: (provider?: string) => Array<{ + name: string; + description: string; + acceptsArgs: boolean; + }>; + matchPluginCommand: ( + commandBody: string, + ) => { + command: { handler: ({ args }: { args?: string }) => Promise<{ text: string }> }; + args?: string; + } | null; + executePluginCommand: (params: { + command: { handler: ({ args }: { args?: string }) => Promise<{ text: string }> }; + args?: string; + }) => Promise<{ text: string }>; + }; + + commandsModule.clearPluginCommands(); + runtimeModule.registerDemoCommand(); + + expect(commandsModule.getPluginCommandSpecs("telegram")).toEqual([ + { name: "pair", description: "Pair a device", acceptsArgs: true }, + ]); + expect(commandsModule.getPluginCommandSpecs("discord")).toEqual([ + { name: "pair", description: "Pair a device", acceptsArgs: true }, + ]); + + const match = commandsModule.matchPluginCommand("/pair now"); + expect(match).not.toBeNull(); + expect(match?.args).toBe("now"); + await expect( + commandsModule.executePluginCommand({ + command: match!.command, + args: match?.args, + }), + ).resolves.toEqual({ text: "paired:now" }); + }); + it("copies package metadata files but symlinks other non-js plugin artifacts into the runtime overlay", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-assets-"); const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs");