fix(bridge): render agent markdown natively on Slack and Telegram - #51
Merged
Conversation
Slack received agent prose through chat.postMessage's top-level text field, which Slack parses as mrkdwn -- a different dialect from the GFM the LLM emits. Headings, **bold**, - bullets, [label](url) links and pipe tables rendered as literal characters; only backtick code survived. Mattermost was unaffected because it parses Post.Message as real GFM. Slack prose is now emitted as Block Kit markdown blocks (slack.NewMarkdownBlock, already available in the vendored slack-go v0.25.0 -- no new dependency), which Slack renders as standard Markdown server-side. Chunked with a shared fence-aware splitter to Slack's documented 12,000-character cumulative budget across all markdown blocks per payload, capped at 50 blocks, with a visible truncation marker. The top-level text field is retained as the notification/accessibility fallback. Legacy mrkdwn <url|label> link syntax that agents sometimes emit is normalized to [label](url) first, leaving <@u123> mentions and non-URL angle brackets alone. Telegram had the mirror-image bug: Send set no ParseMode at all, so every markdown character rendered verbatim. Prose is now converted to Telegram HTML and sent with ParseModeHTML; source markdown is chunked at 3,500 characters so both the raw chunk and its entity-expanded conversion stay inside Telegram's 4,096 cap. Neither platform can lose a message to a formatting failure: Slack retries once as plain text on a block-related API error and latches only on unambiguous capability rejections; Telegram retries a chunk with no ParseMode on an entity-parse error, without latching. New stdlib-only shared package internal/bridge/markdown. The Slack/Telegram RichRenderer tool-card paths, Mattermost, the queued- ack helpers and the dispatcher are unchanged. No config or schema surface.
…ing, href escaping
Three defects found reviewing the native-markdown change, plus the test and
doc gaps that let them through.
markdown.Split could loop forever. The synthetic fence-reopen delimiter is
pushed BACK onto the unconsumed remainder, so the loop only terminates when a
pass consumes strictly more than it pushes back. A fence whose info string
rivals the chunk limit broke that and grew `remaining` without bound; a limit
smaller than the closing delimiter shrank the cut to zero and made no progress
at all. Reproduced at Telegram's real 3,500 limit, so a single agent message
could hang the send goroutine and leak memory. Fixed with an unconditional
forward-progress guard plus a 64-rune clamp on the info string carried into
the synthetic delimiter (the original opening line is still emitted verbatim).
Content is always preserved; only the fence's highlighting degrades.
ToTelegramHTML emitted code/pre nested inside other entities. Telegram's
nesting rules say bold/italic/underline/strikethrough/spoiler "can contain and
can be part of any other entities, except pre and code", and "all other
entities can't contain each other" — the only legal nesting is
<pre><code class="language-x">. A heading containing a code span
("## Fix `foo.go`", ubiquitous in agent prose) produced <b>...<code>...</code></b>,
which Telegram rejects, dropping the whole chunk to raw markdown. Code spans
landing inside a heading, emphasis, link label, blockquote or table cell are
now flattened to escaped plain text.
Link URLs were not attribute-escaped. escapeHTML covers only &<>, so a URL
containing a double quote closed the href early and injected an unsupported
attribute. Added escapeAttr (" -> ", a documented named entity).
Also: the 3,500-limit comment claimed HTML conversion can only shrink the
text. Telegram measures its cap after entities parsing, so tags and escaping
do not count — but a `---` rule expands to 10 em-dashes, and rule-dominated
text still breaches 4,096. Corrected the comment and added "message is too
long" to isParseError so that case degrades to plain text instead of dropping
the message.
The Slack latch is race-free (atomic.Bool) but no test called Send
concurrently, so -race proved nothing about it. Added a concurrent-Send test;
one Adapter is shared by every session bound to an identity.
Regression coverage for all three defects. docs/bridge.md, the delta spec and
design.md corrected — two spec claims were factually false (post-conversion
length was asserted as bounded, and the termination invariant was never
stated).
Tests: go test -race ./internal/bridge/... green (522); make test green;
gofmt/vet clean; openspec validate --all --strict 34 passed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Slack received agent prose through
chat.postMessage's top-leveltextfield, which Slackparses as legacy mrkdwn — a different dialect from the GFM the LLM emits. Observed
symptoms:
## Headingrendered as literal## Heading, not a heading**bold**rendered as literal asterisks, not bold- bulletlist markers rendered as literal dashes, not a list[label](url)rendered as literal brackets/parens, not a hyperlink|-delimited textMattermost was unaffected because it parses
Post.Messageas real GFM.Telegram had the mirror-image bug:
Sendset noParseModeat all, so every markdowncharacter (headings, bold markers, list dashes, link syntax, code fences) rendered verbatim
as literal text with no interpretation whatsoever.
Solution
markdownblocks(
slack.NewMarkdownBlock, already available in the vendored slack-go v0.25.0 — no newdependency), which Slack renders as standard Markdown server-side. Text is chunked with a
shared fence-aware splitter to Slack's documented 12,000-character cumulative budget across
all
markdownblocks per payload, capped at 50 blocks, with a visible truncation markerwhen content is dropped. The top-level
textfield is retained as thenotification/accessibility fallback. Legacy mrkdwn
<url|label>link syntax that agentssometimes emit is normalized to
[label](url)first, leaving<@U123>mentions andnon-URL angle-bracket text alone.
ParseModeHTML.Source markdown is chunked at 3,500 characters (not the raw 4,096
MaxTextLength) so boththe raw chunk and its entity-expanded, post-conversion form stay inside Telegram's
4,096-character
sendMessagecap. Each chunk is a self-contained, independently validmarkdown fragment — a code fence open at a chunk boundary is closed on the outgoing chunk
and reopened with the same info string on the next.
internal/bridge/markdown(stdlib-only) holds the fence-aware chunker,Slack link normalization, and the GFM→Telegram-HTML converter, used by both adapters.
MsgOptionTextonany block-related API error, and sets a sticky per-adapter latch only for the unambiguous
subset of those errors (excluding
invalid_arguments, which Slack also returns forunrelated reasons like a bad channel or timestamp — latching on it would permanently
downgrade formatting based on an ambiguous signal). Telegram retries a chunk with no
ParseModeon an entity-parse error, with no sticky latch (each message's chunks attemptHTML conversion fresh). Neither adapter can drop a message solely because richer rendering
failed.
Dialect reference
## Heading— mrkdwn: literal text → Slackmarkdownblock: real heading / Telegram:<b>Heading</b>**bold**— mrkdwn: literal asterisks → Slackmarkdownblock: bold / Telegram:<b>bold</b>- bullet— mrkdwn: literal dash → Slackmarkdownblock: real bulleted list / Telegram:•-prefixed line[label](url)— mrkdwn: literal brackets → Slackmarkdownblock: clickable link / Telegram:<a href="url">label</a>`code`— mrkdwn: already renders as inline code (unchanged) → Telegram:<code>code</code>```lang\n...\n```fenced block — mrkdwn: literal backticks → Slackmarkdownblock: syntax-highlighted code block / Telegram:<pre><code class="language-lang">...</code></pre>|text → Slackmarkdownblock: real table / Telegram:<pre>-wrapped, column-aligned text---divider — mrkdwn: literal dashes → Slackmarkdownblock: real divider / Telegram: literal dash rule<https://x|label>legacy mrkdwn link syntax some agents emit — normalized to[label](https://x)before Slack block construction;<@U123>mentions and non-URL angle brackets are left untouchedOut of scope
RichRenderertool-call cards (lists, tables, status blocks) — unchanged.opencode.jsonconfig or schema surface changeTesting
gofmt -l internal/bridge— cleango vet ./internal/bridge/...— cleango test -race ./internal/bridge/...— all 8 packages pass (internal/bridge,internal/bridge/external,internal/bridge/markdown,internal/bridge/mattermost,internal/bridge/service,internal/bridge/slack,internal/bridge/store,internal/bridge/telegram)go build ./...— cleanmake test— green (full suite, including the pre-commit hook run on this branch)./scripts/check_hidden_chars.sh— flags only pre-existing files unrelated to this changeNew test coverage added: Slack link normalization (
<url|label>→[label](url), mentionsleft alone), fence-aware chunking invariants (rune-boundary safety, no content loss across
chunk boundaries, truncation marker never landing inside an open code fence), GFM→Telegram-HTML
mapping including adversarial cases (unbalanced
**/_markers, code spans containingmarkdown-look-alike characters), and both adapters' fallback/latch behavior (Slack: broad
retry vs. narrow latch predicate, including the
invalid_argumentsnon-latching case;Telegram: per-chunk entity-parse retry with no persistent state).
OpenSpec
Change folder:
openspec/changes/bridge-slack-native-markdown/openspec validate bridge-slack-native-markdown --strictpasses: