fix(gateway): keep chat history display text truncation surrogate-safe (#102470)

* fix(gateway): keep chat history display text truncation surrogate-safe

truncateChatHistoryText uses slice(0, N) to truncate chat history text
for the Control UI. When the truncation boundary falls inside a UTF-16
surrogate pair (emoji, CJK extended), the resulting string contains a
dangling surrogate that browsers render as U+FFFD (�).

Replace slice(0, maxChars) with truncateUtf16Safe(text, maxChars) so
the truncation point always falls on a complete code-point boundary.

* test(gateway): cover chat display UTF-16 boundary

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
zw-xysk
2026-07-09 15:04:34 +08:00
committed by GitHub
parent 4fa3118049
commit a6d45c46de
2 changed files with 24 additions and 2 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../agents/internal-runtime-context.js";
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js";
@@ -57,7 +58,7 @@ function truncateChatHistoryText(
return { text, truncated: false };
}
return {
text: `${text.slice(0, maxChars)}\n...(truncated)...`,
text: `${truncateUtf16Safe(text, maxChars)}\n...(truncated)...`,
truncated: true,
};
}
@@ -660,7 +660,6 @@ describe("waitForAgentJob", () => {
expect(agentJobTesting.getAgentRunCacheSize()).toBe(max);
agentJobTesting.resetAgentRunCache();
});
});
describe("augmentChatHistoryWithCanvasBlocks", () => {
@@ -824,6 +823,28 @@ describe("injectTimestamp", () => {
});
describe("sanitizeChatHistoryMessages", () => {
it("truncates display text without splitting surrogate pairs", () => {
const prefix = "a".repeat(7);
const result = sanitizeChatHistoryMessages(
[
{
role: "assistant",
content: [{ type: "text", text: `${prefix}😀tail` }],
timestamp: 1,
},
],
8,
);
expect(result).toEqual([
{
role: "assistant",
content: [{ type: "text", text: `${prefix}\n...(truncated)...` }],
timestamp: 1,
},
]);
});
it("redacts base64 audio content blocks from chat history", () => {
const data = Buffer.from("voice-bytes").toString("base64");
const result = sanitizeChatHistoryMessages([