Every agent reply leaves the Slack surface through toSlackMrkdwn and is posted as text (turn-handler.ts:542, delivery.ts:43). mrkdwn has no list, table or heading primitive, so structure is encoded as characters: a bullet becomes a literal •, a GFM table an aligned code block, a heading bold. It reads as a list until a line wraps, and then the second line returns to column 0.
The same pass turns **x** into *x* (mrkdwn.ts:87), and single-asterisk emphasis rendered only where both delimiters sat against a half-width boundary. In a CJK sentence they rarely do, so a Chinese-language reply cannot reliably use bold.
Slack's markdown block takes standard Markdown and translates it into native blocks. I sent one sample twice to the same conversation: A as text only (today's path, the string produced by this repo's own toSlackMrkdwn), B as the same text plus blocks: [{ "type": "markdown", "text": <raw Markdown> }]. Then read both back with conversations.history and walked the element tree.
| construct |
A: mrkdwn text |
B: markdown block |
| bullet list |
no rich_text_list element; all three levels are the same literal •, separated only by leading spaces; a wrapped line returns to column 0 |
rich_text_list style=bullet at indent=0, 1, 2; distinct marker per level; wrapped lines hang-indent |
| ordered list |
passes through as literal text (test/slack-mrkdwn.test.ts:81 asserts this) |
rich_text_list style=ordered |
**x** flanked by CJK |
literal *x*; of the sample's three bold positions only the one flanked by half-width spaces rendered |
all three rendered as bold spans |
| GFM table |
aligned code block; padEnd counts a CJK character as width 1, so any CJK column misaligns |
native table block |
fenced ```python |
rich_text_preformatted with no language; python becomes the block's first line of content |
rich_text_preformatted with language: "python" |
<@U…> mention |
resolved |
resolved, native user element |
A, what the plugin sends today:
B, the same body in a markdown block:
Why
Structure is encoded into characters in toSlackMrkdwn:
:67 fenced blocks pass through untouched, so the info string lands in a
plain-text field as content
:87 **x** / __x__ → *x*
:97 -, *, + at line start → "• "
:158 GFM table → aligned monospace code block
Ordered lists are not handled at all. blocks is only ever set for the task-list surface (presenters.ts:269, deliveries.ts:127) and the approval cards, and those are section blocks carrying mrkdwn, so they share the limitation.
Six sites render an agent-authored body this way, so a change has to cover all of them: turn-handler.ts:325 (first-block ack) and :542 (the reply), approvals.ts:239 and :648, deliveries.ts:105 and :233 (the delivery poller, which is how cron and background results reach Slack).
Measured about the markdown block
- One block came back as three (
rich_text, table, rich_text), so the per-payload block limit applies to the translated result, not to what you send.
- A
section block sent alongside it survived unchanged, so the task-list surface can keep its section block.
- With
blocks present Slack rewrote text, turning newlines into spaces. Without blocks it came back byte-identical.
Implementation notes
- The block has to carry the raw Markdown, not
toSlackMrkdwn's output: *x* is italic in Markdown and <url|label> is not link syntax. The raw body is already in scope (replyBody, turn-handler.ts:533).
- Keeping the mrkdwn string as
text still earns its place as the notification and screen-reader fallback, and mirrorSelfPost passes it to the surface cache the web UI reads.
- The budgets do not line up:
safeChunks at 2,900 per section block (mrkdwn.ts:176), SLACK_TEXT_LIMIT 40,000 for chat.update
(messaging.ts:18), and 12,000 cumulative per payload for markdown blocks. I did not test the last two.
- Splitting long Markdown safely is a different problem from splitting mrkdwn safely:
safe-cut.ts guards <url|label> and unbalanced * / ~ runs, while Markdown needs fences, table rows and list continuity guarded, and a cut inside an ordered list restarts the numbering. Emitting the block only when the body is under budget, and falling back to today's path above it, keeps that off the critical path.
updateSlackMessage (messaging.ts:28) passes blocks: blocks ?? [], and an empty array clears blocks, so the edit paths have to resend the markdown block or a message reverts to plain text on its first update.
test/live-slack/scenarios.ts:90 asserts three lines beginning with •. A native list puts no • in the text layer, and the text field is rewritten anyway, so that assertion needs to look at block structure instead.
Fix
Render each outbound body once into { text, blocks }: text from the existing toSlackMrkdwn, blocks a single markdown block carrying the raw Markdown, omitted when the body is over budget. Then switch the six sites to it. postReply, chat.update and the task-list presenter already accept blocks.
Every agent reply leaves the Slack surface through
toSlackMrkdwnand is posted astext(turn-handler.ts:542,delivery.ts:43). mrkdwn has no list, table or heading primitive, so structure is encoded as characters: a bullet becomes a literal•, a GFM table an aligned code block, a heading bold. It reads as a list until a line wraps, and then the second line returns to column 0.The same pass turns
**x**into*x*(mrkdwn.ts:87), and single-asterisk emphasis rendered only where both delimiters sat against a half-width boundary. In a CJK sentence they rarely do, so a Chinese-language reply cannot reliably use bold.Slack's
markdownblock takes standard Markdown and translates it into native blocks. I sent one sample twice to the same conversation: A astextonly (today's path, the string produced by this repo's owntoSlackMrkdwn), B as the sametextplusblocks: [{ "type": "markdown", "text": <raw Markdown> }]. Then read both back withconversations.historyand walked the element tree.textmarkdownblockrich_text_listelement; all three levels are the same literal•, separated only by leading spaces; a wrapped line returns to column 0rich_text_list style=bulletatindent=0,1,2; distinct marker per level; wrapped lines hang-indenttest/slack-mrkdwn.test.ts:81asserts this)rich_text_list style=ordered**x**flanked by CJK*x*; of the sample's three bold positions only the one flanked by half-width spaces renderedpadEndcounts a CJK character as width 1, so any CJK column misalignstableblock```pythonrich_text_preformattedwith nolanguage;pythonbecomes the block's first line of contentrich_text_preformattedwithlanguage: "python"<@U…>mentionuserelementA, what the plugin sends today:
B, the same body in a
markdownblock:Why
Structure is encoded into characters in
toSlackMrkdwn:Ordered lists are not handled at all.
blocksis only ever set for the task-list surface (presenters.ts:269,deliveries.ts:127) and the approval cards, and those aresectionblocks carrying mrkdwn, so they share the limitation.Six sites render an agent-authored body this way, so a change has to cover all of them:
turn-handler.ts:325(first-block ack) and:542(the reply),approvals.ts:239and:648,deliveries.ts:105and:233(the delivery poller, which is how cron and background results reach Slack).Measured about the markdown block
rich_text,table,rich_text), so the per-payload block limit applies to the translated result, not to what you send.sectionblock sent alongside it survived unchanged, so the task-list surface can keep its section block.blockspresent Slack rewrotetext, turning newlines into spaces. Withoutblocksit came back byte-identical.Implementation notes
toSlackMrkdwn's output:*x*is italic in Markdown and<url|label>is not link syntax. The raw body is already in scope (replyBody,turn-handler.ts:533).textstill earns its place as the notification and screen-reader fallback, andmirrorSelfPostpasses it to the surface cache the web UI reads.safeChunksat 2,900 per section block (mrkdwn.ts:176),SLACK_TEXT_LIMIT40,000 forchat.update(
messaging.ts:18), and 12,000 cumulative per payload for markdown blocks. I did not test the last two.safe-cut.tsguards<url|label>and unbalanced*/~runs, while Markdown needs fences, table rows and list continuity guarded, and a cut inside an ordered list restarts the numbering. Emitting the block only when the body is under budget, and falling back to today's path above it, keeps that off the critical path.updateSlackMessage(messaging.ts:28) passesblocks: blocks ?? [], and an empty array clears blocks, so the edit paths have to resend the markdown block or a message reverts to plain text on its first update.test/live-slack/scenarios.ts:90asserts three lines beginning with•. A native list puts no•in the text layer, and the text field is rewritten anyway, so that assertion needs to look at block structure instead.Fix
Render each outbound body once into
{ text, blocks }:textfrom the existingtoSlackMrkdwn,blocksa singlemarkdownblock carrying the raw Markdown, omitted when the body is over budget. Then switch the six sites to it.postReply,chat.updateand the task-list presenter already acceptblocks.