# NousResearch/hermes-agent · Slack Block Kit markdown and table rendering

## Open now

- Nothing open on GitHub.

These issues and pull requests concern rendering markdown content in Slack using Block Kit, including converting markdown tables to Block Kit table blocks and switching from legacy mrkdwn to Block Kit markdown block types. Multiple approaches appear, including opt-in and opt-out configurations for rich markdown rendering in agent or assistant messages.

## TL;DR

- 18 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 2 merged PR(s), 12 PR(s) closed without merging.
- A fix was merged to main in PR #56102. Verify it addresses your case.
- **Recommended:** read PR #56102 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-56102,pr-70191`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.14`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #8552: Slack platform: use Block Kit markdown block type instead of legacy mrkdwn
- #18918: Slack: render Markdown pipe tables as Block Kit tables
- #26947: \[Feature Request\] Support Slack Block Kit table rendering for markdown tables
- #56218: Slack: support Block Kit markdown/rich_text rendering for assistant replies

## Prior & competing PRs

- **#47051: feat(slack): render rich markdown blocks by default (opt-out)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `151a2bfac`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#56102: feat(slack): opt-in Block Kit rendering for agent messages (salvage #56090)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/platforms/slack/adapter.py`, `plugins/platforms/slack/block_kit.py`, `tests/gateway/test_slack_block_kit.py`, `tests/gateway/test_slack_block_kit_adapter.py`, `website/docs/user-guide/messaging/slack.md`, `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md`
  - fix hunks:
  - `plugins/platforms/slack/adapter.py`

    ```diff
    @@ -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__)
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -1782,6 +1805,37 @@ 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``, and native
    +        Block Kit ``table`` blocks with per-column alignment); over-limit
    +        tables fall back to aligned monospace.
    +        """
    +        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.
     
    ```
  - `plugins/platforms/slack/block_kit.py`

    ```diff
    @@ -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):
    +
    +* **Markdown pipe-tables render as native ``table`` blocks** — real grid
    +  cells with per-column alignment and inline-formatted ``rich_text`` content.
    +  A table that exceeds Slack's limits (100 rows / 20 cols / 10k aggregate
    +  cell chars) or won't parse falls back to aligned monospace
    +  ``rich_text_preformatted`` so a large table never breaks the message.
    +* **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"(?<!!)\[([^\]]+)\]\(([^()\s]+(?:\([^()]*\)[^()\s]*)*)\)")
    +_BOLD_RE = re.compile(r"(?:\*\*|__)(.+?)(?:\*\*|__)")
    +_ITALIC_RE = re.compile(r"(?<![\*_])(?:\*|_)(?![\*_\s])(.+?)(?<![\*_\s])(?:\*|_)(?![\*_])")
    +_STRIKE_RE = re.compile(r"~~(.+?)~~")
    +
    +
    +def _inline_elements(text: str) -> 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
    ```
  - `tests/gateway/test_slack_block_kit.py`

    ```diff
    @@ -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)
    ```
  - `tests/gateway/test_slack_block_kit_adapter.py`

    ```diff
    @@ -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="[redacted:secret-assignment]", 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
    ```
  - `website/docs/user-guide/messaging/slack.md`

    ```diff
    @@ -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 |
    ```

    ```diff
    @@ -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
     
    ```
  - `website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/slack.md`

    ```diff
    @@ -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
     ```
     
     | 键 | 默认值 | 描述 |
    ```

    ```diff
    @@ -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 限制的表格会回退为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 |
     
     ### 会话隔离
     
    ```
- **#70191: fix(slack): markdown rendering fidelity — fences, entities, bold, links + opt-in markdown blocks**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `contributors/emails/[redacted:email]`, `contributors/emails/[redacted:email]`, `contributors/emails/[redacted:email]`, `contributors/emails/[redacted:email]`, `contributors/emails/[redacted:email]`, `contributors/emails/[redacted:email]`, `gateway/platforms/base.py`, `gateway/run.py`, `gateway/stream_consumer.py`, `plugins/platforms/slack/adapter.py`, `plugins/platforms/slack/block_kit.py`, `tests/gateway/test_code_fence_tracking.py`, `tests/gateway/test_escape_reasoning_fences.py`, `tests/gateway/test_slack.py`, `tests/gateway/test_slack_block_kit.py`, `tests/gateway/test_slack_block_kit_adapter.py`, `tests/gateway/test_stream_consumer.py`
- **#8554: fix(slack): use Block Kit markdown block type instead of legacy mrkdwn**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#70191`
- **#18920: fix(slack): render markdown tables as Block Kit tables**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#56102`, `#18918`
- **#19108: fix(slack): use Block Kit markdown block type instead of legacy mrkdwn**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56102`, `#18918`
- **#26950: feat(slack): render markdown tables as column-padded monospace blocks (#26947)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56102`, `#18918`
- **#34000: feat: support Slack table blocks**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56102`, `#18918`
- **#35133: feat(slack): use markdown block for native table rendering**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#35146: feat(slack): use markdown block for native table rendering**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56102`, `#18918`
- **#48737: feat(slack): convert markdown tables to Block Kit table blocks**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56102`, `#18918`
- **#52746: fix(slack): render markdown tables as Block Kit tables**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56102`, `#18918`
- **#53893: feat(slack): render markdown natively via Block Kit markdown block**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#56102`, `#18918`
- **#56090: feat(slack): opt-in Block Kit rendering for agent messages**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `b080b93ad`, `7c7b48981`, `#56102`

## Fix status

- Merged to main in PR #56102 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-23`.
- Subsystem: `gateway`
