feat(slack): opt-in Block Kit rendering for agent messages

Add platforms.slack.extra.rich_blocks (default off). When enabled, the
final agent message is sent as Slack Block Kit blocks — section headers,
dividers, and true nested lists via rich_text — instead of flat mrkdwn.

- New plugins/platforms/slack/block_kit.py: pure markdown->blocks renderer
  (headers, dividers, nested ordered/bullet lists, blockquotes, fenced code;
  pipe-tables as aligned monospace since Block Kit has no robust table block).
  Enforces Slack's 50-block / 3000-char section limits and returns None to
  fall back to plain text on empty/oversized/unexpected input. Never raises.
- adapter.send(): render blocks on the single-chunk primary message; a
  text= fallback is ALWAYS sent alongside (notifications/accessibility).
- adapter.edit_message(): blocks only on finalize=True, so intermediate
  streaming edits stay plain mrkdwn (no per-flush block re-derivation).
- Docs (EN + zh-Hans) + config example. Send-side only: no app reinstall.

Tests: pure-renderer unit suite + adapter integration suite (blocks present
when on, plain text when off, text fallback always set, finalize gating,
multi-chunk fallback). Prove-failed against a stubbed renderer.
This commit is contained in:
Ben 2026-07-01 14:57:32 +10:00
parent 44ddc552f5
commit cbc27c8ef8
6 changed files with 707 additions and 5 deletions

View file

@ -0,0 +1,130 @@
"""Unit tests for the Slack Block Kit renderer (pure function, no adapter)."""
from plugins.platforms.slack.block_kit import (
MAX_BLOCKS,
MAX_HEADER_TEXT,
MAX_SECTION_TEXT,
render_blocks,
)
def _types(blocks):
return [b["type"] for b in blocks]
class TestRenderBlocksBasics:
def test_empty_returns_none(self):
assert render_blocks("") is None
assert render_blocks(" \n ") is None
def test_plain_paragraph_is_section(self):
blocks = render_blocks("just a plain sentence")
assert blocks is not None
assert len(blocks) == 1
assert blocks[0]["type"] == "section"
assert blocks[0]["text"]["type"] == "mrkdwn"
def test_header_becomes_header_block(self):
blocks = render_blocks("# Title")
assert blocks[0]["type"] == "header"
assert blocks[0]["text"]["type"] == "plain_text"
assert blocks[0]["text"]["text"] == "Title"
def test_header_strips_markup_and_caps_length(self):
long = "#" + " " + "x" * 300
blocks = render_blocks(long)
assert blocks[0]["type"] == "header"
assert len(blocks[0]["text"]["text"]) <= MAX_HEADER_TEXT
def test_horizontal_rule_becomes_divider(self):
blocks = render_blocks("above\n\n---\n\nbelow")
assert "divider" in _types(blocks)
def test_fenced_code_becomes_preformatted(self):
md = "```python\ndef f():\n return 1\n```"
blocks = render_blocks(md)
assert len(blocks) == 1
assert blocks[0]["type"] == "rich_text"
assert blocks[0]["elements"][0]["type"] == "rich_text_preformatted"
class TestNestedLists:
def test_nested_bullets_produce_increasing_indent(self):
md = "- a\n - b\n - c"
blocks = render_blocks(md)
rich = [b for b in blocks if b["type"] == "rich_text"][0]
indents = [e["indent"] for e in rich["elements"] if e["type"] == "rich_text_list"]
# true nesting: indent levels must strictly increase across the run
assert indents == sorted(indents)
assert max(indents) >= 2
assert min(indents) == 0
def test_ordered_and_bullet_styles_distinguished(self):
md = "1. first\n2. second\n\n- bullet"
blocks = render_blocks(md)
styles = []
for b in blocks:
if b["type"] == "rich_text":
for e in b["elements"]:
if e["type"] == "rich_text_list":
styles.append(e["style"])
assert "ordered" in styles
assert "bullet" in styles
class TestInlineFormatting:
def test_link_becomes_link_element(self):
blocks = render_blocks("see [docs](https://example.com/x) now")
# link lives in a section (paragraph) — but a bulleted link is a
# rich_text link element; assert the URL survives somewhere.
blob = str(blocks)
assert "https://example.com/x" in blob
def test_bulleted_bold_is_styled(self):
blocks = render_blocks("- this is **bold** text")
rich = [b for b in blocks if b["type"] == "rich_text"][0]
section = rich["elements"][0]["elements"][0]
styled = [
el for el in section["elements"]
if el.get("style", {}).get("bold")
]
assert styled, "expected a bold-styled text element in the list item"
class TestTables:
def test_pipe_table_renders_preformatted(self):
md = (
"| Name | Status |\n"
"|------|--------|\n"
"| a | ok |\n"
"| b | fail |"
)
blocks = render_blocks(md)
assert len(blocks) == 1
assert blocks[0]["type"] == "rich_text"
pre = blocks[0]["elements"][0]
assert pre["type"] == "rich_text_preformatted"
text = pre["elements"][0]["text"]
# header cell values preserved and column aligned
assert "Name" in text and "Status" in text
assert "fail" in text
class TestLimits:
def test_oversized_section_is_split_under_limit(self):
big = "word " * 2000 # ~10000 chars, single paragraph
blocks = render_blocks(big)
assert blocks is not None
for b in blocks:
if b["type"] == "section":
assert len(b["text"]["text"]) <= MAX_SECTION_TEXT
def test_too_many_blocks_returns_none(self):
# 60 dividers => 60 blocks > MAX_BLOCKS => decline (caller uses text)
md = "\n\n".join(["---"] * (MAX_BLOCKS + 10))
assert render_blocks(md) is None
def test_never_raises_on_garbage(self):
for junk in ["```unterminated\ncode", "| broken | table", "> ", "#" * 10]:
# must not raise; either blocks or None
render_blocks(junk)

