Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions agent/resources/prompts/tools/slack_thread_reply.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ question. Omit greetings, preambles, headings, recaps, implementation
details, and redundant context; use bullets only when multiple items are
essential. End the run by posting a concise final outcome here.

Format messages using Slack's mrkdwn format, NOT standard Markdown.
Key differences: *bold*, _italic_, ~strikethrough~, <url|link text>,
bullet lists with "• ", ```code blocks```, > blockquotes. Code fences must be
bare triple backticks; do not add a language identifier such as ```sql.
Do NOT use **bold**, [link](url), or other standard Markdown syntax.
Format `message` using standard Markdown: **bold**, _italic_, ~~strikethrough~~,
[link text](url), and Markdown lists. Replies use Slack's native Markdown blocks.
Use fenced code blocks with a language identifier such as ```python or ```sql
for syntax highlighting, preserving the code's original whitespace.
Messages over 12,000 characters fall back to Slack's legacy mrkdwn and may lose
less-common Markdown formatting. A message with `options` must stay within 12,000
characters so its buttons are not hidden; shorten it or share the body as an artifact.
If supplying explicit `blocks`, use the formatting required by each block type.

To ask a user to choose from predefined options, pass `options`. Slack will
render interactive buttons and the web UI will render the same choices.
Expand Down
89 changes: 89 additions & 0 deletions agent/slack/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from html import escape

from markdown_it import MarkdownIt
from markdown_it.token import Token

_parser = MarkdownIt("commonmark", {"html": False}).enable("strikethrough")


def markdown_to_mrkdwn(markdown: str) -> str:
"""Convert standard Markdown to Slack mrkdwn."""
output: list[str] = []
list_stack: list[tuple[bool, int]] = []
quote_depth = 0
item_prefix = ""
in_heading = False
Comment on lines +9 to +15

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fyi I am not reading or testing this carefully, since this is just rough fallback handling for a pretty extreme edge case. if we actually encounter rendering issues on this path, we can revisit


for token in _parser.parse(markdown):
if token.type == "inline":
prefix = "" if in_heading else "> " * quote_depth + item_prefix
output.append(prefix + _render_inline(token.children or []))
item_prefix = ""
elif token.type == "heading_open":
output.append("> " * quote_depth + "*")
in_heading = True
elif token.type == "heading_close":
output[-1] += "*\n"
in_heading = False
elif token.type == "paragraph_close":
output.append("\n")
elif token.type == "bullet_list_open":
list_stack.append((False, 1))
elif token.type == "ordered_list_open":
list_stack.append((True, int(token.attrGet("start") or "1")))
elif token.type in {"bullet_list_close", "ordered_list_close"}:
list_stack.pop()
elif token.type == "list_item_open":
ordered, number = list_stack[-1]
item_prefix = f"{number}. " if ordered else "• "
if ordered:
list_stack[-1] = (ordered, number + 1)
elif token.type == "blockquote_open":
quote_depth += 1
elif token.type == "blockquote_close":
quote_depth -= 1
elif token.type in {"fence", "code_block"}:
content = escape(token.content, quote=False)
output.append(
"> " * quote_depth
+ f"```\n{content}{'' if content.endswith(chr(10)) else chr(10)}```\n"
)
elif token.type == "hr":
output.append("—\n")

return "".join(output).rstrip()


def _render_inline(tokens: list[Token]) -> str:
output: list[str] = []
links: list[str] = []
markers = {
"strong_open": "*",
"strong_close": "*",
"em_open": "_",
"em_close": "_",
"s_open": "~",
"s_close": "~",
}
for token in tokens:
if token.type == "text":
output.append(escape(token.content, quote=False))
elif token.type in markers:
output.append(markers[token.type])
elif token.type == "code_inline":
output.append(f"`{escape(token.content, quote=False)}`")
elif token.type == "link_open":
links.append(escape(token.attrGet("href") or "", quote=False))
output.append("<" + links[-1] + "|")
elif token.type == "link_close":
output.append(">")
links.pop()
elif token.type in {"softbreak", "hardbreak"}:
output.append("\n")
elif token.type == "image":
output.append(
f"<{escape(token.attrGet('src') or '', quote=False)}|{escape(token.content, quote=False)}>"
)
elif token.type == "html_inline":
output.append(escape(token.content, quote=False))
return "".join(output)
48 changes: 34 additions & 14 deletions agent/slack/tools/thread_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@

from agent.run_config import RunConfig
from agent.slack.client import (
convert_mentions_to_slack_format,
get_active_slack_thread,
post_slack_ephemeral_reply,
post_slack_thread_reply_with_ts,
slack_thread_mutation_lock,
store_slack_message_run_mapping,
)
from agent.slack.markdown import markdown_to_mrkdwn
from agent.slack.orphan import (
dashboard_handoff_message,
move_thread_to_dashboard,
Expand All @@ -28,6 +28,8 @@

logger = logging.getLogger(__name__)

_NATIVE_MARKDOWN_MAX_CHARS = 12000


async def slack_thread_reply(
message: str,
Expand Down Expand Up @@ -81,8 +83,12 @@ async def slack_thread_reply(
)

async with slack_thread_mutation_lock(client, channel_id, thread_ts):
message = convert_mentions_to_slack_format(message)
slack_blocks = blocks or _build_option_blocks(message, options)
slack_blocks = blocks if blocks is not None else _build_option_blocks(message, options)
if blocks is None and len(message) > _NATIVE_MARKDOWN_MAX_CHARS:
if options:
return _oversized_options_error(message)
message = markdown_to_mrkdwn(message)
slack_blocks = None
usage = summarize_run_usage(state)
message_ts, slack_error = await _post_and_store_mapping(
channel_id,
Expand Down Expand Up @@ -146,11 +152,16 @@ async def _ephemeral_reply(
return {"success": False, "error": "Missing the Slack channel or user to answer"}
if not message.strip():
return {"success": False, "error": "Message cannot be empty"}
native_markdown = blocks is None and len(message) <= _NATIVE_MARKDOWN_MAX_CHARS
if blocks is None and not native_markdown:
message = markdown_to_mrkdwn(message)
posted = await post_slack_ephemeral_reply(
channel_id,
user_id,
convert_mentions_to_slack_format(message),
blocks=blocks,
message,
blocks=blocks
if blocks is not None
else (_build_option_blocks(message, None) if native_markdown else None),
usage=summarize_run_usage(state),
agent_thread_id=cfg.thread_id,
)
Expand Down Expand Up @@ -209,14 +220,22 @@ def _triggering_user_id(cfg: RunConfig) -> str | None:
return (cfg.slack_thread.triggering_user_id or None) if cfg.slack_thread else None


def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[str, Any]] | None:
if not options:
return None
clean_options = [option.strip() for option in options if option.strip()]
def _oversized_options_error(message: str) -> dict[str, str | int | bool]:
return {
"success": False,
"error": "Message with options exceeds Slack's 12000-character native Markdown limit",
"message_chars": len(message),
"retry": True,
"hint": "Retry with the options and a message of at most 12000 characters.",
}


def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[str, Any]]:
clean_options = [option.strip() for option in options or [] if option.strip()]
blocks: list[dict[str, Any]] = [{"type": "markdown", "text": message}]
if not clean_options:
return None
return [
{"type": "section", "text": {"type": "mrkdwn", "text": message}},
return blocks
blocks.append(
{
"type": "actions",
"elements": [
Expand All @@ -235,8 +254,9 @@ def _build_option_blocks(message: str, options: list[str] | None) -> list[dict[s
}
for index, option in enumerate(clean_options[:5])
],
},
]
}
)
return blocks


def build_workflow_approval_blocks(message: str, fingerprint: str) -> list[dict[str, Any]]:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"langchain>=1.3.16",
"langgraph>=1.2.11",
"markdownify>=1.2.3",
"markdown-it-py==4.2.0",
"langchain-anthropic>=1.6.1",
"langgraph-cli[inmem]>=0.4.31",
"langsmith>=0.12.0",
Expand Down
25 changes: 25 additions & 0 deletions tests/slack/test_slack_ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,34 @@ async def test_ask_mode_reply_is_ephemeral(monkeypatch: pytest.MonkeyPatch) -> N

assert result == {"success": True}
assert post.await_args.args == ("C1", "U1", "the answer")
assert post.await_args.kwargs["blocks"] == [{"type": "markdown", "text": "the answer"}]
assert post.await_args.kwargs["agent_thread_id"] == "thread-1"


@pytest.mark.asyncio
async def test_ask_mode_falls_back_to_mrkdwn_over_native_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
post = AsyncMock()
monkeypatch.setattr(slack_thread_reply, "post_slack_ephemeral_reply", post)
monkeypatch.setattr(
slack_thread_reply,
"get_config",
lambda: {
"configurable": {
"slack_ask": True,
"slack_thread": {"channel_id": "C1", "triggering_user_id": "U1"},
}
},
)

result = await slack_thread_reply.slack_thread_reply("# Heading\n\n" + "x" * 12000)

assert result == {"success": True}
assert post.await_args.args[2].startswith("*Heading*\n")
assert post.await_args.kwargs["blocks"] is None


@pytest.mark.asyncio
async def test_ask_mode_refuses_options(monkeypatch: pytest.MonkeyPatch) -> None:
post = AsyncMock(return_value=True)
Expand Down
49 changes: 49 additions & 0 deletions tests/slack/test_slack_markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from agent.slack.markdown import markdown_to_mrkdwn


def test_markdown_to_mrkdwn_common_constructs() -> None:
markdown = """# Heading

- **bold** and *italic* and ~~gone~~
- [link](https://example.com?a=1&b=2)

> quoted

```python
if a < b and c > d:
print("*literal*")
```

Use `x < y & z`.
"""

assert (
markdown_to_mrkdwn(markdown)
== """*Heading*
• *bold* and _italic_ and ~gone~
• <https://example.com?a=1&amp;b=2|link>
> quoted
```\nif a &lt; b and c &gt; d:
print("*literal*")
```
Use `x &lt; y &amp; z`."""
)


def test_markdown_to_mrkdwn_preserves_html() -> None:
assert markdown_to_mrkdwn("<div>important content</div>") == (
"&lt;div&gt;important content&lt;/div&gt;"
)


def test_markdown_to_mrkdwn_quotes_heading_once() -> None:
assert markdown_to_mrkdwn("> # Heading") == "> *Heading*"


def test_markdown_to_mrkdwn_numbers_items_not_paragraphs() -> None:
markdown = "1. first paragraph\n\n same item\n2. second item"
assert markdown_to_mrkdwn(markdown) == "1. first paragraph\nsame item\n2. second item"


def test_markdown_to_mrkdwn_separates_unterminated_fence_content() -> None:
assert markdown_to_mrkdwn("```\ncode") == "```\ncode\n```"
53 changes: 48 additions & 5 deletions tests/slack/test_slack_thread_reply_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ async def test_slack_thread_reply_restores_thinking_status_after_interim_reply(
restore_status.assert_awaited_once_with("C1", "1.0")


async def test_slack_thread_reply_posts_plain_text_without_options(
async def test_slack_thread_reply_posts_native_markdown_without_options(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
Expand All @@ -317,12 +317,54 @@ async def fake_post_and_store_mapping(
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", fake_post_and_store_mapping)

result = await slack_reply_tool.slack_thread_reply(
"Plan ready: review it and reply to approve or request changes."
)
message = '```python\nfor value in range(3):\n print("@Name(U123)", value)\n```'
result = await slack_reply_tool.slack_thread_reply(message)

assert result == {"success": True}
assert captured["blocks"] is None
assert captured["message"] == message
assert captured["blocks"] == [{"type": "markdown", "text": message}]


async def test_slack_thread_reply_falls_back_to_mrkdwn_over_native_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
post = AsyncMock(return_value=("2.0", None))
message = "# Heading\n\n" + "x" * 12000
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", post)

assert await slack_reply_tool.slack_thread_reply(message) == {"success": True}
assert post.await_args.args[2].startswith("*Heading*\n")
assert post.await_args.kwargs["blocks"] is None


async def test_slack_thread_reply_rejects_oversized_message_with_options(
monkeypatch: pytest.MonkeyPatch,
) -> None:
post = AsyncMock()
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", post)

result = await slack_reply_tool.slack_thread_reply("x" * 12001, options=["Yes"])

assert result["success"] is False
assert result["retry"] is True
assert "options" in result["error"]
post.assert_not_awaited()


async def test_slack_thread_reply_preserves_explicit_blocks_over_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
post = AsyncMock(return_value=("2.0", None))
blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "kept"}}]
monkeypatch.setattr(slack_reply_tool, "get_config", _config)
monkeypatch.setattr(slack_reply_tool, "_post_and_store_mapping", post)

assert await slack_reply_tool.slack_thread_reply("x" * 12001, blocks=blocks) == {
"success": True
}
assert post.await_args.kwargs["blocks"] is blocks


async def test_slack_thread_reply_builds_option_blocks(monkeypatch: pytest.MonkeyPatch) -> None:
Expand Down Expand Up @@ -350,6 +392,7 @@ async def fake_post_and_store_mapping(
assert captured["channel_id"] == "C1"
assert captured["thread_ts"] == "1.0"
assert captured["message"] == "Pick one"
assert captured["blocks"][0] == {"type": "markdown", "text": "Pick one"}
actions = captured["blocks"][1]
assert actions["type"] == "actions"
assert [button["text"]["text"] for button in actions["elements"]] == ["A", "B"]
Expand Down
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading