From 6ee409ca7b6dd53da47bfb90d159862028bf7c0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 14:57:45 -0700 Subject: [PATCH] refactor(commands): share native plugin command merging (#120972) --- .../.generated/plugin-sdk-api-baseline.sha256 | 12 +- .../src/monitor/provider.commands.test.ts | 262 ++++++++++++++++++ .../discord/src/monitor/provider.commands.ts | 116 ++++---- .../src/monitor/provider.interactions.test.ts | 81 ++++++ .../src/monitor/provider.interactions.ts | 40 +-- extensions/discord/src/monitor/provider.ts | 1 + .../src/test-support/provider.test-support.ts | 7 +- extensions/discord/src/voice/command.ts | 11 +- extensions/slack/src/monitor/slash.test.ts | 85 +++++- extensions/slack/src/monitor/slash.ts | 22 +- scripts/plugin-sdk-surface-report.mts | 6 +- src/auto-reply/commands-registry.test.ts | 42 +++ src/auto-reply/commands-registry.ts | 30 ++ src/plugin-sdk/native-command-registry.ts | 1 + 14 files changed, 610 insertions(+), 106 deletions(-) create mode 100644 extensions/discord/src/monitor/provider.commands.test.ts create mode 100644 extensions/discord/src/monitor/provider.interactions.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index d3967c1ec0c0..0ee748a61edf 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -30,7 +30,7 @@ f1f206b2b9d4283be0aabc13a05008fd8e4121d85eb540d67379159e157e4e76 module/channel 40f8e726de245e17cf7bf908bb7d6fb084f4927f5ccc20b0c089f47af1bd7a0e module/channel-dm-policy 45fd5148ed2ca6b6fdcf0c244e2b51a2fe4d5b6e4d1c3d9875c0e07a31928b12 module/channel-entry-contract 56b185eb99eb4981056befd38cd9511b655923934acb1c22aeec7d9c504bdf96 module/channel-feedback -23673efb723395ee091ba21a986c7b295fc794875c509eb04aa43e06c503bca5 module/channel-inbound +8cb9b544e252c40900ad056a16937b3487a2c4ee67b235cf33a083df9ae6b5d4 module/channel-inbound 780abefd1600c55da219da43c769c873d071ab616b5e02d22f365677ed5a7be4 module/channel-inbound-debounce b72485da4fe5d7f73352f4474d00e30b749b64c8a2066755e104958cd96036c2 module/channel-ingress-runtime f83762680c0d0aa6fa1bad9e53c70bfdf3694126dbdd9491db764a69ed3f6d67 module/channel-lifecycle @@ -51,10 +51,10 @@ e787a315cec9681a49a9e5d1af8fe4fae345561d30996888e5ef6593faae0a75 module/channel 67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config fdeffe356c7c4edeec9f8fd03edcadc375eabc7a9412e582b10c3180e3ef40fc module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -575158115f2a8528a61e5765781534dda5eee8729b61dd409819dcabd68c1ab4 module/command-auth -b908f15287dc66d3e28a8092eeeffe4bfd3ce2956acabf660c306b213e7185fb module/command-auth-native +f3a698172fca42c4effadf11ebf372b1b7d9b3c598ec9967a47db0cbe9faa989 module/command-auth +42c8abf6c5579cb751e803781ddf9e1a6b73d26769e0fb88bd3bad54b02d67e4 module/command-auth-native 396f60be14d8fe1144076cffeafc2e2ddc0379b5e664f9fd45d13a0edc4a9f2f module/command-detection -5155909167a810ca258ec9baf4814e7d495cf25bd287fbb22be55eb1a8d88dfe module/command-primitives-runtime +841389ea61d1389d1dbcc9350832e5cb5307ab5b380cbc14800ea62796e06ed7 module/command-primitives-runtime 0ab02df5c3904386bfb062e7dce0a24e2ed835b54dcc0112e367346624cb0efe module/command-status 02011f70a5de5f1861609206038b101d3e9bbf39bf45a3cfaaae56596299225b module/config-contracts 7ee5bc35d30165be33ab229a96dd6a6e3e04c620fa4c41753d568798c514feb3 module/config-mutation @@ -96,7 +96,7 @@ e5d2b540090c17602a1c36b42559f7b516e20730e06bbdd8606597c66ef2ef95 module/memory- 577d82ef09e96de6af83a22c9b89cc1ddf2d76964abac5808b102ce04ced294f module/model-session-runtime e775d34923ab24ed4be8bf60b115e6869a9cd5bfca4d8eb9324c5b67209edce9 module/models-provider-runtime b518b4caa7f138d448fe08f5fc9e5f67da5d4b26aafaee2370feb2300e1baa21 module/native-command-config-runtime -1e8b5e7bd8234919d339e750368cbe6425e7463d7e63eb5200d728cc17d6e959 module/native-command-registry +7a5b56c37e5d862feab8258eb9f79f44e6946333c93b0c22dd3f792b33a7c19a module/native-command-registry 15d1d490e4f7909d2d563b83988258a9cc5fa1344cffb0cd3dcd7592c0467632 module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe f8b250da3eab2f85b2bf358872f910b29e541ea04358498a6deab353fa0520fc module/plugin-config-runtime @@ -110,7 +110,7 @@ f319c52a0f1655de752efa55a983893e56e4afa0727f6cc4896c3238c984e91c module/reply-c 43562c8fd0c976227051da084698c9aab757a769685b9630cbda8b8cc4cdbb16 module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history ecc6e3190892ff416ce69d26c8ce0aa08a468c8689ca3606c18b0271e2d87597 module/reply-payload -2622e09ff245d5813d305ccb2aadfe485d503484172da9a903508bc4edb344c7 module/reply-runtime +1dec24891dabc4d1556aa184add3754389f2233683041db3dbcefb7b11a40eca module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk 292405cda407e2569f731212f6f981b12bfe682836d5f6e58c6ae194ba27f144 module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command diff --git a/extensions/discord/src/monitor/provider.commands.test.ts b/extensions/discord/src/monitor/provider.commands.test.ts new file mode 100644 index 000000000000..ad2ef9e9a9c1 --- /dev/null +++ b/extensions/discord/src/monitor/provider.commands.test.ts @@ -0,0 +1,262 @@ +import { listNativeCommandSpecsForConfig as listRealNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/command-auth-native"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { discordSetupPlugin } from "../channel.setup.js"; +import { DISCORD_VOICE_COMMAND_SPEC } from "../voice/command.js"; +import { resolveDiscordProviderCommandSpecs } from "./provider.commands.js"; + +type ResolverParams = Parameters[0]; +type SkillCommands = ReturnType>; + +const cfg: OpenClawConfig = {}; +const skillCommands = [ + { name: "skill-only", skillName: "Skill Only", description: "Skill only" }, + { name: "extra-skill", skillName: "Extra Skill", description: "Extra skill" }, +]; + +function createResolverHarness( + options: { + pluginCommandSpecs?: NativeCommandSpec[]; + voiceEnabled?: boolean; + nativeCommandSpecs?: NativeCommandSpec[]; + skillCommands?: SkillCommands; + maxDiscordCommands?: number; + nativeSkillsEnabled?: boolean; + } = {}, +) { + const error = vi.fn(); + const log = vi.fn(); + const runtime: RuntimeEnv = { error, log, exit: vi.fn() }; + const configuredSkillCommands = options.skillCommands ?? skillCommands; + const nativeCommandSpecs = options.nativeCommandSpecs ?? [ + { name: "built-in", description: "Built in", acceptsArgs: false }, + ]; + const listSkillCommandsForAgents = vi.fn(() => configuredSkillCommands); + const listNativeCommandSpecsForConfig = vi.fn( + ( + _config: OpenClawConfig, + listOptions?: Parameters>[1], + ): NativeCommandSpec[] => [ + ...nativeCommandSpecs, + ...(listOptions?.skillCommands ?? []).map((skill) => ({ + name: skill.name, + description: skill.description, + acceptsArgs: true, + })), + ], + ); + const getPluginCommandSpecs = vi.fn(() => options.pluginCommandSpecs ?? []); + + return { + error, + getPluginCommandSpecs, + listNativeCommandSpecsForConfig, + listSkillCommandsForAgents, + log, + resolve: () => + resolveDiscordProviderCommandSpecs({ + cfg, + runtime, + nativeEnabled: true, + nativeSkillsEnabled: options.nativeSkillsEnabled ?? true, + voiceEnabled: options.voiceEnabled ?? false, + maxDiscordCommands: options.maxDiscordCommands ?? 3, + listSkillCommandsForAgents, + listNativeCommandSpecsForConfig, + getPluginCommandSpecs, + }), + }; +} + +describe("resolveDiscordProviderCommandSpecs", () => { + beforeEach(() => { + resetPluginRuntimeStateForTest(); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + }); + + it("discards provisional skill collisions when command overflow removes skills", async () => { + const harness = createResolverHarness({ + voiceEnabled: true, + maxDiscordCommands: 4, + pluginCommandSpecs: [ + { + name: "skill-only", + description: "Plugin skill alias", + descriptionLocalizations: { de: "Plugin-Fertigkeitsalias" }, + acceptsArgs: false, + }, + { name: "plugin-unique", description: "Unique plugin", acceptsArgs: false }, + ], + }); + + const resolved = await harness.resolve(); + + expect(resolved.skillCommands).toEqual([]); + expect(resolved.commandSpecs.map((command) => command.name)).toEqual([ + "built-in", + "vc", + "skill-only", + "plugin-unique", + ]); + expect(resolved.commandSpecs[2]).toEqual({ + name: "skill-only", + description: "Plugin skill alias", + descriptionLocalizations: { de: "Plugin-Fertigkeitsalias" }, + acceptsArgs: false, + }); + expect(harness.error).not.toHaveBeenCalled(); + expect(harness.getPluginCommandSpecs).toHaveBeenCalledOnce(); + expect(harness.getPluginCommandSpecs).toHaveBeenCalledWith("discord", { config: cfg }); + expect(harness.listNativeCommandSpecsForConfig).toHaveBeenCalledTimes(2); + expect(harness.log).toHaveBeenCalledOnce(); + expect(harness.log).toHaveBeenCalledWith( + warn( + "5 commands exceed the 4-command Discord limit; removing per-skill commands and keeping /skill.", + ), + ); + }); + + it("logs a final built-in collision once when command overflow retries without skills", async () => { + const harness = createResolverHarness({ + voiceEnabled: true, + maxDiscordCommands: 4, + pluginCommandSpecs: [ + { name: "built-in", description: "Built-in collision", acceptsArgs: false }, + { name: "plugin-unique", description: "Unique plugin", acceptsArgs: false }, + ], + }); + + const resolved = await harness.resolve(); + + expect(resolved.skillCommands).toEqual([]); + expect(resolved.commandSpecs.map((command) => command.name)).toEqual([ + "built-in", + "vc", + "plugin-unique", + ]); + expect(harness.error).toHaveBeenCalledOnce(); + expect(harness.error).toHaveBeenCalledWith( + danger( + 'discord: plugin command "/built-in" duplicates an existing native command. Skipping.', + ), + ); + expect(harness.getPluginCommandSpecs).toHaveBeenCalledOnce(); + expect(harness.getPluginCommandSpecs).toHaveBeenCalledWith("discord", { config: cfg }); + expect(harness.listNativeCommandSpecsForConfig).toHaveBeenCalledTimes(2); + }); + + it("counts voice in the exact Discord command limit", async () => { + const harness = createResolverHarness({ + voiceEnabled: true, + nativeSkillsEnabled: false, + maxDiscordCommands: 100, + nativeCommandSpecs: Array.from({ length: 99 }, (_value, index) => ({ + name: `command-${String(index + 1)}`, + description: `Command ${String(index + 1)}`, + acceptsArgs: false, + })), + }); + + const resolved = await harness.resolve(); + + expect(resolved.commandSpecs).toHaveLength(100); + expect(resolved.commandSpecs.at(-1)).toBe(DISCORD_VOICE_COMMAND_SPEC); + expect(harness.log).not.toHaveBeenCalled(); + }); + + it("retains voice ownership when a plugin claims vc", async () => { + const harness = createResolverHarness({ + voiceEnabled: true, + nativeSkillsEnabled: false, + maxDiscordCommands: 100, + pluginCommandSpecs: [{ name: "vc", description: "Plugin voice", acceptsArgs: false }], + }); + + const resolved = await harness.resolve(); + + expect(resolved.commandSpecs.map((command) => command.name)).toEqual(["built-in", "vc"]); + expect(resolved.commandSpecs[1]).toBe(DISCORD_VOICE_COMMAND_SPEC); + expect(harness.error).toHaveBeenCalledOnce(); + expect(harness.error).toHaveBeenCalledWith( + danger('discord: plugin command "/vc" duplicates an existing native command. Skipping.'), + ); + expect(harness.getPluginCommandSpecs).toHaveBeenCalledOnce(); + }); + + it("keeps a skill named vc from shadowing or duplicating voice", async () => { + const vcSkillCommands: SkillCommands = [ + { name: "vc", skillName: "Voice Skill", description: "Voice skill" }, + ]; + const harness = createResolverHarness({ + voiceEnabled: true, + maxDiscordCommands: 100, + skillCommands: vcSkillCommands, + }); + + const resolved = await harness.resolve(); + + expect(resolved.skillCommands).toEqual(vcSkillCommands); + expect(resolved.commandSpecs.map((command) => command.name)).toEqual(["built-in", "vc"]); + expect(resolved.commandSpecs[1]).toBe(DISCORD_VOICE_COMMAND_SPEC); + expect(harness.error).not.toHaveBeenCalled(); + }); + + it("deduplicates provider-renamed primary specs before Discord cap planning", async () => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "discord", plugin: discordSetupPlugin, source: "test" }]), + ); + const voiceSkill: SkillCommands[number] = { + name: "voice", + skillName: "Voice Skill", + description: "Skill voice", + }; + const config: OpenClawConfig = { commands: { native: true, nativeSkills: true } }; + const rawPrimary = listRealNativeCommandSpecsForConfig(config, { + provider: "discord", + skillCommands: [voiceSkill], + }); + const rawVoice = rawPrimary.filter( + (spec) => normalizeLowercaseStringOrEmpty(spec.name) === "voice", + ); + const uniqueCount = new Set( + rawPrimary.map((spec) => normalizeLowercaseStringOrEmpty(spec.name)).filter(Boolean), + ).size; + expect(rawVoice).toHaveLength(2); + expect(rawVoice.map((spec) => spec.description)).toEqual([ + "Control text-to-speech (TTS).", + "Skill voice", + ]); + const log = vi.fn(); + + const resolved = await resolveDiscordProviderCommandSpecs({ + cfg: config, + runtime: { log, error: vi.fn(), exit: vi.fn() }, + nativeEnabled: true, + nativeSkillsEnabled: true, + voiceEnabled: false, + maxDiscordCommands: uniqueCount, + listSkillCommandsForAgents: vi.fn(() => [voiceSkill]), + getPluginCommandSpecs: vi.fn(() => []), + }); + + expect(resolved.skillCommands).toEqual([voiceSkill]); + expect(resolved.commandSpecs).toHaveLength(uniqueCount); + expect( + resolved.commandSpecs.filter( + (spec) => normalizeLowercaseStringOrEmpty(spec.name) === "voice", + ), + ).toEqual([expect.objectContaining({ description: "Control text-to-speech (TTS)." })]); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/discord/src/monitor/provider.commands.ts b/extensions/discord/src/monitor/provider.commands.ts index 7be27418444b..bfe1ee943766 100644 --- a/extensions/discord/src/monitor/provider.commands.ts +++ b/extensions/discord/src/monitor/provider.commands.ts @@ -2,15 +2,16 @@ import { listNativeCommandSpecsForConfig, listSkillCommandsForAgents, - type NativeCommandSpec, } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { - normalizeLowercaseStringOrEmpty, - normalizeStringEntriesLower, -} from "openclaw/plugin-sdk/string-coerce-runtime"; + mergeNativeCommandSpecs, + type NativeCommandSpec, +} from "openclaw/plugin-sdk/native-command-registry"; +import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { DISCORD_VOICE_COMMAND_SPEC } from "../voice/command.js"; export type GetPluginCommandSpecs = typeof import("openclaw/plugin-sdk/plugin-runtime").getPluginCommandSpecs; @@ -20,43 +21,12 @@ const loadPluginCommandSpecs = createLazyRuntimeNamedExport( "getPluginCommandSpecs", ); -async function appendPluginCommandSpecs(params: { - commandSpecs: NativeCommandSpec[]; - runtime: RuntimeEnv; - cfg: OpenClawConfig; - getPluginCommandSpecs?: GetPluginCommandSpecs; -}): Promise { - const merged = [...params.commandSpecs]; - const existingNames = new Set(normalizeStringEntriesLower(merged.map((spec) => spec.name))); - const getPluginCommandSpecs = params.getPluginCommandSpecs ?? (await loadPluginCommandSpecs()); - for (const pluginCommand of getPluginCommandSpecs("discord", { config: params.cfg })) { - const normalizedName = normalizeLowercaseStringOrEmpty(pluginCommand.name); - if (!normalizedName) { - continue; - } - if (existingNames.has(normalizedName)) { - params.runtime.error?.( - danger( - `discord: plugin command "/${normalizedName}" duplicates an existing native command. Skipping.`, - ), - ); - continue; - } - existingNames.add(normalizedName); - merged.push({ - name: pluginCommand.name, - description: pluginCommand.description, - acceptsArgs: pluginCommand.acceptsArgs, - }); - } - return merged; -} - export async function resolveDiscordProviderCommandSpecs(params: { cfg: OpenClawConfig; runtime: RuntimeEnv; nativeEnabled: boolean; nativeSkillsEnabled: boolean; + voiceEnabled: boolean; maxDiscordCommands?: number; listSkillCommandsForAgents?: typeof listSkillCommandsForAgents; listNativeCommandSpecsForConfig?: typeof listNativeCommandSpecsForConfig; @@ -69,24 +39,57 @@ export async function resolveDiscordProviderCommandSpecs(params: { const listNativeCommandSpecs = params.listNativeCommandSpecsForConfig ?? listNativeCommandSpecsForConfig; const maxDiscordCommands = params.maxDiscordCommands ?? 100; + const pluginCommandSpecs = params.nativeEnabled + ? (params.getPluginCommandSpecs ?? (await loadPluginCommandSpecs()))("discord", { + config: params.cfg, + }) + : []; + const onCollision = (normalizedName: string) => { + params.runtime.error?.( + danger( + `discord: plugin command "/${normalizedName}" duplicates an existing native command. Skipping.`, + ), + ); + }; + const mergePluginCommandSpecs = ( + primary: readonly NativeCommandSpec[], + collisionHandler: (normalizedName: string) => void, + ) => + mergeNativeCommandSpecs({ + primary, + secondary: pluginCommandSpecs, + onCollision: collisionHandler, + }); + const listPrimaryCommandSpecs = ( + skillCommands: ReturnType, + ): NativeCommandSpec[] => { + const standardSpecs = listNativeCommandSpecs(params.cfg, { + skillCommands, + provider: "discord", + }); + if (!params.voiceEnabled) { + return standardSpecs; + } + // The specialized voice handler owns /vc before plugin merging and cap planning; + // remove generic specs so the deployed catalog cannot contain a shadow duplicate. + const voiceName = DISCORD_VOICE_COMMAND_SPEC.name; + return [ + ...standardSpecs.filter((spec) => normalizeLowercaseStringOrEmpty(spec.name) !== voiceName), + DISCORD_VOICE_COMMAND_SPEC, + ]; + }; + // Defer first-pass diagnostics until the skill-limit decision. A collision can disappear + // when fallback removes skills, so only the final retained command set should report it. + const provisionalCollisions: string[] = []; let skillCommands = params.nativeEnabled && params.nativeSkillsEnabled ? listSkillCommands({ cfg: params.cfg }) : []; let commandSpecs = params.nativeEnabled - ? listNativeCommandSpecs(params.cfg, { - skillCommands, - provider: "discord", - }) + ? mergePluginCommandSpecs(listPrimaryCommandSpecs(skillCommands), (normalizedName) => + provisionalCollisions.push(normalizedName), + ) : []; - if (params.nativeEnabled) { - commandSpecs = await appendPluginCommandSpecs({ - commandSpecs, - runtime: params.runtime, - cfg: params.cfg, - getPluginCommandSpecs: params.getPluginCommandSpecs, - }); - } const initialCommandCount = commandSpecs.length; if ( params.nativeEnabled && @@ -94,21 +97,16 @@ export async function resolveDiscordProviderCommandSpecs(params: { commandSpecs.length > maxDiscordCommands ) { skillCommands = []; - commandSpecs = listNativeCommandSpecs(params.cfg, { - skillCommands: [], - provider: "discord", - }); - commandSpecs = await appendPluginCommandSpecs({ - commandSpecs, - runtime: params.runtime, - cfg: params.cfg, - getPluginCommandSpecs: params.getPluginCommandSpecs, - }); + commandSpecs = mergePluginCommandSpecs(listPrimaryCommandSpecs([]), onCollision); params.runtime.log?.( warn( `${initialCommandCount} commands exceed the ${maxDiscordCommands}-command Discord limit; removing per-skill commands and keeping /skill.`, ), ); + } else { + for (const normalizedName of provisionalCollisions) { + onCollision(normalizedName); + } } if (params.nativeEnabled && commandSpecs.length > maxDiscordCommands) { params.runtime.log?.( diff --git a/extensions/discord/src/monitor/provider.interactions.test.ts b/extensions/discord/src/monitor/provider.interactions.test.ts new file mode 100644 index 000000000000..6bb33a2dd009 --- /dev/null +++ b/extensions/discord/src/monitor/provider.interactions.test.ts @@ -0,0 +1,81 @@ +import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { describe, expect, it, vi } from "vitest"; +import { DISCORD_VOICE_COMMAND_SPEC } from "../voice/command.js"; +import { createDiscordProviderInteractionSurface } from "./provider.interactions.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.manager.js"; + +type InteractionParams = Parameters[0]; +type CreateNativeCommand = NonNullable; + +const normalCommandSpec: NativeCommandSpec = { + name: "normal", + description: "Normal command", + acceptsArgs: false, +}; + +function createInteractionHarness(params: { + commandSpecs: NativeCommandSpec[]; + voiceEnabled: boolean; +}) { + const createNativeCommand = vi.fn( + (options: Parameters[0]): ReturnType => + ({ name: options.command.name }) as ReturnType, + ); + const surface = createDiscordProviderInteractionSurface({ + cfg: {} as OpenClawConfig, + discordConfig: { + agentComponents: { enabled: false }, + execApprovals: { enabled: false }, + } as DiscordAccountConfig, + accountId: "default", + token: "token", + commandSpecs: params.commandSpecs, + nativeEnabled: true, + voiceEnabled: params.voiceEnabled, + groupPolicy: "open", + useAccessGroups: false, + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + voiceManagerRef: { current: null }, + guildEntries: undefined, + allowFrom: [], + dmPolicy: "open", + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } satisfies RuntimeEnv, + createNativeCommand, + }); + return { createNativeCommand, surface }; +} + +describe("createDiscordProviderInteractionSurface", () => { + it("constructs the resolver-owned vc spec as the specialized voice command", () => { + const { createNativeCommand, surface } = createInteractionHarness({ + commandSpecs: [normalCommandSpec, DISCORD_VOICE_COMMAND_SPEC], + voiceEnabled: true, + }); + + expect(createNativeCommand).toHaveBeenCalledOnce(); + expect(createNativeCommand).toHaveBeenCalledWith( + expect.objectContaining({ command: normalCommandSpec }), + ); + expect(surface.commands.map((command) => command.name)).toEqual(["normal", "vc"]); + const voiceCommand = surface.commands[1]; + expect(voiceCommand?.serialize().options?.map((option) => option.name)).toEqual([ + "join", + "leave", + "status", + ]); + }); + + it("does not append a hidden voice command when voice is disabled", () => { + const { createNativeCommand, surface } = createInteractionHarness({ + commandSpecs: [normalCommandSpec], + voiceEnabled: false, + }); + + expect(createNativeCommand).toHaveBeenCalledOnce(); + expect(surface.commands.map((command) => command.name)).toEqual(["normal"]); + }); +}); diff --git a/extensions/discord/src/monitor/provider.interactions.ts b/extensions/discord/src/monitor/provider.interactions.ts index 211e6a4767a8..4a706978af88 100644 --- a/extensions/discord/src/monitor/provider.interactions.ts +++ b/extensions/discord/src/monitor/provider.interactions.ts @@ -2,8 +2,8 @@ import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime"; import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract"; import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context"; -import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth-native"; import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { createDiscordActivityButton } from "../activities/interaction.js"; import { @@ -11,7 +11,7 @@ import { isDiscordExecApprovalClientEnabled, } from "../exec-approvals.js"; import type { BaseCommand, BaseMessageInteractiveComponent, Modal } from "../internal/discord.js"; -import { createDiscordVoiceCommand } from "../voice/command.js"; +import { createDiscordVoiceCommand, DISCORD_VOICE_COMMAND_SPEC } from "../voice/command.js"; import { createAgentComponentControls, createDiscordComponentControls, @@ -60,20 +60,13 @@ export function createDiscordProviderInteractionSurface(params: { modals: Modal[]; } { const createNativeCommand = params.createNativeCommand ?? createDiscordNativeCommand; - const commands: BaseCommand[] = params.commandSpecs.map((spec) => - createNativeCommand({ - command: spec, - cfg: params.cfg, - discordConfig: params.discordConfig, - accountId: params.accountId, - sessionPrefix: params.sessionPrefix, - ephemeralDefault: params.ephemeralDefault, - threadBindings: params.threadBindings, - }), - ); - if (params.nativeEnabled && params.voiceEnabled) { - commands.push( - createDiscordVoiceCommand({ + const commands: BaseCommand[] = params.commandSpecs.map((spec) => { + if ( + params.nativeEnabled && + params.voiceEnabled && + spec.name === DISCORD_VOICE_COMMAND_SPEC.name + ) { + return createDiscordVoiceCommand({ cfg: params.cfg, discordConfig: params.discordConfig, accountId: params.accountId, @@ -81,9 +74,18 @@ export function createDiscordProviderInteractionSurface(params: { useAccessGroups: params.useAccessGroups, getManager: () => params.voiceManagerRef.current, ephemeralDefault: params.ephemeralDefault, - }), - ); - } + }); + } + return createNativeCommand({ + command: spec, + cfg: params.cfg, + discordConfig: params.discordConfig, + accountId: params.accountId, + sessionPrefix: params.sessionPrefix, + ephemeralDefault: params.ephemeralDefault, + threadBindings: params.threadBindings, + }); + }); const execApprovalsConfig = params.discordConfig.execApprovals ?? {}; const execApprovalsEnabled = isDiscordExecApprovalClientEnabled({ diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index c35505907161..f509f50e3f0b 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -249,6 +249,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { runtime, nativeEnabled, nativeSkillsEnabled, + voiceEnabled, listSkillCommandsForAgents: discordProviderRuntime.listSkillCommandsForAgents, listNativeCommandSpecsForConfig: discordProviderRuntime.listNativeCommandSpecsForConfig, getPluginCommandSpecs: discordProviderRuntime.getPluginCommandSpecs, diff --git a/extensions/discord/src/test-support/provider.test-support.ts b/extensions/discord/src/test-support/provider.test-support.ts index 36afc514c158..abf0815a2045 100644 --- a/extensions/discord/src/test-support/provider.test-support.ts +++ b/extensions/discord/src/test-support/provider.test-support.ts @@ -471,7 +471,12 @@ vi.mock(buildDiscordSourceModuleId("token.js"), () => ({ })); vi.mock(buildDiscordSourceModuleId("voice/command.js"), () => ({ - createDiscordVoiceCommand: () => ({ name: "voice-command" }), + DISCORD_VOICE_COMMAND_SPEC: { + name: "vc", + description: "Voice channel controls", + acceptsArgs: false, + }, + createDiscordVoiceCommand: () => ({ name: "vc" }), })); vi.mock(buildDiscordSourceModuleId("monitor/agent-components.js"), () => ({ diff --git a/extensions/discord/src/voice/command.ts b/extensions/discord/src/voice/command.ts index 72689a5b783a..05f738455e68 100644 --- a/extensions/discord/src/voice/command.ts +++ b/extensions/discord/src/voice/command.ts @@ -6,6 +6,7 @@ import { } from "discord-api-types/v10"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry"; import { Command, CommandWithSubcommands, @@ -25,6 +26,12 @@ const VOICE_CHANNEL_TYPES: NonNullable { }; }); +const pluginCommandFixtures = vi.hoisted(() => ({ + specs: [] as NativeCommandSpec[], +})); + +const skillCommandFixtures = vi.hoisted(() => ({ + commands: [] as Array<{ name: string; skillName: string; description: string }>, +})); + vi.mock("./slash-commands.runtime.js", async () => { const actual = await vi.importActual( "./slash-commands.runtime.js", @@ -146,6 +152,26 @@ vi.mock("./slash-commands.runtime.js", async () => { }; }); +vi.mock("./slash-plugin-commands.runtime.js", async () => { + const actual = await vi.importActual( + "./slash-plugin-commands.runtime.js", + ); + return { + ...actual, + listProviderPluginCommandSpecs: () => pluginCommandFixtures.specs, + }; +}); + +vi.mock("./slash-skill-commands.runtime.js", async () => { + const actual = await vi.importActual( + "./slash-skill-commands.runtime.js", + ); + return { + ...actual, + listSkillCommandsForAgents: () => skillCommandFixtures.commands, + }; +}); + type RegisterFn = (params: { ctx: unknown; account: unknown; @@ -157,11 +183,15 @@ const { registerSlackMonitorSlashCommands } = (await import("./slash.js")) as { const { dispatchMock } = getSlackSlashMocks(); beforeEach(() => { + pluginCommandFixtures.specs = []; + skillCommandFixtures.commands = []; clearRuntimeConfigSnapshot(); resetSlackSlashMocks(); }); afterEach(() => { + pluginCommandFixtures.specs = []; + skillCommandFixtures.commands = []; clearRuntimeConfigSnapshot(); }); @@ -193,6 +223,7 @@ function createArgMenusHarness( cfg: OpenClawConfig = { commands: { native: true, nativeSkills: false } }, ) { const commands = new Map Promise>(); + const commandRegistrations: Array = []; const actions = new Map Promise>(); const options = new Map Promise>(); const optionsReceiverContexts: unknown[] = []; @@ -201,6 +232,7 @@ function createArgMenusHarness( const app = { client: { chat: { postEphemeral } }, command: (name: string | RegExp, handler: (args: unknown) => Promise) => { + commandRegistrations.push(name); commands.set(name, handler); }, action: (id: string | RegExp, handler: (args: unknown) => Promise) => { @@ -246,6 +278,7 @@ function createArgMenusHarness( } as unknown; return { + commandRegistrations, commands, actions, options, @@ -726,6 +759,50 @@ describe("Slack native command argument menus", () => { expect(testHarness.optionsReceiverContexts[0]).toBe(testHarness.app); }); + it("registers unique plugin commands and silently keeps primary names on collision", async () => { + pluginCommandFixtures.specs = [ + { name: "slackplugin", description: "Unique plugin command", acceptsArgs: false }, + { name: "reportlong", description: "Colliding plugin command", acceptsArgs: false }, + ]; + const testHarness = createArgMenusHarness(); + const runtimeLog = vi.fn(); + const runtimeError = vi.fn(); + ( + testHarness.ctx as { runtime: { log: typeof runtimeLog; error: typeof runtimeError } } + ).runtime = { log: runtimeLog, error: runtimeError }; + + await registerCommands(testHarness.ctx, testHarness.account); + + expect(testHarness.commands.has("/slackplugin")).toBe(true); + expect(testHarness.commandRegistrations.filter((name) => name === "/slackplugin")).toHaveLength( + 1, + ); + expect(testHarness.commandRegistrations.filter((name) => name === "/reportlong")).toHaveLength( + 1, + ); + expect(runtimeLog).not.toHaveBeenCalled(); + expect(runtimeError).not.toHaveBeenCalled(); + }); + + it("deduplicates a skill after the Slack status native rename", async () => { + skillCommandFixtures.commands = [ + { + name: "agentstatus", + skillName: "Agent Status Skill", + description: "Skill agent status", + }, + ]; + const config: OpenClawConfig = { commands: { native: true, nativeSkills: true } }; + const testHarness = createArgMenusHarness(config); + (testHarness.account as { config: OpenClawConfig }).config = config; + + await registerCommands(testHarness.ctx, testHarness.account); + + expect(testHarness.commandRegistrations.filter((name) => name === "/agentstatus")).toHaveLength( + 1, + ); + }); + it.each([ { agentRuntime: "codex", includesUltra: false }, { agentRuntime: "openclaw", includesUltra: true }, diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 03fdc1e85379..e987891b8dc8 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -23,6 +23,10 @@ import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, } from "openclaw/plugin-sdk/native-command-config-runtime"; +import { + mergeNativeCommandSpecs, + type NativeCommandSpec, +} from "openclaw/plugin-sdk/native-command-registry"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; @@ -30,7 +34,6 @@ import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-s import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, - normalizeStringEntriesLower, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { chunkItems } from "openclaw/plugin-sdk/text-chunking"; import type { ResolvedSlackAccount } from "../accounts.js"; @@ -877,7 +880,7 @@ export async function registerSlackMonitorSlashCommands(params: { } }; - let nativeCommands: Array<{ name: string }> = []; + let nativeCommands: NativeCommandSpec[] = []; let slashCommandsRuntime: typeof import("./slash-commands.runtime.js") | null = null; if ( registration.mode === "disabled" && @@ -899,18 +902,11 @@ export async function registerSlackMonitorSlashCommands(params: { skillCommands, provider: "slack", }); - const existingNativeNames = new Set( - normalizeStringEntriesLower(nativeCommands.map((command) => command.name)), - ); const { listProviderPluginCommandSpecs } = await loadSlackPluginCommandsRuntime(); - for (const pluginCommand of listProviderPluginCommandSpecs("slack")) { - const normalizedName = normalizeLowercaseStringOrEmpty(pluginCommand.name); - if (!normalizedName || existingNativeNames.has(normalizedName)) { - continue; - } - existingNativeNames.add(normalizedName); - nativeCommands.push(pluginCommand); - } + nativeCommands = mergeNativeCommandSpecs({ + primary: nativeCommands, + secondary: listProviderPluginCommandSpecs("slack"), + }); registration = nativeCommands.length > 0 ? { mode: "native" } : { mode: "disabled" }; } diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index f3beb0a536b8..13b557a5978e 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -250,7 +250,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +3: channel streaming config reader re-exports and session-agent scope resolver. // +3: session-catalog terminal-start provider request and Gateway params/result contracts. // +1: worker desktop endpoint contract for desktop-capable worker leases. - 4848, + // +1: native command spec merger through the native-command-registry facade. + 4849, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -303,7 +304,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: simple channel secret contract factory replacing repeated collectors. // +4: focused agent scope functions for doctor migration enumeration. // +3: channel streaming config reader functions and session-agent scope resolver. - 2920, + // +1: native command spec merger through the native-command-registry facade. + 2921, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 67c4de20acd5..46220223b840 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -12,6 +12,7 @@ import { listChatCommandsForConfig, listNativeCommandSpecs, listNativeCommandSpecsForConfig, + mergeNativeCommandSpecs, normalizeCommandBody, parseCommandArgs, resolveCommandArgChoices, @@ -381,6 +382,47 @@ describe("commands registry", () => { }); }); + it("merges native command specs with primary precedence and stable secondary order", () => { + const primary: readonly NativeCommandSpec[] = [ + { name: " Primary ", description: "primary", acceptsArgs: false }, + { name: "", description: "blank primary", acceptsArgs: false }, + { name: "PRIMARY", description: "duplicate primary", acceptsArgs: false }, + ]; + const acceptedSecondary: NativeCommandSpec = { + name: "Secondary", + description: "secondary", + descriptionLocalizations: { de: "Sekundär" }, + acceptsArgs: true, + args: [{ name: "value", description: "value", type: "string" }], + isAlias: true, + }; + const secondary: readonly NativeCommandSpec[] = [ + { name: "primary", description: "primary collision", acceptsArgs: false }, + { name: " ", description: "blank secondary", acceptsArgs: false }, + acceptedSecondary, + { name: " secondary ", description: "secondary collision", acceptsArgs: false }, + { name: "third", description: "third", acceptsArgs: false }, + ]; + const primaryBefore = structuredClone(primary); + const secondaryBefore = structuredClone(secondary); + const collisions: string[] = []; + + const merged = mergeNativeCommandSpecs({ + primary, + secondary, + onCollision: (name) => collisions.push(name), + }); + + expect(merged).toEqual([primary[0], acceptedSecondary, secondary[4]]); + expect(merged).not.toBe(primary); + expect(merged[0]).toBe(primary[0]); + expect(merged[1]).toBe(acceptedSecondary); + expect(merged[2]).toBe(secondary[4]); + expect(collisions).toEqual(["primary", "secondary"]); + expect(primary).toEqual(primaryBefore); + expect(secondary).toEqual(secondaryBefore); + }); + it("applies discord native command overrides", () => { installDiscordNativeCommandOverrides(); const native = listNativeCommandSpecsForConfig( diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 350753ba44d4..51d50ab6cc9f 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -163,6 +163,36 @@ export function listNativeCommandSpecsForConfig( return listNativeSpecsFromCommands(listChatCommandsForConfig(cfg, params), params?.provider); } +export function mergeNativeCommandSpecs(params: { + primary: readonly NativeCommandSpec[]; + secondary: readonly NativeCommandSpec[]; + onCollision?: (normalizedName: string) => void; +}): NativeCommandSpec[] { + const merged: NativeCommandSpec[] = []; + const names = new Set(); + const append = (spec: NativeCommandSpec, reportCollision: boolean) => { + const normalizedName = normalizeOptionalLowercaseString(spec.name); + if (!normalizedName) { + return; + } + if (names.has(normalizedName)) { + if (reportCollision) { + params.onCollision?.(normalizedName); + } + return; + } + names.add(normalizedName); + merged.push(spec); + }; + for (const spec of params.primary) { + append(spec, false); + } + for (const spec of params.secondary) { + append(spec, true); + } + return merged; +} + /** Finds a command definition by provider-native command name or native alias. */ export function findCommandByNativeName( name: string, diff --git a/src/plugin-sdk/native-command-registry.ts b/src/plugin-sdk/native-command-registry.ts index 63d0f07a397a..65827975f4bd 100644 --- a/src/plugin-sdk/native-command-registry.ts +++ b/src/plugin-sdk/native-command-registry.ts @@ -8,6 +8,7 @@ export { listChatCommands, listNativeCommandSpecs, listNativeCommandSpecsForConfig, + mergeNativeCommandSpecs, parseCommandArgs, resolveCommandArgChoices, resolveCommandArgMenu,