From 1069c60e1e2509b23b88e841c8d60b856f2aabc0 Mon Sep 17 00:00:00 2001 From: ly-wang19 <94427531+ly-wang19@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:30:29 +0800 Subject: [PATCH] fix(slack): truncate on code-point boundaries to avoid splitting surrogate pairs (#96382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit truncateSlackText sliced by UTF-16 code unit ('trimmed.slice(0, max - 1)'), so an emoji or other astral character straddling the limit was cut in half, leaving a lone high surrogate before the ellipsis — e.g. truncateSlackText('abc😀def', 5) returned 'abc\uD83D…' instead of 'abc…'. That invalid half-character is sent in live Slack payloads (message text and Block Kit section/button/header/option labels, which truncate at limits as small as 75). Use the repo's canonical sliceUtf16Safe (already re-exported from plugin-sdk/text-utility-runtime, the module slack code imports from) so a straddling pair is dropped whole. Behavior is byte-identical for all-BMP input. Co-authored-by: ly-wang19 Co-authored-by: Claude Opus 4.8 (1M context) --- extensions/slack/src/truncate.test.ts | 27 +++++++++++++++++++++++++++ extensions/slack/src/truncate.ts | 9 +++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 extensions/slack/src/truncate.test.ts diff --git a/extensions/slack/src/truncate.test.ts b/extensions/slack/src/truncate.test.ts new file mode 100644 index 00000000000..6eb535f727a --- /dev/null +++ b/extensions/slack/src/truncate.test.ts @@ -0,0 +1,27 @@ +// Slack tests cover truncate plugin behavior. +import { describe, expect, it } from "vitest"; +import { truncateSlackText } from "./truncate.js"; + +describe("truncateSlackText", () => { + it("drops a surrogate-pair emoji whole when it straddles the limit", () => { + // "abc😀def": 😀 (U+1F600) sits at the cut point. Slicing by UTF-16 code unit + // would keep only its high surrogate — a lone \uD83D — before the ellipsis, + // which serializes to an invalid character in the Slack payload. + const out = truncateSlackText("abc😀def", 5); + expect(out).toBe("abc…"); + // No dangling high surrogate (a high surrogate not followed by a low one). + expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false); + }); + + it("truncates plain BMP text unchanged", () => { + expect(truncateSlackText("hello world", 5)).toBe("hell…"); + }); + + it("keeps an emoji that fits before the cut", () => { + expect(truncateSlackText("😀abcdef", 5)).toBe("😀ab…"); + }); + + it("returns the trimmed input unchanged when it fits", () => { + expect(truncateSlackText("ab😀cd", 10)).toBe("ab😀cd"); + }); +}); diff --git a/extensions/slack/src/truncate.ts b/extensions/slack/src/truncate.ts index 03998f437cf..4e19c4ce593 100644 --- a/extensions/slack/src/truncate.ts +++ b/extensions/slack/src/truncate.ts @@ -1,11 +1,16 @@ // Slack plugin module implements truncate behavior. +import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; + export function truncateSlackText(value: string, max: number): string { const trimmed = value.trim(); if (trimmed.length <= max) { return trimmed; } + // Slice on a code-point boundary so a surrogate pair (emoji / astral char) + // straddling the limit is dropped whole, instead of leaving a lone surrogate + // half that serializes to an invalid `\uD83D` in the Slack payload. if (max <= 1) { - return trimmed.slice(0, max); + return sliceUtf16Safe(trimmed, 0, max); } - return `${trimmed.slice(0, max - 1)}…`; + return `${sliceUtf16Safe(trimmed, 0, max - 1)}…`; }