diff --git a/packages/junior/src/chat/providers/slack/resume.ts b/packages/junior/src/chat/providers/slack/resume.ts index 927fa0d7a8..ab31531aba 100644 --- a/packages/junior/src/chat/providers/slack/resume.ts +++ b/packages/junior/src/chat/providers/slack/resume.ts @@ -95,10 +95,12 @@ async function postSlackMessageBestEffort( text: string, conversationId?: string, replyAttribution?: ReplyAttribution, + bodyFormat?: import("@/chat/slack/footer").SlackReplyBodyFormat, ): Promise { try { if (conversationId) { await sendSlackReply({ + bodyFormat, channelId, conversationId, replyAttribution, @@ -904,6 +906,7 @@ async function resumeSlackTurnInContext( ), runArgs.conversationId, runArgs.run?.dispatch?.replyAttribution, + "mrkdwn", ); } return true; diff --git a/packages/junior/src/chat/providers/slack/turn.ts b/packages/junior/src/chat/providers/slack/turn.ts index ea961dcccc..0c2115b92f 100644 --- a/packages/junior/src/chat/providers/slack/turn.ts +++ b/packages/junior/src/chat/providers/slack/turn.ts @@ -445,6 +445,7 @@ export function createSlackTurn(deps: SlackTurnDeps) { await beforeFirstResponsePost(); if (channelId && threadTs) { await sendSlackReply({ + bodyFormat: "mrkdwn", channelId, conversationId, replyAttribution: options.execution?.dispatch?.replyAttribution, diff --git a/packages/junior/src/chat/slack/footer.ts b/packages/junior/src/chat/slack/footer.ts index 53709d2d3c..46af9eeef6 100644 --- a/packages/junior/src/chat/slack/footer.ts +++ b/packages/junior/src/chat/slack/footer.ts @@ -46,6 +46,22 @@ export interface SlackReplyFooter { items: SlackReplyFooterItem[]; } +/** + * Controls which Slack block type is used for the reply body. + * + * - `"commonmark"` (default): wraps the text in a `{ type: "markdown" }` block, + * which Slack renders from standard Markdown (bold, links, tables, headers). + * Use this for normal agent replies where `normalizeSlackReplyMarkdown` has + * already formatted the output for CommonMark delivery. + * + * - `"mrkdwn"`: wraps the text in a `{ type: "section", text: { type: "mrkdwn" } }` + * block. Use this when the text is already pre-formatted as Slack mrkdwn + * (e.g. `<@user>` mentions, `` angle-bracket links, single-`*` emphasis) + * and must not be passed through the markdown-to-rich_text converter, which + * can mis-tag mrkdwn tokens and trigger `invalid_blocks`. + */ +export type SlackReplyBodyFormat = "commonmark" | "mrkdwn"; + /** Render compact reply attribution for the Slack footer. */ export function formatReplyAttribution(attribution: ReplyAttribution): string { return attribution.detail @@ -90,21 +106,29 @@ export function buildSlackReplyFooter(args: { : undefined; } -/** Build Slack blocks for a reply chunk using the Slack-flavored markdown block for the body. */ +/** + * Build Slack blocks for a reply chunk. + * + * The body block format is controlled by `bodyFormat`: + * - `"commonmark"` (default): uses `{ type: "markdown" }` for CommonMark rendering. + * - `"mrkdwn"`: uses `{ type: "section", text: { type: "mrkdwn" } }` for + * pre-formatted mrkdwn text (e.g. auth-pause notices with `<@user>` mentions). + */ export function buildSlackReplyBlocks( text: string, footer: SlackReplyFooter | undefined, + bodyFormat: SlackReplyBodyFormat = "commonmark", ): SlackMessageBlock[] | undefined { if (!text.trim()) { return undefined; } - const blocks: SlackMessageBlock[] = [ - { - type: "markdown", - text, - }, - ]; + const bodyBlock: SlackMessageBlock = + bodyFormat === "mrkdwn" + ? { type: "section", text: { type: "mrkdwn", text } } + : { type: "markdown", text }; + + const blocks: SlackMessageBlock[] = [bodyBlock]; if (footer && (footer.attribution || footer.items.length > 0)) { const attributionElements: SlackPlainTextObject[] = footer.attribution diff --git a/packages/junior/src/chat/slack/reply.ts b/packages/junior/src/chat/slack/reply.ts index eaad1b1767..5d5ca26d31 100644 --- a/packages/junior/src/chat/slack/reply.ts +++ b/packages/junior/src/chat/slack/reply.ts @@ -8,6 +8,7 @@ import { buildSlackReplyBlocks, buildSlackReplyFooter, formatReplyAttribution, + type SlackReplyBodyFormat, } from "@/chat/slack/footer"; import { escapeSlackMrkdwnText } from "@/chat/slack/mrkdwn"; import { postSlackMessage } from "@/chat/slack/outbound"; @@ -20,6 +21,7 @@ import { splitSlackReplyText } from "@/chat/slack/output"; * context, and posts through the shared Slack outbound boundary. */ export async function sendSlackReply(args: { + bodyFormat?: SlackReplyBodyFormat; channelId: string; conversationId: string; replyAttribution?: ReplyAttribution; @@ -41,6 +43,7 @@ export async function sendSlackReply(args: { const blocks = buildSlackReplyBlocks( text, isFinalChunk ? footer : undefined, + args.bodyFormat, ); const fallbackText = isFinalChunk && args.replyAttribution diff --git a/packages/junior/tests/unit/slack/footer.test.ts b/packages/junior/tests/unit/slack/footer.test.ts index 6657dd4137..731a2f900b 100644 --- a/packages/junior/tests/unit/slack/footer.test.ts +++ b/packages/junior/tests/unit/slack/footer.test.ts @@ -153,6 +153,17 @@ describe("buildSlackReplyBlocks", () => { ]); }); + it("renders a mrkdwn section block for pre-formatted mrkdwn text (auth-pause path)", () => { + const text = + "<@U123> I need access to GitHub to continue.\n\n*Why:* check out and proceed\n\nI sent you a link."; + expect(buildSlackReplyBlocks(text, undefined, "mrkdwn")).toEqual([ + { + type: "section", + text: { type: "mrkdwn", text }, + }, + ]); + }); + it("does not emit blocks when the reply has no visible text", () => { const footer = buildSlackReplyFooter({ conversationId: "slack:C123:1700000000.000100",