From aa3e0ae429358f6d61e4e52e8550bbd5d13defcc Mon Sep 17 00:00:00 2001 From: tzy-17 Date: Tue, 25 Aug 2026 16:51:03 +0800 Subject: [PATCH] fix(i18n): keep surrogate pairs intact when bounding process output tail (#120114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): keep surrogate pairs intact when bounding process output tail appendBoundedProcessOutput used nextText.slice(-maxChars) to keep the newest maxChars of captured process output. When the boundary landed inside a UTF-16 surrogate pair (e.g. emoji in stderr), the retained tail began with a dangling low surrogate, corrupting downstream JSON serialization and fatal TextDecoder paths. Switch to sliceUtf16Safe(nextText, -maxChars) from normalization-core, which adjusts the boundary off the surrogate pair. The helper was already imported elsewhere in the dependency graph. * fix(i18n): count actually-dropped units when bounding surrogate-safe tail Address ClawSweeper P2 finding on PR #120114: sliceUtf16Safe may return fewer than maxChars code units when it advances past a low surrogate at the boundary, so truncatedChars must derive from the actual retained tail length rather than maxChars. For the emoji case ("ab😀cdef", maxChars=5) the safe slice retains "cdef" (4 units), so 4 units are dropped, not 3. Compute truncatedChars from nextText.length - text.length and update the regression test expectation to 4. --- scripts/control-ui-i18n.ts | 6 ++++-- test/scripts/control-ui-i18n.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/control-ui-i18n.ts b/scripts/control-ui-i18n.ts index 65a885d2e410..ef13cd270f03 100644 --- a/scripts/control-ui-i18n.ts +++ b/scripts/control-ui-i18n.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { completeSimple, type AssistantMessage, type Model } from "openclaw/plugin-sdk/llm"; import { expectDefined } from "../packages/normalization-core/src/expect.js"; +import { sliceUtf16Safe } from "../packages/normalization-core/src/utf16-slice.ts"; import { formatErrorMessage } from "../src/infra/errors.ts"; import { formatDurationCompact } from "../src/infra/format-time/format-duration.ts"; import { @@ -532,8 +533,9 @@ export function appendBoundedProcessOutput( if (nextText.length <= maxChars) { return { text: nextText, truncatedChars: capture.truncatedChars }; } - const truncatedChars = capture.truncatedChars + nextText.length - maxChars; - return { text: nextText.slice(-maxChars), truncatedChars }; + const text = sliceUtf16Safe(nextText, -maxChars); + const truncatedChars = capture.truncatedChars + nextText.length - text.length; + return { text, truncatedChars }; } function formatProcessOutput(capture: ProcessOutputCapture): string { diff --git a/test/scripts/control-ui-i18n.test.ts b/test/scripts/control-ui-i18n.test.ts index 5c22f1be1c4e..cc82fbe371b7 100644 --- a/test/scripts/control-ui-i18n.test.ts +++ b/test/scripts/control-ui-i18n.test.ts @@ -437,6 +437,21 @@ describe("control-ui-i18n process runner", () => { expect(second).toEqual({ text: "fghij", truncatedChars: 5 }); }); + it("does not split a UTF-16 surrogate pair at the tail boundary", () => { + // "ab😀cdef" is 8 UTF-16 code units: a, b, , , c, d, e, f. + // maxChars = 5 forces a tail slice whose boundary lands inside the surrogate pair. + // The raw `slice(-5)` would return "cdef" (leading dangling low surrogate). + // sliceUtf16Safe advances past the low surrogate, retaining "cdef" (4 units); + // truncatedChars must reflect the 4 actually-dropped units, not maxChars. + const result = appendBoundedProcessOutput({ text: "", truncatedChars: 0 }, "ab😀cdef", 5); + expect(result.text.length).toBeLessThanOrEqual(5); + // No dangling surrogate (high 0xd800-0xdbff or low 0xdc00-0xdfff) at either edge. + expect(result.text.charCodeAt(0)).toBeLessThan(0xd800); + expect(result.text.charCodeAt(result.text.length - 1)).toBeLessThan(0xd800); + expect(result.text).toBe("cdef"); + expect(result.truncatedChars).toBe(4); + }); + it("bounds failure diagnostics to the newest output", async () => { await expect( runProcess(