mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(slack): opt-in Block Kit rendering for agent messages
Add platforms.slack.extra.rich_blocks (default off). When enabled, the final agent message is sent as Slack Block Kit blocks — section headers, dividers, and true nested lists via rich_text — instead of flat mrkdwn. - New plugins/platforms/slack/block_kit.py: pure markdown->blocks renderer (headers, dividers, nested ordered/bullet lists, blockquotes, fenced code; pipe-tables as aligned monospace since Block Kit has no robust table block). Enforces Slack's 50-block / 3000-char section limits and returns None to fall back to plain text on empty/oversized/unexpected input. Never raises. - adapter.send(): render blocks on the single-chunk primary message; a text= fallback is ALWAYS sent alongside (notifications/accessibility). - adapter.edit_message(): blocks only on finalize=True, so intermediate streaming edits stay plain mrkdwn (no per-flush block re-derivation). - Docs (EN + zh-Hans) + config example. Send-side only: no app reinstall. Tests: pure-renderer unit suite + adapter integration suite (blocks present when on, plain text when off, text fallback always set, finalize gating, multi-chunk fallback). Prove-failed against a stubbed renderer.
This commit is contained in:
parent
44ddc552f5
commit
cbc27c8ef8
6 changed files with 707 additions and 5 deletions
130
tests/gateway/test_slack_block_kit.py
Normal file
130
tests/gateway/test_slack_block_kit.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Unit tests for the Slack Block Kit renderer (pure function, no adapter)."""
|
||||
|
||||
from plugins.platforms.slack.block_kit import (
|
||||
MAX_BLOCKS,
|
||||
MAX_HEADER_TEXT,
|
||||
MAX_SECTION_TEXT,
|
||||
render_blocks,
|
||||
)
|
||||
|
||||
|
||||
def _types(blocks):
|
||||
return [b["type"] for b in blocks]
|
||||
|
||||
|
||||
class TestRenderBlocksBasics:
|
||||
def test_empty_returns_none(self):
|
||||
assert render_blocks("") is None
|
||||
assert render_blocks(" \n ") is None
|
||||
|
||||
def test_plain_paragraph_is_section(self):
|
||||
blocks = render_blocks("just a plain sentence")
|
||||
assert blocks is not None
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "section"
|
||||
assert blocks[0]["text"]["type"] == "mrkdwn"
|
||||
|
||||
def test_header_becomes_header_block(self):
|
||||
blocks = render_blocks("# Title")
|
||||
assert blocks[0]["type"] == "header"
|
||||
assert blocks[0]["text"]["type"] == "plain_text"
|
||||
assert blocks[0]["text"]["text"] == "Title"
|
||||
|
||||
def test_header_strips_markup_and_caps_length(self):
|
||||
long = "#" + " " + "x" * 300
|
||||
blocks = render_blocks(long)
|
||||
assert blocks[0]["type"] == "header"
|
||||
assert len(blocks[0]["text"]["text"]) <= MAX_HEADER_TEXT
|
||||
|
||||
def test_horizontal_rule_becomes_divider(self):
|
||||
blocks = render_blocks("above\n\n---\n\nbelow")
|
||||
assert "divider" in _types(blocks)
|
||||
|
||||
def test_fenced_code_becomes_preformatted(self):
|
||||
md = "```python\ndef f():\n return 1\n```"
|
||||
blocks = render_blocks(md)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "rich_text"
|
||||
assert blocks[0]["elements"][0]["type"] == "rich_text_preformatted"
|
||||
|
||||
|
||||
class TestNestedLists:
|
||||
def test_nested_bullets_produce_increasing_indent(self):
|
||||
md = "- a\n - b\n - c"
|
||||
blocks = render_blocks(md)
|
||||
rich = [b for b in blocks if b["type"] == "rich_text"][0]
|
||||
indents = [e["indent"] for e in rich["elements"] if e["type"] == "rich_text_list"]
|
||||
# true nesting: indent levels must strictly increase across the run
|
||||
assert indents == sorted(indents)
|
||||
assert max(indents) >= 2
|
||||
assert min(indents) == 0
|
||||
|
||||
def test_ordered_and_bullet_styles_distinguished(self):
|
||||
md = "1. first\n2. second\n\n- bullet"
|
||||
blocks = render_blocks(md)
|
||||
styles = []
|
||||
for b in blocks:
|
||||
if b["type"] == "rich_text":
|
||||
for e in b["elements"]:
|
||||
if e["type"] == "rich_text_list":
|
||||
styles.append(e["style"])
|
||||
assert "ordered" in styles
|
||||
assert "bullet" in styles
|
||||
|
||||
|
||||
class TestInlineFormatting:
|
||||
def test_link_becomes_link_element(self):
|
||||
blocks = render_blocks("see [docs](https://example.com/x) now")
|
||||
# link lives in a section (paragraph) — but a bulleted link is a
|
||||
# rich_text link element; assert the URL survives somewhere.
|
||||
blob = str(blocks)
|
||||
assert "https://example.com/x" in blob
|
||||
|
||||
def test_bulleted_bold_is_styled(self):
|
||||
blocks = render_blocks("- this is **bold** text")
|
||||
rich = [b for b in blocks if b["type"] == "rich_text"][0]
|
||||
section = rich["elements"][0]["elements"][0]
|
||||
styled = [
|
||||
el for el in section["elements"]
|
||||
if el.get("style", {}).get("bold")
|
||||
]
|
||||
assert styled, "expected a bold-styled text element in the list item"
|
||||
|
||||
|
||||
class TestTables:
|
||||
def test_pipe_table_renders_preformatted(self):
|
||||
md = (
|
||||
"| Name | Status |\n"
|
||||
"|------|--------|\n"
|
||||
"| a | ok |\n"
|
||||
"| b | fail |"
|
||||
)
|
||||
blocks = render_blocks(md)
|
||||
assert len(blocks) == 1
|
||||
assert blocks[0]["type"] == "rich_text"
|
||||
pre = blocks[0]["elements"][0]
|
||||
assert pre["type"] == "rich_text_preformatted"
|
||||
text = pre["elements"][0]["text"]
|
||||
# header cell values preserved and column aligned
|
||||
assert "Name" in text and "Status" in text
|
||||
assert "fail" in text
|
||||
|
||||
|
||||
class TestLimits:
|
||||
def test_oversized_section_is_split_under_limit(self):
|
||||
big = "word " * 2000 # ~10000 chars, single paragraph
|
||||
blocks = render_blocks(big)
|
||||
assert blocks is not None
|
||||
for b in blocks:
|
||||
if b["type"] == "section":
|
||||
assert len(b["text"]["text"]) <= MAX_SECTION_TEXT
|
||||
|
||||
def test_too_many_blocks_returns_none(self):
|
||||
# 60 dividers => 60 blocks > MAX_BLOCKS => decline (caller uses text)
|
||||
md = "\n\n".join(["---"] * (MAX_BLOCKS + 10))
|
||||
assert render_blocks(md) is None
|
||||
|
||||
def test_never_raises_on_garbage(self):
|
||||
for junk in ["```unterminated\ncode", "| broken | table", "> ", "#" * 10]:
|
||||
# must not raise; either blocks or None
|
||||
render_blocks(junk)
|
||||
102
tests/gateway/test_slack_block_kit_adapter.py
Normal file
102
tests/gateway/test_slack_block_kit_adapter.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue