mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user