diff --git a/src/auto-reply/skill-commands.test.ts b/src/auto-reply/skill-commands.test.ts index e16446e50..a59a7ad0b 100644 --- a/src/auto-reply/skill-commands.test.ts +++ b/src/auto-reply/skill-commands.test.ts @@ -69,9 +69,10 @@ vi.mock("../agents/skills.js", () => { let listSkillCommandsForAgents: typeof import("./skill-commands.js").listSkillCommandsForAgents; let resolveSkillCommandInvocation: typeof import("./skill-commands.js").resolveSkillCommandInvocation; +let skillCommandsTesting: typeof import("./skill-commands.js").__testing; beforeAll(async () => { - ({ listSkillCommandsForAgents, resolveSkillCommandInvocation } = + ({ listSkillCommandsForAgents, resolveSkillCommandInvocation, __testing: skillCommandsTesting } = await import("./skill-commands.js")); }); @@ -125,7 +126,7 @@ describe("listSkillCommandsForAgents", () => { ); }); - it("lists all agents when agentIds is omitted", async () => { + it("deduplicates by skillName across agents, keeping the first registration", async () => { const baseDir = await makeTempDir("openclaw-skills-"); const mainWorkspace = path.join(baseDir, "main"); const researchWorkspace = path.join(baseDir, "research"); @@ -143,8 +144,10 @@ describe("listSkillCommandsForAgents", () => { }, }); const names = commands.map((entry) => entry.name); + // demo-skill appears in both workspaces; only the first registration (demo_skill) survives. expect(names).toContain("demo_skill"); - expect(names).toContain("demo_skill_2"); + expect(names).not.toContain("demo_skill_2"); + // extra-skill is unique to the research workspace and should be present. expect(names).toContain("extra_skill"); }); @@ -297,3 +300,38 @@ describe("listSkillCommandsForAgents", () => { expect(commands.map((entry) => entry.skillName)).toContain("demo-skill"); }); }); + +describe("dedupeBySkillName", () => { + it("keeps the first entry when multiple commands share a skillName", () => { + const input = [ + { name: "github", skillName: "github", description: "GitHub" }, + { name: "github_2", skillName: "github", description: "GitHub" }, + { name: "weather", skillName: "weather", description: "Weather" }, + { name: "weather_2", skillName: "weather", description: "Weather" }, + ]; + const output = skillCommandsTesting.dedupeBySkillName(input); + expect(output.map((e) => e.name)).toEqual(["github", "weather"]); + }); + + it("matches skillName case-insensitively", () => { + const input = [ + { name: "ClawHub", skillName: "ClawHub", description: "ClawHub" }, + { name: "clawhub_2", skillName: "clawhub", description: "ClawHub" }, + ]; + const output = skillCommandsTesting.dedupeBySkillName(input); + expect(output).toHaveLength(1); + expect(output[0]?.name).toBe("ClawHub"); + }); + + it("passes through commands with an empty skillName", () => { + const input = [ + { name: "a", skillName: "", description: "A" }, + { name: "b", skillName: "", description: "B" }, + ]; + expect(skillCommandsTesting.dedupeBySkillName(input)).toHaveLength(2); + }); + + it("returns an empty array for empty input", () => { + expect(skillCommandsTesting.dedupeBySkillName([])).toEqual([]); + }); +}); diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index 63c99e9ed..458469e9a 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -46,6 +46,26 @@ export function listSkillCommandsForWorkspace(params: { }); } +// Deduplicate skill commands by skillName, keeping the first registration. +// When multiple agents have a skill with the same name (e.g. one with a +// workspace override and one from bundled), the suffix-renamed entries +// (github_2, github_3…) are dropped so every interface sees a clean list. +function dedupeBySkillName(commands: SkillCommandSpec[]): SkillCommandSpec[] { + const seen = new Set(); + const out: SkillCommandSpec[] = []; + for (const cmd of commands) { + const key = cmd.skillName.trim().toLowerCase(); + if (key && seen.has(key)) { + continue; + } + if (key) { + seen.add(key); + } + out.push(cmd); + } + return out; +} + export function listSkillCommandsForAgents(params: { cfg: OpenClawConfig; agentIds?: string[]; @@ -109,9 +129,16 @@ export function listSkillCommandsForAgents(params: { entries.push(command); } } - return entries; + // Dedupe by skillName across workspaces so every interface (Discord, TUI, + // Slack, text) sees a consistent command list without platform-specific + // workarounds. + return dedupeBySkillName(entries); } +export const __testing = { + dedupeBySkillName, +}; + function normalizeSkillCommandLookup(value: string): string { return value .trim() diff --git a/src/discord/monitor/provider.skill-dedupe.test.ts b/src/discord/monitor/provider.skill-dedupe.test.ts index d97fa47ca..cb33c8745 100644 --- a/src/discord/monitor/provider.skill-dedupe.test.ts +++ b/src/discord/monitor/provider.skill-dedupe.test.ts @@ -1,30 +1,6 @@ import { describe, expect, it } from "vitest"; import { __testing } from "./provider.js"; -describe("dedupeSkillCommandsForDiscord", () => { - it("keeps first command per skillName and drops suffix duplicates", () => { - const input = [ - { name: "github", skillName: "github", description: "GitHub" }, - { name: "github_2", skillName: "github", description: "GitHub" }, - { name: "weather", skillName: "weather", description: "Weather" }, - { name: "weather_2", skillName: "weather", description: "Weather" }, - ]; - - const output = __testing.dedupeSkillCommandsForDiscord(input); - expect(output.map((entry) => entry.name)).toEqual(["github", "weather"]); - }); - - it("treats skillName case-insensitively", () => { - const input = [ - { name: "ClawHub", skillName: "ClawHub", description: "ClawHub" }, - { name: "clawhub_2", skillName: "clawhub", description: "ClawHub" }, - ]; - const output = __testing.dedupeSkillCommandsForDiscord(input); - expect(output).toHaveLength(1); - expect(output[0]?.name).toBe("ClawHub"); - }); -}); - describe("resolveThreadBindingsEnabled", () => { it("defaults to enabled when unset", () => { expect( diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index a4f5b13f4..5e1163725 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -126,25 +126,6 @@ function formatThreadBindingDurationForConfigLabel(durationMs: number): string { return label === "disabled" ? "off" : label; } -function dedupeSkillCommandsForDiscord( - skillCommands: ReturnType, -) { - const seen = new Set(); - const deduped: ReturnType = []; - for (const command of skillCommands) { - const key = command.skillName.trim().toLowerCase(); - if (!key) { - deduped.push(command); - continue; - } - if (seen.has(key)) { - continue; - } - seen.add(key); - deduped.push(command); - } - return deduped; -} function appendPluginCommandSpecs(params: { commandSpecs: NativeCommandSpec[]; @@ -434,7 +415,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const maxDiscordCommands = 100; let skillCommands = nativeEnabled && nativeSkillsEnabled - ? dedupeSkillCommandsForDiscord(listSkillCommandsForAgents({ cfg })) + ? listSkillCommandsForAgents({ cfg }) : []; let commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "discord" }) @@ -819,7 +800,6 @@ async function clearDiscordNativeCommands(params: { export const __testing = { createDiscordGatewayPlugin, - dedupeSkillCommandsForDiscord, resolveDiscordRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveDiscordRestFetch,