fix(agents): keep read warning previews UTF-16 safe (#106399)

This commit is contained in:
Ben.Li
2026-07-16 21:00:36 +08:00
committed by GitHub
parent cc34a46c14
commit 1f05cb45cf
2 changed files with 77 additions and 10 deletions

View File

@@ -372,7 +372,69 @@ describe("handleToolExecutionStart read path checks", () => {
await handleToolExecutionStart(ctx, evt);
const warnMeta = warn.mock.calls[0]?.[1] as Record<string, unknown> | 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<string, unknown> | undefined;
const argsPreview = warnMeta?.argsPreview;
expect(typeof argsPreview).toBe("string");
expect(argsPreview).toBe(`${"x".repeat(200)}`);
expect(argsPreview).not.toMatch(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u,
);
});
it("marks astral-only read warning args previews as truncated", async () => {
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<string, unknown> | undefined;
const argsPreview = warnMeta?.argsPreview;
expect(typeof argsPreview).toBe("string");
expect(argsPreview).toBe(`${emoji.repeat(100)}`);
expect(argsPreview).not.toMatch(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u,
);
});
it("does not scan visible preview content beyond the raw warning bound", async () => {
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<string, unknown> | 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<string, unknown> | undefined;
expect(warnMeta?.argsPreview).toBe("x".repeat(198));
expect(warnMeta?.argsPreview).toBe(`${"x".repeat(198)}`);
});
it("awaits onBlockReplyFlush before continuing tool start processing", async () => {

View File

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