Skip to content

Commit 04037e3

Browse files
fix(chat): tool titles corrupt boundary emoji in long inputs (#104464)
* fix(chat): tool titles corrupt boundary emoji in long inputs * test(chat): simplify UTF-16 title boundary coverage * test(chat): satisfy title boundary lint --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 49ff54e commit 04037e3

4 files changed

Lines changed: 37 additions & 4 deletions

File tree

src/gateway/chat-tool-titles.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,25 @@ describe("generateToolCallTitles", () => {
133133
expect(content).not.toContain(token.slice(0, 12));
134134
});
135135

136+
it("keeps bounded tool input valid before utility-model prompt construction", async () => {
137+
mockPreparedModel();
138+
mockCompletionTitles({ "0": "Inspected boundary input" });
139+
140+
await generateToolCallTitles({
141+
cfg: {} satisfies OpenClawConfig,
142+
agentId: AGENT_ID,
143+
items: [{ id: "item-1", name: "bash", input: `${"a".repeat(1_999)}😀tail` }],
144+
});
145+
146+
const call = completeWithPreparedSimpleCompletionModel.mock.calls[0]?.[0] as {
147+
context: { messages: Array<{ content: string }> };
148+
};
149+
const promptPayload = JSON.parse(call.context.messages[0]?.content ?? "{}") as {
150+
items?: Array<{ input?: string }>;
151+
};
152+
expect(promptPayload.items?.[0]?.input).toBe("a".repeat(1_999));
153+
});
154+
136155
it("serves repeated items from the SQLite cache without a second completion", async () => {
137156
mockPreparedModel();
138157
mockCompletionTitles({ "0": "Checked repo status" });

src/gateway/chat-tool-titles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ function normalizeItems(items: readonly ToolTitleRequestItem[]): ToolTitleReques
7474
// secret egress path. Redaction runs on the full schema-bounded input and
7575
// only then truncates — slicing first could bisect a secret so its
7676
// fragment no longer matches any redaction pattern.
77-
const input = redactToolPayloadText(item.input).slice(0, TOOL_TITLE_INPUT_MAX_CHARS);
77+
const input = truncateUtf16Safe(redactToolPayloadText(item.input), TOOL_TITLE_INPUT_MAX_CHARS);
7878
if (!id || !name || !input.trim() || seen.has(id)) {
7979
continue;
8080
}

ui/src/pages/chat/tool-titles.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ describe("resolveToolTitleRequest", () => {
5252

5353
expect(first?.key).toBe(second?.key);
5454
});
55+
56+
it.each([
57+
["command", "bash", { command: `${"a".repeat(1_999)}😀tail` }, "a".repeat(1_999)],
58+
["string args", "mcp__linear__create_issue", `${"a".repeat(1_999)}😀tail`, "a".repeat(1_999)],
59+
[
60+
"serialized object args",
61+
"mcp__linear__create_issue",
62+
{ value: `${"a".repeat(1_989)}😀tail` },
63+
'{"value":"' + "a".repeat(1_989),
64+
],
65+
])("keeps bounded %s on a valid UTF-16 boundary", (_label, name, args, expected) => {
66+
expect(resolveToolTitleRequest(name, args)?.input).toBe(expected);
67+
});
5568
});
5669

5770
describe("getToolCallTitle", () => {

ui/src/pages/chat/tool-titles.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* labels.
99
*/
1010

11+
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
1112
import type { GatewayBrowserClient } from "../../api/gateway.ts";
1213
import { resolveToolCallKind, unwrapShellWrapperCommand } from "../../lib/chat/tool-call-view.ts";
1314

@@ -68,11 +69,11 @@ function serializeArgs(args: unknown): string | null {
6869
return null;
6970
}
7071
if (typeof args === "string") {
71-
return args.slice(0, MAX_TITLE_INPUT_CHARS);
72+
return truncateUtf16Safe(args, MAX_TITLE_INPUT_CHARS);
7273
}
7374
try {
7475
const encoded = JSON.stringify(args);
75-
return typeof encoded === "string" ? encoded.slice(0, MAX_TITLE_INPUT_CHARS) : null;
76+
return typeof encoded === "string" ? truncateUtf16Safe(encoded, MAX_TITLE_INPUT_CHARS) : null;
7677
} catch {
7778
return null;
7879
}
@@ -98,7 +99,7 @@ export function resolveToolTitleRequest(
9899
if (command.length < MIN_COMMAND_CHARS_FOR_TITLE) {
99100
return null;
100101
}
101-
const input = command.slice(0, MAX_TITLE_INPUT_CHARS);
102+
const input = truncateUtf16Safe(command, MAX_TITLE_INPUT_CHARS);
102103
return { key: digest("command", input), input };
103104
}
104105
if (kind !== "generic") {

0 commit comments

Comments
 (0)