diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index f2fdbd527b63..ec102a398f0b 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -54,6 +54,11 @@ 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 @@ async def send( # 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 @@ async def edit_message( 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 @@ def _is_retryable_upload_error(self, exc: Exception) -> bool: # ----- 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 000000000000..56c8809ade5e --- /dev/null +++ b/plugins/platforms/slack/block_kit.py @@ -0,0 +1,491 @@ +"""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 +# Native table block limits (https://docs.slack.dev/reference/block-kit/blocks/table-block) +MAX_TABLE_ROWS = 100 +MAX_TABLE_COLS = 20 +MAX_TABLE_CHARS = 10000 # aggregate across all cells + +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 — native Block Kit ``table`` block, monospace fallback +# ---------------------------------------------------------------------------- + + +def _parse_alignment(sep_line: str) -> List[str]: + """Parse a markdown separator row (``|:--|:-:|--:|``) into column aligns. + + Returns a list of ``"left"``/``"center"``/``"right"`` per column. + """ + aligns: List[str] = [] + for cell in sep_line.strip().strip("|").split("|"): + c = cell.strip() + left = c.startswith(":") + right = c.endswith(":") + if left and right: + aligns.append("center") + elif right: + aligns.append("right") + else: + aligns.append("left") + return aligns + + +def _split_row(row: str) -> List[str]: + """Split a markdown table row into trimmed cell strings. + + Respects backslash-escaped pipes (``\\|``) so they aren't treated as + column separators. + """ + # 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("|")] + + +def _rich_text_cell(text: str) -> Dict[str, Any]: + """A ``rich_text`` table cell carrying inline-formatted content.""" + return { + "type": "rich_text", + "elements": [ + {"type": "rich_text_section", "elements": _inline_elements(text)} + ], + } + + +def _table_block(rows: List[str], sep_line: str) -> Optional[Block]: + """Build a native Slack ``table`` block from markdown pipe-table rows. + + ``rows`` includes the header row (index 0) and body rows; ``sep_line`` is + the ``|---|`` alignment row (already consumed by the caller). Returns + ``None`` when the table exceeds Slack's limits (100 rows / 20 cols / + 10,000 aggregate cell chars) or parses to nothing — the caller then falls + back to the monospace preformatted rendering. + """ + parsed = [_split_row(r) for r in rows if r.strip()] + if not parsed: + return None + ncols = max(len(r) for r in parsed) + # Reject rather than silently truncate beyond Slack's structural limits. + if len(parsed) > MAX_TABLE_ROWS or ncols > MAX_TABLE_COLS: + return None + for r in parsed: + r.extend([""] * (ncols - len(r))) + + total_chars = sum(len(c) for r in parsed for c in r) + if total_chars > MAX_TABLE_CHARS: + return None + + aligns = _parse_alignment(sep_line) + column_settings: List[Optional[Dict[str, Any]]] = [] + for c in range(min(ncols, MAX_TABLE_COLS)): + align = aligns[c] if c < len(aligns) else "left" + # Only emit a setting when it differs from the default (left, no wrap); + # use null to skip a column, per the Slack schema. + column_settings.append({"align": align} if align != "left" else None) + + block: Block = { + "type": "table", + "rows": [[_rich_text_cell(cell) for cell in row] for row in parsed], + } + if any(cs is not None for cs in column_settings): + block["column_settings"] = column_settings + return block + + +def _render_table(rows: List[str]) -> str: + """Render markdown pipe-table rows as aligned monospace text (fallback).""" + parsed: List[List[str]] = [] + for r in rows: + cells = _split_row(r) + 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() + header_row = line + sep_line = lines[i + 1] + trows = [header_row] + i += 2 # skip header + separator + while i < n and "|" in lines[i] and lines[i].strip(): + trows.append(lines[i]) + i += 1 + # Prefer a native Block Kit table; fall back to aligned + # monospace when it exceeds Slack's table limits or won't parse. + table = _table_block(trows, sep_line) + if table is not None: + blocks.append(table) + else: + 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 000000000000..6dc0d74c7780 --- /dev/null +++ b/tests/gateway/test_slack_block_kit.py @@ -0,0 +1,189 @@ +"""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_native_table_block(self): + md = ( + "| Name | Status |\n" + "|------|--------|\n" + "| a | ok |\n" + "| b | fail |" + ) + blocks = render_blocks(md) + assert len(blocks) == 1 + assert blocks[0]["type"] == "table" + rows = blocks[0]["rows"] + # header + 2 body rows, 2 columns each + assert len(rows) == 3 + assert all(len(r) == 2 for r in rows) + # cells are rich_text carrying the values + assert str(rows[0]).count("Name") == 1 + assert "fail" in str(rows[2]) + + def test_alignment_parsed_into_column_settings(self): + md = ( + "| L | C | R |\n" + "|:---|:--:|---:|\n" + "| 1 | 2 | 3 |" + ) + blocks = render_blocks(md) + cs = blocks[0]["column_settings"] + # left is default -> null; center/right emitted + assert cs[0] is None + assert cs[1] == {"align": "center"} + assert cs[2] == {"align": "right"} + + def test_inline_formatting_inside_cells(self): + md = ( + "| Item | Link |\n" + "|------|------|\n" + "| **bold** | [x](https://e.io) |" + ) + blocks = render_blocks(md) + body = blocks[0]["rows"][1] + # bold styled text element in first cell + bold = [ + el for el in body[0]["elements"][0]["elements"] + if el.get("style", {}).get("bold") + ] + assert bold + # link element in second cell + links = [el for el in body[1]["elements"][0]["elements"] if el["type"] == "link"] + assert links and links[0]["url"] == "https://e.io" + + def test_oversized_table_falls_back_to_monospace(self): + # 120 rows > MAX_TABLE_ROWS -> monospace rich_text fallback, not a table + big = "| a | b |\n|---|---|\n" + "\n".join(f"| x{i} | y |" for i in range(120)) + blocks = render_blocks(big) + assert blocks[0]["type"] == "rich_text" # preformatted fallback + assert blocks[0]["elements"][0]["type"] == "rich_text_preformatted" + + def test_too_many_columns_falls_back_to_monospace(self): + header = "|" + "|".join(f"c{i}" for i in range(25)) + "|" + sep = "|" + "|".join("-" for _ in range(25)) + "|" + row = "|" + "|".join("v" for _ in range(25)) + "|" + blocks = render_blocks(f"{header}\n{sep}\n{row}") + assert blocks[0]["type"] == "rich_text" + + def test_escaped_pipe_not_a_column_separator(self): + md = ( + "| Expr | Meaning |\n" + "|------|--------|\n" + "| a \\| b | or |" + ) + blocks = render_blocks(md) + assert blocks[0]["type"] == "table" + # the escaped-pipe cell stays a single cell containing a literal pipe + body = blocks[0]["rows"][1] + assert len(body) == 2 + assert "|" in str(body[0]) + + +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 000000000000..5f220ffeecad --- /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 67ccb6617339..34827cf79d0e 100644 --- a/website/docs/user-guide/messaging/slack.md +++ b/website/docs/user-guide/messaging/slack.md @@ -343,6 +343,15 @@ 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, true nested lists (via rich_text), and + # native Block Kit tables — instead of flat mrkdwn text. A plain-text + # fallback is always sent alongside for notifications/accessibility. + # Tables exceeding Slack's limits (100 rows / 20 cols / 10k chars) + # gracefully fall back to aligned monospace. + rich_blocks: false ``` | Key | Default | Description | @@ -350,6 +359,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, and native tables). A plain-text fallback is always sent. Tables over Slack's limits fall back to 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 1fb03a1dc5f1..02ee4c9cf0cf 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)以及 + # 原生 Block Kit 表格——而非扁平的 mrkdwn 文本。同时始终附带 + # 纯文本回退内容,用于通知和无障碍访问。超出 Slack 限制 + # (100 行 / 20 列 / 1 万字符)的表格会优雅地回退为对齐的等宽文本。 + 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/) 区块(标题、分隔线、真正的嵌套列表以及原生表格)。始终附带纯文本回退。超出 Slack 限制的表格会回退为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 | ### 会话隔离