fix: truncate skill command descriptions to 100 chars for Discord (#1018)

* fix: truncate skill command descriptions to 100 chars for Discord

Discord slash commands have a 100 character limit for descriptions.
Skill descriptions were not being truncated, causing command registration
to fail with an empty error from the Discord API.

* style: format

* style: format
This commit is contained in:
Wilkins
2026-01-16 16:01:59 +00:00
committed by GitHub
parent 0d6af15d1c
commit bb14b19922
11 changed files with 83 additions and 66 deletions

View File

@@ -50,7 +50,7 @@ test("background exec still times out after tool signal abort", async () => {
const result = await tool.execute( const result = await tool.execute(
"toolcall", "toolcall",
{ {
command: "node -e \"setTimeout(() => {}, 5000)\"", command: 'node -e "setTimeout(() => {}, 5000)"',
background: true, background: true,
timeout: 0.2, timeout: 0.2,
}, },
@@ -85,7 +85,7 @@ test("yielded background exec is not killed when tool signal aborts", async () =
const result = await tool.execute( const result = await tool.execute(
"toolcall", "toolcall",
{ command: "node -e \"setTimeout(() => {}, 5000)\"", yieldMs: 5 }, { command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 },
abortController.signal, abortController.signal,
); );
@@ -112,7 +112,7 @@ test("yielded background exec still times out", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 }); const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
const result = await tool.execute("toolcall", { const result = await tool.execute("toolcall", {
command: "node -e \"setTimeout(() => {}, 5000)\"", command: 'node -e "setTimeout(() => {}, 5000)"',
yieldMs: 5, yieldMs: 5,
timeout: 0.2, timeout: 0.2,
}); });

View File

@@ -61,4 +61,32 @@ describe("buildWorkspaceSkillCommandSpecs", () => {
expect(names).toEqual(["hello_world", "hello_world_2", "help_2"]); expect(names).toEqual(["hello_world", "hello_world_2", "help_2"]);
expect(commands.find((entry) => entry.skillName === "hidden-skill")).toBeUndefined(); expect(commands.find((entry) => entry.skillName === "hidden-skill")).toBeUndefined();
}); });
it("truncates descriptions longer than 100 characters for Discord compatibility", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const longDescription =
"This is a very long description that exceeds Discord's 100 character limit for slash command descriptions and should be truncated";
await writeSkill({
dir: path.join(workspaceDir, "skills", "long-desc"),
name: "long-desc",
description: longDescription,
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "short-desc"),
name: "short-desc",
description: "Short description",
});
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
});
const longCmd = commands.find((entry) => entry.skillName === "long-desc");
const shortCmd = commands.find((entry) => entry.skillName === "short-desc");
expect(longCmd?.description.length).toBeLessThanOrEqual(100);
expect(longCmd?.description.endsWith("…")).toBe(true);
expect(shortCmd?.description).toBe("Short description");
});
}); });

View File

@@ -87,12 +87,7 @@ function parseFrontmatterBool(value: string | undefined, fallback: boolean): boo
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true; return true;
} }
if ( if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
normalized === "false" ||
normalized === "0" ||
normalized === "no" ||
normalized === "off"
) {
return false; return false;
} }
return fallback; return fallback;

View File

