From c934c533db35ecc50833f2474c49c293a460ca94 Mon Sep 17 00:00:00 2001 From: shivasymbl Date: Thu, 23 Jul 2026 08:16:22 -0700 Subject: [PATCH] feat(slack): opt-in Block Kit markdown block rendering for standard markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/platforms/slack/adapter.py | 66 ++++++++++++-- tests/gateway/test_slack_block_kit_adapter.py | 88 +++++++++++++++++++ 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 05bcc0304801..f898d9a71c87 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -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 markdown→mrkdwn 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: diff --git a/tests/gateway/test_slack_block_kit_adapter.py b/tests/gateway/test_slack_block_kit_adapter.py index 59351d72e916..0923e34c787b 100644 --- a/tests/gateway/test_slack_block_kit_adapter.py +++ b/tests/gateway/test_slack_block_kit_adapter.py @@ -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