
















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+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。