Skip to content

fix(slack): truncate rich text preview on a UTF-16 boundary#96577

Merged
vincentkoc merged 1 commit into
openclaw:mainfrom
WeeLi-009:fix/slack-richtext-preview-surrogate
Jun 28, 2026
Merged

fix(slack): truncate rich text preview on a UTF-16 boundary#96577
vincentkoc merged 1 commit into
openclaw:mainfrom
WeeLi-009:fix/slack-richtext-preview-surrogate

Conversation

@WeeLi-009

@WeeLi-009 WeeLi-009 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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 loadSessionStore whole-store helper (kept only during the SQLite migration transition); this migrates it to the getSessionEntry point-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 a rich_text_input value placed an emoji at the preview boundary, the old slice kept only the high-surrogate half before appending , emitting a lone \uD83D in the Slack interaction payload:

  • Input: 118 ASCII characters + 😀 + trailing text (total 124 code units)
  • Old preview (broken): "aaa…[118]…\uD83D…" — lone high surrogate before the ellipsis
  • The resulting payload fails encodeURIComponent and corrupts any downstream JSON field expecting valid UTF-16.

Fix 2 — deprecated loadSessionStoregetSessionEntry (slash.ts)

resolveSlackCommandMenuModelContext called the whole-store helper loadSessionStore and then indexed into it with store[params.sessionKey]. The SDK maintainer note on loadSessionStore reads:

@deprecated Use getSessionEntry/listSessionEntries for reads … This whole-store helper is kept only during the transition before SQLite migration. Callers must migrate away from reading sessions.json directly.

The call also passed sessionStore: store to resolveStoredModelOverride, which expected a function-based accessor in the updated SDK signature.

Fix

Fix 1: Replace the raw .slice(0, max - 1) + "…" in summarizeRichTextPreview with truncateSlackText(joined, max), which delegates to sliceUtf16Safe so a surrogate pair straddling the boundary is dropped whole. The existing 120 code-unit cap and the joined.length <= max no-op path are unchanged.

Fix 2: Replace loadSessionStore(storePath) + store[sessionKey] with a single getSessionEntry({ storePath, sessionKey }) call. Update the resolveStoredModelOverride callsite to pass the loadSessionEntry function-based accessor instead of the raw store dictionary, matching the current SDK signature.

Evidence

Fix 1 — terminal demo (node --import tsx demo.mts on the PR branch):

input.length= 124
input.cutBoundary= 0061 d83d de00 0074
BEFORE old .slice(0, 119)+ellipsis= "aaa…[118 ×'a' elided]\ud83d…"
BEFORE tail code units= 0061 d83d 2026
BEFORE lone-surrogate= true
AFTER summarizeAction.richTextPreview= "aaa…[118 ×'a' elided]…"
AFTER tail code units= 0061 0061 2026
AFTER lone-surrogate= false

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):

/**
 * @deprecated Use getSessionEntry/listSessionEntries for reads and
 * patchSessionEntry/upsertSessionEntry for writes. This whole-store helper is
 * kept only during the transition before SQLite migration. Callers must
 * migrate away from reading sessions.json directly.
 */
export const loadSessionStore = loadSessionStoreImpl;

The slash-command path was one of the remaining callers. This change completes the migration for that callsite.

Tests

Fix 1 testsextensions/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; encodeURIComponent does 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.

@openclaw-barnacle openclaw-barnacle Bot added channel: slack Channel integration: slack size: S triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. 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 25, 2026, 11:53 PM ET / 03:53 UTC.

Summary
The branch changes Slack block-action rich-text preview truncation to call truncateSlackText and adds regression tests for surrogate-boundary emoji previews.

PR surface: Source +1, Tests +90. Total +91 across 2 files.

Reproducibility: yes. Current main's summarizeRichTextPreview still uses raw UTF-16 slicing at the preview boundary, and a focused Node probe of that formula reproduces a lone surrogate for 118 ASCII characters followed by 😀.

Review metrics: none identified.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🦞 diamond lobster
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

  • [P2] No repair lane is needed because the current head already makes the narrow helper change previously requested; remaining action is normal maintainer and CI review.

Security
Cleared: The diff only changes Slack TypeScript runtime and tests, with no dependency, workflow, secret, package, or code-execution surface changes.

Review details

Best possible solution:

Land the Slack-local truncateSlackText fix with the new boundary regression tests once current-head CI and maintainer review complete.

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

Yes. Current main's summarizeRichTextPreview still uses raw UTF-16 slicing at the preview boundary, and a focused Node probe of that formula reproduces a lone surrogate for 118 ASCII characters followed by 😀.

Is this the best way to solve the issue?

Yes. Reusing truncateSlackText(joined, max) is the narrow maintainable fix because it centralizes Slack ellipsis, trim, and UTF-16-safe slicing behavior while preserving the existing 120-code-unit preview cap.

AGENTS.md: found and applied where relevant.

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

Label changes

Label changes:

  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • remove rating: 🦞 diamond lobster: Current PR rating is rating: 🐚 platinum hermit, so this older rating label is no longer current.

