Skip to content

feat(slack): opt-in Block Kit rendering for agent messages (salvage #56090) - #182

Merged
hashbender merged 1 commit into
mainfrom
mirror/pr-56102
Jul 1, 2026
Merged

feat(slack): opt-in Block Kit rendering for agent messages (salvage #56090)#182
hashbender merged 1 commit into
mainfrom
mirror/pr-56102

Conversation

@hashbender

Copy link
Copy Markdown
Owner

Infographic

Slack Block Kit rendering

Summary

Slack agent replies can now render as structured Block Kit — headers, dividers, true nested lists, blockquotes, code, and native table blocks — behind an opt-in flag (platforms.slack.extra.rich_blocks: true, default off). Salvage of NousResearch#56090 by @benbarclay onto current main. Closes NousResearch#18918.

Changes

  • plugins/platforms/slack/block_kit.py (new): pure render_blocks(markdown) — headers, dividers, rich_text nested lists, blockquotes, preformatted code, and native table blocks with per-column alignment and inline-formatted rich_text cells. Enforces Slack's 50-block / 3000-char / table (100 rows · 20 cols · 10k chars) limits; over-limit or unparseable tables fall back to aligned monospace. Never raises → None on any unexpected input.
  • plugins/platforms/slack/adapter.py: send() renders blocks on the single-chunk primary message; edit_message() renders blocks only on finalize=True (streaming edits stay plain mrkdwn). A text= fallback is always sent alongside blocks.
  • Docs: website/docs/user-guide/messaging/slack.md + zh-Hans i18n — config example + key-table row.
  • Follow-up commit (ours): corrected the module + _rich_blocks_enabled docstrings, which still described the earlier monospace-only table approach.

Validation

Result
Targeted tests tests/gateway/test_slack_block_kit.py + _adapter.py — 27/27 pass
E2E render native table block, alignment (left→null, right emitted), in-cell bold + link survive
Footprint plugin-only; core untouched; opt-in default-off = zero behavior change

Plugin-only PR (touches plugins/platforms/slack/, tests/gateway/, website/docs/), contributor authorship preserved via rebase-merge.


Mirror-of: NousResearch#56102
NousResearch#56102

@tenki-reviewer

tenki-reviewer Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Complete

Files Reviewed: 6
Findings: 4

By Severity:

  • 🟠 High: 1
  • 🟡 Medium: 2
  • 🟢 Low: 1

Adds a markdown-to-Slack-Block-Kit renderer to the Slack adapter with 4 bugs found: rich_text_quote schema violation, broken inline nesting (links/code inside emphasis dropped), escaped-pipe GFM parsing bug, and unnecessarily capped indent levels.

Files Reviewed (6 files)
plugins/platforms/slack/adapter.py
plugins/platforms/slack/block_kit.py
tests/gateway/test_slack_block_kit.py
tests/gateway/test_slack_block_kit_adapter.py
website/docs/user-guide/messaging/slack.md
website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md

@hashbender
hashbender merged commit c6ad1c2 into main Jul 1, 2026
3 checks passed

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Risk: 🟠 High (72/100) — 1 high finding, 2 medium, 1 low · 865 LOC across 6 files


Summary

PR #182 introduces block_kit.py, a new markdown-to-Slack-Block-Kit compiler for the Slack messaging adapter, with corresponding tests and docs.

Findings

High — Broken inline nesting hierarchy (block_kit.py:120)

_walk_emphasis never re-enters _walk_links or walk (for inline code). Links and inline code inside bold/italic/strikethrough spans are silently dropped instead of being properly nested. Real agent output (e.g., **see [the docs](url)**) will lose its bold formatting on the link text.

Medium — rich_text_quote schema violation (block_kit.py:169)

_quote_block places raw element dicts directly into rich_text_quote.elements, bypassing the required rich_text_section wrapper. Slack may reject the entire blocks payload for this block, falling back to plain text.

Medium — Escaped-pipe GFM parsing bug (block_kit.py:244)

_split_row incorrectly absorbs \\| (backslash + pipe) as an escaped pipe. Per GFM, \| is a literal backslash followed by a real column separator, not an escaped pipe. Both native table and monospace fallback paths are affected.

Low — Indent cap too restrictive (block_kit.py:63)

_indent_level caps rich_text_list indent at 5, but Slack API supports indents 0–8. Deeply nested lists render at degraded indent levels.

Assessment

All bugs live in the new block_kit.py renderer — a self-contained plugin file. No core files are touched. The bugs cause degraded rendering (high and medium) but do not crash or leak data. Recommended: fix before merge.

Comment on lines +169 to +178
def _quote_block(lines: List[str]) -> Block:
section_children: List[Dict[str, Any]] = []
for i, ln in enumerate(lines):
if i:
section_children.append({"type": "text", "text": "\n"})
section_children.extend(_inline_elements(ln))
return {
"type": "rich_text",
"elements": [{"type": "rich_text_quote", "elements": section_children}],
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 rich_text_quote block has invalid schema: raw text elements not wrapped in rich_text_section (bug)

The _quote_block function at block_kit.py:169-178 builds a rich_text_quote block whose elements array contains raw text/link elements (output of _inline_elements) and bare newline text separators, without wrapping each line in a rich_text_section object. Per Slack's Block Kit reference, rich_text_quote.elements must be an array of rich_text_section objects. The sibling _list_block at line 204-206 correctly wraps each item in rich_text_section, confirming this is an inconsistency/bug. Slack may reject or degrade the malformed block, causing the entire rich_blocks payload to fall back to the plain text field.

💡 Suggestion: Wrap each quoted line's inline elements in a rich_text_section. For multi-line quotes, emit separate rich_text_section elements — Slack handles line breaks between sibling rich_text_section children within a rich_text_quote natively. Remove the bare newline text element injection.

📋 Prompt for AI Agents

In plugins/platforms/slack/block_kit.py, rewrite _quote_block (lines 169-178). For each input line, create a rich_text_section element wrapping _inline_elements(ln), and put those sections directly into rich_text_quote.elements. Remove the newline text element injection. The corrected per-line structure: {"type": "rich_text_section", "elements": _inline_elements(ln)}. Multi-line quotes become a rich_text_quote element containing multiple rich_text_section children — Slack handles the line separation between sibling sections natively.

Comment on lines +244 to +246
# Temporarily protect escaped pipes, split on real ones, then restore.
protected = row.strip().strip("|").replace(r"\|", "\x00PIPE\x00")
return [c.strip().replace("\x00PIPE\x00", "|") for c in protected.split("|")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Escaped-pipe handling in _split_row incorrectly absorbs '\|' (GFM: literal backslash + real pipe) (bug)

In block_kit.py:238-246, _split_row uses row.strip().strip('|').replace(r'\|', '\x00PIPE\x00') to protect escaped pipes before splitting. This naive string replacement treats \| (GFM: literal backslash followed by a real pipe column separator) as an escaped pipe because the trailing | matches. The result: a cell containing a literal backslash absorbs the following column separator. Correct GFM handling requires a left-to-right scan tracking whether each backslash is itself escaped (odd consecutive backslashes before a pipe = escaped; even = real separator). Both the native table block path and the monospace preformatted fallback are affected since both call _split_row.

💡 Suggestion: Replace the naive str.replace with a character-by-character scan that tracks consecutive backslash counts. Only treat a pipe preceded by an odd number of backslashes as escaped. This correctly handles \| (even count -> backslash is escaped, pipe is a real separator) vs. \| (odd count -> pipe is escaped).

📋 Prompt for AI Agents

In plugins/platforms/slack/block_kit.py, _split_row function (lines 238-246). Replace the row.strip().strip('|').replace(r'\|', '\x00PIPE\x00') approach with a left-to-right scan that counts consecutive backslashes before each pipe character. A pipe preceded by an odd number of unescaped backslashes is an escaped pipe (emit as literal pipe in the cell); a pipe preceded by an even number of backslashes (including 0) is a real column separator. Accumulate cell content and only split on real pipes.

Comment on lines +120 to +133
def _walk_emphasis(s: str, style: Dict[str, bool]) -> None:
if not s:
return
# Try bold, then strike, then italic, recursing into the inner span.
for rx, key in ((_BOLD_RE, "bold"), (_STRIKE_RE, "strike"), (_ITALIC_RE, "italic")):
m = rx.search(s)
if m:
_walk_emphasis(s[:m.start()], style)
inner_style = dict(style)
inner_style[key] = True
_walk_emphasis(m.group(1), inner_style)
_walk_emphasis(s[m.end():], style)
return
emit_text(s, dict(style) if style else None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Links and inline code inside emphasis spans are silently dropped — nesting hierarchy broken in inline parser (bug)

The _walk_emphasis function at block_kit.py:120-133 processes emphasis-inner content (bold/italic/strikethrough captures) by recursively calling only _walk_emphasis. It never re-enters _walk_links (for link detection) or walk (for inline code detection). The outer-inner nesting chain is walk -> _walk_links -> _walk_emphasis, but once inside emphasis all higher-priority inline elements (links and code) are invisible. Input like link renders as literal ** markers around a correctly-styled link element (bold is lost). Input like code renders the backticks as literal bold text rather than bold code-styled text. Real agent output frequently contains links inside emphasized text.

💡 Suggestion: In _walk_emphasis, replace recursive _walk_emphasis() calls with walk() calls so that inline code and links within emphasis spans are detected. The walk function is in scope as a closure within _inline_elements. The change is 3 tokens: _walk_emphasis -> walk on lines 127, 130, and 131.

Suggested change
def _walk_emphasis(s: str, style: Dict[str, bool]) -> None:
if not s:
return
# Try bold, then strike, then italic, recursing into the inner span.
for rx, key in ((_BOLD_RE, "bold"), (_STRIKE_RE, "strike"), (_ITALIC_RE, "italic")):
m = rx.search(s)
if m:
_walk_emphasis(s[:m.start()], style)
inner_style = dict(style)
inner_style[key] = True
_walk_emphasis(m.group(1), inner_style)
_walk_emphasis(s[m.end():], style)
return
emit_text(s, dict(style) if style else None)
def _walk_emphasis(s: str, style: Dict[str, bool]) -> None:
if not s:
return
# Try bold, then strike, then italic, recursing into the inner span.
for rx, key in ((_BOLD_RE, "bold"), (_STRIKE_RE, "strike"), (_ITALIC_RE, "italic")):
m = rx.search(s)
if m:
walk(s[:m.start()], style)
inner_style = dict(style)
inner_style[key] = True
walk(m.group(1), inner_style)
walk(s[m.end():], style)
return
emit_text(s, dict(style) if style else None)
📋 Prompt for AI Agents

In plugins/platforms/slack/block_kit.py, in the _walk_emphasis function (lines 120-133), change the three recursive calls from _walk_emphasis(...) to walk(...): line 127 prefix call, line 130 inner-content call, and line 131 suffix call. This re-enters the full inline pipeline (code -> links -> emphasis) within emphasis spans so that inline code and links inside bold/italic/strikethrough text are correctly detected and styled.

width = 0
for ch in spaces:
width += 4 if ch == "\t" else 1
return min(width // 2, 5) # Slack rich_text_list supports up to indent 5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 rich_text_list indent capped at 5 but Slack API supports indents 0-8 (bug)

At block_kit.py:63, _indent_level returns min(width // 2, 5), capping rich_text_list indent at 5. The accompanying comment states 'Slack rich_text_list supports up to indent 5' — but the Slack API reference documents indent as an integer 0-8. Markdown lists nested 6+ levels deep all render at indent 5 on Slack instead of showing their true nesting depth. Visual hierarchy is degraded but no content is lost.

💡 Suggestion: Change the cap from 5 to 8 and update the comment.

Suggested change
return min(width // 2, 5) # Slack rich_text_list supports up to indent 5
return min(width // 2, 8) # Slack rich_text_list supports indents 0-8
📋 Prompt for AI Agents

In plugins/platforms/slack/block_kit.py, line 63, change min(width // 2, 5) to min(width // 2, 8) and update the trailing comment from '# Slack rich_text_list supports up to indent 5' to '# Slack rich_text_list supports indents 0-8'.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slack: render Markdown pipe tables as Block Kit tables

1 participant