diff --git a/.changeset/tangy-waves-shout.md b/.changeset/tangy-waves-shout.md new file mode 100644 index 000000000..dd4d51683 --- /dev/null +++ b/.changeset/tangy-waves-shout.md @@ -0,0 +1,13 @@ +--- +"eve": patch +--- + +fix(slack): route HITL input-request prompts through GFM → mrkdwn conversion (#1293) + +Model-authored prompts in Slack HITL input-request widgets were being placed into `{ type: "mrkdwn" }` blocks verbatim — `**bold**`, `__bold__`, `~~strike~~`, and `[label](url)` rendered as literal punctuation instead of formatting. The asymmetry with `thread.post(string)` (which converts via eve's `gfmToSlackMrkdwn`) made every eve+Slack app rediscover the same workaround. + +`renderInputRequestBlocks` and `buildFreeformModalView` now both pass the prompt through `gfmToSlackMrkdwn` before truncating, matching the behavior of the rest of the Slack surface. Code fences and inline code are preserved untouched by the fence-aware splitter already used elsewhere. + +Includes regression coverage for both the block-renderer path and the modal path. + +Closes #1293 diff --git a/packages/eve/src/public/channels/slack/hitl.test.ts b/packages/eve/src/public/channels/slack/hitl.test.ts index 6ff2db34a..9237e56a9 100644 --- a/packages/eve/src/public/channels/slack/hitl.test.ts +++ b/packages/eve/src/public/channels/slack/hitl.test.ts @@ -360,6 +360,25 @@ describe("renderInputRequestBlocks", () => { optionId: "weekly_report", }); }); + + it("converts model-authored GFM in the prompt block to Slack mrkdwn (#1293)", () => { + const request = makeRequest({ + prompt: + "Got it — a couple things to confirm:\n\n1. **Which environment** should I deploy to?\n2. Should the deploy *[skip the build cache](https://example.com)*?\n\n```ts\n// ** keep literal in code **\nconst x = 1;\n```", + }); + + const blocks = renderInputRequestBlocks(request); + const section = blocks[0] as { text: { type: string; text: string }; type: string }; + + // `**bold**` and `*italic*` collapse to Slack's single-asterisk bold. + expect(section.text.text).toContain("*Which environment*"); + // Markdown links become Slack's `` form. + expect(section.text.text).toContain(""); + // Code fence content is passed through untouched — asterisks stay literal. + expect(section.text.text).toContain("** keep literal in code **"); + // The prompt surface must advertise Slack's mrkdwn dialect. + expect(section.text.type).toBe("mrkdwn"); + }); }); describe("formatInputRequestFallbackText", () => { @@ -454,6 +473,28 @@ describe("buildFreeformModalView", () => { const blocks = view.blocks as Array>; expect(blocks.find((b) => b.type === "section")).toBeUndefined(); }); + + it("converts model-authored GFM in the modal prompt to Slack mrkdwn (#1293)", () => { + const view = buildFreeformModalView({ + metadata: { + continuationToken: "slack:C01:1.0", + channelId: "C01", + threadTs: "1.0", + messageTs: "1.1", + requestId: "call_abc", + }, + prompt: + "One more before I proceed — **which environment** should I target? See [the runbook](https://example.com/runbook).", + }); + + const blocks = view.blocks as Array<{ type: string; text?: { type: string; text: string } }>; + const section = blocks.find((b) => b.type === "section"); + + expect(section?.text?.text).toContain("*which environment*"); + expect(section?.text?.text).toContain(""); + // The prompt surface must advertise Slack's mrkdwn dialect. + expect(section?.text?.type).toBe("mrkdwn"); + }); }); describe("buildAnsweredBlocks", () => { diff --git a/packages/eve/src/public/channels/slack/hitl.ts b/packages/eve/src/public/channels/slack/hitl.ts index 9f5d0f30b..5205c6110 100644 --- a/packages/eve/src/public/channels/slack/hitl.ts +++ b/packages/eve/src/public/channels/slack/hitl.ts @@ -19,6 +19,7 @@ import { truncatePlainText, truncateSectionText, } from "#public/channels/slack/limits.js"; +import { gfmToSlackMrkdwn } from "#public/channels/slack/mrkdwn.js"; import type { InputRequest } from "#runtime/input/types.js"; /** @@ -144,8 +145,17 @@ export function isHitlAction(actionId: string): boolean { * Always emits at least the prompt section. */ export function renderInputRequestBlocks(request: InputRequest): unknown[] { + // Model-authored prompts are GitHub-flavored markdown by default; convert + // to Slack's mrkdwn dialect before embedding so `**bold**`, `__bold__`, + // `~~strike~~`, and `[label](url)` render natively instead of showing up + // as literal punctuation. The conversion is fence-aware — inline code and + // code blocks are passed through untouched. Truncate after conversion so + // the limit applies to the rendered text, not the source markdown. (#1293) const prompt = { - text: { text: truncateSectionText(request.prompt), type: "mrkdwn" }, + text: { + text: truncateSectionText(gfmToSlackMrkdwn(request.prompt)), + type: "mrkdwn", + }, type: "section", }; const details = renderInputRequestDetailBlocks(request); @@ -231,8 +241,16 @@ export function buildFreeformModalView(input: { readonly prompt?: string; }): Record { const title = input.prompt ? truncateModalTitle(input.prompt) : "Your answer"; + // See the matching conversion in `renderInputRequestBlocks` — the modal + // prompt is the same model-authored markdown surface and needs the same + // gfm → mrkdwn pass before reaching Slack. (#1293) const promptBlocks = input.prompt - ? [{ type: "section", text: { type: "mrkdwn", text: truncateSectionText(input.prompt) } }] + ? [ + { + type: "section", + text: { type: "mrkdwn", text: truncateSectionText(gfmToSlackMrkdwn(input.prompt)) }, + }, + ] : []; return { type: "modal",