Skip to content

Commit 6fb6578

Browse files
fix(imessage): drop dangling surrogates in debounced merge preview and probe snippet
1 parent 56c2d63 commit 6fb6578

4 files changed

Lines changed: 77 additions & 3 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Imessage tests cover the UTF-16-safe debounced-merge preview in
2+
// monitor-provider.ts:731. Verifies that `sliceUtf16Safe` drops a surrogate
3+
// pair that straddles the 50-char truncation boundary instead of leaving a
4+
// lone high-surrogate half in the preview, and that the existing conditional
5+
// `"..."` ellipsis at L732 still fires correctly when the original input
6+
// exceeded the cap.
7+
import { describe, expect, it } from "vitest";
8+
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
9+
10+
describe("monitor-provider debounced-merge preview truncation", () => {
11+
const emoji = "🎉";
12+
13+
it("sliceUtf16Safe drops a trailing surrogate straddling the 50-char boundary", () => {
14+
const input = "a".repeat(49) + emoji;
15+
const out = sliceUtf16Safe(input, 0, 50);
16+
expect(out.length).toBe(49);
17+
expect(out.charCodeAt(out.length - 1)).toBeLessThan(0xd800);
18+
});
19+
20+
it("ellipsis marker is appended when the original text exceeded 50 chars (no surrogate regression)", () => {
21+
const input = "a".repeat(60);
22+
const out = sliceUtf16Safe(input, 0, 50);
23+
const ellipsis = input.length > 50 ? "..." : "";
24+
expect(out + ellipsis).toBe("a".repeat(50) + "...");
25+
});
26+
27+
it("empty input stays empty", () => {
28+
expect(sliceUtf16Safe("", 0, 50)).toBe("");
29+
});
30+
31+
it("emoji fully inside the window is preserved (no false-positive drops)", () => {
32+
const input = emoji + "a".repeat(50);
33+
const out = sliceUtf16Safe(input, 0, 50);
34+
expect(out.startsWith(emoji)).toBe(true);
35+
expect(out.length).toBe(50);
36+
});
37+
});

extensions/imessage/src/monitor/monitor-provider.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ import {
4545
resolveSendPolicy,
4646
resolveStorePath,
4747
} from "openclaw/plugin-sdk/session-store-runtime";
48-
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
48+
import {
49+
sliceUtf16Safe,
50+
truncateUtf16Safe,
51+
} from "openclaw/plugin-sdk/text-utility-runtime";
4952
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
5053
import { resolveIMessageAccount } from "../accounts.js";
5154
import { pollPendingIMessageApprovalReactions } from "../approval-reaction-poller.js";
@@ -728,7 +731,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
728731
const combined = combineIMessagePayloads(messages);
729732
if (shouldLogVerbose()) {
730733
const text = combined.text ?? "";
731-
const preview = text.slice(0, 50);
734+
const preview = sliceUtf16Safe(text, 0, 50);
732735
const ellipsis = text.length > 50 ? "..." : "";
733736
logVerbose(
734737
`[imessage] merged ${entries.length} debounced messages: "${preview}${ellipsis}"`,

extensions/imessage/src/probe.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Imessage tests cover probe plugin behavior.
22
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
34
import {
45
clearCachedIMessagePrivateApiStatus,
56
getCachedIMessagePrivateApiStatus,
@@ -135,3 +136,35 @@ describe("iMessage private API status cache", () => {
135136
expect(getCachedIMessagePrivateApiStatus("imsg-overflow-private-clock")).toBeUndefined();
136137
});
137138
});
139+
140+
// Verifies the `lines[0]?.slice(0, 120)` snippet site in probe.ts:161 drops a
141+
// surrogate pair that straddles the truncation boundary instead of leaving a
142+
// lone high-surrogate half in the diagnostic preview.
143+
describe("probe first-line snippet truncation", () => {
144+
const emoji = "🎉";
145+
146+
it("truncateUtf16Safe drops a trailing surrogate straddling the 120-char boundary", () => {
147+
const input = "a".repeat(119) + emoji;
148+
const out = truncateUtf16Safe(input, 120);
149+
expect(out.length).toBe(119);
150+
expect(out.charCodeAt(out.length - 1)).toBeLessThan(0xd800);
151+
});
152+
153+
it("plain ASCII under the cap passes through unchanged", () => {
154+
const input = "x".repeat(50);
155+
expect(truncateUtf16Safe(input, 120)).toBe(input);
156+
});
157+
158+
it("undefined input is preserved (no truncation attempted)", () => {
159+
const lines: string[] = [];
160+
const snippet = lines[0] ? truncateUtf16Safe(lines[0], 120) : undefined;
161+
expect(snippet).toBeUndefined();
162+
});
163+
164+
it("emoji fully inside the 120-char window is preserved", () => {
165+
const input = emoji + "a".repeat(120);
166+
const out = truncateUtf16Safe(input, 120);
167+
expect(out.startsWith(emoji)).toBe(true);
168+
expect(out.length).toBe(120);
169+
});
170+
});

extensions/imessage/src/probe.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
normalizeLowercaseStringOrEmpty,
1414
normalizeStringEntries,
1515
} from "openclaw/plugin-sdk/string-coerce-runtime";
16+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1617
import { createIMessageRpcClient } from "./client.js";
1718
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
1819
import {
@@ -158,7 +159,7 @@ function parseStatusPayload(stdout: string): {
158159
// No JSONL line parsed. Surface a small snippet of the first non-empty
159160
// line so the operator can grep imsg release notes if the status output
160161
// schema has shifted.
161-
const snippet = lines[0]?.slice(0, 120);
162+
const snippet = lines[0] ? truncateUtf16Safe(lines[0], 120) : undefined;
162163
return { payload: null, firstLineSnippet: snippet };
163164
}
164165

0 commit comments

Comments
 (0)