Label justifications:

  • P2: This is a normal Slack channel correctness fix with limited blast radius and focused regression coverage.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body includes terminal output from a demo importing the fixed summarizeAction path and showing before/after lone-surrogate detection, plus targeted Vitest output.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes terminal output from a demo importing the fixed summarizeAction path and showing before/after lone-surrogate detection, plus targeted Vitest output.
Evidence reviewed

PR surface:

Source +1, Tests +90. Total +91 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 2 1 +1
Tests 1 90 0 +90
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 92 1 +91

What I checked:

  • Repository policy read: Root AGENTS.md and the scoped extensions AGENTS.md were read fully; their review-depth and plugin-boundary guidance shaped the helper/caller/test/history checks. (AGENTS.md:1, 448b7c75b669)
  • Current main still has the bug path: On current main, summarizeRichTextPreview joins rich-text fragments and truncates with raw joined.slice(0, max - 1) before adding an ellipsis. (extensions/slack/src/monitor/events/interactions.block-actions.ts:176, 448b7c75b669)
  • Raw-slice reproduction: A read-only Node probe using the current source formula with 118 ASCII characters plus an emoji produced tail code units 0061 d83d 2026 and lone=true, matching the reported malformed UTF-16 boundary.
  • Slack helper contract: truncateSlackText already owns Slack trimming, ellipsis budgeting, and UTF-16-safe slicing through sliceUtf16Safe, so the PR's current helper call is the right local owner boundary. (extensions/slack/src/truncate.ts:4, 448b7c75b669)
  • PR head uses the Slack helper: The current PR diff imports truncateSlackText and returns joined.length <= max ? joined : truncateSlackText(joined, max), resolving the previous duplicated lower-level truncation concern. (extensions/slack/src/monitor/events/interactions.block-actions.ts:174, 21450b1fc293)
  • Adjacent runtime path checked: registerSlackInteractionEvents formats Slack interaction system events, keeps richTextPreview in compact input summaries, and already uses truncateSlackText for other Slack interaction string values. (extensions/slack/src/monitor/events/interactions.ts:37, 448b7c75b669)

Likely related people:

  • steipete: Peter Steinberger authored the Slack block-action split and also appears heavily in Slack interaction history, making him a strong routing candidate for this boundary. (role: feature-history contributor; confidence: medium; commits: 9cd9c7a48841, 63f5fa47deb6; files: extensions/slack/src/monitor/events/interactions.block-actions.ts, extensions/slack/src/monitor/events/interactions.ts, src/utils.ts)
  • vincentkoc: Vincent Koc authored recent Slack interactive delivery and truncation-adjacent work and shows substantial history across the Slack monitor/send surfaces. (role: recent adjacent Slack contributor; confidence: medium; commits: 12ae4eee7ed1, c7d31bae8a; files: extensions/slack/src/monitor/events/interactions.ts, extensions/slack/src/blocks-render.ts, extensions/slack/src/send.ts)
  • obviyus: Current-main blame for the rich-text preview function and Slack helper traces to a grafted snapshot commit committed by Ayaan Zaidi; confidence is lower because the local history is compressed there. (role: recent current-main committer; confidence: low; commits: 31a0f97dd944; files: extensions/slack/src/monitor/events/interactions.block-actions.ts, extensions/slack/src/truncate.ts)
  • scoootscooob: The Slack extension move into extensions/slack/src is tied to this account, so they are relevant for plugin-boundary and Slack surface history. (role: adjacent owner-history contributor; confidence: medium; commits: 8746362f5ebf; files: extensions/slack/src/monitor/events/interactions.ts, extensions/slack/src/truncate.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 rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. merge-risk: 🚨 other 🚨 Merging this PR has meaningful risk outside the owned taxonomy. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 24, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the triage: needs-pr-context Candidate: external PR body lacks required problem context or evidence. label Jun 25, 2026
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.
@WeeLi-009
WeeLi-009 force-pushed the fix/slack-richtext-preview-surrogate branch from 27805af to 21450b1 Compare June 26, 2026 03:48
@clawsweeper clawsweeper Bot added 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. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. merge-risk: 🚨 other 🚨 Merging this PR has meaningful risk outside the owned taxonomy. labels Jun 26, 2026
@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. and removed rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. labels Jun 27, 2026
@WeeLi-009

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@vincentkoc
vincentkoc merged commit e8d7b1f into openclaw:main Jun 28, 2026
140 of 152 checks passed
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jun 29, 2026
…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.
QiuYuang pushed a commit to QiuYuang/openclaw that referenced this pull request Jul 1, 2026
…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.
chenyangjun-xy pushed a commit to chenyangjun-xy/openclaw that referenced this pull request Jul 1, 2026
…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.
chenyangjun-xy pushed a commit to chenyangjun-xy/openclaw that referenced this pull request Jul 3, 2026
…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.
Rorqualx pushed a commit to Rorqualx/cortex that referenced this pull request Jul 4, 2026
…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)
hugenshen added a commit to hugenshen/openclaw that referenced this pull request Jul 7, 2026
…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.
hugenshen added a commit to hugenshen/openclaw that referenced this pull request Jul 7, 2026
…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.
Rorqualx pushed a commit to Rorqualx/cortex that referenced this pull request Jul 8, 2026
…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)
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: S 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.

3 participants