diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index b0e64d6230e7..8531f452c3d0 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -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 => { + 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: [] }, diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index ffa7a927567c..13dd3f62dfa9 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -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}`; diff --git a/src/agents/acp-spawn-parent-stream.test.ts b/src/agents/acp-spawn-parent-stream.test.ts index 377baffab269..62581a275abf 100644 --- a/src/agents/acp-spawn-parent-stream.test.ts +++ b/src/agents/acp-spawn-parent-stream.test.ts @@ -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", diff --git a/src/agents/acp-spawn-parent-stream.ts b/src/agents/acp-spawn-parent-stream.ts index 3589350d0521..6595c70aea4d 100644 --- a/src/agents/acp-spawn-parent-stream.ts +++ b/src/agents/acp-spawn-parent-stream.ts @@ -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();