From 77d4f55012eb4c5e866ac16fefd96643386bae6c Mon Sep 17 00:00:00 2001 From: Parker Date: Fri, 18 Sep 2026 01:09:01 +0000 Subject: [PATCH 1/3] fix(slack): render agent replies with native Markdown Co-authored-by: open-swe[bot] --- .../prompts/tools/slack_thread_reply.md | 11 ++--- agent/slack/tools/thread_reply.py | 43 +++++++++++++------ tests/slack/test_slack_ask.py | 26 +++++++++++ tests/slack/test_slack_thread_reply_tool.py | 40 ++++++++++++++--- 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/agent/resources/prompts/tools/slack_thread_reply.md b/agent/resources/prompts/tools/slack_thread_reply.md index 8f790e1e4d..56bb27d5f6 100644 --- a/agent/resources/prompts/tools/slack_thread_reply.md +++ b/agent/resources/prompts/tools/slack_thread_reply.md @@ -9,11 +9,12 @@ 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~, , -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. +Keep `message` within 12,000 characters; share longer content 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. diff --git a/agent/slack/tools/thread_reply.py b/agent/slack/tools/thread_reply.py index e169318b82..87c5d0488b 100644 --- a/agent/slack/tools/thread_reply.py +++ b/agent/slack/tools/thread_reply.py @@ -9,7 +9,6 @@ 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, @@ -28,6 +27,8 @@ logger = logging.getLogger(__name__) +_NATIVE_MARKDOWN_MAX_CHARS = 12000 + async def slack_thread_reply( message: str, @@ -81,8 +82,15 @@ 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: + return { + "success": False, + "error": "Message exceeds Slack's 12000-character native Markdown block limit", + "message_chars": len(message), + "retry": True, + "hint": "Retry with a shorter message of at most 12000 characters.", + } usage = summarize_run_usage(state) message_ts, slack_error = await _post_and_store_mapping( channel_id, @@ -146,11 +154,19 @@ 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"} + if blocks is None and len(message) > _NATIVE_MARKDOWN_MAX_CHARS: + return { + "success": False, + "error": "Message exceeds Slack's 12000-character native Markdown block limit", + "message_chars": len(message), + "retry": True, + "hint": "Retry with a shorter message of at most 12000 characters.", + } 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), usage=summarize_run_usage(state), agent_thread_id=cfg.thread_id, ) @@ -209,14 +225,12 @@ 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 _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": [ @@ -235,8 +249,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]]: diff --git a/tests/slack/test_slack_ask.py b/tests/slack/test_slack_ask.py index a4c4bd2815..fd55a6c0ae 100644 --- a/tests/slack/test_slack_ask.py +++ b/tests/slack/test_slack_ask.py @@ -257,9 +257,35 @@ 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_rejects_oversized_default_before_posting( + 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("x" * 12001) + + assert result["success"] is False + assert result["retry"] is True + assert "12000" in result["error"] + post.assert_not_awaited() + + @pytest.mark.asyncio async def test_ask_mode_refuses_options(monkeypatch: pytest.MonkeyPatch) -> None: post = AsyncMock(return_value=True) diff --git a/tests/slack/test_slack_thread_reply_tool.py b/tests/slack/test_slack_thread_reply_tool.py index 74f11b6c72..6013d7607c 100644 --- a/tests/slack/test_slack_thread_reply_tool.py +++ b/tests/slack/test_slack_thread_reply_tool.py @@ -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] = {} @@ -317,12 +317,41 @@ 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_rejects_oversized_default_before_posting( + 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) + + assert result["success"] is False + assert result["retry"] is True + assert "12000" 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: @@ -350,6 +379,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"] From 17698401ee2ec549951ee70b61e66c4eb5938709 Mon Sep 17 00:00:00 2001 From: Parker Date: Fri, 18 Sep 2026 01:25:53 +0000 Subject: [PATCH 2/3] fix(slack): retain long replies with token-based mrkdwn fallback Co-authored-by: open-swe[bot] --- .../prompts/tools/slack_thread_reply.md | 4 +- agent/slack/markdown.py | 89 +++++++++++++++++++ agent/slack/tools/thread_reply.py | 37 ++++---- pyproject.toml | 1 + tests/slack/test_slack_ask.py | 11 ++- tests/slack/test_slack_markdown.py | 49 ++++++++++ tests/slack/test_slack_thread_reply_tool.py | 19 +++- uv.lock | 2 + 8 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 agent/slack/markdown.py create mode 100644 tests/slack/test_slack_markdown.py diff --git a/agent/resources/prompts/tools/slack_thread_reply.md b/agent/resources/prompts/tools/slack_thread_reply.md index 56bb27d5f6..c386c1d611 100644 --- a/agent/resources/prompts/tools/slack_thread_reply.md +++ b/agent/resources/prompts/tools/slack_thread_reply.md @@ -13,7 +13,9 @@ 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. -Keep `message` within 12,000 characters; share longer content as an artifact. +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 diff --git a/agent/slack/markdown.py b/agent/slack/markdown.py new file mode 100644 index 0000000000..edf0eca0ce --- /dev/null +++ b/agent/slack/markdown.py @@ -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 + + 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) diff --git a/agent/slack/tools/thread_reply.py b/agent/slack/tools/thread_reply.py index 87c5d0488b..bb97246c9c 100644 --- a/agent/slack/tools/thread_reply.py +++ b/agent/slack/tools/thread_reply.py @@ -15,6 +15,7 @@ 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, @@ -84,13 +85,10 @@ async def slack_thread_reply( async with slack_thread_mutation_lock(client, channel_id, thread_ts): 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: - return { - "success": False, - "error": "Message exceeds Slack's 12000-character native Markdown block limit", - "message_chars": len(message), - "retry": True, - "hint": "Retry with a shorter message of at most 12000 characters.", - } + 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, @@ -154,19 +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"} - if blocks is None and len(message) > _NATIVE_MARKDOWN_MAX_CHARS: - return { - "success": False, - "error": "Message exceeds Slack's 12000-character native Markdown block limit", - "message_chars": len(message), - "retry": True, - "hint": "Retry with a shorter message of at most 12000 characters.", - } + 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, message, - blocks=blocks if blocks is not None else _build_option_blocks(message, None), + 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, ) @@ -225,6 +220,16 @@ def _triggering_user_id(cfg: RunConfig) -> str | None: return (cfg.slack_thread.triggering_user_id or None) if cfg.slack_thread else None +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}] diff --git a/pyproject.toml b/pyproject.toml index 1e6c27c054..f34fb6ed5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/slack/test_slack_ask.py b/tests/slack/test_slack_ask.py index fd55a6c0ae..c95a36be62 100644 --- a/tests/slack/test_slack_ask.py +++ b/tests/slack/test_slack_ask.py @@ -262,7 +262,7 @@ async def test_ask_mode_reply_is_ephemeral(monkeypatch: pytest.MonkeyPatch) -> N @pytest.mark.asyncio -async def test_ask_mode_rejects_oversized_default_before_posting( +async def test_ask_mode_falls_back_to_mrkdwn_over_native_limit( monkeypatch: pytest.MonkeyPatch, ) -> None: post = AsyncMock() @@ -278,12 +278,11 @@ async def test_ask_mode_rejects_oversized_default_before_posting( }, ) - result = await slack_thread_reply.slack_thread_reply("x" * 12001) + result = await slack_thread_reply.slack_thread_reply("# Heading\n\n" + "x" * 12000) - assert result["success"] is False - assert result["retry"] is True - assert "12000" in result["error"] - post.assert_not_awaited() + assert result == {"success": True} + assert post.await_args.args[2].startswith("*Heading*\n") + assert post.await_args.kwargs["blocks"] is None @pytest.mark.asyncio diff --git a/tests/slack/test_slack_markdown.py b/tests/slack/test_slack_markdown.py new file mode 100644 index 0000000000..6a5abf7fa3 --- /dev/null +++ b/tests/slack/test_slack_markdown.py @@ -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~ +• +> quoted +```\nif a < b and c > d: + print("*literal*") +``` +Use `x < y & z`.""" + ) + + +def test_markdown_to_mrkdwn_preserves_html() -> None: + assert markdown_to_mrkdwn("
important content
") == ( + "<div>important content</div>" + ) + + +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```" diff --git a/tests/slack/test_slack_thread_reply_tool.py b/tests/slack/test_slack_thread_reply_tool.py index 6013d7607c..e444cb607f 100644 --- a/tests/slack/test_slack_thread_reply_tool.py +++ b/tests/slack/test_slack_thread_reply_tool.py @@ -325,18 +325,31 @@ async def fake_post_and_store_mapping( assert captured["blocks"] == [{"type": "markdown", "text": message}] -async def test_slack_thread_reply_rejects_oversized_default_before_posting( +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) + result = await slack_reply_tool.slack_thread_reply("x" * 12001, options=["Yes"]) assert result["success"] is False assert result["retry"] is True - assert "12000" in result["error"] + assert "options" in result["error"] post.assert_not_awaited() diff --git a/uv.lock b/uv.lock index 8a5a17823e..fa83a049cf 100644 --- a/uv.lock +++ b/uv.lock @@ -1982,6 +1982,7 @@ dependencies = [ { name = "langgraph-cli", extra = ["inmem"] }, { name = "langgraph-sdk" }, { name = "langsmith" }, + { name = "markdown-it-py" }, { name = "markdownify" }, { name = "openai" }, { name = "pyjwt" }, @@ -2026,6 +2027,7 @@ requires-dist = [ { name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.31" }, { name = "langgraph-sdk", specifier = ">=0.4.3" }, { name = "langsmith", specifier = ">=0.12.0" }, + { name = "markdown-it-py", specifier = "==4.2.0" }, { name = "markdownify", specifier = ">=1.2.3" }, { name = "openai", specifier = ">=3.10.0,<4.0.0" }, { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.21.0" }, From 04e1eb8e4ee0f48301220fdd62cedcb1c966cd56 Mon Sep 17 00:00:00 2001 From: Parker Date: Fri, 18 Sep 2026 01:33:15 +0000 Subject: [PATCH 3/3] fix(slack): normalize token attributes before escaping Co-authored-by: open-swe[bot] --- agent/slack/markdown.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/slack/markdown.py b/agent/slack/markdown.py index edf0eca0ce..01ea2543a9 100644 --- a/agent/slack/markdown.py +++ b/agent/slack/markdown.py @@ -73,7 +73,7 @@ def _render_inline(tokens: list[Token]) -> str: 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)) + links.append(escape(str(token.attrGet("href") or ""), quote=False)) output.append("<" + links[-1] + "|") elif token.type == "link_close": output.append(">") @@ -82,7 +82,7 @@ def _render_inline(tokens: list[Token]) -> str: output.append("\n") elif token.type == "image": output.append( - f"<{escape(token.attrGet('src') or '', quote=False)}|{escape(token.content, quote=False)}>" + f"<{escape(str(token.attrGet('src') or ''), quote=False)}|{escape(token.content, quote=False)}>" ) elif token.type == "html_inline": output.append(escape(token.content, quote=False))