fix(skills): apply command description limits per channel (#99593)

* fix(skills): keep command spec descriptions UTF-16 safe at the truncation cut

* chore: fix oxlint no-unnecessary-type-assertion in test

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: fix Skill type completeness in test fixture

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(skills): prove UTF-16-safe command truncation

* fix(skills): apply description limits per channel

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
pick-cat
2026-07-06 09:12:51 +08:00
committed by GitHub
parent 60cf7eaa00
commit ca4e8a7dc8
7 changed files with 126 additions and 33 deletions
@@ -580,8 +580,8 @@ describe("createDiscordNativeCommand option wiring", () => {
expect(respond).toHaveBeenCalledWith([]);
});
it("truncates Discord command and option descriptions to Discord's limit", () => {
const longDescription = "x".repeat(140);
it("truncates Discord command and option descriptions on a UTF-16 boundary", () => {
const longDescription = `${"x".repeat(99)}😀 trailing`;
const cfg = {} as OpenClawConfig;
const discordConfig = {} as NonNullable<OpenClawConfig["channels"]>["discord"];
const command = createDiscordNativeCommand({
@@ -606,14 +606,12 @@ describe("createDiscordNativeCommand option wiring", () => {
threadBindings: createNoopThreadBindingManager("default"),
});
expect(command.description).toHaveLength(100);
expect(command.description).toBe("x".repeat(100));
expect(requireOption(command, "input").description).toHaveLength(100);
expect(requireOption(command, "input").description).toBe("x".repeat(100));
expect(command.description).toBe("x".repeat(99));
expect(requireOption(command, "input").description).toBe("x".repeat(99));
});
it("serializes localized command descriptions", () => {
const longDescription = "k".repeat(140);
it("serializes localized command descriptions on a UTF-16 boundary", () => {
const longDescription = `${"k".repeat(99)}😀 trailing`;
const command = createDiscordNativeCommand({
command: {
name: "localized",
@@ -634,14 +632,14 @@ describe("createDiscordNativeCommand option wiring", () => {
expect(command.descriptionLocalizations).toEqual({
ko: "현지화된 설명",
"en-GB": "k".repeat(100),
"en-GB": "k".repeat(99),
});
expect(command.serialize()).toEqual({
name: "localized",
description: "Default description",
description_localizations: {
ko: "현지화된 설명",
"en-GB": "k".repeat(100),
"en-GB": "k".repeat(99),
},
type: ApplicationCommandType.ChatInput,
integration_types: [0, 1],
@@ -8,6 +8,7 @@ import {
} from "openclaw/plugin-sdk/native-command-registry";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { AutocompleteInteraction, CommandOptions } from "../internal/discord.js";
const log = createSubsystemLogger("discord/native-command");
@@ -27,7 +28,7 @@ export function truncateDiscordCommandDescription(params: {
log.warn(
`discord: truncating native command description (${label}) from ${value.length} to ${DISCORD_COMMAND_DESCRIPTION_MAX}: ${JSON.stringify(value)}`,
);
return value.slice(0, DISCORD_COMMAND_DESCRIPTION_MAX);
return truncateUtf16Safe(value, DISCORD_COMMAND_DESCRIPTION_MAX);
}
export function truncateDiscordCommandDescriptionLocalizations(params: {
@@ -14,6 +14,7 @@ import {
describe("slash-commands", () => {
async function registerSingleStatusCommand(
requestImpl: (path: string, init?: RequestInit) => Promise<unknown>,
description = "status",
) {
const client: MattermostClient = {
baseUrl: "https://chat.example.com",
@@ -30,7 +31,7 @@ describe("slash-commands", () => {
commands: [
{
trigger: "oc_status",
description: "status",
description,
autoComplete: true,
},
],
@@ -162,6 +163,37 @@ describe("slash-commands", () => {
expect(request).toHaveBeenCalledTimes(1);
});
it("truncates command descriptions to Mattermost's UTF-8 byte limit", async () => {
const description = `${"x".repeat(127)}😀 trailing`;
const request = vi.fn(async (path: string, init?: RequestInit) => {
if (path.startsWith("/commands?team_id=")) {
return [];
}
if (path === "/commands" && init?.method === "POST") {
const body = JSON.parse(typeof init.body === "string" ? init.body : "{}");
expect(body.description).toBe("x".repeat(127));
expect(body.auto_complete_desc).toBe("x".repeat(127));
expect(Buffer.byteLength(body.description, "utf8")).toBeLessThanOrEqual(128);
return {
id: "cmd-1",
token: "tok-1",
team_id: "team-1",
creator_id: "bot-user",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "http://gateway/callback",
auto_complete: true,
};
}
throw new Error(`unexpected request path: ${path}`);
});
const result = await registerSingleStatusCommand(request, description);
expect(result).toHaveLength(1);
expect(request).toHaveBeenCalledTimes(2);
});
it("skips foreign command trigger collisions instead of mutating non-owned commands", async () => {
const request = vi.fn(async (path: string, init?: { method?: string }) => {
if (path.startsWith("/commands?team_id=")) {
@@ -5,6 +5,26 @@ import type { MattermostClient } from "./client.js";
// ─── Types ───────────────────────────────────────────────────────────────────
export const MATTERMOST_SLASH_POST_METHOD = "P";
const MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES = 128;
// Mattermost rejects command descriptions above 128 UTF-8 bytes. Keep portable
// descriptions intact until this API boundary so other channels retain their text.
function truncateMattermostCommandDescription(description: string): string {
if (Buffer.byteLength(description, "utf8") <= MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES) {
return description;
}
let bytes = 0;
let end = 0;
for (const char of description) {
const charBytes = Buffer.byteLength(char, "utf8");
if (bytes + charBytes > MATTERMOST_COMMAND_DESCRIPTION_MAX_BYTES) {
break;
}
bytes += charBytes;
end += char.length;
}
return description.slice(0, end);
}
export type MattermostSlashCommandConfig = {
/** Enable native slash commands. "auto" resolves to false for now (opt-in). */
@@ -177,7 +197,8 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
originalName: "queue",
description: "Adjust active-run queue behavior",
autoComplete: true,
autoCompleteHint: "[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
autoCompleteHint:
"[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
},
];
@@ -290,6 +311,7 @@ export async function registerSlashCommands(params: {
const registered: MattermostRegisteredCommand[] = [];
for (const spec of commands) {
const description = truncateMattermostCommandDescription(spec.description);
const existingForTrigger = existingByTrigger.get(spec.trigger) ?? [];
const ownedCommands = existingForTrigger.filter(
(cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId,
@@ -344,9 +366,9 @@ export async function registerSlashCommands(params: {
trigger: spec.trigger,
method: MATTERMOST_SLASH_POST_METHOD,
url: callbackUrl,
description: spec.description,
description,
auto_complete: spec.autoComplete,
auto_complete_desc: spec.description,
auto_complete_desc: description,
auto_complete_hint: spec.autoCompleteHint,
});
registered.push({
@@ -383,9 +405,9 @@ export async function registerSlashCommands(params: {
trigger: spec.trigger,
method: MATTERMOST_SLASH_POST_METHOD,
url: callbackUrl,
description: spec.description,
description,
auto_complete: spec.autoComplete,
auto_complete_desc: spec.description,
auto_complete_desc: description,
auto_complete_hint: spec.autoCompleteHint,
});
log?.(`mattermost: registered command /${spec.trigger} (id=${created.id})`);