feat(slack): render markdown tables as native Block Kit table blocks

Replace the interim monospace table fallback with Slack's native `table`
block (rows of rich_text cells). Addresses the core ask in #18918.

- _table_block(): builds type:"table" with rich_text cells, so inline
  formatting (bold, links, code) renders inside cells.
- Column alignment parsed from the markdown separator row (:---, :-:, --:)
  into column_settings (left = default/null-skip, center/right emitted).
- Escaped pipes (\\|) are not treated as column separators.
- Respects Slack's table limits (100 rows / 20 cols / 10k aggregate chars);
  oversized or unparseable tables gracefully fall back to aligned monospace
  (rich_text_preformatted), so a big table never breaks the message.

Docs (EN + zh-Hans) updated to describe native tables + the fallback.
Tests: native table shape, alignment->column_settings, inline-formatted
cells, oversized/too-wide monospace fallback, escaped-pipe cell. Prove-
failed against a stubbed _table_block (native-table tests fail, fallback
tests stay green). All existing Slack tests still pass.
This commit is contained in:
Ben 2026-07-01 16:15:12 +10:00 committed by Teknium
parent b080b93ad8
commit 7c7b489813
4 changed files with 174 additions and 22 deletions

View file

@ -35,6 +35,10 @@ from typing import Any, Dict, List, Optional, Tuple
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]
@ -208,15 +212,95 @@ def _section_block(text: str) -> Block:
# ----------------------------------------------------------------------------
# Table handling (best-effort monospace fallback)
# 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."""
"""Render markdown pipe-table rows as aligned monospace text (fallback)."""
parsed: List[List[str]] = []
for r in rows:
cells = [c.strip() for c in r.strip().strip("|").split("|")]
cells = _split_row(r)
parsed.append(cells)
if not parsed:
return "\n".join(rows)
@ -320,12 +404,20 @@ def render_blocks(
# 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]
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
blocks.append(_preformatted_block(_render_table(trows)))
# 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

View file

@ -92,7 +92,7 @@ class TestInlineFormatting:
class TestTables:
def test_pipe_table_renders_preformatted(self):
def test_pipe_table_renders_native_table_block(self):
md = (
"| Name | Status |\n"
"|------|--------|\n"
@ -101,13 +101,72 @@ class TestTables:
)
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"
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
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:

View file

@ -346,10 +346,11 @@ platforms:
# 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).
# 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
```
@ -358,7 +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). A plain-text fallback is always sent. Tables render as aligned monospace. No app reinstall required — it's a send-side change only. |
| `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

View file

@ -301,10 +301,10 @@ platforms:
# 将 Agent 消息渲染为 Slack Block Kit 区块默认false
# 为 true 时,最终的 Agent 消息会以结构化区块发送——包括
# 章节标题、分隔线以及真正的嵌套列表(通过 rich_text——
# 而非扁平的 mrkdwn 文本。同时始终附带纯文本回退内容,
# 用于通知和无障碍访问。Markdown 表格会渲染为对齐的等宽文本
# Block Kit 没有原生表格区块)
# 章节标题、分隔线、真正的嵌套列表(通过 rich_text以及
# 原生 Block Kit 表格——而非扁平的 mrkdwn 文本。同时始终附带
# 纯文本回退内容,用于通知和无障碍访问。超出 Slack 限制
# 100 行 / 20 列 / 1 万字符)的表格会优雅地回退为对齐的等宽文本
rich_blocks: false
```
@ -313,7 +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/) 区块(标题、分隔线、真正的嵌套列表)。始终附带纯文本回退。表格渲染为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 |
| `platforms.slack.extra.rich_blocks` | `false` | 为 `true`Agent 消息会渲染为 [Block Kit](https://docs.slack.dev/block-kit/) 区块(标题、分隔线、真正的嵌套列表以及原生表格)。始终附带纯文本回退。超出 Slack 限制的表格会回退为对齐的等宽文本。无需重新安装应用——这仅是发送端的改动。 |
### 会话隔离