From f0d4d4f2fccda02915cfa9d467e660f82ff9bb1d Mon Sep 17 00:00:00 2001 From: xydt-tanshanshan <0668000363@xydigit.com> Date: Thu, 9 Jul 2026 15:39:38 +0800 Subject: [PATCH] fix(talk): use truncateUtf16Safe for LLM-prompt-facing text truncation (#102477) * fix(talk): use truncateUtf16Safe for snippet text truncation fast-context-runtime.ts normalizeSnippet and heartbeat-events-filter.ts buildExecEventPrompt used raw .slice(0, N), which can produce lone surrogates when truncation boundaries cross emoji surrogate pairs. * test(talk): cover UTF-16 snippet boundary --------- Co-authored-by: hailory Co-authored-by: Peter Steinberger Co-authored-by: Peter Steinberger --- src/talk/fast-context-runtime.test.ts | 40 +++++++++++++++++++++++++++ src/talk/fast-context-runtime.ts | 3 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/talk/fast-context-runtime.test.ts b/src/talk/fast-context-runtime.test.ts index 1b358f4f3184..1b00903c89aa 100644 --- a/src/talk/fast-context-runtime.test.ts +++ b/src/talk/fast-context-runtime.test.ts @@ -79,4 +79,44 @@ describe("resolveRealtimeVoiceFastContextConsult", () => { ); expect(vi.getTimerCount()).toBe(0); }); + + it("does not split a surrogate pair at the fast-context snippet limit", async () => { + const safePrefix = "x".repeat(698); + mocks.getActiveMemorySearchManager.mockResolvedValue({ + manager: { + search: vi.fn().mockResolvedValue([ + { + path: "memory/test.md", + startLine: 1, + endLine: 1, + snippet: `${safePrefix}🚀tail`, + source: "memory", + score: 1, + }, + ]), + }, + }); + + const result = await resolveRealtimeVoiceFastContextConsult({ + cfg: {}, + agentId: "main", + sessionKey: "voice:15550001234", + config: { + enabled: true, + timeoutMs: 1_000, + maxResults: 1, + sources: ["memory"], + fallbackToConsult: true, + }, + args: { question: "What do you remember?" }, + logger: {}, + }); + + expect(result).toEqual({ + handled: true, + result: { + text: expect.stringContaining(`1. [memory] memory/test.md:1-1\n${safePrefix}...`), + }, + }); + }); }); diff --git a/src/talk/fast-context-runtime.ts b/src/talk/fast-context-runtime.ts index 32412a1ed8c9..0871ac6344de 100644 --- a/src/talk/fast-context-runtime.ts +++ b/src/talk/fast-context-runtime.ts @@ -6,6 +6,7 @@ * back to the normal consult flow. */ import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { getActiveMemorySearchManager } from "../plugins/memory-runtime.js"; @@ -69,7 +70,7 @@ function normalizeSnippet(text: string): string { } // Keep individual memory snippets bounded so several hits still fit in a // short realtime response prompt. - return `${normalized.slice(0, MAX_SNIPPET_CHARS - 1).trimEnd()}...`; + return `${truncateUtf16Safe(normalized, MAX_SNIPPET_CHARS - 1).trimEnd()}...`; } function buildSearchQuery(args: unknown): string {