Skip to content

fix: render Slack replies with native formatting - #1586

Open
dcbuild3r wants to merge 1 commit into
paradigmxyz:mainfrom
dcbuild3r:codex/upstream-slack-native-formatting
Open

fix: render Slack replies with native formatting#1586
dcbuild3r wants to merge 1 commit into
paradigmxyz:mainfrom
dcbuild3r:codex/upstream-slack-native-formatting

Conversation

@dcbuild3r

@dcbuild3r dcbuild3r commented Sep 1, 2026

Copy link
Copy Markdown

Why

The model emits CommonMark, but Slack renders mrkdwn. The two overlap enough that most output looked fine, so the mismatches were easy to miss until they showed up in real replies:

Model output Slack showed Should show
[Issue #276](https://…/276) the literal brackets and URL a labeled link (<https://…/276|Issue #276>)
**Title:** Example meeting **Title:** Example meeting Title: Example meeting

Two delivery paths were sending CommonMark straight to Slack:

  1. Live streaming — the patched @chat-adapter/slack adapter passed each delta to chat.appendStream as markdown_text unchanged. Slack's markdown_text accepts mrkdwn, not full CommonMark, so labeled links in particular came through raw.
  2. Fallback / recovery — when the streaming render fails, renderFallbackFinalAnswer re-posts (or edits in) the durable final answer via thread.post / editMessage as plain text. Root DMs hit this path constantly: a top-level DM has no thread_ts, so chat.startStream isn't usable and every answer lands as a chat.postMessage — with the CommonMark intact.

Fixing this at the transport boundary (rather than with prompt rules asking the model for mrkdwn) means every Slack agent built on the adapter gets native formatting, and the model can keep producing one canonical Markdown output for all surfaces.

What changed

patches/@chat-adapter__slack@4.31.0.patch (+ mechanical pnpm-lock.yaml patch-hash bump)

  • In the streaming append path, run each syntactically committable delta through the adapter's existing formatConverter.toResponseUrlText() before handing it to chat.appendStream. The existing "commit only complete constructs" buffering is what makes this safe — a link split across deltas ([Issue / #276](https://… / …) was updated.) is held until it's whole, then converted once.

services/slackbotv2/src/index.ts

  • Instantiate a module-level SlackFormatConverter and convert the fallback answer to mrkdwn before both the fresh thread.post(...) and the editMessage(...) replacement.
  • Post/edit with { raw: … } so the chat SDK sends the already-converted mrkdwn as-is instead of running its own Markdown pass over it (which would re-escape or double-convert).

services/slackbotv2/test/chat-sdk-emulate.test.ts

  • renders streamed CommonMark links as Slack-native rich links — splits a link across three item/agentMessage/delta events and asserts the joined markdown_text stream is exactly Completed: <https://…/276|Issue #276> was updated.
  • renders root-DM fallback answers as Slack-native rich text — drives a root DM through the fallback path and asserts bold, a bulleted bold label (• *Title:*), and a labeled link all arrive as mrkdwn with no CommonMark residue.
  • The emulator now records chat.postMessage calls so the fallback path is observable in tests.

Not changed

  • No changes to how the model is prompted; output stays CommonMark.
  • No change to threaded (non-root) streaming semantics beyond the per-delta conversion — chunking, rotation, and expiry handling in the patch are untouched.

Validation

  • bun test test/chat-sdk-emulate.test.ts --test-name-pattern "renders (streamed CommonMark links|root-DM fallback answers)" — 2 passed
  • bun test test — 254 passed, 1 skipped
  • git diff --check — clean
  • bun run check:types — new code is clean; still reports the three pre-existing fetch.preconnect mock errors in test/slack-user.test.ts (unrelated, present on main)
  • Live Slack root DM: bold text, a bullet with a bold label, and a labeled link all rendered natively.

@dcbuild3r

Copy link
Copy Markdown
Author

I tested a clean Chat SDK family upgrade from 4.31.0 to 4.39.0 in a separate worktree.

Findings:

  • 4.39.0 improves the exact root-DM/no-thread path: SlackAdapter.stream() returns null when threadTs is empty, allowing Chat SDK to fall back to a post using native markdown_text rather than throwing during stream setup.
  • 4.39.0 intentionally distinguishes message shapes: bare strings / { raw } become literal Slack text, while { markdown } becomes native markdown_text. Centaur's durable renderFallbackFinalAnswer() still passes a bare string, so upgrade alone does not protect recovery after other streaming failures; that app-level call still needs to identify the content as markdown (or convert it).
  • The native streaming implementation still forwards committed CommonMark as markdown_text; it does not convert it to legacy mrkdwn. That is intentional in current Vercel Chat SDK because Slack owns rendering of this field.
  • Removing the four 4.31 patches and installing the 4.39 family produced 13 Slackbot test failures: three attachment-materialization failures from the new internal-URL guard, the failed-stream replacement case, eight stream segmentation/rotation/oversized-output cases, and one bot-recipient identity case.

So the upgrade direction is useful, but it is not a safe substitute for this focused fix without separately porting or retiring Centaur's adapter/state patches. Full test evidence: 239 passed, 1 skipped, 13 failed on clean 4.39.0; the focused PR remains 254 passed, 1 skipped.

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