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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 230 additions and 13 deletions

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)}...`;
}
}
}