diff --git a/packages/junior/src/chat/message-text.ts b/packages/junior/src/chat/message-text.ts new file mode 100644 index 00000000..63f4f4fe --- /dev/null +++ b/packages/junior/src/chat/message-text.ts @@ -0,0 +1,39 @@ +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(); +} + +const BLOCK_TYPES = new Set([ + "root", + "paragraph", + "list", + "listItem", + "blockquote", + "heading", +]); + +function visitNode(node: AstNode): string { + if ( + node.type === "text" || + node.type === "inlineCode" || + node.type === "code" + ) + return node.value ?? ""; + if (node.type === "link") { + const childText = (node.children ?? []).map(visitNode).join(""); + return childText === node.url + ? node.url + : `[${childText}](${node.url ?? ""})`; + } + const separator = BLOCK_TYPES.has(node.type) ? "\n" : ""; + return (node.children ?? []).map(visitNode).join(separator); +} 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..e6e5a0e7 100644 --- a/packages/junior/src/chat/runtime/thread-context.ts +++ b/packages/junior/src/chat/runtime/thread-context.ts @@ -21,7 +21,7 @@ export function stripLeadingBotMention( let next = text; if (options.stripLeadingSlackMentionToken) { - next = next.replace(/^\s*<@[^>]+>[\s,:-]*/, "").trim(); + next = next.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(