Skip to content

Commit 14df266

Browse files
authored
Merge a090c64 into ff35f3b
2 parents ff35f3b + a090c64 commit 14df266

4 files changed

Lines changed: 140 additions & 50 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Regression coverage for surrogate-safe truncation in compact tool display
3+
* detail coercion (coerceDisplayValue, reached via resolveToolVerbAndDetailForArgs
4+
* -> resolveDetailFromKeys).
5+
*/
6+
import { describe, expect, it } from "vitest";
7+
import { resolveToolVerbAndDetailForArgs } from "./tool-display-common.js";
8+
9+
function isHighSurrogate(codeUnit: number): boolean {
10+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
11+
}
12+
function isLowSurrogate(codeUnit: number): boolean {
13+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
14+
}
15+
function hasLoneSurrogate(value: string): boolean {
16+
for (let i = 0; i < value.length; i += 1) {
17+
const codeUnit = value.charCodeAt(i);
18+
if (isHighSurrogate(codeUnit)) {
19+
if (i + 1 >= value.length || !isLowSurrogate(value.charCodeAt(i + 1))) {
20+
return true;
21+
}
22+
} else if (isLowSurrogate(codeUnit)) {
23+
if (i === 0 || !isHighSurrogate(value.charCodeAt(i - 1))) {
24+
return true;
25+
}
26+
}
27+
}
28+
return false;
29+
}
30+
31+
describe("coerceDisplayValue surrogate-safe truncation", () => {
32+
it("does not split an emoji across the truncation boundary (default maxStringChars=160)", () => {
33+
// 200 UTF-16 units: 78 'a', an emoji (surrogate pair at indices 78-79), 120 'b'.
34+
// With maxStringChars=160, half = floor(159/2) = 79, so the naive
35+
// firstLine.slice(0, 79) keeps only the emoji's high surrogate at index 78.
36+
const detailValue = `${"a".repeat(78)}\u{1F600}${"b".repeat(120)}`;
37+
expect(detailValue.length).toBe(200);
38+
39+
const { detail } = resolveToolVerbAndDetailForArgs({
40+
toolKey: "custom_tool",
41+
args: { note: detailValue },
42+
fallbackDetailKeys: ["note"],
43+
detailMode: "first",
44+
});
45+
46+
expect(detail).toBeDefined();
47+
// The bug rendered a lone high surrogate (and possibly a lone low surrogate
48+
// at the tail head); the fix must drop the whole emoji at the cut.
49+
expect(hasLoneSurrogate(detail as string)).toBe(false);
50+
// Head keeps only the 78 leading 'a's (emoji dropped, not half-kept).
51+
expect((detail as string).split("…")[0]).toBe("a".repeat(78));
52+
// Tail must not begin mid-pair on a lone low surrogate.
53+
const tail = (detail as string).split("…")[1] ?? "";
54+
expect(isLowSurrogate(tail.charCodeAt(0))).toBe(false);
55+
});
56+
57+
it("leaves plain (non-surrogate) long values truncated as before", () => {
58+
const detailValue = "x".repeat(300);
59+
60+
const { detail } = resolveToolVerbAndDetailForArgs({
61+
toolKey: "custom_tool",
62+
args: { note: detailValue },
63+
fallbackDetailKeys: ["note"],
64+
detailMode: "first",
65+
});
66+
67+
// Behavior-preserving for ASCII: half = 79, so 79 'x' + ellipsis + 80 'x'.
68+
expect(detail).toBe(`${"x".repeat(79)}${"x".repeat(80)}`);
69+
expect(hasLoneSurrogate(detail as string)).toBe(false);
70+
});
71+
72+
it("returns short values unchanged", () => {
73+
const { detail } = resolveToolVerbAndDetailForArgs({
74+
toolKey: "custom_tool",
75+
args: { note: "short value with no emoji" },
76+
fallbackDetailKeys: ["note"],
77+
detailMode: "first",
78+
});
79+
expect(detail).toBe("short value with no emoji");
80+
});
81+
});

src/agents/tool-display-common.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33
* Redacts and summarizes arguments into short labels/details for chat and UI
44
* tool update streams.
55
*/
6-
import {
7-
asOptionalObjectRecord as asRecord,
8-
} from "@openclaw/normalization-core/record-coerce";
6+
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
97
import {
108
normalizeLowercaseStringOrEmpty,
119
normalizeOptionalString,
1210
} from "@openclaw/normalization-core/string-coerce";
1311
import { parseStrictFiniteNumber } from "../infra/parse-finite-number.js";
1412
import { redactToolPayloadText } from "../logging/redact.js";
13+
import { sliceUtf16Safe } from "../shared/utf16-slice.js";
1514
import { resolveExecDetail, type ToolDetailMode } from "./tool-display-exec.js";
1615

