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)}…`; }