diff --git a/src/gateway/chat-tool-titles.test.ts b/src/gateway/chat-tool-titles.test.ts index a2fad5649dd0..61669b42140b 100644 --- a/src/gateway/chat-tool-titles.test.ts +++ b/src/gateway/chat-tool-titles.test.ts @@ -133,6 +133,25 @@ describe("generateToolCallTitles", () => { expect(content).not.toContain(token.slice(0, 12)); }); + it("keeps bounded tool input valid before utility-model prompt construction", async () => { + mockPreparedModel(); + mockCompletionTitles({ "0": "Inspected boundary input" }); + + await generateToolCallTitles({ + cfg: {} satisfies OpenClawConfig, + agentId: AGENT_ID, + items: [{ id: "item-1", name: "bash", input: `${"a".repeat(1_999)}😀tail` }], + }); + + const call = completeWithPreparedSimpleCompletionModel.mock.calls[0]?.[0] as { + context: { messages: Array<{ content: string }> }; + }; + const promptPayload = JSON.parse(call.context.messages[0]?.content ?? "{}") as { + items?: Array<{ input?: string }>; + }; + expect(promptPayload.items?.[0]?.input).toBe("a".repeat(1_999)); + }); + it("serves repeated items from the SQLite cache without a second completion", async () => { mockPreparedModel(); mockCompletionTitles({ "0": "Checked repo status" }); diff --git a/src/gateway/chat-tool-titles.ts b/src/gateway/chat-tool-titles.ts index 76c7d74eaa52..4c33af9cc281 100644 --- a/src/gateway/chat-tool-titles.ts +++ b/src/gateway/chat-tool-titles.ts @@ -74,7 +74,7 @@ function normalizeItems(items: readonly ToolTitleRequestItem[]): ToolTitleReques // secret egress path. Redaction runs on the full schema-bounded input and // only then truncates — slicing first could bisect a secret so its // fragment no longer matches any redaction pattern. - const input = redactToolPayloadText(item.input).slice(0, TOOL_TITLE_INPUT_MAX_CHARS); + const input = truncateUtf16Safe(redactToolPayloadText(item.input), TOOL_TITLE_INPUT_MAX_CHARS); if (!id || !name || !input.trim() || seen.has(id)) { continue; } diff --git a/ui/src/pages/chat/tool-titles.test.ts b/ui/src/pages/chat/tool-titles.test.ts index 03b756b97894..93cf4353f494 100644 --- a/ui/src/pages/chat/tool-titles.test.ts +++ b/ui/src/pages/chat/tool-titles.test.ts @@ -52,6 +52,19 @@ describe("resolveToolTitleRequest", () => { expect(first?.key).toBe(second?.key); }); + + it.each([ + ["command", "bash", { command: `${"a".repeat(1_999)}😀tail` }, "a".repeat(1_999)], + ["string args", "mcp__linear__create_issue", `${"a".repeat(1_999)}😀tail`, "a".repeat(1_999)], + [ + "serialized object args", + "mcp__linear__create_issue", + { value: `${"a".repeat(1_989)}😀tail` }, + '{"value":"' + "a".repeat(1_989), + ], + ])("keeps bounded %s on a valid UTF-16 boundary", (_label, name, args, expected) => { + expect(resolveToolTitleRequest(name, args)?.input).toBe(expected); + }); }); describe("getToolCallTitle", () => { diff --git a/ui/src/pages/chat/tool-titles.ts b/ui/src/pages/chat/tool-titles.ts index b86ab117ff03..16639f9204a1 100644 --- a/ui/src/pages/chat/tool-titles.ts +++ b/ui/src/pages/chat/tool-titles.ts @@ -8,6 +8,7 @@ * labels. */ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { resolveToolCallKind, unwrapShellWrapperCommand } from "../../lib/chat/tool-call-view.ts"; @@ -68,11 +69,11 @@ function serializeArgs(args: unknown): string | null { return null; } if (typeof args === "string") { - return args.slice(0, MAX_TITLE_INPUT_CHARS); + return truncateUtf16Safe(args, MAX_TITLE_INPUT_CHARS); } try { const encoded = JSON.stringify(args); - return typeof encoded === "string" ? encoded.slice(0, MAX_TITLE_INPUT_CHARS) : null; + return typeof encoded === "string" ? truncateUtf16Safe(encoded, MAX_TITLE_INPUT_CHARS) : null; } catch { return null; } @@ -98,7 +99,7 @@ export function resolveToolTitleRequest( if (command.length < MIN_COMMAND_CHARS_FOR_TITLE) { return null; } - const input = command.slice(0, MAX_TITLE_INPUT_CHARS); + const input = truncateUtf16Safe(command, MAX_TITLE_INPUT_CHARS); return { key: digest("command", input), input }; } if (kind !== "generic") {