fix(text): keep reachable truncation boundaries UTF-16 safe
Some checks failed
CI / runner-admission (push) Waiting to run
CI / preflight (push) Blocked by required conditions
CI / security-fast (push) Blocked by required conditions
CI / pnpm-store-warmup (push) Blocked by required conditions
CI / build-artifacts (push) Blocked by required conditions
CI / native-i18n (push) Blocked by required conditions
CI / (push) Blocked by required conditions
CI / QA Smoke CI (matrix) (push) Blocked by required conditions
CI / QA Smoke CI (telegram 1/2) (push) Blocked by required conditions
CI / QA Smoke CI (telegram 2/2) (push) Blocked by required conditions
CI / QA Smoke CI (push) Blocked by required conditions
CI / -1 (push) Blocked by required conditions
CI / -2 (push) Blocked by required conditions
CI / checks-node-compat-node22 (push) Blocked by required conditions
CI / -3 (push) Blocked by required conditions
CI / check-bundled-channel-config-metadata (push) Blocked by required conditions
CI / check-dependencies (push) Blocked by required conditions
CI / check-guards (push) Blocked by required conditions
CI / check-lint (push) Blocked by required conditions
CI / check-prod-types (push) Blocked by required conditions
CI / check-shrinkwrap (push) Blocked by required conditions
CI / check-test-types (push) Blocked by required conditions
CI / check-additional-boundaries-a (push) Blocked by required conditions
CI / check-additional-boundaries-bcd (push) Blocked by required conditions
CI / check-additional-extension-package-boundary (push) Blocked by required conditions
CI / check-additional-runtime-topology-architecture (push) Blocked by required conditions
CI / check-session-accessor-boundary (push) Blocked by required conditions
CI / check-session-transcript-reader-boundary (push) Blocked by required conditions
CI / check-docs (push) Blocked by required conditions
CI / skills-python (push) Blocked by required conditions
CI / -4 (push) Blocked by required conditions
CI / -5 (push) Blocked by required conditions
CI / macos-swift (push) Blocked by required conditions
CI / ios-build (push) Blocked by required conditions
CI / -6 (push) Blocked by required conditions
CI / ci-timings-summary (push) Blocked by required conditions
ClawSweeper Dispatch / dispatch (push) Waiting to run
CodeQL / Security High (actions) (push) Waiting to run
CodeQL / Security High (channel-runtime-boundary) (push) Waiting to run
CodeQL / Security High (core-auth-secrets) (push) Waiting to run
CodeQL / Security High (mcp-process-tool-boundary) (push) Waiting to run
CodeQL / Security High (network-ssrf-boundary) (push) Waiting to run
CodeQL / Security High (plugin-trust-boundary) (push) Waiting to run
CodeQL / Security High (process-exec-boundary) (push) Waiting to run
Control UI Locale Refresh / plan (push) Waiting to run
Control UI Locale Refresh / Refresh (push) Blocked by required conditions
Control UI Locale Refresh / Commit control UI locale refresh (push) Blocked by required conditions
Docs Sync Publish Repo / sync-publish-repo (push) Waiting to run
Docs / docs (push) Waiting to run
Native App Locale Refresh / Refresh native ar (push) Waiting to run
Native App Locale Refresh / Refresh native de (push) Waiting to run
Native App Locale Refresh / Refresh native es (push) Waiting to run
CI / check-additional-extension-bundled (push) Blocked by required conditions
CI / check-additional-extension-channels (push) Blocked by required conditions
Native App Locale Refresh / Refresh native fa (push) Waiting to run
Native App Locale Refresh / Refresh native fr (push) Waiting to run
Native App Locale Refresh / Refresh native hi (push) Waiting to run
Native App Locale Refresh / Refresh native id (push) Waiting to run
Native App Locale Refresh / Refresh native it (push) Waiting to run
Native App Locale Refresh / Refresh native ja-JP (push) Waiting to run
Native App Locale Refresh / Refresh native ko (push) Waiting to run
Native App Locale Refresh / Refresh native nl (push) Waiting to run
Native App Locale Refresh / Refresh native pl (push) Waiting to run
Native App Locale Refresh / Refresh native pt-BR (push) Waiting to run
Native App Locale Refresh / Refresh native ru (push) Waiting to run
Native App Locale Refresh / Refresh native sv (push) Waiting to run
Native App Locale Refresh / Refresh native th (push) Waiting to run
Native App Locale Refresh / Refresh native tr (push) Waiting to run
Native App Locale Refresh / Refresh native uk (push) Waiting to run
Native App Locale Refresh / Refresh native vi (push) Waiting to run
Native App Locale Refresh / Refresh native zh-CN (push) Waiting to run
Native App Locale Refresh / Refresh native zh-TW (push) Waiting to run
Native App Locale Refresh / Commit native locale refresh (push) Blocked by required conditions
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Waiting to run
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Blocked by required conditions
Plugin Init Scaffold Validation / Validate provider scaffold (push) Waiting to run
Plugin NPM Release / preview_plugins_npm (push) Waiting to run
Plugin NPM Release / Validate release publish approval (push) Blocked by required conditions
Plugin NPM Release / preview_plugin_pack (push) Blocked by required conditions
Plugin NPM Release / publish_plugins_npm (push) Blocked by required conditions
Plugin NPM Release / verify_plugins_npm (push) Blocked by required conditions
Workflow Sanity / no-tabs (push) Waiting to run
Workflow Sanity / actionlint (push) Waiting to run
Workflow Sanity / generated-doc-baselines (push) Waiting to run
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Consolidates #102007, #101818, and #101782.

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>
Co-authored-by: NIO <nocodet@mail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vincent Koc 2026-07-07 23:37:14 -07:00
parent fc4626c122
commit 7e0324263b
No known key found for this signature in database
8 changed files with 88 additions and 9 deletions