View file

@ -0,0 +1,102 @@
"""Integration tests: SlackAdapter wiring of Block Kit into send paths.
Verifies the opt-in behaviour contract:
* rich_blocks off (default) => no ``blocks`` kwarg, plain ``text`` only
* rich_blocks on => ``blocks`` present AND ``text`` fallback set
* edit_message: blocks only on finalize (streaming edits stay plain)
* multi-chunk (>39k) messages fall back to plain text
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.slack.adapter import SlackAdapter
def _make_adapter(extra=None):
config = PlatformConfig(enabled=True, token="xoxb-fake", extra=extra or {})
a = SlackAdapter(config)
a._app = MagicMock()
client = AsyncMock()
client.chat_postMessage = AsyncMock(return_value={"ts": "111.222"})
client.chat_update = AsyncMock(return_value={"ts": "111.222"})
a._get_client = MagicMock(return_value=client)
a.stop_typing = AsyncMock()
a._running = True
return a, client
RICH_MD = "# Title\n\n- a\n - nested\n\n---\n\nbody text"
class TestSendMessageBlocks:
@pytest.mark.asyncio
async def test_disabled_by_default_no_blocks(self):
adapter, client = _make_adapter()
await adapter.send("C1", RICH_MD)
kwargs = client.chat_postMessage.await_args.kwargs
assert "blocks" not in kwargs
assert kwargs["text"] # plain text still sent
@pytest.mark.asyncio
async def test_enabled_sends_blocks_with_text_fallback(self):
adapter, client = _make_adapter({"rich_blocks": True})
await adapter.send("C1", RICH_MD)
kwargs = client.chat_postMessage.await_args.kwargs
assert "blocks" in kwargs and kwargs["blocks"]
# text fallback is ALWAYS present alongside blocks (notifications/a11y)
assert kwargs["text"]
types = [b["type"] for b in kwargs["blocks"]]
assert "header" in types
assert "divider" in types
@pytest.mark.asyncio
async def test_enabled_but_unrenderable_falls_back_to_text(self):
# 60 dividers -> renderer returns None -> no blocks kwarg, text stands
adapter, client = _make_adapter({"rich_blocks": True})
await adapter.send("C1", "\n\n".join(["---"] * 60))
kwargs = client.chat_postMessage.await_args.kwargs
assert "blocks" not in kwargs
assert kwargs["text"]
@pytest.mark.asyncio
async def test_string_true_coerced(self):
adapter, client = _make_adapter({"rich_blocks": "true"})
await adapter.send("C1", RICH_MD)
assert "blocks" in client.chat_postMessage.await_args.kwargs
@pytest.mark.asyncio
async def test_multichunk_message_no_blocks(self):
adapter, client = _make_adapter({"rich_blocks": True})
huge = "word " * 20000 # well over MAX_MESSAGE_LENGTH -> chunked
await adapter.send("C1", huge)
# every posted chunk is plain text, none carry blocks
for c in client.chat_postMessage.await_args_list:
assert "blocks" not in c.kwargs
assert c.kwargs["text"]
class TestEditMessageBlocks:
@pytest.mark.asyncio
async def test_intermediate_edit_no_blocks(self):
adapter, client = _make_adapter({"rich_blocks": True})
await adapter.edit_message("C1", "111.222", RICH_MD, finalize=False)
kwargs = client.chat_update.await_args.kwargs
assert "blocks" not in kwargs
assert kwargs["text"]
@pytest.mark.asyncio
async def test_finalize_edit_gets_blocks(self):
adapter, client = _make_adapter({"rich_blocks": True})
await adapter.edit_message("C1", "111.222", RICH_MD, finalize=True)
kwargs = client.chat_update.await_args.kwargs
assert "blocks" in kwargs and kwargs["blocks"]
assert kwargs["text"]
@pytest.mark.asyncio
async def test_finalize_edit_disabled_no_blocks(self):
adapter, client = _make_adapter() # rich_blocks off
await adapter.edit_message("C1", "111.222", RICH_MD, finalize=True)
assert "blocks" not in client.chat_update.await_args.kwargs