From 08663917fa3732bb6192da78dae6c093730a3a26 Mon Sep 17 00:00:00 2001 From: lzyyzznl Date: Thu, 9 Jul 2026 19:09:34 +0800 Subject: [PATCH] fix(inworld): use truncateUtf16Safe for error body and parse error truncation (#102608) * fix(inworld): use truncateUtf16Safe for error body and parse error truncation Two .slice(0, N) truncation sites in the Inworld TTS extension may cut UTF-16 surrogate pairs in half when the error body or parse error message contains multi-byte characters such as emoji. Replace both with truncateUtf16Safe to keep the truncated output valid Unicode. * test(inworld): cover UTF-16-safe TTS errors Co-authored-by: lizeyu-xydt --------- Co-authored-by: Peter Steinberger --- extensions/inworld/tts.test.ts | 16 ++++++++++++---- extensions/inworld/tts.ts | 5 +++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/extensions/inworld/tts.test.ts b/extensions/inworld/tts.test.ts index bfb0b21ba47..b7e8bbcac09 100644 --- a/extensions/inworld/tts.test.ts +++ b/extensions/inworld/tts.test.ts @@ -228,6 +228,14 @@ describe("inworldTTS", () => { ); }); + it("keeps truncated HTTP error bodies UTF-16 safe", async () => { + queueGuardedResponse(new Response(`${"e".repeat(399)}😀tail`, { status: 400 })); + + await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toMatchObject({ + message: `Inworld TTS API error (400): ${"e".repeat(399)}…`, + }); + }); + it("throws on in-stream errors", async () => { const body = JSON.stringify({ error: { code: 3, message: "Invalid voice ID" }, @@ -249,11 +257,11 @@ describe("inworldTTS", () => { }); it("throws descriptive error on non-JSON line in stream", async () => { - queueGuardedResponse(new Response("Rate limited", { status: 200 })); + queueGuardedResponse(new Response(`${"p".repeat(79)}😀tail`, { status: 200 })); - await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow( - "Inworld TTS stream parse error: unexpected non-JSON line:", - ); + await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toMatchObject({ + message: `Inworld TTS stream parse error: unexpected non-JSON line: ${"p".repeat(79)}`, + }); }); it("sends correct request body with defaults", async () => { diff --git a/extensions/inworld/tts.ts b/extensions/inworld/tts.ts index 80a5762ecd2..2748442ace2 100644 --- a/extensions/inworld/tts.ts +++ b/extensions/inworld/tts.ts @@ -3,6 +3,7 @@ import { MAX_AUDIO_BYTES } from "openclaw/plugin-sdk/media-runtime"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; const DEFAULT_INWORLD_BASE_URL = "https://api.inworld.ai"; export const DEFAULT_INWORLD_VOICE_ID = "Sarah"; @@ -57,7 +58,7 @@ async function readInworldErrorBodySnippet(response: Response): Promise const collapsed = buffer.toString("utf8").replace(/\s+/g, " ").trim(); if (collapsed.length > INWORLD_ERROR_BODY_MAX_CHARS) { - return `${collapsed.slice(0, INWORLD_ERROR_BODY_MAX_CHARS)}…`; + return `${truncateUtf16Safe(collapsed, INWORLD_ERROR_BODY_MAX_CHARS)}…`; } return collapsed; } @@ -176,7 +177,7 @@ export async function inworldTTS(params: { parsed = JSON.parse(trimmed) as typeof parsed; } catch { throw new Error( - `Inworld TTS stream parse error: unexpected non-JSON line: ${trimmed.slice(0, 80)}`, + `Inworld TTS stream parse error: unexpected non-JSON line: ${truncateUtf16Safe(trimmed, 80)}`, ); }