From 1f05cb45cf5b14def072c2cf94add8968d545b8d Mon Sep 17 00:00:00 2001 From: "Ben.Li" Date: Thu, 16 Jul 2026 21:00:36 +0800 Subject: [PATCH] fix(agents): keep read warning previews UTF-16 safe (#106399) --- ...ded-agent-subscribe.handlers.tools.test.ts | 66 ++++++++++++++++++- ...embedded-agent-subscribe.handlers.tools.ts | 21 +++--- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts index 24f99494a5c8..facf04b50181 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.test.ts @@ -372,7 +372,69 @@ describe("handleToolExecutionStart read path checks", () => { await handleToolExecutionStart(ctx, evt); const warnMeta = warn.mock.calls[0]?.[1] as Record | undefined; - expect(warnMeta?.argsPreview).toBe(`${"x".repeat(200)}…`); + const argsPreview = warnMeta?.argsPreview; + expect(typeof argsPreview).toBe("string"); + expect(argsPreview).toBe(`${"x".repeat(200)}…`); + }); + + it("keeps read warning args previews on UTF-16 boundaries", async () => { + const { ctx, warn } = createTestContext(); + const emoji = "😀"; + + const evt: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolName: "read", + toolCallId: "tool-surrogate-args", + args: `${"x".repeat(200)}${emoji}tail`, + }; + + await handleToolExecutionStart(ctx, evt); + + const warnMeta = warn.mock.calls[0]?.[1] as Record | undefined; + const argsPreview = warnMeta?.argsPreview; + expect(typeof argsPreview).toBe("string"); + expect(argsPreview).toBe(`${"x".repeat(200)}…`); + expect(argsPreview).not.toMatch( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const { ctx, warn } = createTestContext(); + const emoji = "😀"; + + const evt: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolName: "read", + toolCallId: "tool-astral-args", + args: emoji.repeat(101), + }; + + await handleToolExecutionStart(ctx, evt); + + const warnMeta = warn.mock.calls[0]?.[1] as Record | undefined; + const argsPreview = warnMeta?.argsPreview; + expect(typeof argsPreview).toBe("string"); + expect(argsPreview).toBe(`${emoji.repeat(100)}…`); + expect(argsPreview).not.toMatch( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const { ctx, warn } = createTestContext(); + + const evt: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolName: "read", + toolCallId: "tool-bounded-args", + args: `${" ".repeat(200)}hidden`, + }; + + await handleToolExecutionStart(ctx, evt); + + const warnMeta = warn.mock.calls[0]?.[1] as Record | undefined; + expect(warnMeta).not.toHaveProperty("argsPreview"); }); it("does not split surrogate pairs when bounding read warning preview", async () => { @@ -389,7 +451,7 @@ describe("handleToolExecutionStart read path checks", () => { await handleToolExecutionStart(ctx, evt); const warnMeta = warn.mock.calls[0]?.[1] as Record | undefined; - expect(warnMeta?.argsPreview).toBe("x".repeat(198)); + expect(warnMeta?.argsPreview).toBe(`${"x".repeat(198)}…`); }); it("awaits onBlockReplyFlush before continuing tool start processing", async () => { diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.ts b/src/agents/embedded-agent-subscribe.handlers.tools.ts index d90f9c736d30..e20199f3f1d6 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.ts @@ -11,7 +11,7 @@ import { normalizeOptionalLowercaseString, readStringValue, } from "@openclaw/normalization-core/string-coerce"; -import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { HEARTBEAT_RESPONSE_TOOL_NAME, normalizeHeartbeatToolResponse, @@ -242,7 +242,17 @@ function traceToolExecutionStart(params: { } const TOOL_START_WARNING_PREVIEW_MAX_CHARS = 200; -const TOOL_START_WARNING_RAW_PREVIEW_MAX_CHARS = TOOL_START_WARNING_PREVIEW_MAX_CHARS + 1; + +function buildToolStartWarningArgsPreview(rawArgsPreview: string | undefined): string | undefined { + if (rawArgsPreview == null) { + return undefined; + } + // Bound before regex normalization so malformed tool args cannot make warning work unbounded. + const wasTruncated = rawArgsPreview.length > TOOL_START_WARNING_PREVIEW_MAX_CHARS; + const bounded = truncateUtf16Safe(rawArgsPreview, TOOL_START_WARNING_PREVIEW_MAX_CHARS); + const preview = sanitizeForConsole(bounded, TOOL_START_WARNING_PREVIEW_MAX_CHARS); + return wasTruncated && preview ? `${preview}…` : preview; +} type ToolStartRecord = { startTime: number; @@ -984,12 +994,7 @@ export function handleToolExecutionStart( if (!filePath) { const argsType = typeof args; const rawArgsPreview = readStringValue(args); - const argsPreview = sanitizeForConsole( - rawArgsPreview - ? sliceUtf16Safe(rawArgsPreview, 0, TOOL_START_WARNING_RAW_PREVIEW_MAX_CHARS) - : undefined, - TOOL_START_WARNING_PREVIEW_MAX_CHARS, - ); + const argsPreview = buildToolStartWarningArgsPreview(rawArgsPreview); const safeRunId = sanitizeForConsole(runId) ?? "-"; const safeSessionKey = sanitizeForConsole(ctx.params.sessionKey); const safeSessionId = sanitizeForConsole(ctx.params.sessionId);