Skip to content

fix(slack): truncate on code-point boundaries to avoid splitting surrogate pairs#96382

Merged
vincentkoc merged 1 commit into
openclaw:mainfrom
ly-wang19:fix/slack-truncate-surrogate-pair
Jun 24, 2026
Merged

fix(slack): truncate on code-point boundaries to avoid splitting surrogate pairs#96382
vincentkoc merged 1 commit into
openclaw:mainfrom
ly-wang19:fix/slack-truncate-surrogate-pair

Conversation

@ly-wang19

Copy link
Copy Markdown
Contributor

What Problem This Solves

truncateSlackText (extensions/slack/src/truncate.ts) slices by UTF-16 code unit:

return `${trimmed.slice(0, max - 1)}…`;

When an emoji or other astral character (a surrogate pair) straddles the limit, slice keeps only its leading high surrogate, emitting a lone, invalid \uD83D right before the ellipsis:

truncateSlackText("abc😀def", 5)
  before: "abc\uD83D…"   ❌ lone high surrogate (renders as �, can be mangled/rejected by strict JSON consumers)
  after : "abc…"         ✅ the un-fitting emoji is dropped whole

This text is sent in live Slack payloadschat.postMessage text (send.ts) and Block Kit section text, button labels, option labels, and header titles (blocks-render.ts), which truncate at limits as small as SLACK_PLAIN_TEXT_MAX = 75. Any user/agent text ending with an emoji near a limit produces a broken half-character.

Fix

Use the repo's canonical surrogate-safe slice, sliceUtf16Safe (defined in src/utils.ts, already re-exported from openclaw/plugin-sdk/text-utility-runtime — the module Slack code already imports from). It drops a straddling pair whole. truncateSlackText was the lone straggler still slicing raw; this aligns it with the rest of the codebase (truncateUtf16Safe, discord clampToCodePointBoundary, src/shared/text-chunking.ts, etc.).

Behavior is byte-identical for all-BMP inputsliceUtf16Safe(s, 0, n) === s.slice(0, n) whenever no pair straddles the edge.

Evidence

Added extensions/slack/src/truncate.test.ts (the function is exported; no test existed). Verified by running the exact sliceUtf16Safe + truncateSlackText logic:

truncateSlackText("abc😀def", 5)   before "abc\uD83D…" (LONE surrogate)  ->  after "abc…"        ✅
truncateSlackText("hello world",5) before "hell…"                        ->  after "hell…"       (unchanged)
truncateSlackText("😀abcdef", 5)   before "😀ab…"                        ->  after "😀ab…"        (unchanged)
truncateSlackText("ab😀cd", 10)    before "ab😀cd"                       ->  after "ab😀cd"       (unchanged)

All-BMP and emoji-that-fits cases are unchanged; only the straddling-pair case changes, and the fixed output contains no lone surrogate.

…ogate pairs

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: Claude Opus 4.8 (1M context) <[email protected]>
@openclaw-barnacle openclaw-barnacle Bot added channel: slack Channel integration: slack size: XS labels Jun 24, 2026
@clawsweeper

clawsweeper Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 24, 2026, 7:26 AM ET / 11:26 UTC.

Summary
The PR changes Slack text truncation to use UTF-16-safe slicing and adds focused tests for emoji boundary cases.

PR surface: Source +5, Tests +27. Total +32 across 2 files.

Reproducibility: yes. Current main's raw slice path on abc😀def at max 5 produces "abc\ud83d…", and a dangling-high-surrogate regex matches the output.

Review metrics: none identified.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🐚 platinum hermit
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • none.

Next step before merge

  • No automated repair is needed; this PR is already a narrow implementation candidate with no blocking code finding.

Security
Cleared: The diff only changes Slack string truncation and adds tests; it does not add dependencies, workflows, lockfiles, secret handling, or executable supply-chain surface.

Review details

Best possible solution:

Land this narrow Slack helper fix after ordinary maintainer review and required CI, keeping UTF-16-safe truncation centralized on the SDK helper.

Do we have a high-confidence way to reproduce the issue?

Yes. Current main's raw slice path on abc😀def at max 5 produces "abc\ud83d…", and a dangling-high-surrogate regex matches the output.