@@ -50,6 +50,8 @@ function filterSkillEntries(
const SKILL_COMMAND_MAX_LENGTH = 32; const SKILL_COMMAND_MAX_LENGTH = 32;
const SKILL_COMMAND_FALLBACK = "skill"; const SKILL_COMMAND_FALLBACK = "skill";
// Discord command descriptions must be ≤100 characters
const SKILL_COMMAND_DESCRIPTION_MAX_LENGTH = 100;
function sanitizeSkillCommandName(raw: string): string { function sanitizeSkillCommandName(raw: string): string {
const normalized = raw const normalized = raw
@@ -311,9 +313,7 @@ export function buildWorkspaceSkillCommandSpecs(
opts?.skillFilter, opts?.skillFilter,
opts?.eligibility, opts?.eligibility,
); );
const userInvocable = eligible.filter( const userInvocable = eligible.filter((entry) => entry.invocation?.userInvocable !== false);
(entry) => entry.invocation?.userInvocable !== false,
);
const used = new Set<string>(); const used = new Set<string>();
for (const reserved of opts?.reservedNames ?? []) { for (const reserved of opts?.reservedNames ?? []) {
used.add(reserved.toLowerCase()); used.add(reserved.toLowerCase());
@@ -324,10 +324,15 @@ export function buildWorkspaceSkillCommandSpecs(
const base = sanitizeSkillCommandName(entry.skill.name); const base = sanitizeSkillCommandName(entry.skill.name);
const unique = resolveUniqueSkillCommandName(base, used); const unique = resolveUniqueSkillCommandName(base, used);
used.add(unique.toLowerCase()); used.add(unique.toLowerCase());
const rawDescription = entry.skill.description?.trim() || entry.skill.name;
const description =
rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH
? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…"
: rawDescription;
specs.push({ specs.push({
name: unique, name: unique,
skillName: entry.skill.name, skillName: entry.skill.name,
description: entry.skill.description?.trim() || entry.skill.name, description,
}); });
} }
return specs; return specs;

View File

@@ -62,9 +62,7 @@ function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
function buildSkillCommandDefinitions( function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatCommandDefinition[] {
skillCommands?: SkillCommandSpec[],
): ChatCommandDefinition[] {
if (!skillCommands || skillCommands.length === 0) return []; if (!skillCommands || skillCommands.length === 0) return [];
return skillCommands.map((spec) => ({ return skillCommands.map((spec) => ({
key: `skill:${spec.skillName}`, key: `skill:${spec.skillName}`,
@@ -77,7 +75,9 @@ function buildSkillCommandDefinitions(
})); }));
} }
export function listChatCommands(params?: { skillCommands?: SkillCommandSpec[] }): ChatCommandDefinition[] { export function listChatCommands(params?: {
skillCommands?: SkillCommandSpec[];
}): ChatCommandDefinition[] {
if (!params?.skillCommands?.length) return [...CHAT_COMMANDS]; if (!params?.skillCommands?.length) return [...CHAT_COMMANDS];
return [...CHAT_COMMANDS, ...buildSkillCommandDefinitions(params.skillCommands)]; return [...CHAT_COMMANDS, ...buildSkillCommandDefinitions(params.skillCommands)];
} }
@@ -98,7 +98,9 @@ export function listChatCommandsForConfig(
return [...base, ...buildSkillCommandDefinitions(params.skillCommands)]; return [...base, ...buildSkillCommandDefinitions(params.skillCommands)];
} }
export function listNativeCommandSpecs(params?: { skillCommands?: SkillCommandSpec[] }): NativeCommandSpec[] { export function listNativeCommandSpecs(params?: {
skillCommands?: SkillCommandSpec[];
}): NativeCommandSpec[] {
return listChatCommands({ skillCommands: params?.skillCommands }) return listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName) .filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({ .map((command) => ({

View File

@@ -187,34 +187,34 @@ export async function handleInlineActions(params: {
commandBodyNormalized: inlineCommand.command, commandBodyNormalized: inlineCommand.command,
}; };
const inlineResult = await handleCommands({ const inlineResult = await handleCommands({
ctx, ctx,
cfg, cfg,
command: inlineCommandContext, command: inlineCommandContext,
agentId, agentId,
directives, directives,
elevated: { elevated: {
enabled: elevatedEnabled, enabled: elevatedEnabled,
allowed: elevatedAllowed, allowed: elevatedAllowed,
failures: elevatedFailures, failures: elevatedFailures,
}, },
sessionEntry, sessionEntry,
sessionStore, sessionStore,
sessionKey, sessionKey,
storePath, storePath,
sessionScope, sessionScope,
workspaceDir, workspaceDir,
defaultGroupActivation: defaultActivation, defaultGroupActivation: defaultActivation,
resolvedThinkLevel, resolvedThinkLevel,
resolvedVerboseLevel: resolvedVerboseLevel ?? "off", resolvedVerboseLevel: resolvedVerboseLevel ?? "off",
resolvedReasoningLevel, resolvedReasoningLevel,
resolvedElevatedLevel, resolvedElevatedLevel,
resolveDefaultThinkingLevel, resolveDefaultThinkingLevel,
provider, provider,
model, model,
contextTokens, contextTokens,
isGroup, isGroup,
skillCommands, skillCommands,
}); });
if (inlineResult.reply) { if (inlineResult.reply) {
if (!inlineCommand.cleaned) { if (!inlineCommand.cleaned) {
typing.cleanup(); typing.cleanup();

View File

@@ -5,9 +5,7 @@ describe("resolveSkillCommandInvocation", () => {
it("matches skill commands and parses args", () => { it("matches skill commands and parses args", () => {
const invocation = resolveSkillCommandInvocation({ const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/demo_skill do the thing", commandBodyNormalized: "/demo_skill do the thing",
skillCommands: [ skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }],
{ name: "demo_skill", skillName: "demo-skill", description: "Demo" },
],
}); });
expect(invocation?.command.skillName).toBe("demo-skill"); expect(invocation?.command.skillName).toBe("demo-skill");
expect(invocation?.args).toBe("do the thing"); expect(invocation?.args).toBe("do the thing");
@@ -16,9 +14,7 @@ describe("resolveSkillCommandInvocation", () => {
it("returns null for unknown commands", () => { it("returns null for unknown commands", () => {
const invocation = resolveSkillCommandInvocation({ const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/unknown arg", commandBodyNormalized: "/unknown arg",
skillCommands: [ skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }],
{ name: "demo_skill", skillName: "demo-skill", description: "Demo" },
],
}); });
expect(invocation).toBeNull(); expect(invocation).toBeNull();
}); });

View File

@@ -1,9 +1,6 @@
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agents/skills.js";
buildWorkspaceSkillCommandSpecs,
type SkillCommandSpec,
} from "../agents/skills.js";
import { listChatCommands } from "./commands-registry.js"; import { listChatCommands } from "./commands-registry.js";
function resolveReservedCommandNames(): Set<string> { function resolveReservedCommandNames(): Set<string> {
@@ -42,9 +39,7 @@ export function resolveSkillCommandInvocation(params: {
if (!match) return null; if (!match) return null;
const commandName = match[1]?.trim().toLowerCase(); const commandName = match[1]?.trim().toLowerCase();
if (!commandName) return null; if (!commandName) return null;
const command = params.skillCommands.find( const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName);
(entry) => entry.name.toLowerCase() === commandName,
);
if (!command) return null; if (!command) return null;
const args = match[2]?.trim(); const args = match[2]?.trim();
return { command, args: args || undefined }; return { command, args: args || undefined };

View File

@@ -211,9 +211,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
), ),
); );
defaultRuntime.log( defaultRuntime.log(
theme.muted( theme.muted("Examples: `npm i -g clawdbot@latest` or `pnpm add -g clawdbot@latest`"),
"Examples: `npm i -g clawdbot@latest` or `pnpm add -g clawdbot@latest`",
),
); );
} }
defaultRuntime.exit(0); defaultRuntime.exit(0);

View File

@@ -124,9 +124,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
cfg, cfg,
}) })
: []; : [];
const commandSpecs = nativeEnabled const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : [];
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
: [];
const commands = commandSpecs.map((spec) => const commands = commandSpecs.map((spec) =>
createDiscordNativeCommand({ createDiscordNativeCommand({
command: spec, command: spec,

View File

@@ -185,7 +185,7 @@ export async function processMessage(params: {
const responsePrefix = const responsePrefix =
resolvedMessages.responsePrefix ?? resolvedMessages.responsePrefix ??
(configuredResponsePrefix === undefined && isSelfChat (configuredResponsePrefix === undefined && isSelfChat
? resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[clawdbot]" ? (resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[clawdbot]")
: undefined); : undefined);
// Create mutable context for response prefix template interpolation // Create mutable context for response prefix template interpolation