From 0e4ddf7b388a373ac5366dbcb1f6c91996550def Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 20:56:17 -0400 Subject: [PATCH] Tests: avoid bundled Discord runtime lookup --- .../native-command.plugin-dispatch.test.ts | 86 +++++++++---------- .../discord/src/monitor/native-command.ts | 40 ++++++--- src/auto-reply/commands-registry.test.ts | 18 ++++ src/auto-reply/commands-registry.ts | 22 ++++- 4 files changed, 107 insertions(+), 59 deletions(-) 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 7cd6e435924..5f233fa1784 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -1,17 +1,25 @@ import { ChannelType } from "discord-api-types/v10"; import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth"; +import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearPluginCommands, + executePluginCommand, + matchPluginCommand, + registerPluginCommand, +} from "openclaw/plugin-sdk/plugin-runtime"; +import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestRegistry, setActivePluginRegistry, } from "../../../../test/helpers/plugins/plugin-registry.js"; +import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js"; import { createMockCommandInteraction, type MockCommandInteraction, } from "./native-command.test-helpers.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.manager.js"; let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand; let discordNativeCommandTesting: typeof import("./native-command.js").__testing; @@ -22,33 +30,6 @@ const runtimeModuleMocks = vi.hoisted(() => ({ resolveDirectStatusReplyForSession: vi.fn(), })); -vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/plugin-runtime", - ); - return { - ...actual, - matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args), - executePluginCommand: (...args: unknown[]) => runtimeModuleMocks.executePluginCommand(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/reply-runtime", - ); - return { - ...actual, - dispatchReplyWithDispatcher: (...args: unknown[]) => - runtimeModuleMocks.dispatchReplyWithDispatcher(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({ - resolveDirectStatusReplyForSession: (...args: unknown[]) => - runtimeModuleMocks.resolveDirectStatusReplyForSession(...args), -})); - function createInteraction(params?: { channelType?: ChannelType; channelId?: string; @@ -270,13 +251,16 @@ async function expectPairCommandReply(params: { cfg: OpenClawConfig; commandName: string; interaction: MockCommandInteraction; + expectedRegisteredName?: string; }) { const command = await createPluginCommand({ cfg: params.cfg, name: params.commandName, }); const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher; - + const executeSpy = runtimeModuleMocks.executePluginCommand.mockResolvedValue({ + text: "paired:now", + }); await (command as { run: (interaction: unknown) => Promise }).run( Object.assign(params.interaction, { options: { @@ -288,6 +272,12 @@ async function expectPairCommandReply(params: { ); expect(dispatchSpy).not.toHaveBeenCalled(); + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ name: params.expectedRegisteredName ?? "pair" }), + args: "now", + }), + ); expect(params.interaction.followUp).toHaveBeenCalledWith( expect.objectContaining({ content: "paired:now" }), ); @@ -338,21 +328,28 @@ describe("Discord native plugin command dispatch", () => { await import("./native-command.js")); }); - beforeEach(async () => { + afterAll(() => { + clearPluginCommands(); + setActivePluginRegistry(createTestRegistry()); + discordNativeCommandTesting.setMatchPluginCommand(matchPluginCommand); + discordNativeCommandTesting.setExecutePluginCommand(executePluginCommand); + discordNativeCommandTesting.setDispatchReplyWithDispatcher(dispatchReplyWithDispatcher); + discordNativeCommandTesting.setResolveDirectStatusReplyForSession( + resolveDirectStatusReplyForSession, + ); + discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState( + resolveDiscordNativeInteractionRouteState, + ); + }); + + beforeEach(() => { vi.clearAllMocks(); clearPluginCommands(); setActivePluginRegistry(createTestRegistry()); - const actualPluginRuntime = await vi.importActual< - typeof import("openclaw/plugin-sdk/plugin-runtime") - >("openclaw/plugin-sdk/plugin-runtime"); runtimeModuleMocks.matchPluginCommand.mockReset(); - runtimeModuleMocks.matchPluginCommand.mockImplementation( - actualPluginRuntime.matchPluginCommand, - ); + runtimeModuleMocks.matchPluginCommand.mockImplementation(matchPluginCommand); runtimeModuleMocks.executePluginCommand.mockReset(); - runtimeModuleMocks.executePluginCommand.mockImplementation( - actualPluginRuntime.executePluginCommand, - ); + runtimeModuleMocks.executePluginCommand.mockImplementation(executePluginCommand); runtimeModuleMocks.dispatchReplyWithDispatcher.mockReset(); runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({ counts: { @@ -372,7 +369,10 @@ describe("Discord native plugin command dispatch", () => { runtimeModuleMocks.executePluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand, ); discordNativeCommandTesting.setDispatchReplyWithDispatcher( - runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher, + runtimeModuleMocks.dispatchReplyWithDispatcher as typeof dispatchReplyWithDispatcher, + ); + discordNativeCommandTesting.setResolveDirectStatusReplyForSession( + runtimeModuleMocks.resolveDirectStatusReplyForSession as typeof resolveDirectStatusReplyForSession, ); discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) => createUnboundRouteState({ @@ -436,7 +436,6 @@ describe("Discord native plugin command dispatch", () => { description: "Pair", acceptsArgs: true, }; - const command = await createNativeCommand(cfg, commandSpec); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId: "234567890123456789", @@ -455,6 +454,7 @@ describe("Discord native plugin command dispatch", () => { handler: async ({ args }) => ({ text: `open:${args ?? ""}` }), }), ).toEqual({ ok: true }); + const command = await createNativeCommand(cfg, commandSpec); const executeSpy = runtimeModuleMocks.executePluginCommand; const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue( diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 22fb5e9c464..69ed16921f7 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -94,6 +94,7 @@ const DISCORD_COMMAND_DESCRIPTION_MAX = 100; let matchPluginCommandImpl = pluginRuntime.matchPluginCommand; let executePluginCommandImpl = pluginRuntime.executePluginCommand; let dispatchReplyWithDispatcherImpl = dispatchReplyWithDispatcher; +let resolveDirectStatusReplyForSessionImpl = resolveDirectStatusReplyForSession; let resolveDiscordNativeInteractionRouteStateImpl = resolveDiscordNativeInteractionRouteState; export const __testing = { @@ -118,6 +119,13 @@ export const __testing = { dispatchReplyWithDispatcherImpl = next; return previous; }, + setResolveDirectStatusReplyForSession( + next: typeof resolveDirectStatusReplyForSession, + ): typeof resolveDirectStatusReplyForSession { + const previous = resolveDirectStatusReplyForSessionImpl; + resolveDirectStatusReplyForSessionImpl = next; + return previous; + }, setResolveDiscordNativeInteractionRouteState( next: typeof resolveDiscordNativeInteractionRouteState, ): typeof resolveDiscordNativeInteractionRouteState { @@ -621,6 +629,19 @@ async function safeDiscordInteractionCall( } } +function createNativeCommandDefinition(command: NativeCommandSpec): ChatCommandDefinition { + return { + key: command.name, + nativeName: command.name, + description: command.description, + textAliases: [], + acceptsArgs: command.acceptsArgs, + args: command.args, + argsParsing: "none", + scope: "native", + }; +} + export function createDiscordNativeCommand(params: { command: NativeCommandSpec; cfg: ReturnType; @@ -639,18 +660,13 @@ export function createDiscordNativeCommand(params: { ephemeralDefault, threadBindings, } = params; + const fallbackCommandDefinition = createNativeCommandDefinition(command); const commandDefinition = - findCommandByNativeName(command.name, "discord") ?? - ({ - key: command.name, - nativeName: command.name, - description: command.description, - textAliases: [], - acceptsArgs: command.acceptsArgs, - args: command.args, - argsParsing: "none", - scope: "native", - } satisfies ChatCommandDefinition); + matchPluginCommandImpl(`/${command.name}`) !== null + ? fallbackCommandDefinition + : (findCommandByNativeName(command.name, "discord", { + includeBundledChannelFallback: false, + }) ?? fallbackCommandDefinition); const argDefinitions = commandDefinition.args ?? command.args; const commandOptions = buildDiscordCommandOptions({ command: commandDefinition, @@ -1130,7 +1146,7 @@ async function dispatchDiscordCommandInteraction(params: { }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId); if (!suppressReplies && commandName === "status") { - const statusReply = await resolveDirectStatusReplyForSession({ + const statusReply = await resolveDirectStatusReplyForSessionImpl({ cfg, sessionKey: commandTargetSessionKey?.trim() || sessionKey, channel: "discord", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index af81f326074..a4309e1232e 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -172,6 +172,24 @@ describe("commands registry", () => { expect(native.find((spec) => spec.name === "agentstatus")).toBeTruthy(); expect(findCommandByNativeName("agentstatus", "slack")?.key).toBe("status"); expect(findCommandByNativeName("status", "slack")).toBeUndefined(); + expect( + findCommandByNativeName("agentstatus", "slack", { + includeBundledChannelFallback: false, + })?.key, + ).toBe("status"); + expect( + findCommandByNativeName("status", "slack", { + includeBundledChannelFallback: false, + }), + ).toBeUndefined(); + }); + + it("can resolve default native command names without loading bundled channel fallbacks", () => { + expect( + findCommandByNativeName("status", "discord", { + includeBundledChannelFallback: false, + })?.key, + ).toBe("status"); }); it("keeps discord native command specs within slash-command limits", () => { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 8a5555cd268..4fb7687b9d9 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,7 +1,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { SkillCommandSpec } from "../agents/skills.js"; -import { getChannelPlugin } from "../channels/plugins/index.js"; +import { getChannelPlugin, getLoadedChannelPlugin } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeLowercaseStringOrEmpty, @@ -55,15 +55,27 @@ export type { ShouldHandleTextCommandsParams, } from "./commands-registry.types.js"; -function resolveNativeName(command: ChatCommandDefinition, provider?: string): string | undefined { +type NativeCommandProviderLookupOptions = { + includeBundledChannelFallback?: boolean; +}; + +function resolveNativeName( + command: ChatCommandDefinition, + provider?: string, + options?: NativeCommandProviderLookupOptions, +): string | undefined { if (!command.nativeName) { return undefined; } if (!provider) { return command.nativeName; } + const channelPlugin = + options?.includeBundledChannelFallback === false + ? getLoadedChannelPlugin(provider) + : getChannelPlugin(provider); return ( - getChannelPlugin(provider)?.commands?.resolveNativeCommandName?.({ + channelPlugin?.commands?.resolveNativeCommandName?.({ commandKey: command.key, defaultName: command.nativeName, }) ?? command.nativeName @@ -108,6 +120,7 @@ export function listNativeCommandSpecsForConfig( export function findCommandByNativeName( name: string, provider?: string, + options?: NativeCommandProviderLookupOptions, ): ChatCommandDefinition | undefined { const normalized = normalizeOptionalLowercaseString(name); if (!normalized) { @@ -116,7 +129,8 @@ export function findCommandByNativeName( return getChatCommands().find( (command) => command.scope !== "text" && - normalizeOptionalLowercaseString(resolveNativeName(command, provider)) === normalized, + normalizeOptionalLowercaseString(resolveNativeName(command, provider, options)) === + normalized, ); }