Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/tangy-waves-shout.md
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions packages/eve/src/public/channels/slack/hitl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<url|label>` form.
expect(section.text.text).toContain("<https://example.com|skip the build cache>");
// 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", () => {
Expand Down Expand Up @@ -454,6 +473,28 @@ describe("buildFreeformModalView", () => {
const blocks = view.blocks as Array<Record<string, unknown>>;
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("<https://example.com/runbook|the runbook>");
// The prompt surface must advertise Slack's mrkdwn dialect.
expect(section?.text?.type).toBe("mrkdwn");
});
});

describe("buildAnsweredBlocks", () => {
Expand Down
22 changes: 20 additions & 2 deletions packages/eve/src/public/channels/slack/hitl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -231,8 +241,16 @@ export function buildFreeformModalView(input: {
readonly prompt?: string;
}): Record<string, unknown> {
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",
Expand Down
Loading