fix(slack): truncate rich text preview on a UTF-16 boundary#96577
Conversation
|
Codex review: needs maintainer review before merge. Reviewed June 25, 2026, 11:53 PM ET / 03:53 UTC. Summary PR surface: Source +1, Tests +90. Total +91 across 2 files. Reproducibility: yes. Current main's Review metrics: none identified. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Next step before merge
Security Review detailsBest possible solution: Land the Slack-local Do we have a high-confidence way to reproduce the issue? Yes. Current main's Is this the best way to solve the issue? Yes. Reusing AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 448b7c75b669. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +1, Tests +90. Total +91 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
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
|
summarizeRichTextPreview truncated the block-action rich text preview with String.slice(0, 119) on a UTF-16 code-unit index, so an astral character straddling the 120-char boundary was cut into a lone surrogate in the interaction summary preview. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 120-char budget and ellipsis suffix. Adds tests driving the block_actions handler with a rich_text_input at the truncation boundary, asserting the preview stays UTF-16 well formed and that a complete emoji is kept when it fits.
27805af to
21450b1
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
…6577) summarizeRichTextPreview truncated the block-action rich text preview with String.slice(0, 119) on a UTF-16 code-unit index, so an astral character straddling the 120-char boundary was cut into a lone surrogate in the interaction summary preview. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 120-char budget and ellipsis suffix. Adds tests driving the block_actions handler with a rich_text_input at the truncation boundary, asserting the preview stays UTF-16 well formed and that a complete emoji is kept when it fits.
…6577) summarizeRichTextPreview truncated the block-action rich text preview with String.slice(0, 119) on a UTF-16 code-unit index, so an astral character straddling the 120-char boundary was cut into a lone surrogate in the interaction summary preview. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 120-char budget and ellipsis suffix. Adds tests driving the block_actions handler with a rich_text_input at the truncation boundary, asserting the preview stays UTF-16 well formed and that a complete emoji is kept when it fits.
…6577) summarizeRichTextPreview truncated the block-action rich text preview with String.slice(0, 119) on a UTF-16 code-unit index, so an astral character straddling the 120-char boundary was cut into a lone surrogate in the interaction summary preview. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 120-char budget and ellipsis suffix. Adds tests driving the block_actions handler with a rich_text_input at the truncation boundary, asserting the preview stays UTF-16 well formed and that a complete emoji is kept when it fits.
…6577) summarizeRichTextPreview truncated the block-action rich text preview with String.slice(0, 119) on a UTF-16 code-unit index, so an astral character straddling the 120-char boundary was cut into a lone surrogate in the interaction summary preview. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 120-char budget and ellipsis suffix. Adds tests driving the block_actions handler with a rich_text_input at the truncation boundary, asserting the preview stays UTF-16 well formed and that a complete emoji is kept when it fits.
…6577) summarizeRichTextPreview truncated the block-action rich text preview with String.slice(0, 119) on a UTF-16 code-unit index, so an astral character straddling the 120-char boundary was cut into a lone surrogate in the interaction summary preview. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 120-char budget and ellipsis suffix. Adds tests driving the block_actions handler with a rich_text_input at the truncation boundary, asserting the preview stays UTF-16 well formed and that a complete emoji is kept when it fits. (cherry picked from commit e8d7b1f)
…void lone surrogates The Mattermost inbound handler in extensions/mattermost/src/mattermost/monitor.ts truncates the message body preview with bodyText.slice(0, 200), which can split a UTF-16 surrogate pair when an emoji (e.g. 🎉, 🦞) or other astral character lands at the 200-char boundary. The resulting lone surrogate half leaks into the verbose log preview and could propagate to downstream consumers that reject ill-formed strings. Replace the raw .slice(0, 200) with the shared truncateUtf16Safe helper (from openclaw/plugin-sdk/text-utility-runtime), which backs up one code unit when the cut would land inside a surrogate pair. This matches the same pattern already applied to the Slack, Discord, Telegram, IRC, Line, MS Teams, and Feishu channel previews (openclaw#96456, openclaw#96569, openclaw#96572, openclaw#96577, openclaw#96578, openclaw#97462, openclaw#97472, openclaw#98994). The preview is only used for the verbose inbound log line so the behavioral change is cosmetic: when the 200th code unit would split an emoji, the preview now includes one fewer code unit rather than emitting a broken surrogate.
…void lone surrogates The Mattermost inbound handler in extensions/mattermost/src/mattermost/monitor.ts truncates the message body preview with bodyText.slice(0, 200), which can split a UTF-16 surrogate pair when an emoji (e.g. 🎉, 🦞) or other astral character lands at the 200-char boundary. The resulting lone surrogate half leaks into the verbose log preview and could propagate to downstream consumers that reject ill-formed strings. Replace the raw .slice(0, 200) with the shared truncateUtf16Safe helper (from openclaw/plugin-sdk/text-utility-runtime), which backs up one code unit when the cut would land inside a surrogate pair. This matches the same pattern already applied to the Slack, Discord, Telegram, IRC, Line, MS Teams, and Feishu channel previews (openclaw#96456, openclaw#96569, openclaw#96572, openclaw#96577, openclaw#96578, openclaw#97462, openclaw#97472, openclaw#98994). The preview is only used for the verbose inbound log line so the behavioral change is cosmetic: when the 200th code unit would split an emoji, the preview now includes one fewer code unit rather than emitting a broken surrogate.
…6577) summarizeRichTextPreview truncated the block-action rich text preview with String.slice(0, 119) on a UTF-16 code-unit index, so an astral character straddling the 120-char boundary was cut into a lone surrogate in the interaction summary preview. Use truncateUtf16Safe so truncation never splits a surrogate pair, keeping the existing 120-char budget and ellipsis suffix. Adds tests driving the block_actions handler with a rich_text_input at the truncation boundary, asserting the preview stays UTF-16 well formed and that a complete emoji is kept when it fits. (cherry picked from commit e8d7b1f)
Summary: Two correctness fixes in the Slack extension. (1) The block-action rich-text preview was truncated on raw UTF-16 indices, so an emoji straddling the preview boundary was torn into a lone surrogate — malformed in the Slack interaction payload. (2) The slash-command session lookup used the
@deprecated loadSessionStorewhole-store helper (kept only during the SQLite migration transition); this migrates it to thegetSessionEntrypoint-read API as the maintainer note requires.What Problem This Solves
Fix 1 — rich-text preview lone-surrogate truncation (
interactions.block-actions.ts)Slack rich text interaction previews were truncated with raw
.slice(0, max - 1)on the UTF-16 string. When arich_text_inputvalue placed an emoji at the preview boundary, the old slice kept only the high-surrogate half before appending…, emitting a lone\uD83Din the Slack interaction payload:😀+ trailing text (total 124 code units)"aaa…[118]…\uD83D…"— lone high surrogate before the ellipsisencodeURIComponentand corrupts any downstream JSON field expecting valid UTF-16.Fix 2 — deprecated
loadSessionStore→getSessionEntry(slash.ts)resolveSlackCommandMenuModelContextcalled the whole-store helperloadSessionStoreand then indexed into it withstore[params.sessionKey]. The SDK maintainer note onloadSessionStorereads:The call also passed
sessionStore: storetoresolveStoredModelOverride, which expected a function-based accessor in the updated SDK signature.Fix
Fix 1: Replace the raw
.slice(0, max - 1) + "…"insummarizeRichTextPreviewwithtruncateSlackText(joined, max), which delegates tosliceUtf16Safeso a surrogate pair straddling the boundary is dropped whole. The existing 120 code-unit cap and thejoined.length <= maxno-op path are unchanged.Fix 2: Replace
loadSessionStore(storePath)+store[sessionKey]with a singlegetSessionEntry({ storePath, sessionKey })call. Update theresolveStoredModelOverridecallsite to pass theloadSessionEntryfunction-based accessor instead of the raw store dictionary, matching the current SDK signature.Evidence
Fix 1 — terminal demo (
node --import tsx demo.mtson the PR branch):The fixed output is well-formed UTF-16: the boundary emoji is dropped whole, and the lone-surrogate detector reports
false.Fix 2 — API deprecation notice (from
src/plugin-sdk/session-store-runtime.ts):The slash-command path was one of the remaining callers. This change completes the migration for that callsite.
Tests
Fix 1 tests —
extensions/slack/src/monitor/events/interactions.test.ts(red before, green after):keeps block action rich text previews UTF-16 safe at the truncation boundary— boundary emoji dropped whole;richTextPreview.length ≤ 120; no lone surrogate;encodeURIComponentdoes not throw.keeps complete emoji in rich text previews when the UTF-16 boundary can include it— emoji within budget is preserved whole with no lone surrogate.Targeted Vitest run (
interactions.test.ts+truncate.test.ts): 54 tests passed.