From b080b93ad87428221f7bf40360d11fc13e837727 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 1 Jul 2026 14:57:32 +1000 Subject: [PATCH] feat(slack): opt-in Block Kit rendering for agent messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/platforms/slack/adapter.py | 63 ++- plugins/platforms/slack/block_kit.py | 399 ++++++++++++++++++ tests/gateway/test_slack_block_kit.py | 130 ++++++ tests/gateway/test_slack_block_kit_adapter.py | 102 +++++ website/docs/user-guide/messaging/slack.md | 9 + .../current/user-guide/messaging/slack.md | 9 + 6 files changed, 707 insertions(+), 5 deletions(-) create mode 100644 plugins/platforms/slack/block_kit.py create mode 100644 tests/gateway/test_slack_block_kit.py create mode 100644 tests/gateway/test_slack_block_kit_adapter.py diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index f2fdbd527..ec102a398 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -54,6 +54,11 @@ from gateway.platforms.base import ( cache_video_from_bytes, ) +try: # sibling module; support both package and flat plugin-dir import + from .block_kit import render_blocks +except ImportError: # pragma: no cover - plugin loaded outside package context + from block_kit import render_blocks # type: ignore + logger = logging.getLogger(__name__) @@ -1372,12 +1377,21 @@ class SlackAdapter(BasePlatformAdapter): # Controlled via platform config: gateway.slack.reply_broadcast broadcast = self.config.extra.get("reply_broadcast", False) + # Block Kit (opt-in): render the primary message as structured + # blocks. Only applied to a single-chunk message — a >39k response + # that had to be split is pathological for Block Kit's 50-block / + # 3000-char limits, so those fall back to plain text. The ``text`` + # field is always kept as the notification/accessibility fallback. + blocks = self._maybe_blocks(content) if len(chunks) == 1 else None + for i, chunk in enumerate(chunks): kwargs = { "channel": chat_id, "text": chunk, "mrkdwn": True, } + if blocks and i == 0: + kwargs["blocks"] = blocks if thread_ts: kwargs["thread_ts"] = thread_ts # Only broadcast the first chunk of the first reply @@ -1462,11 +1476,20 @@ class SlackAdapter(BasePlatformAdapter): return SendResult(success=False, error="Not connected") try: formatted = self.format_message(content) - await self._get_client(chat_id).chat_update( - channel=chat_id, - ts=message_id, - text=formatted, - ) + update_kwargs: Dict[str, Any] = { + "channel": chat_id, + "ts": message_id, + "text": formatted, + } + # Only render Block Kit on the FINAL edit. Intermediate streaming + # edits stay plain mrkdwn — re-deriving a full block layout on every + # progressive flush would be wasteful and jittery. ``text`` is kept + # as the fallback either way. + if finalize: + blocks = self._maybe_blocks(content) + if blocks: + update_kwargs["blocks"] = blocks + await self._get_client(chat_id).chat_update(**update_kwargs) if finalize: await self.stop_typing(chat_id) return SendResult(success=True, message_id=message_id) @@ -1782,6 +1805,36 @@ class SlackAdapter(BasePlatformAdapter): # ----- Markdown → mrkdwn conversion ----- + def _rich_blocks_enabled(self) -> bool: + """Whether to render outbound agent messages as Slack Block Kit blocks. + + Opt-in via ``platforms.slack.extra.rich_blocks`` (config.yaml). Default + off: messages continue to go out as flat mrkdwn ``text``. Enabling it + renders the *final* agent message with real structural primitives + (headers, dividers, true nested lists via ``rich_text``); tables are + rendered as aligned monospace (Block Kit has no robust table block). + """ + raw = self.config.extra.get("rich_blocks") + if raw is None: + return False + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + def _maybe_blocks(self, content: str) -> Optional[list]: + """Render ``content`` to Block Kit blocks when the feature 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. + """ + if not self._rich_blocks_enabled(): + return None + try: + return render_blocks(content, mrkdwn_fn=self.format_message) + except Exception: # pragma: no cover - renderer already guards itself + logger.debug("[Slack] block render failed; using plain text", exc_info=True) + return None + def format_message(self, content: str) -> str: """Convert standard markdown to Slack mrkdwn format. diff --git a/plugins/platforms/slack/block_kit.py b/plugins/platforms/slack/block_kit.py new file mode 100644 index 000000000..67faa7632 --- /dev/null +++ b/plugins/platforms/slack/block_kit.py @@ -0,0 +1,399 @@ +"""Render agent markdown into Slack Block Kit blocks. + +Opt-in (``slack.extra.rich_blocks: true``) alternative to the flat mrkdwn +``text`` payload produced by :meth:`SlackAdapter.format_message`. Block Kit +gives us real structural primitives — section headers, dividers, and true +*nested* lists via ``rich_text`` — that plain mrkdwn can only approximate. + +Design constraints (why this module is deliberately conservative): + +* **Block Kit has no robust table primitive.** The newer ``table`` block is + limited and fragile, so markdown pipe-tables are rendered as monospace + ``rich_text_preformatted`` — the same thing a human would paste today, just + aligned. Proper ``table`` blocks are a future iteration (see the PR's Open + Questions), not a v1 gate. +* **Slack caps a message at 50 blocks** and a ``section``/text object at 3000 + characters. :func:`render_blocks` enforces both and, if the content simply + cannot be expressed within them, returns ``None`` so the caller falls back + to the plain-text path. A rich render is a nice-to-have; it must never lose + a message. +* **Every blocks payload MUST ship a ``text`` fallback.** Slack uses it for + notifications, screen readers, and old clients. This module only builds the + ``blocks`` list; the adapter pairs it with the existing mrkdwn string. + +The renderer never raises: any unexpected input degrades to ``None`` (caller +uses plain text). It is a pure function of its input — no Slack client, no +adapter state — so it is trivially unit-testable. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, List, Optional, Tuple + +# Slack Block Kit hard limits (https://docs.slack.dev/reference/block-kit/blocks) +MAX_BLOCKS = 50 +MAX_SECTION_TEXT = 3000 +MAX_HEADER_TEXT = 150 + +Block = Dict[str, Any] + +# ---------------------------------------------------------------------------- +# Line classification +# ---------------------------------------------------------------------------- + +_HR_RE = re.compile(r"^\s{0,3}([-*_])(?:\s*\1){2,}\s*$") +_HEADER_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$") +_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$") +_ORDERED_RE = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$") +_BULLET_RE = re.compile(r"^(\s*)[-*+]\s+(.*)$") +_QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$") +_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$") + + +def _indent_level(spaces: str) -> int: + """Map leading whitespace to a nesting level (2 spaces or 1 tab per level).""" + 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 + + +# ---------------------------------------------------------------------------- +# Inline markdown → rich_text elements +# ---------------------------------------------------------------------------- + +# Order matters: code first (opaque), then links, then emphasis. +_INLINE_CODE_RE = re.compile(r"`([^`]+)`") +_LINK_RE = re.compile(r"(? List[Dict[str, Any]]: + """Parse a run of inline markdown into rich_text section child elements. + + Produces ``text`` elements (optionally styled bold/italic/strike/code) and + ``link`` elements. Unmatched markup is emitted verbatim as plain text, so + this never loses characters. + """ + elements: List[Dict[str, Any]] = [] + + def emit_text(s: str, style: Optional[Dict[str, bool]] = None) -> None: + if not s: + return + el: Dict[str, Any] = {"type": "text", "text": s} + if style: + el["style"] = style + elements.append(el) + + # Tokenize by the highest-priority markers first using a single scan. + # We recursively split on code, then links, then emphasis to keep spans + # from overlapping incorrectly. + def walk(s: str, style: Dict[str, bool]) -> None: + pos = 0 + # inline code is opaque — no nested styling + for m in _INLINE_CODE_RE.finditer(s): + _walk_links(s[pos:m.start()], style) + code_style = dict(style) + code_style["code"] = True + emit_text(m.group(1), code_style or None) + pos = m.end() + _walk_links(s[pos:], style) + + def _walk_links(s: str, style: Dict[str, bool]) -> None: + pos = 0 + for m in _LINK_RE.finditer(s): + _walk_emphasis(s[pos:m.start()], style) + link_el: Dict[str, Any] = {"type": "link", "url": m.group(2), "text": m.group(1)} + if style: + link_el["style"] = dict(style) + elements.append(link_el) + pos = m.end() + _walk_emphasis(s[pos:], style) + + 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) + + walk(text, {}) + return elements or [{"type": "text", "text": text}] + + +# ---------------------------------------------------------------------------- +# Structural block builders +# ---------------------------------------------------------------------------- + + +def _header_block(text: str) -> Block: + # header blocks are plain_text only, 150 char cap. + clean = re.sub(r"[*_~`]", "", text).strip() + if len(clean) > MAX_HEADER_TEXT: + clean = clean[: MAX_HEADER_TEXT - 1] + "…" + return {"type": "header", "text": {"type": "plain_text", "text": clean, "emoji": True}} + + +def _divider_block() -> Block: + return {"type": "divider"} + + +def _preformatted_block(text: str) -> Block: + # rich_text_preformatted renders monospace; used for code fences + tables. + return { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_preformatted", + "elements": [{"type": "text", "text": text.rstrip("\n")}], + } + ], + } + + +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}], + } + + +def _list_block(items: List[Tuple[int, bool, str]]) -> Block: + """Build ONE rich_text block from consecutive list items. + + ``items`` is a list of ``(indent, ordered, text)``. Each contiguous run + sharing the same (indent, ordered) becomes a ``rich_text_list`` element; + indentation changes start a new element, which is how Slack renders true + nesting. + """ + elements: List[Dict[str, Any]] = [] + cur: Optional[Dict[str, Any]] = None + cur_key: Optional[Tuple[int, bool]] = None + for indent, ordered, text in items: + key = (indent, ordered) + if key != cur_key: + cur = { + "type": "rich_text_list", + "style": "ordered" if ordered else "bullet", + "indent": indent, + "elements": [], + } + elements.append(cur) + cur_key = key + assert cur is not None + cur["elements"].append( + {"type": "rich_text_section", "elements": _inline_elements(text)} + ) + return {"type": "rich_text", "elements": elements} + + +def _section_block(text: str) -> Block: + return {"type": "section", "text": {"type": "mrkdwn", "text": text}} + + +# ---------------------------------------------------------------------------- +# Table handling (best-effort monospace fallback) +# ---------------------------------------------------------------------------- + + +def _render_table(rows: List[str]) -> str: + """Render markdown pipe-table rows as aligned monospace text.""" + parsed: List[List[str]] = [] + for r in rows: + cells = [c.strip() for c in r.strip().strip("|").split("|")] + parsed.append(cells) + if not parsed: + return "\n".join(rows) + ncols = max(len(r) for r in parsed) + for r in parsed: + r.extend([""] * (ncols - len(r))) + widths = [max(len(r[c]) for r in parsed) for c in range(ncols)] + out_lines = [] + for ri, r in enumerate(parsed): + line = " | ".join(r[c].ljust(widths[c]) for c in range(ncols)) + out_lines.append(line.rstrip()) + if ri == 0: # header underline + out_lines.append("-+-".join("-" * widths[c] for c in range(ncols))) + return "\n".join(out_lines) + + +# ---------------------------------------------------------------------------- +# Public entry point +# ---------------------------------------------------------------------------- + + +def render_blocks( + markdown: str, + mrkdwn_fn=None, +) -> Optional[List[Block]]: + """Convert agent markdown to a Slack Block Kit ``blocks`` list. + + Args: + markdown: The agent's response text (standard markdown). + mrkdwn_fn: Optional callable converting a markdown paragraph to Slack + mrkdwn for ``section`` blocks (the adapter passes + ``format_message``). When ``None``, the raw paragraph text is used. + + Returns: + A list of Block Kit block dicts, or ``None`` when the content is empty, + exceeds Slack's structural limits, or hits an unexpected shape — the + caller then falls back to the flat ``text`` payload. Never raises. + """ + if not markdown or not markdown.strip(): + return None + + fmt = mrkdwn_fn or (lambda s: s) + + try: + blocks: List[Block] = [] + lines = markdown.replace("\r\n", "\n").split("\n") + i = 0 + n = len(lines) + para: List[str] = [] + + def flush_para() -> None: + if not para: + return + text = "\n".join(para).strip() + para.clear() + if not text: + return + rendered = fmt(text) + # Split oversized sections on the 3000-char limit. + for chunk in _split_text(rendered, MAX_SECTION_TEXT): + blocks.append(_section_block(chunk)) + + while i < n: + line = lines[i] + + # Blank line: paragraph boundary + if not line.strip(): + flush_para() + i += 1 + continue + + # Fenced code block + fence = _FENCE_RE.match(line) + if fence: + flush_para() + marker = fence.group(1) + body: List[str] = [] + i += 1 + while i < n and not lines[i].lstrip().startswith(marker): + body.append(lines[i]) + i += 1 + i += 1 # consume closing fence + blocks.append(_preformatted_block("\n".join(body))) + continue + + # Horizontal rule → divider + if _HR_RE.match(line): + flush_para() + blocks.append(_divider_block()) + i += 1 + continue + + # ATX header + hm = _HEADER_RE.match(line) + if hm: + flush_para() + blocks.append(_header_block(hm.group(2))) + i += 1 + continue + + # Pipe table: current line has a pipe AND next line is a separator + if "|" in line and i + 1 < n and _TABLE_SEP_RE.match(lines[i + 1]): + flush_para() + trows = [line] + i += 2 # skip header + separator + while i < n and "|" in lines[i] and lines[i].strip(): + trows.append(lines[i]) + i += 1 + blocks.append(_preformatted_block(_render_table(trows))) + continue + + # Blockquote group + if _QUOTE_RE.match(line): + flush_para() + qlines: List[str] = [] + while i < n: + qm = _QUOTE_RE.match(lines[i]) + if not qm: + break + qlines.append(qm.group(1)) + i += 1 + blocks.append(_quote_block(qlines)) + continue + + # List group (bullets + ordered, with nesting) + if _BULLET_RE.match(line) or _ORDERED_RE.match(line): + flush_para() + items: List[Tuple[int, bool, str]] = [] + while i < n: + bm = _BULLET_RE.match(lines[i]) + om = _ORDERED_RE.match(lines[i]) + if bm: + items.append((_indent_level(bm.group(1)), False, bm.group(2))) + i += 1 + elif om: + items.append((_indent_level(om.group(1)), True, om.group(3))) + i += 1 + elif lines[i].strip() and lines[i].startswith((" ", "\t")) and items: + # continuation line of the previous item + indent, ordered, txt = items[-1] + items[-1] = (indent, ordered, txt + " " + lines[i].strip()) + i += 1 + else: + break + blocks.append(_list_block(items)) + continue + + # Default: accumulate into a paragraph + para.append(line) + i += 1 + + flush_para() + + if not blocks: + return None + if len(blocks) > MAX_BLOCKS: + # Too structurally complex to express safely — let the caller fall + # back to plain text rather than truncating and losing content. + return None + return blocks + except Exception: + # Never let a rendering bug drop a message. + return None + + +def _split_text(text: str, limit: int) -> List[str]: + """Split ``text`` into <= ``limit``-char chunks on line, then hard, boundaries.""" + if len(text) <= limit: + return [text] + out: List[str] = [] + remaining = text + while len(remaining) > limit: + cut = remaining.rfind("\n", 0, limit) + if cut <= 0: + cut = limit + out.append(remaining[:cut]) + remaining = remaining[cut:].lstrip("\n") + if remaining: + out.append(remaining) + return out diff --git a/tests/gateway/test_slack_block_kit.py b/tests/gateway/test_slack_block_kit.py new file mode 100644 index 000000000..5b1be679f --- /dev/null +++ b/tests/gateway/test_slack_block_kit.py @@ -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) diff --git a/tests/gateway/test_slack_block_kit_adapter.py b/tests/gateway/test_slack_block_kit_adapter.py new file mode 100644 index 000000000..5f220ffee --- /dev/null +++ b/tests/gateway/test_slack_block_kit_adapter.py @@ -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 diff --git a/website/docs/user-guide/messaging/slack.md b/website/docs/user-guide/messaging/slack.md index 67ccb6617..aa9b817f6 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -343,6 +343,14 @@ platforms: # (Slack's "Also send to channel" feature). # Only the first chunk of the first reply is broadcast. reply_broadcast: false + + # Render agent messages as Slack Block Kit blocks (default: false). + # When true, the final agent message is sent with structured blocks — + # section headers, dividers, and true nested lists (via rich_text) — + # instead of flat mrkdwn text. A plain-text fallback is always sent + # alongside for notifications/accessibility. Markdown tables are + # rendered as aligned monospace (Block Kit has no native table block). + rich_blocks: false ``` | Key | Default | Description | @@ -350,6 +358,7 @@ platforms: | `platforms.slack.reply_to_mode` | `"first"` | Threading mode for multi-part messages: `"off"`, `"first"`, or `"all"` | | `platforms.slack.extra.reply_in_thread` | `true` | When `false`, channel messages get direct replies instead of threads. Messages inside existing threads still reply in-thread. | | `platforms.slack.extra.reply_broadcast` | `false` | When `true`, thread replies are also posted to the main channel. Only the first chunk is broadcast. | +| `platforms.slack.extra.rich_blocks` | `false` | When `true`, agent messages are rendered as [Block Kit](https://docs.slack.dev/block-kit/) blocks (headers, dividers, true nested lists). A plain-text fallback is always sent. Tables render as aligned monospace. No app reinstall required — it's a send-side change only. | ### Session Isolation diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md index 1fb03a1dc..06c3a8f4d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md @@ -298,6 +298,14 @@ platforms: # (Slack 的"同时发送到频道"功能)。 # 仅广播第一条回复的第一个分块。 reply_broadcast: false + + # 将 Agent 消息渲染为 Slack Block Kit 区块(默认:false)。 + # 为 true 时,最终的 Agent 消息会以结构化区块发送——包括 + # 章节标题、分隔线以及真正的嵌套列表(通过 rich_text)—— + # 而非扁平的 mrkdwn 文本。同时始终附带纯文本回退内容, + # 用于通知和无障碍访问。Markdown 表格会渲染为对齐的等宽文本 + # (Block Kit 没有原生表格区块)。 + rich_blocks: false ``` | 键 | 默认值 | 描述 | @@ -305,6 +313,7 @@ platforms: | `platforms.slack.reply_to_mode` | `"first"` | 多部分消息的话题模式:`"off"`、`"first"` 或 `"all"` | | `platforms.slack.extra.reply_in_thread` | `true` | 为 `false` 时,频道消息直接回复而非话题。已在话题中的消息仍在话题中回复。 | | `platforms.slack.extra.reply_broadcast` | `false` | 为 `true` 时,话题回复也会发布到主频道。仅广播第一个分块。 | +| `platforms.slack.extra.rich_blocks` | `false` | 为 `true` 时,Agent 消息会渲染为 [Block Kit](https://docs.slack.dev/block-kit/) 区块(标题、分隔线、真正的嵌套列表)。始终附带纯文本回退。表格渲染为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 | ### 会话隔离