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);