Skip to content

fix(bridge): render agent markdown natively on Slack and Telegram - #51

Merged
obukhovaa merged 2 commits into
mainfrom
fix/bridge-slack-native-markdown
Sep 7, 2026
Merged

fix(bridge): render agent markdown natively on Slack and Telegram#51
obukhovaa merged 2 commits into
mainfrom
fix/bridge-slack-native-markdown

Conversation

@obukhovaa

Copy link
Copy Markdown
Owner

Problem

Slack received agent prose through chat.postMessage's top-level text field, which Slack
parses as legacy mrkdwn — a different dialect from the GFM the LLM emits. Observed
symptoms:

  • ## Heading rendered as literal ## Heading, not a heading
  • **bold** rendered as literal asterisks, not bold
  • - bullet list markers rendered as literal dashes, not a list
  • [label](url) rendered as literal brackets/parens, not a hyperlink
  • pipe tables rendered as literal |-delimited text
  • only single-backtick inline code survived, since mrkdwn does understand that construct

Mattermost was unaffected because it parses Post.Message as real GFM.

Telegram had the mirror-image bug: Send set no ParseMode at all, so every markdown
character (headings, bold markers, list dashes, link syntax, code fences) rendered verbatim
as literal text with no interpretation whatsoever.

Solution

  • Slack: outbound 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. Text is 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
    when content is dropped. 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-bracket text alone.
  • Telegram: outbound prose is converted to Telegram HTML and sent with ParseModeHTML.
    Source markdown is chunked at 3,500 characters (not the raw 4,096 MaxTextLength) so both
    the raw chunk and its entity-expanded, post-conversion form stay inside Telegram's
    4,096-character sendMessage cap. Each chunk is a self-contained, independently valid
    markdown 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.
  • Shared package: internal/bridge/markdown (stdlib-only) holds the fence-aware chunker,
    Slack link normalization, and the GFM→Telegram-HTML converter, used by both adapters.
  • Degradation, never data loss: Slack retries once as plain text via MsgOptionText on
    any 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 for
    unrelated 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
    ParseMode on an entity-parse error, with no sticky latch (each message's chunks attempt
    HTML conversion fresh). Neither adapter can drop a message solely because richer rendering
    failed.

Dialect reference

  • ## Heading — mrkdwn: literal text → Slack markdown block: real heading / Telegram: <b>Heading</b>
  • **bold** — mrkdwn: literal asterisks → Slack markdown block: bold / Telegram: <b>bold</b>
  • - bullet — mrkdwn: literal dash → Slack markdown block: real bulleted list / Telegram: -prefixed line
  • [label](url) — mrkdwn: literal brackets → Slack markdown block: 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 → Slack markdown block: syntax-highlighted code block / Telegram: <pre><code class="language-lang">...</code></pre>
  • pipe tables — mrkdwn: literal | text → Slack markdown block: real table / Telegram: <pre>-wrapped, column-aligned text
  • --- divider — mrkdwn: literal dashes → Slack markdown block: 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 untouched

Out of scope

  • RichRenderer tool-call cards (lists, tables, status blocks) — unchanged
  • Mattermost adapter — unaffected, already parses real GFM
  • Queued-ack helpers and the dispatcher — unchanged
  • No file-upload-on-truncation fallback (a truncation marker is used instead)
  • No .opencode.json config or schema surface change

Testing

  • gofmt -l internal/bridge — clean
  • go vet ./internal/bridge/... — clean
  • go 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 ./... — clean
  • make 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 change

New test coverage added: Slack link normalization (<url|label>[label](url), mentions
left 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 containing
markdown-look-alike characters), and both adapters' fallback/latch behavior (Slack: broad
retry vs. narrow latch predicate, including the invalid_arguments non-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 --strict passes:

Change 'bridge-slack-native-markdown' is valid

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 (" -> &quot;, 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.
@obukhovaa
obukhovaa merged commit f7bf465 into main Sep 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant