mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
feat(slack): opt-in Block Kit markdown block rendering for standard markdown
Slack's Block Kit `markdown` block accepts standard markdown (tables, headers, task lists, fenced code with syntax highlighting, links) and lets Slack translate it natively — eliminating the lossy markdown→mrkdwn conversion for the rendered layout. Enable via platforms.slack.extra.markdown_blocks. Safety rails added on top of the original design: * opt-in (default off) — Slack documents the block for 'apps that use platform AI features' and does not guarantee availability across all app types / surfaces, so unconditional adoption is not safe yet * the mrkdwn-converted text field is ALWAYS kept as the notification/search/accessibility fallback * content over Slack's 12k cumulative markdown-block cap declines to the rich_blocks renderer / plain text path * the existing block-rejection retry (invalid_blocks / msg_too_long / too_many_blocks) re-sends the plain mrkdwn payload, so an unsupported surface degrades gracefully instead of dropping the message * when both modes are enabled, markdown_blocks is preferred over the local rich_blocks renderer; rich_blocks remains the fallback Adapted from #8554 by @shivasymbl — the original patched the deleted gateway/platforms/slack.py and switched unconditionally; reimplemented against the plugin adapter's _maybe_blocks/sanitize_blocks pipeline. Fixes #8552.
This commit is contained in:
parent
a7738796e1
commit
c934c533db
2 changed files with 149 additions and 5 deletions
|
|
@ -3265,6 +3265,47 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return False
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def _markdown_blocks_enabled(self) -> bool:
|
||||
"""Whether to render outbound messages via Slack's ``markdown`` block.
|
||||
|
||||
Opt-in via ``platforms.slack.extra.markdown_blocks`` (config.yaml).
|
||||
Slack's Block Kit ``markdown`` block accepts *standard* markdown
|
||||
(tables, headers, task lists, fenced code with syntax highlighting,
|
||||
links) and lets Slack do the translation natively — eliminating the
|
||||
lossy markdown→mrkdwn conversion for the rendered layout. The
|
||||
mrkdwn-converted ``text`` field is always kept as the
|
||||
notification/search/accessibility fallback, and the block-rejection
|
||||
retry path drops blocks and re-sends plain mrkdwn on surfaces or
|
||||
workspaces where the block type is not accepted — so enabling this
|
||||
can never lose a message.
|
||||
|
||||
Kept opt-in rather than default because Slack documents the block for
|
||||
"apps that use platform AI features" and caps cumulative ``markdown``
|
||||
block text at 12,000 characters per payload; availability on every
|
||||
plan tier / app type is not guaranteed.
|
||||
"""
|
||||
raw = self.config.extra.get("markdown_blocks")
|
||||
if raw is None:
|
||||
return False
|
||||
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
# Slack caps the cumulative text of all ``markdown`` blocks in a single
|
||||
# payload at 12,000 characters. Leave margin for the feedback block.
|
||||
_MARKDOWN_BLOCK_MAX = 11_500
|
||||
|
||||
def _markdown_block_payload(self, content: str) -> Optional[list]:
|
||||
"""Return a ``markdown`` block payload for ``content``, or ``None``.
|
||||
|
||||
Declines (returns ``None``) for empty content and for content over
|
||||
Slack's 12k cumulative markdown-block cap — the caller then falls
|
||||
through to the rich_blocks renderer or the plain mrkdwn text path.
|
||||
"""
|
||||
if not content or not content.strip():
|
||||
return None
|
||||
if len(content) > self._MARKDOWN_BLOCK_MAX:
|
||||
return None
|
||||
return [{"type": "markdown", "text": content}]
|
||||
|
||||
def _feedback_buttons_enabled(self) -> bool:
|
||||
"""Whether to include Slack AI feedback buttons on final responses."""
|
||||
raw = self.config.extra.get("feedback_buttons")
|
||||
|
|
@ -3307,13 +3348,28 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return [*blocks, self._feedback_block()]
|
||||
|
||||
def _maybe_blocks(self, content: str) -> Optional[list]:
|
||||
"""Render ``content`` to Block Kit blocks when the feature is enabled.
|
||||
"""Render ``content`` to Block Kit blocks when a block mode 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.
|
||||
Preference order:
|
||||
|
||||
1. ``markdown_blocks`` — Slack's native ``markdown`` block renders the
|
||||
*raw* standard markdown (tables, headers, code fences with syntax
|
||||
highlighting) with Slack doing the translation (#8552).
|
||||
2. ``rich_blocks`` — the local Block Kit renderer (headers, dividers,
|
||||
``rich_text`` lists, native ``table`` blocks).
|
||||
|
||||
Returns ``None`` when both are disabled, or when the renderer
|
||||
declines (empty / too long / 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, and the block-rejection retry path recovers
|
||||
when Slack rejects the payload (e.g. a surface without ``markdown``
|
||||
block support).
|
||||
"""
|
||||
if self._markdown_blocks_enabled():
|
||||
md_blocks = self._markdown_block_payload(content)
|
||||
if md_blocks:
|
||||
return sanitize_blocks(self._append_feedback_block(md_blocks))
|
||||
if not self._rich_blocks_enabled():
|
||||
return None
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -341,3 +341,91 @@ class TestEditMessageBlocks:
|
|||
assert result.success is False
|
||||
assert result.retryable is True
|
||||
assert result.error_kind == "transient"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# markdown_blocks mode — Slack's native ``markdown`` Block Kit block (#8552)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMarkdownBlockMode:
|
||||
"""Opt-in ``markdown_blocks`` renders raw standard markdown via Slack's
|
||||
native ``markdown`` block, keeping the mrkdwn ``text`` fallback."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_by_default(self):
|
||||
adapter, client = _make_adapter()
|
||||
await adapter.send("C1", RICH_TABLE_MD)
|
||||
kwargs = client.chat_postMessage.await_args.kwargs
|
||||
assert "blocks" not in kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_sends_markdown_block_with_raw_content(self):
|
||||
adapter, client = _make_adapter({"markdown_blocks": True})
|
||||
await adapter.send("C1", RICH_TABLE_MD)
|
||||
kwargs = client.chat_postMessage.await_args.kwargs
|
||||
blocks = kwargs["blocks"]
|
||||
assert blocks[0]["type"] == "markdown"
|
||||
# RAW standard markdown, not mrkdwn-converted — Slack translates it
|
||||
assert blocks[0]["text"] == RICH_TABLE_MD
|
||||
# mrkdwn fallback text is still present for notifications/search
|
||||
assert kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_fallback_is_mrkdwn_converted(self):
|
||||
adapter, client = _make_adapter({"markdown_blocks": True})
|
||||
await adapter.send("C1", "**bold**")
|
||||
kwargs = client.chat_postMessage.await_args.kwargs
|
||||
assert kwargs["blocks"][0]["text"] == "**bold**"
|
||||
assert kwargs["text"] == "*bold*" # mrkdwn conversion for fallback
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_markdown_block_preferred_over_rich_blocks(self):
|
||||
adapter, client = _make_adapter(
|
||||
{"markdown_blocks": True, "rich_blocks": True}
|
||||
)
|
||||
await adapter.send("C1", RICH_TABLE_MD)
|
||||
blocks = client.chat_postMessage.await_args.kwargs["blocks"]
|
||||
assert blocks[0]["type"] == "markdown"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_over_cap_falls_back_to_rich_or_text(self):
|
||||
adapter, client = _make_adapter({"markdown_blocks": True})
|
||||
big = "x" * (SlackAdapter._MARKDOWN_BLOCK_MAX + 1)
|
||||
payload = adapter._markdown_block_payload(big)
|
||||
assert payload is None # declines >12k cumulative markdown cap
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejection_retries_without_blocks(self):
|
||||
"""Workspaces/surfaces without markdown-block support degrade to
|
||||
the plain mrkdwn text payload instead of dropping the message."""
|
||||
adapter, client = _make_adapter({"markdown_blocks": True})
|
||||
client.chat_postMessage = AsyncMock(
|
||||
side_effect=[SlackRejectedBlocks(), {"ts": "111.222"}]
|
||||
)
|
||||
result = await adapter.send("C1", RICH_TABLE_MD)
|
||||
assert result.success is True
|
||||
assert client.chat_postMessage.await_count == 2
|
||||
retry_kwargs = client.chat_postMessage.await_args_list[1].kwargs
|
||||
assert "blocks" not in retry_kwargs
|
||||
assert retry_kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_finalize_uses_markdown_block(self):
|
||||
adapter, client = _make_adapter({"markdown_blocks": True})
|
||||
await adapter.edit_message("C1", "111.222", RICH_TABLE_MD, finalize=True)
|
||||
kwargs = client.chat_update.await_args.kwargs
|
||||
assert kwargs["blocks"][0]["type"] == "markdown"
|
||||
assert kwargs["blocks"][0]["text"] == RICH_TABLE_MD
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_streaming_stays_plain(self):
|
||||
adapter, client = _make_adapter({"markdown_blocks": True})
|
||||
await adapter.edit_message("C1", "111.222", RICH_TABLE_MD, finalize=False)
|
||||
kwargs = client.chat_update.await_args.kwargs
|
||||
assert "blocks" not in kwargs
|
||||
|
||||
def test_empty_content_declines(self):
|
||||
adapter, _ = _make_adapter({"markdown_blocks": True})
|
||||
assert adapter._markdown_block_payload("") is None
|
||||
assert adapter._markdown_block_payload(" ") is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue