fix(core): keep backend truncation UTF-16 safe (#100244)

* fix(core): use UTF-16-safe truncation for chat display, ACP stream relay, and native hook relay

* fix(core): narrow UTF-16 truncation repair

Co-authored-by: xialonglee <li.xialong@xydigit.com>

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
This commit is contained in:
Peter Lee
2026-07-05 07:48:35 -05:00
committed by GitHub
parent 601b80c5f4
commit 66081c09ee
4 changed files with 93 additions and 27 deletions
+53 -21
View File
@@ -290,6 +290,27 @@ describe("active-memory plugin", () => {
(result as { prependContext?: unknown } | undefined)?.prependContext,
"expected prependContext",
);
const runRecallWithSummary = async (params: {
prompt: string;
summary: string;
memoryText?: string;
}): Promise<string> => {
runEmbeddedAgent.mockImplementationOnce(async (runParams: { sessionFile: string }) => {
await writeUsableMemoryTranscript(runParams.sessionFile, params.memoryText ?? params.summary);
return { payloads: [{ text: params.summary }] };
});
return requirePrependContext(
await hooks.before_prompt_build(
{ prompt: params.prompt, messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
),
);
};
const expectPrependContextContains = (result: unknown, text: string) => {
expect(requirePrependContext(result)).toContain(text);
};
@@ -5335,34 +5356,45 @@ describe("active-memory plugin", () => {
maxSummaryChars: 40,
};
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => {
await writeUsableMemoryTranscript(params.sessionFile, "alpha beta gamma");
return {
payloads: [
{
text: "alpha beta gamma delta epsilon zetalongword",
},
],
};
const prependContext = await runRecallWithSummary({
prompt: "what wings should i order? word-boundary-truncation-40",
summary: "alpha beta gamma delta epsilon zetalongword",
memoryText: "alpha beta gamma",
});
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? word-boundary-truncation-40", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prependContext = requirePrependContext(result);
expect(prependContext).toContain("alpha beta gamma");
expect(prependContext).toContain("alpha beta gamma delta epsilon…");
expect(prependContext).not.toContain("zetalo");
expect(prependContext).not.toContain("zetalongword");
});
it.each([
{
name: "split surrogate",
summary: `${"a".repeat(38)}🎉TAILWORD`,
expected: `${"a".repeat(38)}`,
},
{
name: "whitespace before a split surrogate",
summary: `alpha beta ${"c".repeat(26)} 🎉TAILWORD`,
expected: `alpha beta ${"c".repeat(26)}`,
},
])("keeps $name truncation UTF-16 safe", async ({ name, summary, expected }) => {
api.pluginConfig = {
agents: ["main"],
maxSummaryChars: 40,
};
plugin.register(api as unknown as OpenClawPluginApi);
const prependContext = await runRecallWithSummary({
prompt: `recall summary boundary: ${name}`,
summary,
memoryText: expected,
});
expect(prependContext).toContain(expected);
expect(prependContext).not.toContain("TAILWORD");
});
it("asks recall subagents to mark mutable operational facts stale unless source status is current", async () => {
await hooks.before_prompt_build(
{ prompt: "is autonomous pickup running?", messages: [] },
+5 -3
View File
@@ -37,6 +37,7 @@ import {
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_AGENT_ID = "main";
@@ -2570,15 +2571,16 @@ function truncateSummary(summary: string, maxSummaryChars: number): string {
return ellipsis.slice(0, Math.max(0, maxSummaryChars));
}
const contentMaxChars = maxSummaryChars - ellipsis.length;
const bounded = trimmed.slice(0, contentMaxChars).trimEnd();
const rawBounded = trimmed.slice(0, contentMaxChars).trimEnd();
const bounded = truncateUtf16Safe(trimmed, contentMaxChars).trimEnd();
const nextChar = trimmed.charAt(contentMaxChars);
if (!nextChar || /\s/.test(nextChar)) {
return `${bounded}${ellipsis}`;
}
const lastBoundary = bounded.search(/\s\S*$/);
const lastBoundary = rawBounded.search(/\s\S*$/);
if (lastBoundary > 0) {
return `${bounded.slice(0, lastBoundary).trimEnd()}${ellipsis}`;
return `${truncateUtf16Safe(trimmed, lastBoundary).trimEnd()}${ellipsis}`;
}
return `${bounded}${ellipsis}`;
@@ -1406,6 +1406,37 @@ describe("startAcpSpawnParentStreamRelay", () => {
relay.dispose();
});
it.each([
{
name: "preview cutoff",
delta: `${"a".repeat(218)}😀tail`,
expected: `${"a".repeat(218)}`,
},
{
name: "retained buffer start",
delta: `😀${"b".repeat(3_999)}`,
expected: `${"b".repeat(219)}`,
},
])("keeps $name on UTF-16 boundaries", ({ delta, expected }) => {
const relay = startAcpSpawnParentStreamRelay({
runId: "run-utf16-safe",
parentSessionKey: "agent:main:main",
childSessionKey: "agent:codex:acp:utf16-safe",
agentId: "codex",
streamFlushMs: 0,
noOutputNoticeMs: 120_000,
});
emitAgentEvent({
runId: "run-utf16-safe",
stream: "assistant",
data: { delta },
});
expect(collectedTexts()[1]).toBe(`codex: ${expected}`);
relay.dispose();
});
it("resolves ACP spawn stream log path from session metadata", () => {
readAcpSessionEntryMock.mockReturnValue({
storePath: "/tmp/openclaw/agents/codex/sessions/sessions.json",
+4 -3
View File
@@ -3,6 +3,7 @@ import { mkdir } from "node:fs/promises";
import path from "node:path";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { readAcpSessionEntry } from "../acp/runtime/session-meta.js";
import {
isAcpTagVisible,
@@ -50,9 +51,9 @@ function truncate(value: string, maxChars: number): string {
return value;
}
if (maxChars <= 1) {
return value.slice(0, maxChars);
return truncateUtf16Safe(value, maxChars);
}
return `${value.slice(0, maxChars - 1)}`;
return `${truncateUtf16Safe(value, maxChars - 1)}`;
}
function normalizeStringArray(value: unknown): string[] {
@@ -503,7 +504,7 @@ export function startAcpSpawnParentStreamRelay(params: {
pendingProgressKind = kind;
pendingText += delta;
if (pendingText.length > STREAM_BUFFER_MAX_CHARS) {
pendingText = pendingText.slice(-STREAM_BUFFER_MAX_CHARS);
pendingText = sliceUtf16Safe(pendingText, -STREAM_BUFFER_MAX_CHARS);
}
if (pendingText.length >= STREAM_SNIPPET_MAX_CHARS || delta.includes("\n\n")) {
flushPending();