feat(slack): opt-in Block Kit markdown block rendering for standard markdown

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.
This commit is contained in:
shivasymbl 2026-07-23 08:16:22 -07:00 committed by Teknium
parent a7738796e1
commit c934c533db
2 changed files with 149 additions and 5 deletions

View file

@ -3265,6 +3265,47 @@ class SlackAdapter(BasePlatformAdapter):
return False
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
def _markdown_blocks_enabled(self) -> bool:
"""Whether to render outbound messages via Slack's ``markdown`` block.
Opt-in via ``platforms.slack.extra.markdown_blocks`` (config.yaml).
Slack's Block Kit ``markdown`` block accepts *standard* markdown
(tables, headers, task lists, fenced code with syntax highlighting,
links) and lets Slack do the translation natively eliminating the
lossy markdownmrkdwn conversion for the rendered layout. The
mrkdwn-converted ``text`` field is always kept as the
notification/search/accessibility fallback, and the block-rejection
retry path drops blocks and re-sends plain mrkdwn on surfaces or
workspaces where the block type is not accepted so enabling this
can never lose a message.
Kept opt-in rather than default because Slack documents the block for
"apps that use platform AI features" and caps cumulative ``markdown``
block text at 12,000 characters per payload; availability on every
plan tier / app type is not guaranteed.
"""
raw = self.config.extra.get("markdown_blocks")
if raw is None:
return False
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
# Slack caps the cumulative text of all ``markdown`` blocks in a single
# payload at 12,000 characters. Leave margin for the feedback block.
_MARKDOWN_BLOCK_MAX = 11_500
def _markdown_block_payload(self, content: str) -> Optional[list]:
"""Return a ``markdown`` block payload for ``content``, or ``None``.
Declines (returns ``None``) for empty content and for content over
Slack's 12k cumulative markdown-block cap — the caller then falls
through to the rich_blocks renderer or the plain mrkdwn text path.
"""
if not content or not content.strip():
return None
if len(content) > self._MARKDOWN_BLOCK_MAX:
return None
return [{"type": "markdown", "text": content}]
def _feedback_buttons_enabled(self) -> bool:
"""Whether to include Slack AI feedback buttons on final responses."""
raw = self.config.extra.get("feedback_buttons")
@ -3307,13 +3348,28 @@ class SlackAdapter(BasePlatformAdapter):
return [*blocks, self._feedback_block()]
def _maybe_blocks(self, content: str) -> Optional[list]:
"""Render ``content`` to Block Kit blocks when the feature is enabled.
"""Render ``content`` to Block Kit blocks when a block mode is enabled.
Returns ``None`` when rich blocks are disabled, or when the renderer
declines (empty / too complex / unexpected shape) the caller then
falls back to the plain ``text`` payload. A ``text`` fallback is ALWAYS
sent alongside blocks, so this can safely return ``None`` at any time.
Preference order:
1. ``markdown_blocks`` Slack's native ``markdown`` block renders the
*raw* standard markdown (tables, headers, code fences with syntax
highlighting) with Slack doing the translation (#8552).
2. ``rich_blocks`` the local Block Kit renderer (headers, dividers,
``rich_text`` lists, native ``table`` blocks).
Returns ``None`` when both are disabled, or when the renderer
declines (empty / too long / too complex / unexpected shape) the
caller then falls back to the plain ``text`` payload. A ``text``
fallback is ALWAYS sent alongside blocks, so this can safely return
``None`` at any time, and the block-rejection retry path recovers
when Slack rejects the payload (e.g. a surface without ``markdown``
block support).
"""
if self._markdown_blocks_enabled():
md_blocks = self._markdown_block_payload(content)
if md_blocks:
return sanitize_blocks(self._append_feedback_block(md_blocks))
if not self._rich_blocks_enabled():
return None
try:

View file

@ -341,3 +341,91 @@ class TestEditMessageBlocks:
assert result.success is False
assert result.retryable is True
assert result.error_kind == "transient"
# ---------------------------------------------------------------------------
# markdown_blocks mode — Slack's native ``markdown`` Block Kit block (#8552)
# ---------------------------------------------------------------------------
class TestMarkdownBlockMode:
"""Opt-in ``markdown_blocks`` renders raw standard markdown via Slack's
native ``markdown`` block, keeping the mrkdwn ``text`` fallback."""
@pytest.mark.asyncio
async def test_disabled_by_default(self):
adapter, client = _make_adapter()
await adapter.send("C1", RICH_TABLE_MD)
kwargs = client.chat_postMessage.await_args.kwargs
assert "blocks" not in kwargs
@pytest.mark.asyncio
async def test_enabled_sends_markdown_block_with_raw_content(self):
adapter, client = _make_adapter({"markdown_blocks": True})
await adapter.send("C1", RICH_TABLE_MD)
kwargs = client.chat_postMessage.await_args.kwargs
blocks = kwargs["blocks"]
assert blocks[0]["type"] == "markdown"
# RAW standard markdown, not mrkdwn-converted — Slack translates it
assert blocks[0]["text"] == RICH_TABLE_MD
# mrkdwn fallback text is still present for notifications/search
assert kwargs["text"]
@pytest.mark.asyncio
async def test_text_fallback_is_mrkdwn_converted(self):
adapter, client = _make_adapter({"markdown_blocks": True})
await adapter.send("C1", "**bold**")
kwargs = client.chat_postMessage.await_args.kwargs
assert kwargs["blocks"][0]["text"] == "**bold**"
assert kwargs["text"] == "*bold*" # mrkdwn conversion for fallback
@pytest.mark.asyncio
async def test_markdown_block_preferred_over_rich_blocks(self):
adapter, client = _make_adapter(
{"markdown_blocks": True, "rich_blocks": True}
)
await adapter.send("C1", RICH_TABLE_MD)
blocks = client.chat_postMessage.await_args.kwargs["blocks"]
assert blocks[0]["type"] == "markdown"
@pytest.mark.asyncio
async def test_over_cap_falls_back_to_rich_or_text(self):
adapter, client = _make_adapter({"markdown_blocks": True})
big = "x" * (SlackAdapter._MARKDOWN_BLOCK_MAX + 1)
payload = adapter._markdown_block_payload(big)
assert payload is None # declines >12k cumulative markdown cap
@pytest.mark.asyncio
async def test_rejection_retries_without_blocks(self):
"""Workspaces/surfaces without markdown-block support degrade to
the plain mrkdwn text payload instead of dropping the message."""
adapter, client = _make_adapter({"markdown_blocks": True})
client.chat_postMessage = AsyncMock(
side_effect=[SlackRejectedBlocks(), {"ts": "111.222"}]
)
result = await adapter.send("C1", RICH_TABLE_MD)
assert result.success is True
assert client.chat_postMessage.await_count == 2
retry_kwargs = client.chat_postMessage.await_args_list[1].kwargs
assert "blocks" not in retry_kwargs
assert retry_kwargs["text"]
@pytest.mark.asyncio
async def test_edit_finalize_uses_markdown_block(self):
adapter, client = _make_adapter({"markdown_blocks": True})
await adapter.edit_message("C1", "111.222", RICH_TABLE_MD, finalize=True)
kwargs = client.chat_update.await_args.kwargs
assert kwargs["blocks"][0]["type"] == "markdown"
assert kwargs["blocks"][0]["text"] == RICH_TABLE_MD
@pytest.mark.asyncio
async def test_edit_streaming_stays_plain(self):
adapter, client = _make_adapter({"markdown_blocks": True})
await adapter.edit_message("C1", "111.222", RICH_TABLE_MD, finalize=False)
kwargs = client.chat_update.await_args.kwargs
assert "blocks" not in kwargs
def test_empty_content_declines(self):
adapter, _ = _make_adapter({"markdown_blocks": True})
assert adapter._markdown_block_payload("") is None
assert adapter._markdown_block_payload(" ") is None