fix(slack): markdown rendering fidelity — fences, entities, bold, links + opt-in markdown blocks - #70191
Merged
Conversation
Contributor
૮ >ﻌ< ა ci reviewran on 4f6d32b ℹ️ InfoDesktop E2E visual evidence · View test artifacts · View job1 visual diff. inline evidence upload failed. Failed to upload diff-665a0833239e-onboarding-overlay-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-665a0833239e-onboarding-overlay-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso) |
…text
format_message unescapes already-escaped input before re-escaping, so that
pre-escaped text doesn't get double-escaped. That unescape was three
sequential str.replace calls, which re-scan each other's output:
"&lt;" --(& -> &)--> "<" --(< -> <)--> "<"
The & produced by the first replace pairs with the following "lt;" and
decodes a second time. "&lt;" is the wire form of the literal text
"<", so the text is silently destroyed: Slack receives "<" and renders
"<". Anyone writing about HTML or markup ("&lt;b&gt;" -> "<b>")
loses their literal text, with no error.
re.sub scans left-to-right and never re-scans its own replacements, so a
single pass fixes it. The escape pass on the next line is left untouched --
it is correctly ordered (& first, so the &s it inserts aren't re-escaped).
Only the double-decode cases change; every other input is byte-identical
before and after. This is the same round-trip invariant the neighbouring
test_pre_escaped_{ampersand,lt,gt}_not_double_escaped tests already assert,
extended to the case they miss. Affects the plain mrkdwn path (send,
edit_message) and Block Kit sections, which route section text through
format_message.
Slack's mrkdwn does not strip the optional language tag from fenced code blocks like GitHub-flavored markdown does — it renders ```text\nfoo\n``` as a code block whose literal first line is "text". The agent emitted ```text fences around raw command output, which surfaced "text" as the first line of every such block. Drop the tag from the opening fence in format_message() before stashing the block behind a placeholder. Stripping only fires for a genuine opening fence — a ``` at the start of a line, tagged with a single token (no spaces or backticks) — and the original line ending is preserved. The fence-protection regex deliberately matches loosely, so a mid-line ``` (e.g. an inline ```span``` wrapping across a newline) can be grouped as an "opening fence" whose first line is real content; differential fuzzing against the pre-change formatter (40k generated messages) confirms the only behavioral delta is the tag strip itself. The Block Kit renderer is unaffected: render_blocks() intercepts fences itself before mrkdwn_fn is applied, so this only changes the mrkdwn surfaces that still go through format_message() — the plain-text fallback field (notifications, search indexing, accessibility), slash-command ephemeral replies, and standalone cron delivery. Originally written against gateway/platforms/slack.py; ported to plugins/platforms/slack/adapter.py after the adapter migration in 5600105. Manually verified against a live Slack workspace (pre-migration adapter; the ```text case strips identically) — code blocks no longer carry a literal "text" first line.
…d chars Slack's mrkdwn parser can fail to recognize the closing * of a bold span when it is immediately preceded by a non-word character (), ], }, ., :, em-dash, ...), mis-rendering the span and in reported cases truncating the rest of the message. Insert a zero-width space (U+200B) between the last character and the closing * whenever the last character is not alphanumeric or underscore. Reapplied from #35144 by @gonzalofrancoceballos — the original patched gateway/platforms/slack.py, which was migrated to plugins/platforms/slack/adapter.py in the plugin migration.
…e block B1: When reasoning content contains ``` e.g. model quoting code in its thinking, wrapping it in an outer ``` for display causes the inner fence to break the outer block. Adds escape_code_fences_for_display() in gateway/stream_consumer.py, called from gateway/run.py before wrapping reasoning in the outer ``` display block.
- A/B/C: truncate_message with reasoning format, carry_lang, multiple blocks - D: last-chunk early-break gap - E: _filter_and_accumulate preserves triple-backtick outside think blocks - F: _split_text_chunks has no fence tracking - G: reasoning truncation (short pass-through, long gap) - H: stream consumer accumulator has no fence state - I: fix stub - J: edit path bypasses truncate_message entirely - K: overflow split first chunk via edit path - L: fallback final + split_text_chunks noop Four gaps identified: G1: truncate_message last-chunk early-break path G2: streaming edit path bypasses truncate_message G3: overflow split first chunk via edit path G4: split_text_chunks + truncate_message no-op
Adds `ensure_closed_code_fences()` helper to detect text with an odd count of triple-backtick markers (indicating an unclosed code block) and append a closing fence. Applies the fix to all four identified gap paths: G1: truncate_message early-break path for final chunk G2: _send_or_edit streaming edit path (most commonly hit) G3: overflow split first chunk (covered by G2's fix) G4: _send_fallback_final fallback send path Closes: #TBD
ensure_closed_code_fences previously only handled triple-backtick (```) code-block fences. Single backtick (`) inline-code spans have the same problem: an orphaned opening backtick causes the remainder of the message to render as inline code on Discord and other platforms. After balancing triple-backtick fences, strip complete ```...``` regions and count remaining standalone backtick markers. If odd, append a closing backtick. Same trade-off as the triple-backtick fix: a stray closing backtick may create a brief empty inline-code span, which is far less harmful than the rest of the message being inline code.
Slack mrkdwn has no table syntax — GFM pipe tables render as literal-pipe noise with a raw |---|---| separator row. Wrap detected tables in ``` fences so they render as monospace preformatted text, and pad cells to per-column max display width (East-Asian Wide / Full-width chars counted as 2 columns) so columns stay aligned even with CJK content. Tables already inside fenced code blocks are left untouched, and the emitted fences carry no language tag so they compose with the lang-tag-strip pass. This covers the plain-mrkdwn text path; the opt-in rich_blocks path already renders native Block Kit table blocks. Reapplied from #16648 by @kylezh — the original patched gateway/platforms/slack.py, which was migrated to plugins/platforms/slack/adapter.py in the plugin migration. Fixes the non-rich_blocks table path described in #8552.
…arkdown Slack's Block Kit `markdown` block accepts standard markdown (tables, headers, task lists, fenced code with syntax highlighting, links) and lets Slack translate it natively — eliminating the lossy markdown→mrkdwn conversion for the rendered layout. Enable via platforms.slack.extra.markdown_blocks. Safety rails added on top of the original design: * opt-in (default off) — Slack documents the block for 'apps that use platform AI features' and does not guarantee availability across all app types / surfaces, so unconditional adoption is not safe yet * the mrkdwn-converted text field is ALWAYS kept as the notification/search/accessibility fallback * content over Slack's 12k cumulative markdown-block cap declines to the rich_blocks renderer / plain text path * the existing block-rejection retry (invalid_blocks / msg_too_long / too_many_blocks) re-sends the plain mrkdwn payload, so an unsupported surface degrades gracefully instead of dropping the message * when both modes are enabled, markdown_blocks is preferred over the local rich_blocks renderer; rich_blocks remains the fallback Adapted from #8554 by @shivasymbl — the original patched the deleted gateway/platforms/slack.py and switched unconditionally; reimplemented against the plugin adapter's _maybe_blocks/sanitize_blocks pipeline. Fixes #8552.
Widen #48476's fence guarantees to the two splitters that still emitted fence-broken chunks: * GatewayStreamConsumer._split_text_chunks (fallback final send): close the orphaned ``` at each chunk boundary and reopen it — with the original language tag — on the next chunk, mirroring BasePlatformAdapter.truncate_message's contract. Headroom is reserved so balanced chunks stay within the platform limit. * Slack block_kit._split_text (3000-char section chunking): same close/reopen balancing for mrkdwn section text carrying fences. With these, every chunk boundary — non-streaming send (truncate_message), streaming overflow (_truncate_for_stream via adapter.truncate_message per #45938), fallback final (_split_text_chunks), final-send balance (ensure_closed_code_fences), and Block Kit section splits — delivers fence-balanced chunks. Regression tests probe each path with fenced fixtures, assert per-chunk balance, limit compliance, language-tag reopening, and prose passthrough.
shivasymbl, z23, Skywind5487, 2001Y, gonzalofrancoceballos, kylezh. briandevans' noreply mapping already present; mzkarami's mapping file shipped inside #66204's own commit. Also fixes the TestFormatMessageTableIntegration fixture to match PlatformConfig's current signature (no 'name' kwarg).
teknium1
force-pushed
the
slack/c11-rendering
branch
from
July 23, 2026 18:40
571b144 to
4f6d32b
Compare
This was referenced Jul 23, 2026
This was referenced Jul 23, 2026
19 tasks
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Agent output renders faithfully in Slack: code fences close on every chunk boundary, HTML entities decode exactly once, bold edge cases don't corrupt, language tags don't leak, links don't duplicate — plus an opt-in
markdown_blocksmode using Slack's native markdown block type.Fixes #8552.
Changes
markdown_blocksconfig mode in_maybe_blocks(fix(slack): use Block Kit markdown block type instead of legacy mrkdwn #8554, adapted — mrkdwn remains the default and fallback).&lt;double-decoded on main); ZWSP bold guard (fix(slack): broaden bold-text zero-width-space guard to all non-word chars #35144 — premise corrected: no prior guard existed, this ADDS it); language-tag stripping with line-start guard (fix(gateway): strip language tag from Slack fenced code blocks #21085);ensure_closed_code_fenceson all send paths + final-chunk fix (fix: close orphaned code fences on all send paths #48476); triple-backtick escaping in code-style reasoning (fix: escape triple-backtick inside reasoning before wrapping in outer code block #48477); rich-text link dedup (fix(slack): avoid duplicating rich-text link messages #66204); editable overflow stream chunks (fix(gateway): keep overflow stream chunks editable #45938).Credits
Salvaged with authorship preserved: #8554 (@shivasymbl), #64748 (@briandevans), #21085 (@z23), #35144 (@gonzalofrancoceballos), #48476 + #48477 (@Skywind5487), #66204 (@mzkarami), #45938 (@2001Y).
Supersedes #15854, #35802 (table handling covered by main's rich_blocks machinery).
Validation
tests/gateway/ -q -k slackInfographic