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 <steipete@gmail.com>
This commit is contained in:
xingzhou
2026-07-12 01:45:04 +08:00
committed by GitHub
parent 49ff54e63a
commit 04037e35c0
4 changed files with 37 additions and 4 deletions
+19
View File
@@ -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" });
+1 -1
View File
@@ -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;
}
+13
View File
@@ -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", () => {
+4 -3
View File
@@ -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") {