refactor(commands): share native plugin command merging (#120972)

This commit is contained in:
Peter Steinberger
2026-08-09 14:57:45 -07:00
committed by GitHub
parent 0dbdf994b3
commit 6ee409ca7b
14 changed files with 610 additions and 106 deletions
@@ -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
@@ -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<typeof resolveDiscordProviderCommandSpecs>[0];
type SkillCommands = ReturnType<NonNullable<ResolverParams["listSkillCommandsForAgents"]>>;
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<NonNullable<ResolverParams["listNativeCommandSpecsForConfig"]>>[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();
});
});
@@ -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<NativeCommandSpec[]> {
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<typeof listSkillCommandsForAgents>,
): 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?.(
@@ -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<typeof createDiscordProviderInteractionSurface>[0];
type CreateNativeCommand = NonNullable<InteractionParams["createNativeCommand"]>;
const normalCommandSpec: NativeCommandSpec = {
name: "normal",
description: "Normal command",
acceptsArgs: false,
};
function createInteractionHarness(params: {
commandSpecs: NativeCommandSpec[];
voiceEnabled: boolean;
}) {
const createNativeCommand = vi.fn(
(options: Parameters<CreateNativeCommand>[0]): ReturnType<CreateNativeCommand> =>
({ name: options.command.name }) as ReturnType<CreateNativeCommand>,
);
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"]);
});
});
@@ -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({
@@ -249,6 +249,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
runtime,
nativeEnabled,
nativeSkillsEnabled,
voiceEnabled,
listSkillCommandsForAgents: discordProviderRuntime.listSkillCommandsForAgents,
listNativeCommandSpecsForConfig: discordProviderRuntime.listNativeCommandSpecsForConfig,
getPluginCommandSpecs: discordProviderRuntime.getPluginCommandSpecs,
@@ -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"), () => ({
+9 -2
View File
@@ -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<APIApplicationCommandChannelOption["chann
DiscordChannelType.GuildStageVoice,
];
export const DISCORD_VOICE_COMMAND_SPEC = {
name: "vc",
description: "Voice channel controls",
acceptsArgs: false,
} satisfies NativeCommandSpec;
type VoiceCommandContext = {
cfg: OpenClawConfig;
discordConfig: DiscordAccountConfig;
@@ -272,8 +279,8 @@ export function createDiscordVoiceCommand(params: VoiceCommandContext): CommandW
}
return new (class extends CommandWithSubcommands {
override name = "vc";
override description = "Voice channel controls";
override name = DISCORD_VOICE_COMMAND_SPEC.name;
override description = DISCORD_VOICE_COMMAND_SPEC.description;
subcommands = [new JoinCommand(), new LeaveCommand(), new StatusCommand()];
})();
}
+81 -4
View File
@@ -1,10 +1,8 @@
import type {
ChatCommandDefinition,
NativeCommandSpec,
} from "openclaw/plugin-sdk/command-auth-native";
import type { ChatCommandDefinition } from "openclaw/plugin-sdk/command-auth-native";
// Slack tests cover slash plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import type { NativeCommandSpec } from "openclaw/plugin-sdk/native-command-registry";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
@@ -118,6 +116,14 @@ const slashCommandFixtures = vi.hoisted(() => {
};
});
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<typeof import("./slash-commands.runtime.js")>(
"./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<typeof import("./slash-plugin-commands.runtime.js")>(
"./slash-plugin-commands.runtime.js",
);
return {
...actual,
listProviderPluginCommandSpecs: () => pluginCommandFixtures.specs,
};
});
vi.mock("./slash-skill-commands.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./slash-skill-commands.runtime.js")>(
"./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<string | RegExp, (args: unknown) => Promise<void>>();
const commandRegistrations: Array<string | RegExp> = [];
const actions = new Map<string | RegExp, (args: unknown) => Promise<void>>();
const options = new Map<string, (args: unknown) => Promise<void>>();
const optionsReceiverContexts: unknown[] = [];
@@ -201,6 +232,7 @@ function createArgMenusHarness(
const app = {
client: { chat: { postEphemeral } },
command: (name: string | RegExp, handler: (args: unknown) => Promise<void>) => {
commandRegistrations.push(name);
commands.set(name, handler);
},
action: (id: string | RegExp, handler: (args: unknown) => Promise<void>) => {
@@ -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 },
+9 -13
View File
@@ -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" };
}
+4 -2
View File
@@ -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(
+42
View File
@@ -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(
+30
View File
@@ -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<string>();
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,
@@ -8,6 +8,7 @@ export {
listChatCommands,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
mergeNativeCommandSpecs,
parseCommandArgs,
resolveCommandArgChoices,
resolveCommandArgMenu,