fix(text): keep bounded outputs UTF-16 safe (#101355)

Co-authored-by: Alix-007 <li.long15@xydigit.com>
This commit is contained in:
Vincent Koc
2026-07-06 21:33:24 -07:00
committed by GitHub
parent da20b65231
commit 84e5327720
18 changed files with 230 additions and 13 deletions

View File

@@ -0,0 +1,11 @@
// Discord tests cover error body summary behavior.
import { describe, expect, it } from "vitest";
import { summarizeDiscordResponseBody } from "./error-body.js";
describe("summarizeDiscordResponseBody", () => {
it("keeps truncated summaries on a UTF-16 boundary", () => {
const summary = summarizeDiscordResponseBody(`${"a".repeat(239)}😀tail`);
expect(summary).toBe("a".repeat(239));
});
});

View File

@@ -1,4 +1,6 @@
// Discord plugin module implements error body behavior.
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
const DISCORD_RESPONSE_BODY_SUMMARY_MAX_CHARS = 240;
export function summarizeDiscordResponseBody(
@@ -18,7 +20,7 @@ export function summarizeDiscordResponseBody(
if (!summary) {
return opts.emptyText;
}
return summary.slice(0, DISCORD_RESPONSE_BODY_SUMMARY_MAX_CHARS);
return truncateUtf16Safe(summary, DISCORD_RESPONSE_BODY_SUMMARY_MAX_CHARS);
}
export function isDiscordHtmlResponseBody(body: string, contentType?: string | null): boolean {

View File

@@ -173,4 +173,19 @@ describe("stringifyTelegramRawUpdateForLog", () => {
expect(rawLog).not.toContain(privateValue);
}
});
it("truncates long raw update strings without splitting UTF-16 surrogate pairs", () => {
const prefix = "a".repeat(499);
const rawLog = stringifyTelegramRawUpdateForLog({
update_id: 123,
diagnostic: `${prefix}\uD83D\uDE80tail`,
});
const parsed = JSON.parse(rawLog) as { diagnostic: string };
expect(parsed.diagnostic).toBe(`${prefix}...`);
expect(parsed.diagnostic).not.toContain("\uD83D");
expect(parsed.diagnostic).not.toContain("\uDE80");
expect(rawLog).not.toContain("\\ud83d");
expect(rawLog).not.toContain("\\ude80");
});
});

View File

