Skip to content

Commit 7e03242

Browse files
vincentkocmushuiyu886NIOcursoragent
committed
fix(text): keep reachable truncation boundaries UTF-16 safe
Consolidates #102007, #101818, and #101782. Co-authored-by: 杨浩宇0668001029 <[email protected]> Co-authored-by: NIO <[email protected]> Co-authored-by: Cursor <[email protected]>
1 parent fc4626c commit 7e03242

8 files changed

Lines changed: 88 additions & 9 deletions

File tree

extensions/slack/src/monitor/message-handler/prepare-thread-context-root.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,4 +244,11 @@ describe("formatSlackBotStarterThreadLabel", () => {
244244
}),
245245
).toBe("Slack thread DM (assistant root): Line one Line two");
246246
});
247+
248+
it("drops a surrogate-pair emoji whole when it straddles the snippet limit", () => {
249+
const starterText = `${"a".repeat(79)}🐱tail`;
250+
expect(formatSlackBotStarterThreadLabel({ roomLabel: "DM", starterText })).toBe(
251+
`Slack thread DM (assistant root): ${"a".repeat(79)}`,
252+
);
253+
});
247254
});

extensions/slack/src/monitor/message-handler/prepare-thread-context-root.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// Slack plugin module implements prepare thread context root behavior.
2+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
3+
24
type SlackBotAuthorIdentity = {
35
botUserId?: string;
46
botId?: string;
@@ -107,9 +109,13 @@ export function formatSlackBotStarterThreadLabel(params: {
107109
if (!params.starterText) {
108110
return base;
109111
}
110-
const snippet = params.starterText.replace(/\s+/g, " ").slice(0, 80).trim();
112+
const snippet = formatSlackThreadLabelSnippet(params.starterText).trim();
111113
if (!snippet) {
112114
return base;
113115
}
114116
return `${base} (assistant root): ${snippet}`;
115117
}
118+
119+
export function formatSlackThreadLabelSnippet(text: string): string {
120+
return truncateUtf16Safe(text.replace(/\s+/g, " "), 80);
121+
}

extensions/slack/src/monitor/message-handler/prepare-thread-context.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,22 @@ describe("resolveSlackThreadContextData", () => {
225225
expect(result.threadHistoryBody).not.toContain("blocked follow-up");
226226
});
227227

228+
it("keeps a user-started thread label UTF-16 safe at the snippet limit", async () => {
229+
const starterText = `${"a".repeat(79)}🐱tail`;
230+
const { result } = await resolveAllowlistedThreadContext({
231+
repliesMessages: [],
232+
threadStarter: {
233+
text: starterText,
234+
userId: "U1",
235+
ts: "100.000",
236+
},
237+
allowFromLower: ["u1"],
238+
allowNameMatching: false,
239+
});
240+
241+
expect(result.threadLabel).toBe(`Slack thread #general: ${"a".repeat(79)}`);
242+
});
243+
228244
it("includes bot-authored starter as assistant root context for a new thread session (default)", async () => {
229245
const { result } = await resolveAllowlistedThreadContext({
230246
repliesMessages: [

extensions/slack/src/monitor/message-handler/prepare-thread-context.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
applySlackThreadHistoryFilterPolicy,
2020
ensureSlackThreadHistoryHasBotRoot,
2121
formatSlackBotStarterThreadLabel,
22+
formatSlackThreadLabelSnippet,
2223
isSlackThreadAuthorCurrentBot,
2324
resolveSlackThreadHistoryFilterPolicy,
2425
shouldIncludeBotThreadStarterContext,
@@ -224,7 +225,7 @@ export async function resolveSlackThreadContextData(params: {
224225

225226
if (starter?.text && includeStarterContext) {
226227
threadStarterBody = starter.text;
227-
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
228+
const snippet = formatSlackThreadLabelSnippet(starter.text);
228229
threadLabel = `Slack thread ${params.roomLabel}${snippet ? `: ${snippet}` : ""}`;
229230
// Root media seeds a new thread session once. Rehydrating it later makes
230231
// old files look like current-turn uploads and repeats media processing.

extensions/zalo/src/send.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,29 @@ describe("zalo send", () => {
119119
expect(sendPhotoMock).not.toHaveBeenCalled();
120120
});
121121

122+
it("keeps outbound text and photo captions UTF-16 safe at the 2000-char limit", async () => {
123+
sendMessageMock.mockResolvedValueOnce({
124+
ok: true,
125+
result: { message_id: "z-msg-surrogate" },
126+
});
127+
sendPhotoMock.mockResolvedValueOnce({
128+
ok: true,
129+
result: { message_id: "z-photo-surrogate" },
130+
});
131+
const boundaryText = `${"a".repeat(1999)}🐱`;
132+
133+
await sendMessageZalo("dm-chat-surrogate-text", boundaryText, {
134+
token: "zalo-token",
135+
});
136+
await sendPhotoZalo("dm-chat-surrogate-caption", "https://example.com/photo.jpg", {
137+
token: "zalo-token",
138+
caption: boundaryText,
139+
});
140+
141+
expect(sendMessageMock.mock.calls[0]?.[1]?.text).toBe("a".repeat(1999));
142+
expect(sendPhotoMock.mock.calls[0]?.[1]?.caption).toBe("a".repeat(1999));
143+
});
144+
122145
it("sends cfg-backed media directly without hosted-media rewrites", async () => {
123146
sendPhotoMock.mockResolvedValueOnce({
124147
ok: true,

extensions/zalo/src/send.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "openclaw/plugin-sdk/channel-outbound";
77
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
88
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
9+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
910
import { resolveZaloAccount } from "./accounts.js";
1011
import type { ZaloFetch } from "./api.js";
1112
import { sendMessage, sendPhoto } from "./api.js";
@@ -167,7 +168,7 @@ export async function sendMessageZalo(
167168
context.token,
168169
{
169170
chat_id: context.chatId,
170-
text: text.slice(0, 2000),
171+
text: truncateUtf16Safe(text, 2000),
171172
},
172173
context.fetcher,
173174
),
@@ -200,7 +201,8 @@ export async function sendPhotoZalo(
200201
{
201202
chat_id: context.chatId,
202203
photo: photoUrl.trim(),
203-
caption: options.caption?.slice(0, 2000),
204+
caption:
205+
options.caption !== undefined ? truncateUtf16Safe(options.caption, 2000) : undefined,
204206
},
205207
context.fetcher,
206208
))(),

src/agents/sessions/exec.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,25 @@ describe("execCommand", () => {
121121
expect(result.stdoutTruncatedChars).toBe(3);
122122
});
123123

124+
it("keeps caller-capped retained output UTF-16 safe", async () => {
125+
const child = createStubChild();
126+
const wait = createDeferred<number | null>();
127+
spawnMock.mockReturnValue(child);
128+
waitForChildProcessMock.mockReturnValue(wait.promise);
129+
const { execCommand } = await import("./exec.js");
130+
131+
const resultPromise = execCommand("cmd", [], "/tmp", { maxOutputChars: 2 });
132+
child.stdout.emit("data", Buffer.from("A😀B"));
133+
child.stderr.emit("data", Buffer.from("C😀D"));
134+
wait.resolve(0);
135+
136+
const result = await resultPromise;
137+
expect(result.stdout).toBe("B");
138+
expect(result.stderr).toBe("D");
139+
expect(result.stdoutTruncatedChars).toBe(3);
140+
expect(result.stderrTruncatedChars).toBe(3);
141+
});
142+
124143
it("fails instead of silently truncating default exec output", async () => {
125144
const child = createStubChild();
126145
const wait = createDeferred<number | null>();
@@ -129,7 +148,7 @@ describe("execCommand", () => {
129148
const { execCommand } = await import("./exec.js");
130149

131150
const resultPromise = execCommand("cmd", [], "/tmp");
132-
child.stdout.emit("data", Buffer.from("x".repeat(16 * 1024 * 1024 + 1)));
151+
child.stdout.emit("data", Buffer.from(`${"x".repeat(16 * 1024 * 1024 - 1)}😀`));
133152
wait.resolve(0);
134153

135154
const result = await resultPromise;
@@ -141,8 +160,9 @@ describe("execCommand", () => {
141160
expect(result.code).toBe(1);
142161
expect(result.killed).toBe(true);
143162
expect(result.outputLimitExceeded).toBe("stdout");
144-
expect(result.stdout.length).toBe(16 * 1024 * 1024);
145-
expect(result.stdoutTruncatedChars).toBe(1);
163+
expect(result.stdout.length).toBe(16 * 1024 * 1024 - 1);
164+
expect(result.stdout.endsWith("x")).toBe(true);
165+
expect(result.stdoutTruncatedChars).toBe(2);
146166
expect(result.stderr).toContain("exec stdout exceeded output limit");
147167
});
148168

src/agents/sessions/exec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { spawn } from "node:child_process";
6+
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
67
import { killProcessTree } from "../../process/kill-tree.js";
78
import { waitForChildProcess } from "../utils/child-process.js";
89

@@ -63,9 +64,12 @@ function appendCapturedOutput(
6364
truncatedChars: current.truncatedChars,
6465
};
6566
}
67+
const nextText = truncateTail
68+
? sliceUtf16Safe(combined, overflowChars)
69+
: sliceUtf16Safe(combined, 0, maxOutputChars);
6670
return {
67-
text: truncateTail ? combined.slice(overflowChars) : combined.slice(0, maxOutputChars),
68-
truncatedChars: current.truncatedChars + overflowChars,
71+
text: nextText,
72+
truncatedChars: current.truncatedChars + combined.length - nextText.length,
6973
};
7074
}
7175

0 commit comments

Comments
 (0)