Is this the best way to solve the issue?

Yes. Reusing sliceUtf16Safe through the plugin SDK export is narrower and lower-drift than adding a Slack-local surrogate-boundary implementation.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against ae06d846faf3.

Label changes

Label justifications:

  • P2: This is a focused Slack payload correctness bug with limited blast radius.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body includes copied before/after output for the exact truncation behavior, showing the lone surrogate before and the safe result after the fix.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes copied before/after output for the exact truncation behavior, showing the lone surrogate before and the safe result after the fix.
Evidence reviewed

PR surface:

Source +5, Tests +27. Total +32 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 7 2 +5
Tests 1 27 0 +27
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 34 2 +32

What I checked:

  • repository policy read: Root and scoped extension policy were read fully; the extension guide permits bundled plugin production code to import from openclaw/plugin-sdk/*, which matches the proposed helper import. (extensions/AGENTS.md:12, ae06d846faf3)
  • current-main bug: Current truncateSlackText trims and then uses raw slice, so the ellipsis path can cut through a surrogate pair. (extensions/slack/src/truncate.ts:10, ae06d846faf3)
  • current-main reproduction: A direct Node probe of current-main logic for abc😀def at max 5 produced "abc\ud83d…", and the dangling-high-surrogate regex returned true. (extensions/slack/src/truncate.ts:10, ae06d846faf3)
  • Slack caller surface: The truncator feeds Slack chat.postMessage fallback text and Block Kit text surfaces including headers, sections, buttons, placeholders, and option labels. (extensions/slack/src/send.ts:780, ae06d846faf3)
  • existing helper contract: sliceUtf16Safe is implemented to avoid dangling surrogate halves at slice edges and is already re-exported through plugin-sdk/text-utility-runtime. (src/utils.ts:81, ae06d846faf3)
  • SDK export: The root package exports ./plugin-sdk/text-utility-runtime, so the proposed import uses a supported SDK subpath rather than a core deep import. (package.json:1420, ae06d846faf3)

Likely related people:

  • Pick-cat: Local blame shows the current Slack truncator, its raw-slice branch, and the shared UTF-16 helper entering current main in the same merged commit. (role: introduced current behavior; confidence: medium; commits: 20a87e17f52d; files: extensions/slack/src/truncate.ts, src/utils.ts, src/plugin-sdk/text-utility-runtime.ts)
  • vincentkoc: Live PR metadata for fix(gateway): resolve plugin-registered gateway methods through live registry #94154 shows he refreshed and merged the current-main commit that introduced this Slack helper surface. (role: merger and recent adjacent contributor; confidence: medium; commits: 20a87e17f52d, 0671c0890073; files: extensions/slack/src/truncate.ts, extensions/slack/src/blocks-render.ts, extensions/slack/src/send.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P2 Normal backlog priority with limited blast radius. labels Jun 24, 2026
@vincentkoc
vincentkoc merged commit 1069c60 into openclaw:main Jun 24, 2026
130 of 137 checks passed
@vincentkoc

Copy link
Copy Markdown
Member

Merged via squash.

vincentkoc pushed a commit to he-yufeng/openclaw that referenced this pull request Jun 24, 2026
…one surrogates

clipProgressMarkdownText used text.slice(0, MAX - 1), which splits UTF-16
surrogate pairs. When an astral character (e.g. an emoji) straddles the
299/300-code-unit boundary, the slice keeps only the high surrogate half
(\uD83D), producing an invalid character that the Telegram Bot API rejects.

Extract the helper to extensions/telegram/src/truncate.ts as
clipTelegramProgressText and switch to sliceUtf16Safe (from
openclaw/plugin-sdk/text-utility-runtime), mirroring the fix applied to
the equivalent Slack helper in openclaw#96382.

Adds truncate.test.ts with five vitest cases covering the straddle,
fits-before-cut, unchanged-short, trimEnd, and ASCII-exact-limit paths.
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jun 25, 2026
…ogate pairs (openclaw#96382)

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 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
QiuYuang pushed a commit to QiuYuang/openclaw that referenced this pull request Jul 1, 2026
…ogate pairs (openclaw#96382)

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 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: slack Channel integration: slack P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: XS status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants