From 79d6bbb80d6fd00b4f0c963132e119f44703b868 Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Sun, 2 Aug 2026 10:36:44 +0530 Subject: [PATCH] =?UTF-8?q?fix(slack):=20route=20HITL=20input-request=20pr?= =?UTF-8?q?ompts=20through=20GFM=20=E2=86=92=20mrkdwn=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 in `hitl.test.ts` for both the block-renderer path and the modal path: - `**bold**` and `*italic*` collapse to Slack's single-asterisk bold. - `[label](url)` becomes Slack's `` form. - Backtick fences pass through untouched (no `**` inside ```` ``` ```` is rewritten). - Each path still declares `type: "mrkdwn"` on the resulting section. Verified: - `pnpm exec vitest run --config vitest.unit.config.ts src/public/channels/slack` — 15 files / 291 tests green - `pnpm exec tsc -p tsconfig.json --noEmit` — clean Closes #1293 Signed-off-by: Sarthak Singh --- .changeset/tangy-waves-shout.md | 13 ++++++ .../src/public/channels/slack/hitl.test.ts | 41 +++++++++++++++++++ .../eve/src/public/channels/slack/hitl.ts | 22 +++++++++- 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 .changeset/tangy-waves-shout.md 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",