fix(gateway): keep command-list field clamping UTF-16 safe (#102816)

* fix(gateway): keep command-list field clamping UTF-16 safe

clampString truncated command names, descriptions, aliases, arg names/descriptions, and choice values/labels with a raw value.slice(0, maxLength). When an emoji (or other astral code point) straddles the clamp limit, the raw slice keeps a dangling high surrogate and emits a lone surrogate over the gateway commands.list protocol result. Route the clamp through the shared truncateUtf16Safe primitive so the boundary code point is dropped whole. Adds a regression test through buildCommandsListResult.

* test(gateway): fold UTF-16 clamp regression into bounds coverage

Co-authored-by: MoerAI <friendnt@g.skku.edu>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ToToKr
2026-07-09 23:25:59 +09:00
committed by GitHub
parent 772e0f7683
commit fe18ceda76
2 changed files with 5 additions and 2 deletions
@@ -1,6 +1,7 @@
// Command list serialization gathers chat, skill, and plugin commands into the
// gateway protocol result while clamping names, descriptions, aliases, and args.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type {
CommandEntry,
CommandsListResult,
@@ -36,7 +37,7 @@ type SerializedArg = NonNullable<CommandEntry["args"]>[number];
type CommandNameSurface = "text" | "native";
function clampString(value: string, maxLength: number): string {
return value.length > maxLength ? value.slice(0, maxLength) : value;
return value.length > maxLength ? truncateUtf16Safe(value, maxLength) : value;
}
function trimClampNonEmpty(value: string, maxLength: number): string | null {
+3 -1
View File
@@ -517,7 +517,8 @@ describe("commands.list handler", () => {
const originalCommands = [...mockChatCommands];
const longToken = "x".repeat(COMMAND_NAME_MAX_LENGTH + 50);
const aliasBase = "alias".repeat(20);
const longDescription = "d".repeat(COMMAND_DESCRIPTION_MAX_LENGTH + 50);
const descriptionPrefix = "d".repeat(COMMAND_DESCRIPTION_MAX_LENGTH - 1);
const longDescription = `${descriptionPrefix}😀tail`;
const oversizedArgs = Array.from({ length: COMMAND_ARGS_MAX_ITEMS + 5 }, (_, argIndex) => ({
name: `${longToken}-${argIndex}`,
description: longDescription,
@@ -554,6 +555,7 @@ describe("commands.list handler", () => {
expect((first.description as string).length).toBeLessThanOrEqual(
COMMAND_DESCRIPTION_MAX_LENGTH,
);
expect(first.description).toBe(descriptionPrefix);
expect((first.textAliases as unknown[]).length).toBeLessThanOrEqual(COMMAND_ALIAS_MAX_ITEMS);
expect(first.args as unknown[]).toHaveLength(COMMAND_ARGS_MAX_ITEMS);
const firstArg = (first.args as Array<Record<string, unknown>>)[0];