From 07934cd81766c13ae25a228cac52cd73a00b9dec Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 5 Apr 2026 12:03:49 -0700 Subject: [PATCH 1/3] fix(slack): Preserve hyperlink URLs in assistant context When a user sends a message with Slack hyperlinks (), the URLs were stripped before reaching the model. The adapter's extractPlainText() converts the AST to plain text, discarding link URLs entirely. The assistant only saw the display text with no knowledge of embedded URLs. Use message.formatted (the AST) instead of message.text, and walk the tree with a new extractTextPreservingLinks() helper that renders links as [text](url) while keeping all other content as plain text. Also update stripLeadingBotMention to handle the @USER_ID format produced by the AST (the existing <@USER_ID> regex was dead code since the adapter's toAst already strips angle brackets). Fixes getsentry/junior#137 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/junior/src/chat/message-text.ts | 29 ++++++++++++ .../junior/src/chat/runtime/reply-executor.ts | 12 +++-- .../junior/src/chat/runtime/slack-runtime.ts | 3 +- .../junior/src/chat/runtime/thread-context.ts | 5 ++- .../src/chat/services/conversation-memory.ts | 5 ++- .../junior/tests/fixtures/slack-harness.ts | 27 ++++++++---- .../slack/message-content-behavior.test.ts | 44 ++++++++++++++++++- .../tests/unit/slack/slack-runtime.test.ts | 2 +- 8 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 packages/junior/src/chat/message-text.ts diff --git a/packages/junior/src/chat/message-text.ts b/packages/junior/src/chat/message-text.ts new file mode 100644 index 00000000..8f7ab9ac --- /dev/null +++ b/packages/junior/src/chat/message-text.ts @@ -0,0 +1,29 @@ +import type { FormattedContent } from "chat"; + +interface AstNode { + type: string; + value?: string; + url?: string; + children?: AstNode[]; +} + +/** Extract plain text from a message AST, preserving hyperlink URLs as `[text](url)`. */ +export function extractTextPreservingLinks(ast: FormattedContent): string { + return visitNode(ast as AstNode).trim(); +} + +function visitNode(node: AstNode): string { + if (node.type === "text") return node.value ?? ""; + if (node.type === "link") { + const childText = visitChildren(node); + return childText === node.url ? node.url : `[${childText}](${node.url})`; + } + if (node.type === "root") { + return (node.children ?? []).map(visitNode).join("\n"); + } + return visitChildren(node); +} + +function visitChildren(node: AstNode): string { + return (node.children ?? []).map(visitNode).join(""); +} diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 83d5d1c6..469fc092 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -1,4 +1,5 @@ import type { Message, SentMessage, Thread } from "chat"; +import { extractTextPreservingLinks } from "@/chat/message-text"; import type { SlackAdapter } from "@chat-adapter/slack"; import { botConfig } from "@/chat/config"; import { isExplicitChannelPostIntent } from "@/chat/services/channel-intent"; @@ -143,10 +144,13 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { modelId: botConfig.modelId, }, async () => { - const userText = stripLeadingBotMention(message.text, { - stripLeadingSlackMentionToken: - options.explicitMention || Boolean(message.isMention), - }); + const userText = stripLeadingBotMention( + extractTextPreservingLinks(message.formatted), + { + stripLeadingSlackMentionToken: + options.explicitMention || Boolean(message.isMention), + }, + ); const explicitChannelPostIntent = isExplicitChannelPostIntent(userText); const preparedState = diff --git a/packages/junior/src/chat/runtime/slack-runtime.ts b/packages/junior/src/chat/runtime/slack-runtime.ts index fe7af59b..0c95736a 100644 --- a/packages/junior/src/chat/runtime/slack-runtime.ts +++ b/packages/junior/src/chat/runtime/slack-runtime.ts @@ -1,5 +1,6 @@ import type { Message, Thread } from "chat"; import { getSubscribedReplyPreflightDecision } from "@/chat/services/subscribed-decision"; +import { extractTextPreservingLinks } from "@/chat/message-text"; import { isRetryableTurnError } from "@/chat/runtime/turn"; import type { ErrorReference } from "@/chat/logging"; import { getSlackErrorObservabilityAttributes } from "@/chat/runtime/thread-context"; @@ -347,7 +348,7 @@ export function createSlackTurnRuntime< const threadId = deps.getThreadId(thread, message); const channelId = deps.getChannelId(thread, message); const runId = deps.getRunId(thread, message); - const rawUserText = message.text; + const rawUserText = extractTextPreservingLinks(message.formatted); const userText = deps.stripLeadingBotMention(rawUserText, { stripLeadingSlackMentionToken: Boolean(message.isMention), }); diff --git a/packages/junior/src/chat/runtime/thread-context.ts b/packages/junior/src/chat/runtime/thread-context.ts index 48170766..65cb850f 100644 --- a/packages/junior/src/chat/runtime/thread-context.ts +++ b/packages/junior/src/chat/runtime/thread-context.ts @@ -21,7 +21,10 @@ export function stripLeadingBotMention( let next = text; if (options.stripLeadingSlackMentionToken) { - next = next.replace(/^\s*<@[^>]+>[\s,:-]*/, "").trim(); + next = next + .replace(/^\s*<@[^>]+>[\s,:-]*/, "") + .replace(/^\s*@[A-Z][A-Z0-9_]+\b[\s,:-]*/i, "") + .trim(); } const mentionByNameRe = new RegExp( diff --git a/packages/junior/src/chat/services/conversation-memory.ts b/packages/junior/src/chat/services/conversation-memory.ts index adc379ec..90d8a147 100644 --- a/packages/junior/src/chat/services/conversation-memory.ts +++ b/packages/junior/src/chat/services/conversation-memory.ts @@ -1,4 +1,5 @@ import type { Message, Thread } from "chat"; +import { extractTextPreservingLinks } from "@/chat/message-text"; import { botConfig } from "@/chat/config"; import { completeText } from "@/chat/pi/client"; import type { @@ -390,7 +391,9 @@ export const generateThreadTitle = function createConversationMessageFromSdkMessage( entry: Message, ): ConversationMessage | null { - const rawText = normalizeConversationText(entry.text); + const rawText = normalizeConversationText( + extractTextPreservingLinks(entry.formatted), + ); if (!rawText) { return null; } diff --git a/packages/junior/tests/fixtures/slack-harness.ts b/packages/junior/tests/fixtures/slack-harness.ts index 3f154a74..a077e42f 100644 --- a/packages/junior/tests/fixtures/slack-harness.ts +++ b/packages/junior/tests/fixtures/slack-harness.ts @@ -1,10 +1,11 @@ -import type { - Adapter, - Author, - Channel, - Message, - SentMessage, - Thread, +import { + type Adapter, + type Author, + type Channel, + type Message, + type SentMessage, + type Thread, + parseMarkdown, } from "chat"; // ── Helpers ────────────────────────────────────────────────────────── @@ -15,6 +16,16 @@ function parseChannelFromThreadId(threadId: string): string | undefined { return undefined; } +/** Convert Slack mrkdwn syntax to standard markdown for AST parsing. */ +function slackTextToMarkdown(text: string): string { + return text + .replace(/<@([A-Z0-9_]+)\|([^<>]+)>/g, "@$2") + .replace(/<@([A-Z0-9_]+)>/g, "@$1") + .replace(/<#[A-Z0-9_]+\|([^<>]+)>/g, "#$1") + .replace(/<(https?:\/\/[^|<>]+)\|([^<>]+)>/g, "[$2]($1)") + .replace(/<(https?:\/\/[^<>]+)>/g, "$1"); +} + // ── Test Author ────────────────────────────────────────────────────── const defaultAuthor: Author = { @@ -52,7 +63,7 @@ export function createTestMessage(args: { isMention: args.isMention, attachments: args.attachments ?? [], metadata: { dateSent: new Date(), edited: false }, - formatted: { type: "root", children: [] }, + formatted: parseMarkdown(slackTextToMarkdown(args.text ?? "hello")), raw: args.raw ?? { ...(inferredChannel ? { channel: inferredChannel } : {}), ...(inferredTs ? { ts: inferredTs, thread_ts: inferredTs } : {}), diff --git a/packages/junior/tests/integration/slack/message-content-behavior.test.ts b/packages/junior/tests/integration/slack/message-content-behavior.test.ts index 27e8487b..0ae76ba6 100644 --- a/packages/junior/tests/integration/slack/message-content-behavior.test.ts +++ b/packages/junior/tests/integration/slack/message-content-behavior.test.ts @@ -103,7 +103,49 @@ describe("Slack behavior: message content", () => { await slackRuntime.handleNewMention(thread, message); expect(calls).toHaveLength(1); - expect(calls[0]?.prompt).toContain("message <@U_ONCALL> after deploy"); + expect(calls[0]?.prompt).toContain("message @U_ONCALL after deploy"); + }); + + it("preserves hyperlink URLs from Slack mrkdwn in user content", async () => { + const calls: CapturedCall[] = []; + + const { slackRuntime } = createTestChatRuntime({ + services: { + replyExecutor: { + generateAssistantReply: async (prompt) => { + calls.push({ prompt }); + return { + text: "Done.", + diagnostics: { + assistantMessageCount: 1, + modelId: "fake-agent-model", + outcome: "success", + toolCalls: [], + toolErrorCount: 0, + toolResultCount: 0, + usedPrimaryText: true, + }, + }; + }, + }, + }, + }); + + const thread = createTestThread({ id: "slack:C_BEHAVIOR:1700005004.000" }); + const message = createTestMessage({ + id: "m-content-links", + text: "<@U_APP> check please", + isMention: true, + threadId: thread.id, + author: { userId: "U_TESTER" }, + }); + + await slackRuntime.handleNewMention(thread, message); + + expect(calls).toHaveLength(1); + expect(calls[0]?.prompt).toContain( + "[this PR](https://github.com/foo/bar/pull/1)", + ); }); it("does not invoke the agent for self-authored mention messages", async () => { diff --git a/packages/junior/tests/unit/slack/slack-runtime.test.ts b/packages/junior/tests/unit/slack/slack-runtime.test.ts index d374402c..1f8e7286 100644 --- a/packages/junior/tests/unit/slack/slack-runtime.test.ts +++ b/packages/junior/tests/unit/slack/slack-runtime.test.ts @@ -273,7 +273,7 @@ describe("createSlackTurnRuntime", () => { await runtime.handleSubscribedMessage(thread, message); expect(deps.stripLeadingBotMention).toHaveBeenCalledWith( - "<@U123> stripped text", + "@U123 stripped text", { stripLeadingSlackMentionToken: true }, ); expect(deps.prepareTurnState).toHaveBeenCalledWith( From 9d80039fbe15242be483f78147995d0faa950d4a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 5 Apr 2026 12:27:51 -0700 Subject: [PATCH 2/3] fix(slack): Handle inlineCode and code AST nodes in text extraction visitNode only handled 'text' type nodes for reading node.value. mdast inlineCode and code nodes also store content in value (not in children), so backtick-wrapped content was silently dropped. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/junior/src/chat/message-text.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/junior/src/chat/message-text.ts b/packages/junior/src/chat/message-text.ts index 8f7ab9ac..4d6e9ab8 100644 --- a/packages/junior/src/chat/message-text.ts +++ b/packages/junior/src/chat/message-text.ts @@ -13,7 +13,12 @@ export function extractTextPreservingLinks(ast: FormattedContent): string { } function visitNode(node: AstNode): string { - if (node.type === "text") return node.value ?? ""; + if ( + node.type === "text" || + node.type === "inlineCode" || + node.type === "code" + ) + return node.value ?? ""; if (node.type === "link") { const childText = visitChildren(node); return childText === node.url ? node.url : `[${childText}](${node.url})`; From b73c604b7c6559a68b90e0f1d8a7a933857017b3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 5 Apr 2026 14:33:10 -0700 Subject: [PATCH 3/3] fix(slack): Handle block-level AST nodes and remove dead mention regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add newline separators for block-level AST containers (list, listItem, blockquote, etc.) so bulleted lists and similar structures are not concatenated without whitespace. Remove the dead <@USER_ID> regex in stripLeadingBotMention — the AST never produces angle-bracket mentions, and chaining it with the new @USER_ID regex risked double-stripping. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/junior/src/chat/message-text.ts | 25 +++++++++++-------- .../junior/src/chat/runtime/thread-context.ts | 5 +--- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/junior/src/chat/message-text.ts b/packages/junior/src/chat/message-text.ts index 4d6e9ab8..63f4f4fe 100644 --- a/packages/junior/src/chat/message-text.ts +++ b/packages/junior/src/chat/message-text.ts @@ -12,6 +12,15 @@ export function extractTextPreservingLinks(ast: FormattedContent): string { return visitNode(ast as AstNode).trim(); } +const BLOCK_TYPES = new Set([ + "root", + "paragraph", + "list", + "listItem", + "blockquote", + "heading", +]); + function visitNode(node: AstNode): string { if ( node.type === "text" || @@ -20,15 +29,11 @@ function visitNode(node: AstNode): string { ) return node.value ?? ""; if (node.type === "link") { - const childText = visitChildren(node); - return childText === node.url ? node.url : `[${childText}](${node.url})`; - } - if (node.type === "root") { - return (node.children ?? []).map(visitNode).join("\n"); + const childText = (node.children ?? []).map(visitNode).join(""); + return childText === node.url + ? node.url + : `[${childText}](${node.url ?? ""})`; } - return visitChildren(node); -} - -function visitChildren(node: AstNode): string { - return (node.children ?? []).map(visitNode).join(""); + const separator = BLOCK_TYPES.has(node.type) ? "\n" : ""; + return (node.children ?? []).map(visitNode).join(separator); } diff --git a/packages/junior/src/chat/runtime/thread-context.ts b/packages/junior/src/chat/runtime/thread-context.ts index 65cb850f..e6e5a0e7 100644 --- a/packages/junior/src/chat/runtime/thread-context.ts +++ b/packages/junior/src/chat/runtime/thread-context.ts @@ -21,10 +21,7 @@ export function stripLeadingBotMention( let next = text; if (options.stripLeadingSlackMentionToken) { - next = next - .replace(/^\s*<@[^>]+>[\s,:-]*/, "") - .replace(/^\s*@[A-Z][A-Z0-9_]+\b[\s,:-]*/i, "") - .trim(); + next = next.replace(/^\s*@[A-Z][A-Z0-9_]+\b[\s,:-]*/i, "").trim(); } const mentionByNameRe = new RegExp(