1716
type ToolDisplayActionSpec = {
@@ -136,7 +135,7 @@ function coerceDisplayValue(
136135
const firstLine = redactToolPayloadText(rawLine);
137136
if (firstLine.length > maxStringChars) {
138137
const half = Math.floor((maxStringChars - 1) / 2);
139-
return `${firstLine.slice(0, half)}${firstLine.slice(-(maxStringChars - 1 - half))}`;
138+
return `${sliceUtf16Safe(firstLine, 0, half)}${sliceUtf16Safe(firstLine, -(maxStringChars - 1 - half))}`;
140139
}
141140
return firstLine;
142141
}

src/shared/utf16-slice.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Surrogate-safe UTF-16 string slicing helpers.
2+
//
3+
// Kept dependency-free (no node: imports) so browser/UI bundles can import them
4+
// without dragging in filesystem/runtime code. See utils.ts, which re-exports
5+
// these for the broad runtime surface.
6+
7+
function isHighSurrogate(codeUnit: number): boolean {
8+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
9+
}
10+
11+
function isLowSurrogate(codeUnit: number): boolean {
12+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
13+
}
14+
15+
/** Slices a UTF-16 string without returning dangling surrogate halves at either edge. */
16+
export function sliceUtf16Safe(input: string, start: number, end?: number): string {
17+
const len = input.length;
18+
19+
let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
20+
let to = end === undefined ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len);
21+
22+
if (to < from) {
23+
const tmp = from;
24+
from = to;
25+
to = tmp;
26+
}
27+
28+
if (from > 0 && from < len) {
29+
const codeUnit = input.charCodeAt(from);
30+
if (isLowSurrogate(codeUnit) && isHighSurrogate(input.charCodeAt(from - 1))) {
31+
from += 1;
32+
}
33+
}
34+
35+
if (to > 0 && to < len) {
36+
const codeUnit = input.charCodeAt(to - 1);
37+
if (isHighSurrogate(codeUnit) && isLowSurrogate(input.charCodeAt(to))) {
38+
to -= 1;
39+
}
40+
}
41+
42+
return input.slice(from, to);
43+
}
44+
45+
/** Truncates a UTF-16 string without cutting a surrogate pair in half. */
46+
export function truncateUtf16Safe(input: string, maxLen: number): string {
47+
const limit = Math.max(0, Math.floor(maxLen));
48+
if (input.length <= limit) {
49+
return input;
50+
}
51+
return sliceUtf16Safe(input, 0, limit);
52+
}

src/utils.ts

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -69,52 +69,10 @@ export function sleep(ms: number) {
6969
});
7070
}
7171

72-
function isHighSurrogate(codeUnit: number): boolean {
73-
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
74-
}
75-
76-
function isLowSurrogate(codeUnit: number): boolean {
77-
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
78-
}
79-
80-
/** Slices a UTF-16 string without returning dangling surrogate halves at either edge. */
81-
export function sliceUtf16Safe(input: string, start: number, end?: number): string {
82-
const len = input.length;
83-
84-
let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
85-
let to = end === undefined ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len);
86-
87-
if (to < from) {
88-
const tmp = from;
89-
from = to;
90-
to = tmp;
91-
}
92-
93-
if (from > 0 && from < len) {
94-
const codeUnit = input.charCodeAt(from);
95-
if (isLowSurrogate(codeUnit) && isHighSurrogate(input.charCodeAt(from - 1))) {
96-
from += 1;
97-
}
98-
}
99-
100-
if (to > 0 && to < len) {
101-
const codeUnit = input.charCodeAt(to - 1);
102-
if (isHighSurrogate(codeUnit) && isLowSurrogate(input.charCodeAt(to))) {
103-
to -= 1;
104-
}
105-
}
106-
107-
return input.slice(from, to);
108-
}
109-
110-
/** Truncates a UTF-16 string without cutting a surrogate pair in half. */
111-
export function truncateUtf16Safe(input: string, maxLen: number): string {
112-
const limit = Math.max(0, Math.floor(maxLen));
113-
if (input.length <= limit) {
114-
return input;
115-
}
116-
return sliceUtf16Safe(input, 0, limit);
117-
}
72+
// Surrogate-safe slicing helpers live in a node-free leaf module so browser/UI
73+
// bundles can import them without pulling in filesystem code. Re-exported here
74+
// to preserve the historical `utils.ts` import surface.
75+
export { sliceUtf16Safe, truncateUtf16Safe } from "./shared/utf16-slice.js";
11876

11977
/** Resolves `~` and OpenClaw home-relative paths with injectable env/home sources. */
12078
export function resolveUserPath(

0 commit comments

Comments
 (0)