@@ -1,4 +1,6 @@
// Telegram plugin module implements raw update log behavior.
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
const MAX_RAW_UPDATE_STRING = 500;
const MAX_RAW_UPDATE_ARRAY = 20;
const REDACTED_TELEGRAM_FIELD = "[redacted]";
@@ -80,7 +82,7 @@ export function stringifyTelegramRawUpdateForLog(update: unknown): string {
}
if (typeof value === "string") {
return value.length > MAX_RAW_UPDATE_STRING
? `${value.slice(0, MAX_RAW_UPDATE_STRING)}...`
? `${truncateUtf16Safe(value, MAX_RAW_UPDATE_STRING)}...`
: value;
}
if (Array.isArray(value)) {

View File

@@ -444,6 +444,13 @@ describe("MediaStreamHandler security hardening", () => {
expect(reason).toContain("forged line entry");
});
it("truncates websocket close reason without splitting UTF-16 surrogate pairs", () => {
const reason = sanitizeLogText(`abc\uD83D\uDE80tail`, 4);
expect(reason).toBe("abc...");
expect(reason).not.toContain("\uD83D");
expect(reason).not.toContain("\uDE80");
});
it("closes idle pre-start connections after timeout", async () => {
const shouldAcceptStreamCalls: Array<{ callId: string; streamSid: string; token?: string }> =
[];

View File

@@ -23,6 +23,7 @@ import {
type TalkEventInput,
type TalkSessionController,
} from "openclaw/plugin-sdk/realtime-voice";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { type RawData, WebSocket, WebSocketServer } from "ws";
/**
@@ -109,7 +110,7 @@ export function sanitizeLogText(value: string, maxChars: number): string {
if (sanitized.length <= maxChars) {
return sanitized;
}
return `${sanitized.slice(0, maxChars)}...`;
return `${truncateUtf16Safe(sanitized, maxChars)}...`;
}
function normalizeWsMessageData(data: RawData): Buffer {

View File

@@ -1930,6 +1930,48 @@ describe("VoiceCallWebhookServer barge-in suppression during initial message", (
};
};
it("logs streaming transcripts without splitting UTF-16 surrogate pairs", async () => {
const manager = {
getActiveCalls: () => [],
getCallByProviderCallId: vi.fn(() => undefined),
endCall: vi.fn(async () => ({ success: true })),
speakInitialMessage: vi.fn(async () => {}),
processEvent: vi.fn(),
} as unknown as CallManager;
const config = createConfig({
provider: "twilio",
streaming: {
...createConfig().streaming,
enabled: true,
providers: {
openai: {
apiKey: "test-key", // pragma: allowlist secret
},
},
},
});
const server = new VoiceCallWebhookServer(config, manager, createTwilioProvider(vi.fn()));
await server.start();
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const transcript = `${"a".repeat(199)}\uD83D\uDE80tail`;
getMediaCallbacks(server).config.onTranscript?.("CA-utf16", transcript);
const transcriptLog = logSpy.mock.calls
.map(([message]) => String(message))
.find((message) => message.includes("Transcript for CA-utf16"));
expect(transcriptLog).toContain(`${"a".repeat(199)}...`);
expect(transcriptLog).not.toContain("\uD83D");
expect(transcriptLog).not.toContain("\uDE80");
} finally {
logSpy.mockRestore();
warnSpy.mockRestore();
await server.stop();
}
});
it("suppresses barge-in clear while outbound conversation initial message is pending", async () => {
const call = createCall(Date.now() - 1_000);
call.callId = "call-barge";

View File

@@ -13,6 +13,7 @@ import {
normalizeOptionalString,
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
createWebhookInFlightLimiter,
normalizeWebhookPath,
@@ -79,7 +80,7 @@ function sanitizeTranscriptForLog(value: string): string {
if (sanitized.length <= TRANSCRIPT_LOG_MAX_CHARS) {
return sanitized;
}
return `${sanitized.slice(0, TRANSCRIPT_LOG_MAX_CHARS)}...`;
return `${truncateUtf16Safe(sanitized, TRANSCRIPT_LOG_MAX_CHARS)}...`;
}
function appendRecentTalkEventMetadata(call: CallRecord, event: TalkEvent): void {

View File

@@ -115,6 +115,8 @@ const {
getTtsProvider,
maybeApplyTtsToPayload,
resolveTtsConfig,
setSummarizationEnabled,
setTtsMaxLength,
synthesizeSpeech,
textToSpeechTelephony,
} = await import("./tts.js");
@@ -938,6 +940,35 @@ describe("speech-core native voice-note routing", () => {
}
});
it("truncates long TTS text on a UTF-16 boundary", async () => {
const prefsName = "openclaw-speech-core-utf16-truncate-test";
const prefsPath = `/tmp/${prefsName}.json`;
const cfg = createTtsConfig(prefsName);
setTtsMaxLength(prefsPath, 11);
setSummarizationEnabled(prefsPath, false);
let mediaDir: string | undefined;
try {
const result = await maybeApplyTtsToPayload({
payload: { text: `${"a".repeat(7)}😀tail long enough for TTS` },
cfg,
channel: "telegram",
kind: "final",
});
expect(synthesizeMock).toHaveBeenCalled();
const request = requireFirstSynthesisRequest("utf16 truncated TTS request");
const spokenText = String(request.text);
expect(spokenText).toBe(`${"a".repeat(7)}...`);
expect(result.spokenText).toBe(spokenText);
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
} finally {
rmSync(prefsPath, { force: true });
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
});
it("skips block delivery kind in final mode (accumulated final tail synthesizes instead)", async () => {
synthesizeMock.mockClear();
const cfg = createTtsConfig("openclaw-speech-core-block-kind-tts-test");

View File

@@ -33,7 +33,11 @@ import {
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
import { resolveConfigDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
import {
resolveConfigDir,
resolveUserPath,
truncateUtf16Safe,
} from "openclaw/plugin-sdk/text-utility-runtime";
import {
canonicalizeSpeechProviderId,
getSpeechProvider,
@@ -2028,7 +2032,7 @@ export async function maybeApplyTtsToPayload(params: {
logVerbose(
`TTS: truncating long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
);
textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
textForAudio = `${truncateUtf16Safe(textForAudio, maxLength - 3)}...`;
} else {
try {
const summary = await summarizeText({
@@ -2044,12 +2048,12 @@ export async function maybeApplyTtsToPayload(params: {
logVerbose(
`TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
);
textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
textForAudio = `${truncateUtf16Safe(textForAudio, config.maxTextLength - 3)}...`;
}
} catch (err) {
const error = err as Error;
logVerbose(`TTS: summarization failed, truncating instead: ${error.message}`);
textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
textForAudio = `${truncateUtf16Safe(textForAudio, maxLength - 3)}...`;
}
}
}

View File

@@ -1,6 +1,6 @@
// web_fetch extraction utility tests cover HTML entity decoding.
import { describe, expect, it } from "vitest";
import { htmlToMarkdown } from "./web-fetch-utils.js";
import { htmlToMarkdown, truncateText } from "./web-fetch-utils.js";
describe("web-fetch-utils htmlToMarkdown entity decoding", () => {
const grin = String.fromCodePoint(0x1f600); // 😀 — an astral (> U+FFFF) code point
@@ -39,4 +39,13 @@ describe("web-fetch-utils htmlToMarkdown entity decoding", () => {
// not be consumed by a lenient parseInt (e.g. "&#39x;" must not become "'").
expect(htmlToMarkdown(`<p>&#39x; end</p>`).text).toBe("&#39x; end");
});
it("truncates without splitting a boundary emoji", () => {
const prefix = "a".repeat(79);
const result = truncateText(`${prefix}${grin}tail`, 80);
expect(result.truncated).toBe(true);
expect(result.text).toBe(prefix);
expect(result.text).not.toContain(String.fromCharCode(0xd83d));
});
});

View File

@@ -3,6 +3,7 @@
*
* Converts lightweight HTML into bounded markdown/text without pulling in a full renderer.
*/
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { decodeHtmlEntityAt } from "../utils/html.js";
import { sanitizeHtml, stripInvisibleUnicode } from "./web-fetch-visibility.js";
@@ -130,7 +131,7 @@ export function truncateText(
if (value.length <= maxChars) {
return { text: value, truncated: false };
}
return { text: value.slice(0, maxChars), truncated: true };
return { text: truncateUtf16Safe(value, maxChars), truncated: true };
}
/** Sanitizes HTML and extracts either markdown or plain text content. */

View File

@@ -7,6 +7,7 @@ import {
normalizeNullableString,
} from "@openclaw/normalization-core/string-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { MediaUnderstandingSkipError } from "../../packages/media-understanding-common/src/errors.js";
import { extractGeminiResponse } from "../../packages/media-understanding-common/src/output-extract.js";
import {
@@ -100,7 +101,7 @@ function trimOutput(text: string, maxChars?: number): string {
if (!maxChars || trimmed.length <= maxChars) {
return trimmed;
}
return trimmed.slice(0, maxChars).trim();
return truncateUtf16Safe(trimmed, maxChars).trim();
}
function extractSherpaOnnxText(raw: string): { matched: boolean; text: string } {

View File

@@ -41,6 +41,53 @@ function requireCapabilityOutput(result: CapabilityResult, index: number) {
}
describe("runCapability video provider wiring", () => {
it("truncates provider output without splitting a boundary emoji", async () => {
await withVideoFixture("openclaw-video-utf16-output", async ({ ctx, media, cache }) => {
const prefix = "v".repeat(79);
const result = await runCapability({
capability: "video",
cfg: {
models: {
providers: {
moonshot: {
apiKey: "test-key",
models: [],
},
},
},
tools: {
media: {
video: {
enabled: true,
models: [{ provider: "moonshot", model: "kimi-k2.5", maxChars: 80 }],
},
},
},
} as unknown as OpenClawConfig,
ctx,
attachments: cache,
media,
providerRegistry: new Map<string, MediaUnderstandingProvider>([
[
"moonshot",
{
id: "moonshot",
capabilities: ["video"],
describeVideo: async (req) => ({
text: `${prefix}${String.fromCodePoint(0x1f600)}tail`,
model: req.model,
}),
},
],
]),
});
const output = requireCapabilityOutput(result, 0);
expect(output.text).toBe(prefix);
expect(output.text).not.toContain(String.fromCharCode(0xd83d));
});
});
it("merges video baseUrl and headers with entry precedence", async () => {
let seenBaseUrl: string | undefined;
let seenHeaders: Record<string, string> | undefined;

View File

@@ -40,4 +40,14 @@ describe("realtime voice consult question helpers", () => {
),
).toBe("abcdefgh [truncated]");
});
it("does not split a boundary emoji in truncated speakable text", () => {
const result = readSpeakableRealtimeVoiceToolResult(
{ text: `${"a".repeat(7)}😀${"b".repeat(20)}` },
{ maxChars: 23 },
);
expect(result).toBe(`${"a".repeat(7)} [truncated]`);
expect(result).toContain("[truncated]");
});
});

View File

@@ -5,6 +5,7 @@
* pulling human-readable questions/results out of provider-owned payloads.
*/
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { readTrimmedStringAlias } from "../utils/string-readers.js";
const REALTIME_VOICE_CONSULT_QUESTION_STOPWORDS = new Set([
@@ -166,5 +167,5 @@ function limitSpeakableRealtimeVoiceToolResult(
}
// Reserve space for the marker so callers can keep audio replies under a
// provider or UX limit without losing the truncation signal.
return `${trimmed.slice(0, Math.max(0, maxChars - 16)).trimEnd()} [truncated]`;
return `${truncateUtf16Safe(trimmed, Math.max(0, maxChars - 16)).trimEnd()} [truncated]`;
}

View File

@@ -198,6 +198,35 @@ describe("resolveStatusTtsSnapshot", () => {
});
});
it("keeps truncated status detail fields well-formed at UTF-16 boundaries", async () => {
await withStatusTempHome(async () => {
const displayName = `${"d".repeat(92)}😀tail`;
const model = `${"m".repeat(92)}😀tail`;
const voice = `${"v".repeat(92)}😀tail`;
const snapshot = resolveStatusTtsSnapshot({
cfg: {
messages: {
tts: {
auto: "always",
provider: "elevenlabs",
providers: {
elevenlabs: {
displayName,
model,
voice,
},
},
},
},
} as OpenClawConfig,
});
expect(snapshot?.displayName).toBe(`${"d".repeat(92)}...`);
expect(snapshot?.model).toBe(`${"m".repeat(92)}...`);
expect(snapshot?.voice).toBe(`${"v".repeat(92)}...`);
});
});
it("omits default OpenAI endpoint details from status", async () => {
await withStatusTempHome(async () => {
expect(

View File

@@ -5,6 +5,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { OpenClawConfig } from "../config/types.js";
import type { TtsAutoMode, TtsConfig, TtsProvider } from "../config/types.tts.js";
import { tryReadJsonSync } from "../infra/json-files.js";
@@ -114,7 +115,9 @@ function normalizeStatusDetail(
if (!normalized) {
return undefined;
}
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
return normalized.length > maxLength
? `${truncateUtf16Safe(normalized, maxLength - 3)}...`
: normalized;
}
function sanitizeBaseUrlForStatus(value: unknown): string | undefined {