mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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: [] },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user