Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 29 additions & 0 deletions packages/junior/src/chat/message-text.ts
Original file line number Diff line number Diff line change
@@ -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("");
}
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
5 changes: 4 additions & 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,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(
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