View file

@ -244,4 +244,11 @@ describe("formatSlackBotStarterThreadLabel", () => {
}),
).toBe("Slack thread DM (assistant root): Line one Line two");
});
it("drops a surrogate-pair emoji whole when it straddles the snippet limit", () => {
const starterText = `${"a".repeat(79)}🐱tail`;
expect(formatSlackBotStarterThreadLabel({ roomLabel: "DM", starterText })).toBe(
`Slack thread DM (assistant root): ${"a".repeat(79)}`,
);
});
});

View file

@ -1,4 +1,6 @@
// Slack plugin module implements prepare thread context root behavior.
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
type SlackBotAuthorIdentity = {
botUserId?: string;
botId?: string;
@ -107,9 +109,13 @@ export function formatSlackBotStarterThreadLabel(params: {
if (!params.starterText) {
return base;
}
const snippet = params.starterText.replace(/\s+/g, " ").slice(0, 80).trim();
const snippet = formatSlackThreadLabelSnippet(params.starterText).trim();
if (!snippet) {
return base;
}
return `${base} (assistant root): ${snippet}`;
}
export function formatSlackThreadLabelSnippet(text: string): string {
return truncateUtf16Safe(text.replace(/\s+/g, " "), 80);
}

View file

@ -225,6 +225,22 @@ describe("resolveSlackThreadContextData", () => {
expect(result.threadHistoryBody).not.toContain("blocked follow-up");
});
it("keeps a user-started thread label UTF-16 safe at the snippet limit", async () => {
const starterText = `${"a".repeat(79)}🐱tail`;
const { result } = await resolveAllowlistedThreadContext({
repliesMessages: [],
threadStarter: {
text: starterText,
userId: "U1",
ts: "100.000",
},
allowFromLower: ["u1"],
allowNameMatching: false,
});
expect(result.threadLabel).toBe(`Slack thread #general: ${"a".repeat(79)}`);
});
it("includes bot-authored starter as assistant root context for a new thread session (default)", async () => {
const { result } = await resolveAllowlistedThreadContext({
repliesMessages: [

View file

@ -19,6 +19,7 @@ import {
applySlackThreadHistoryFilterPolicy,
ensureSlackThreadHistoryHasBotRoot,
formatSlackBotStarterThreadLabel,
formatSlackThreadLabelSnippet,
isSlackThreadAuthorCurrentBot,
resolveSlackThreadHistoryFilterPolicy,
shouldIncludeBotThreadStarterContext,
@ -224,7 +225,7 @@ export async function resolveSlackThreadContextData(params: {
if (starter?.text && includeStarterContext) {
threadStarterBody = starter.text;
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
const snippet = formatSlackThreadLabelSnippet(starter.text);
threadLabel = `Slack thread ${params.roomLabel}${snippet ? `: ${snippet}` : ""}`;
// Root media seeds a new thread session once. Rehydrating it later makes
// old files look like current-turn uploads and repeats media processing.

View file

@ -119,6 +119,29 @@ describe("zalo send", () => {
expect(sendPhotoMock).not.toHaveBeenCalled();
});
it("keeps outbound text and photo captions UTF-16 safe at the 2000-char limit", async () => {
sendMessageMock.mockResolvedValueOnce({
ok: true,
result: { message_id: "z-msg-surrogate" },
});
sendPhotoMock.mockResolvedValueOnce({
ok: true,
result: { message_id: "z-photo-surrogate" },
});
const boundaryText = `${"a".repeat(1999)}🐱`;
await sendMessageZalo("dm-chat-surrogate-text", boundaryText, {
token: "zalo-token",
});
await sendPhotoZalo("dm-chat-surrogate-caption", "https://example.com/photo.jpg", {
token: "zalo-token",
caption: boundaryText,
});
expect(sendMessageMock.mock.calls[0]?.[1]?.text).toBe("a".repeat(1999));
expect(sendPhotoMock.mock.calls[0]?.[1]?.caption).toBe("a".repeat(1999));
});
it("sends cfg-backed media directly without hosted-media rewrites", async () => {
sendPhotoMock.mockResolvedValueOnce({
ok: true,

View file

@ -6,6 +6,7 @@ import {
} from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveZaloAccount } from "./accounts.js";
import type { ZaloFetch } from "./api.js";
import { sendMessage, sendPhoto } from "./api.js";
@ -167,7 +168,7 @@ export async function sendMessageZalo(
context.token,
{
chat_id: context.chatId,
text: text.slice(0, 2000),
text: truncateUtf16Safe(text, 2000),
},
context.fetcher,
),
@ -200,7 +201,8 @@ export async function sendPhotoZalo(
{
chat_id: context.chatId,
photo: photoUrl.trim(),
caption: options.caption?.slice(0, 2000),
caption:
options.caption !== undefined ? truncateUtf16Safe(options.caption, 2000) : undefined,
},
context.fetcher,
))(),

View file

@ -121,6 +121,25 @@ describe("execCommand", () => {
expect(result.stdoutTruncatedChars).toBe(3);
});
it("keeps caller-capped retained output UTF-16 safe", async () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
spawnMock.mockReturnValue(child);
waitForChildProcessMock.mockReturnValue(wait.promise);
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", [], "/tmp", { maxOutputChars: 2 });
child.stdout.emit("data", Buffer.from("A😀B"));
child.stderr.emit("data", Buffer.from("C😀D"));
wait.resolve(0);
const result = await resultPromise;
expect(result.stdout).toBe("B");
expect(result.stderr).toBe("D");
expect(result.stdoutTruncatedChars).toBe(3);
expect(result.stderrTruncatedChars).toBe(3);
});
it("fails instead of silently truncating default exec output", async () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
@ -129,7 +148,7 @@ describe("execCommand", () => {
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", [], "/tmp");
child.stdout.emit("data", Buffer.from("x".repeat(16 * 1024 * 1024 + 1)));
child.stdout.emit("data", Buffer.from(`${"x".repeat(16 * 1024 * 1024 - 1)}😀`));
wait.resolve(0);
const result = await resultPromise;
@ -141,8 +160,9 @@ describe("execCommand", () => {
expect(result.code).toBe(1);
expect(result.killed).toBe(true);
expect(result.outputLimitExceeded).toBe("stdout");
expect(result.stdout.length).toBe(16 * 1024 * 1024);
expect(result.stdoutTruncatedChars).toBe(1);
expect(result.stdout.length).toBe(16 * 1024 * 1024 - 1);
expect(result.stdout.endsWith("x")).toBe(true);
expect(result.stdoutTruncatedChars).toBe(2);
expect(result.stderr).toContain("exec stdout exceeded output limit");
});

View file

@ -3,6 +3,7 @@
*/
import { spawn } from "node:child_process";
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { killProcessTree } from "../../process/kill-tree.js";
import { waitForChildProcess } from "../utils/child-process.js";
@ -63,9 +64,12 @@ function appendCapturedOutput(
truncatedChars: current.truncatedChars,
};
}
const nextText = truncateTail
? sliceUtf16Safe(combined, overflowChars)
: sliceUtf16Safe(combined, 0, maxOutputChars);
return {
text: truncateTail ? combined.slice(overflowChars) : combined.slice(0, maxOutputChars),
truncatedChars: current.truncatedChars + overflowChars,
text: nextText,
truncatedChars: current.truncatedChars + combined.length - nextText.length,
};
}