Skip to content
Draft
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
39 changes: 39 additions & 0 deletions packages/junior/src/chat/message-text.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Paragraph in BLOCK_TYPES inserts newlines between inline elements

High Severity

paragraph and heading are included in BLOCK_TYPES, which causes their inline children (text, links, emphasis, etc.) to be joined with "\n" instead of "". In a standard markdown AST, paragraphs contain inline children — the paragraph itself is a block element, but its children are not. A message like "check [this PR](url) please" produces "check \n[this PR](url)\n please" with spurious newlines splitting a single sentence. The existing tests don't catch this because they use toContain rather than exact matching. This directly impacts the core goal of the PR — every message with a hyperlink or any inline formatting will have corrupted text sent to the model.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b73c604. Configure here.


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);
}
12 changes: 8 additions & 4 deletions packages/junior/src/chat/runtime/reply-executor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 =
Expand Down
3 changes: 2 additions & 1 deletion packages/junior/src/chat/runtime/slack-runtime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/junior/src/chat/runtime/thread-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion packages/junior/src/chat/services/conversation-memory.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
27 changes: 19 additions & 8 deletions packages/junior/tests/fixtures/slack-harness.ts
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────────────────────────
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/foo/bar/pull/1|this PR> 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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/junior/tests/unit/slack/slack-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading