diff --git a/extensions/codex/src/app-server/dynamic-tools.test.ts b/extensions/codex/src/app-server/dynamic-tools.test.ts index 262b2981763..45ee6af454f 100644 --- a/extensions/codex/src/app-server/dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/dynamic-tools.test.ts @@ -733,6 +733,41 @@ describe("createCodexDynamicToolBridge", () => { expect(text).toContain("rerun with narrower args"); }); + it("keeps a whole code point when dynamic tool text crosses the configured boundary", async () => { + const maxChars = 180; + const totalChars = 400; + const noticeText = `...(OpenClaw truncated dynamic tool result: original ${totalChars} chars, showing ${maxChars}; rerun with narrower args.)`; + const textBudget = maxChars - noticeText.length - 1; + const prefix = "a".repeat(textBudget - 1); + const longText = `${prefix}😀${"z".repeat(totalChars - prefix.length - 2)}`; + const bridge = createCodexDynamicToolBridge({ + tools: [ + createTool({ + name: "large_lookup", + execute: vi.fn(async () => textToolResult(longText)), + }), + ], + signal: new AbortController().signal, + hookContext: { + agentId: "main", + config: { + agents: { defaults: { contextLimits: { toolResultMaxChars: maxChars } } }, + } as never, + }, + }); + + const result = await bridge.handleToolCall({ + threadId: "thread-1", + turnId: "turn-1", + callId: "call-1", + namespace: null, + tool: "large_lookup", + arguments: {}, + }); + + expect(result.contentItems).toEqual([{ type: "inputText", text: `${prefix}\n${noticeText}` }]); + }); + it("honors normalized per-agent dynamic tool result caps", async () => { const bridge = createCodexDynamicToolBridge({ tools: [ diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 5bfac4e8db7..5b8fbe42e7e 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -47,6 +47,7 @@ import { asOptionalRecord as readRecord, isRecord, } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { CodexDynamicToolsLoading } from "./config.js"; import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js"; import type { @@ -1359,16 +1360,18 @@ function convertToolContents( continue; } if (notice.length >= maxChars) { - output.push({ type: "inputText", text: noticeText.slice(0, maxChars) }); + output.push({ type: "inputText", text: truncateUtf16Safe(noticeText, maxChars) }); appendedNotice = true; continue; } const sliceLength = Math.min(item.text.length, remainingTextBudget); remainingTextBudget -= sliceLength; const shouldAppendNotice = remainingTextBudget <= 0; - const text = item.text.slice(0, sliceLength); + const text = truncateUtf16Safe(item.text, sliceLength); if (shouldAppendNotice) { - output.push({ type: "inputText", text: `${text.trimEnd()}${notice}`.slice(0, maxChars) }); + // The notice budget is reserved before slicing text, so the combined + // result is already bounded without another boundary-sensitive cut. + output.push({ type: "inputText", text: `${text.trimEnd()}${notice}` }); appendedNotice = true; } else if (text.length > 0) { output.push({ type: "inputText", text }); @@ -1376,7 +1379,7 @@ function convertToolContents( } if (!appendedNotice) { - output.push({ type: "inputText", text: noticeText.slice(0, maxChars) }); + output.push({ type: "inputText", text: truncateUtf16Safe(noticeText, maxChars) }); } return output; }