fix(i18n): keep surrogate pairs intact when bounding process output tail (#120114)

* 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.
This commit is contained in:
tzy-17
2026-08-25 16:51:03 +08:00
committed by GitHub
parent db1b8a12ff
commit aa3e0ae429
2 changed files with 19 additions and 2 deletions
+4 -2
View File
@@ -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 {
+15
View File
@@ -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, <high>, <low>, c, d, e, f.
// maxChars = 5 forces a tail slice whose boundary lands inside the surrogate pair.
// The raw `slice(-5)` would return "<low>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(