From 84e5327720b409cdfbbd7e54e72d7ef211d3ec25 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 6 Jul 2026 21:33:24 -0700 Subject: [PATCH] fix(text): keep bounded outputs UTF-16 safe (#101355) Co-authored-by: Alix-007 --- extensions/discord/src/error-body.test.ts | 11 +++++ extensions/discord/src/error-body.ts | 4 +- .../src/bot-core.raw-update-log.test.ts | 15 ++++++ extensions/telegram/src/raw-update-log.ts | 4 +- .../voice-call/src/media-stream.test.ts | 7 +++ extensions/voice-call/src/media-stream.ts | 3 +- extensions/voice-call/src/webhook.test.ts | 42 +++++++++++++++++ extensions/voice-call/src/webhook.ts | 3 +- packages/speech-core/src/tts.test.ts | 31 ++++++++++++ packages/speech-core/src/tts.ts | 12 +++-- src/agents/tools/web-fetch-utils.test.ts | 11 ++++- src/agents/tools/web-fetch-utils.ts | 3 +- src/media-understanding/runner.entries.ts | 3 +- src/media-understanding/runner.video.test.ts | 47 +++++++++++++++++++ src/talk/consult-question.test.ts | 10 ++++ src/talk/consult-question.ts | 3 +- src/tts/status-config.test.ts | 29 ++++++++++++ src/tts/status-config.ts | 5 +- 18 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 extensions/discord/src/error-body.test.ts diff --git a/extensions/discord/src/error-body.test.ts b/extensions/discord/src/error-body.test.ts new file mode 100644 index 000000000000..4a3211fed3bd --- /dev/null +++ b/extensions/discord/src/error-body.test.ts @@ -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)); + }); +}); diff --git a/extensions/discord/src/error-body.ts b/extensions/discord/src/error-body.ts index 7c51a8af2a12..0620679e7e40 100644 --- a/extensions/discord/src/error-body.ts +++ b/extensions/discord/src/error-body.ts @@ -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 { diff --git a/extensions/telegram/src/bot-core.raw-update-log.test.ts b/extensions/telegram/src/bot-core.raw-update-log.test.ts index a2e536e28338..aa621e53c57c 100644 --- a/extensions/telegram/src/bot-core.raw-update-log.test.ts +++ b/extensions/telegram/src/bot-core.raw-update-log.test.ts @@ -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"); + }); }); diff --git a/extensions/telegram/src/raw-update-log.ts b/extensions/telegram/src/raw-update-log.ts index bdb023d9e2d8..6dc0977916fa 100644 --- a/extensions/telegram/src/raw-update-log.ts +++ b/extensions/telegram/src/raw-update-log.ts @@ -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)) { diff --git a/extensions/voice-call/src/media-stream.test.ts b/extensions/voice-call/src/media-stream.test.ts index 7af507dfacb1..d9496f5f9aa0 100644 --- a/extensions/voice-call/src/media-stream.test.ts +++ b/extensions/voice-call/src/media-stream.test.ts @@ -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 }> = []; diff --git a/extensions/voice-call/src/media-stream.ts b/extensions/voice-call/src/media-stream.ts index ab18041eb6b6..cd9dfa49c19d 100644 --- a/extensions/voice-call/src/media-stream.ts +++ b/extensions/voice-call/src/media-stream.ts @@ -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 { diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 25b3240206e7..f6a2964df825 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -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"; diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 53e15f679838..a0ed9644feb8 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -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 { diff --git a/packages/speech-core/src/tts.test.ts b/packages/speech-core/src/tts.test.ts index fb286f231d0d..56797987a060 100644 --- a/packages/speech-core/src/tts.test.ts +++ b/packages/speech-core/src/tts.test.ts @@ -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"); diff --git a/packages/speech-core/src/tts.ts b/packages/speech-core/src/tts.ts index c69ca39aef00..a37c672767af 100644 --- a/packages/speech-core/src/tts.ts +++ b/packages/speech-core/src/tts.ts @@ -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)}...`; } } } diff --git a/src/agents/tools/web-fetch-utils.test.ts b/src/agents/tools/web-fetch-utils.test.ts index 9224dce09e2f..b0f004462e53 100644 --- a/src/agents/tools/web-fetch-utils.test.ts +++ b/src/agents/tools/web-fetch-utils.test.ts @@ -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. "'x;" must not become "'"). expect(htmlToMarkdown(`

'x; end

`).text).toBe("'x; 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)); + }); }); diff --git a/src/agents/tools/web-fetch-utils.ts b/src/agents/tools/web-fetch-utils.ts index 05845e9617be..2499ee550f30 100644 --- a/src/agents/tools/web-fetch-utils.ts +++ b/src/agents/tools/web-fetch-utils.ts @@ -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. */ diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index dab99aa7cf09..0bf640a9faa1 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -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 } { diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 5cff075a7c2d..34f8ee9b158a 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -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([ + [ + "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 | undefined; diff --git a/src/talk/consult-question.test.ts b/src/talk/consult-question.test.ts index 52e8c81ee43a..671191bec693 100644 --- a/src/talk/consult-question.test.ts +++ b/src/talk/consult-question.test.ts @@ -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]"); + }); }); diff --git a/src/talk/consult-question.ts b/src/talk/consult-question.ts index 3783e98fb993..a0a8793aa5c0 100644 --- a/src/talk/consult-question.ts +++ b/src/talk/consult-question.ts @@ -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]`; } diff --git a/src/tts/status-config.test.ts b/src/tts/status-config.test.ts index 05b94e5fc58f..56a027a7d2e7 100644 --- a/src/tts/status-config.test.ts +++ b/src/tts/status-config.test.ts @@ -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( diff --git a/src/tts/status-config.ts b/src/tts/status-config.ts index 160a453f64dd..d5a55c5e8555 100644 --- a/src/tts/status-config.ts +++ b/src/tts/status-config.ts @@ -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 {