diff --git a/packages/junior/src/chat/slack/mrkdwn.ts b/packages/junior/src/chat/slack/mrkdwn.ts index be155b4d..85af040d 100644 --- a/packages/junior/src/chat/slack/mrkdwn.ts +++ b/packages/junior/src/chat/slack/mrkdwn.ts @@ -58,6 +58,25 @@ export function ensureBlockSpacing(text: string): string { export function renderSlackMrkdwn(text: string): string { let normalized = text.replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, ""); normalized = ensureBlockSpacing(normalized); + // The body is sent as a Slack `markdown` block, which expects standard + // Markdown links ([label](url)), not Slack mrkdwn angle-bracket syntax + // ( / ). Convert to explicit Markdown links so the block + // renders them correctly and bold markers (**) can never collide with the + // angle brackets and corrupt the URL. + // + // Pass 1: repair malformed bold-wrapped angle autolinks produced by the + // model: ** → [https://url](https://url) + normalized = normalized.replace( + /\*\*<(https?:\/\/[^*>\s]+)\*\*>/g, + "[$1]($1)", + ); + // Pass 2: Slack labeled links → Markdown: → [label](https://url) + normalized = normalized.replace( + /<(https?:\/\/[^|>\s]+)\|([^>]+)>/g, + "[$2]($1)", + ); + // Pass 3: remaining angle autolinks → explicit Markdown links: → [https://url](https://url) + normalized = normalized.replace(/<(https?:\/\/[^>\s]+)>/g, "[$1]($1)"); return normalized.replace(/\n{3,}/g, "\n\n").trim(); } diff --git a/packages/junior/tests/unit/misc/output.test.ts b/packages/junior/tests/unit/misc/output.test.ts index 507aacd8..221ed202 100644 --- a/packages/junior/tests/unit/misc/output.test.ts +++ b/packages/junior/tests/unit/misc/output.test.ts @@ -15,6 +15,52 @@ describe("renderSlackMrkdwn", () => { "one\n\n- item a\n- item b\n\ntwo", ); }); + + it("converts Slack angle-bracket URL to explicit Markdown link", () => { + expect( + renderSlackMrkdwn("See for details."), + ).toBe("See [https://example.com](https://example.com) for details."); + }); + + it("converts Slack labeled links to Markdown format", () => { + expect( + renderSlackMrkdwn(""), + ).toBe("[PR #1](https://github.com/org/repo/pull/1)"); + }); + + it("resolves bold+angle-bracket collision that broke GitHub PR links", () => { + // The model generated ** — closing ** absorbed inside the + // angle brackets. Pass 1 catches this and converts to [url](url). + expect( + renderSlackMrkdwn( + "**", + ), + ).toBe( + "[https://github.com/getsentry/sentry/pull/116765](https://github.com/getsentry/sentry/pull/116765)", + ); + }); + + it("converts multiple angle-bracket URLs in one message", () => { + expect( + renderSlackMrkdwn( + "PR1: and PR2: ", + ), + ).toBe( + "PR1: [https://github.com/org/repo/pull/1](https://github.com/org/repo/pull/1) and PR2: [https://github.com/org/repo/pull/2](https://github.com/org/repo/pull/2)", + ); + }); + + it("leaves Markdown links unchanged", () => { + expect( + renderSlackMrkdwn("**[PR #123](https://github.com/org/repo/pull/123)**"), + ).toBe("**[PR #123](https://github.com/org/repo/pull/123)**"); + }); + + it("leaves plain bold text unchanged", () => { + expect(renderSlackMrkdwn("**Root cause** is here.")).toBe( + "**Root cause** is here.", + ); + }); }); describe("buildSlackOutputMessage", () => {