Skip to content

Slack replies are mrkdwn text, so lists have no structure and CJK bold prints asterisks #651

Description

@hihiben

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:

Image

B, the same body in a markdown block:

Image

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

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions