From c6ebf35c940864624773591c95306c6992ac5d82 Mon Sep 17 00:00:00 2001 From: Yoshihiro Takahara Date: Wed, 28 Jan 2026 00:51:44 +0000 Subject: [PATCH 1/4] slack: forward threadId for message read actions Fixes Slack provider/channel read action ignoring threadId, preventing thread reply reads. --- extensions/slack/src/channel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 8323a9ce0..08be9dcba 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -337,6 +337,7 @@ export const slackPlugin: ChannelPlugin = { limit, before: readStringParam(params, "before"), after: readStringParam(params, "after"), + threadId: readStringParam(params, "threadId"), accountId: accountId ?? undefined, }, cfg, From cdb2608ed41b256b6f893d1a9891fc795711afa6 Mon Sep 17 00:00:00 2001 From: Yoshihiro Takahara Date: Wed, 28 Jan 2026 01:12:18 +0000 Subject: [PATCH 2/4] slack: add regression test for read threadId forwarding Covers actions.handleAction(read) -> handleSlackAction(readMessages) including threadId. --- .../slack/src/channel.read-threadid.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 extensions/slack/src/channel.read-threadid.test.ts diff --git a/extensions/slack/src/channel.read-threadid.test.ts b/extensions/slack/src/channel.read-threadid.test.ts new file mode 100644 index 000000000..4ccc965e0 --- /dev/null +++ b/extensions/slack/src/channel.read-threadid.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { slackPlugin } from "./channel.js"; +import { setSlackRuntime } from "./runtime.js"; + +import type { MoltbotConfig } from "../../../src/config/config.js"; +import { createPluginRuntime } from "../../../src/plugins/runtime/index.js"; + +describe("slack plugin read action", () => { + it("forwards threadId to readMessages", async () => { + const runtime = createPluginRuntime(); + + const handleSlackAction = vi.fn(async () => ({ ok: true })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (runtime.channel.slack as any).handleSlackAction = handleSlackAction; + + setSlackRuntime(runtime); + + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + } as MoltbotConfig; + + await slackPlugin.actions.handleAction({ + action: "read", + params: { + channelId: "C123", + threadId: "1712345678.000100", + limit: 3, + }, + cfg, + accountId: undefined, + toolContext: undefined, + }); + + expect(handleSlackAction).toHaveBeenCalledTimes(1); + expect(handleSlackAction.mock.calls[0]?.[0]).toMatchObject({ + action: "readMessages", + channelId: "C123", + limit: 3, + threadId: "1712345678.000100", + }); + }); +}); From eb8e004499e0e954339562b2b76e03aaa7c61c93 Mon Sep 17 00:00:00 2001 From: Yoshihiro Takahara Date: Thu, 29 Jan 2026 03:29:58 +0000 Subject: [PATCH 3/4] fix(slack): pass toolContext to read action for thread reply support - Pass toolContext to handleSlackAction in read action (extensions/slack) - Auto-inject threadId when reading from current thread context - Add ThreadRepliesBody for automatic thread context in agent prompts - Add resolveSlackThreadReplies helper for fetching thread replies - Add threadId parameter to message tool fetch schema Fixes issue where agent could not read thread replies via message.read tool --- extensions/slack/src/channel.ts | 1 + src/agents/tools/message-tool.ts | 6 +++ src/agents/tools/slack-actions.ts | 13 ++++- src/auto-reply/reply/get-reply-run.ts | 8 ++- src/auto-reply/reply/inbound-context.ts | 1 + src/auto-reply/templating.ts | 2 + src/channels/plugins/slack.actions.ts | 1 + src/slack/monitor/media.ts | 53 ++++++++++++++++++++ src/slack/monitor/message-handler/prepare.ts | 36 ++++++++++++- 9 files changed, 117 insertions(+), 4 deletions(-) diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 08be9dcba..e0d4645c8 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -341,6 +341,7 @@ export const slackPlugin: ChannelPlugin = { accountId: accountId ?? undefined, }, cfg, + toolContext, ); } diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 4ea178a54..7e07e347f 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -112,6 +112,12 @@ function buildFetchSchema() { around: Type.Optional(Type.String()), fromMe: Type.Optional(Type.Boolean()), includeArchived: Type.Optional(Type.Boolean()), + threadId: Type.Optional( + Type.String({ + description: + "Thread ID (ts) to read replies from. Required for reading Slack thread messages.", + }), + ), }; } diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 00d567428..76e064dfe 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -214,13 +214,22 @@ export async function handleSlackAction( typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); - const threadId = readStringParam(params, "threadId"); + const explicitThreadId = readStringParam(params, "threadId"); + // Auto-inject threadId from context when reading from current channel in a thread. + // For reads, always inject if we're in a thread context and reading from the same channel. + let threadId = explicitThreadId ?? undefined; + if (!threadId && context?.currentThreadTs && context?.currentChannelId) { + const parsedTarget = parseSlackTarget(channelId, { defaultKind: "channel" }); + if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) { + threadId = context.currentThreadTs; + } + } const result = await readSlackMessages(channelId, { ...readOpts, limit, before: before ?? undefined, after: after ?? undefined, - threadId: threadId ?? undefined, + threadId, }); const messages = result.messages.map((message) => withNormalizedTimestamp( diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index ba96023ce..aad1d8c47 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -231,6 +231,10 @@ export async function runPreparedReply( isNewSession && threadStarterBody ? `[Thread starter - for context]\n${threadStarterBody}` : undefined; + const threadRepliesBody = ctx.ThreadRepliesBody?.trim(); + const threadRepliesNote = threadRepliesBody + ? `[Thread replies - for context]\n${threadRepliesBody}` + : undefined; const skillResult = await ensureSkillSnapshot({ sessionEntry, sessionStore, @@ -245,7 +249,9 @@ export async function runPreparedReply( sessionEntry = skillResult.sessionEntry ?? sessionEntry; currentSystemSent = skillResult.systemSent; const skillsSnapshot = skillResult.skillsSnapshot; - const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n"); + const prefixedBody = [threadStarterNote, threadRepliesNote, prefixedBodyBase] + .filter(Boolean) + .join("\n\n"); const mediaNote = buildInboundMediaNote(ctx); const mediaReplyHint = mediaNote ? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body." diff --git a/src/auto-reply/reply/inbound-context.ts b/src/auto-reply/reply/inbound-context.ts index 1af20f7e9..4e30e3d19 100644 --- a/src/auto-reply/reply/inbound-context.ts +++ b/src/auto-reply/reply/inbound-context.ts @@ -29,6 +29,7 @@ export function finalizeInboundContext>( normalized.CommandBody = normalizeTextField(normalized.CommandBody); normalized.Transcript = normalizeTextField(normalized.Transcript); normalized.ThreadStarterBody = normalizeTextField(normalized.ThreadStarterBody); + normalized.ThreadRepliesBody = normalizeTextField(normalized.ThreadRepliesBody); const chatType = normalizeChatType(normalized.ChatType); if (chatType && (opts.forceChatType || normalized.ChatType !== chatType)) { diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 242cee232..156b1cc05 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -58,6 +58,8 @@ export type MsgContext = { ForwardedFromSignature?: string; ForwardedDate?: number; ThreadStarterBody?: string; + /** Thread replies context (recent messages in the thread, excluding starter and current). */ + ThreadRepliesBody?: string; ThreadLabel?: string; MediaPath?: string; MediaUrl?: string; diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index ca8aa6fb8..e2c1be539 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -137,6 +137,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap accountId: accountId ?? undefined, }, cfg, + toolContext, ); } diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 2674e2d50..c9d4a396c 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -90,6 +90,12 @@ export type SlackThreadStarter = { files?: SlackFile[]; }; +export type SlackThreadReply = { + text: string; + userId?: string; + ts?: string; +}; + const THREAD_STARTER_CACHE = new Map(); export async function resolveSlackThreadStarter(params: { @@ -122,3 +128,50 @@ export async function resolveSlackThreadStarter(params: { return null; } } + +/** + * Fetches recent thread replies (excluding the thread starter and a specific message). + * Returns messages in chronological order (oldest first). + */ +export async function resolveSlackThreadReplies(params: { + channelId: string; + threadTs: string; + client: SlackWebClient; + /** Message ts to exclude (usually the current inbound message). */ + excludeTs?: string; + /** Maximum number of replies to fetch (default: 10). */ + limit?: number; +}): Promise { + const limit = params.limit ?? 10; + try { + const response = (await params.client.conversations.replies({ + channel: params.channelId, + ts: params.threadTs, + // Fetch more than limit to account for exclusions. + limit: limit + 2, + inclusive: true, + })) as { messages?: Array<{ text?: string; user?: string; ts?: string }> }; + const messages = response?.messages ?? []; + // Filter out the thread starter (first message) and the excluded message. + const replies = messages + .filter((msg) => { + if (!msg.ts) return false; + // Exclude thread starter (ts === threadTs). + if (msg.ts === params.threadTs) return false; + // Exclude the specified message (usually the current inbound). + if (params.excludeTs && msg.ts === params.excludeTs) return false; + return true; + }) + .map((msg) => ({ + text: (msg.text ?? "").trim(), + userId: msg.user, + ts: msg.ts, + })) + .filter((reply) => reply.text.length > 0); + // Return in chronological order, limited to the specified count. + // conversations.replies returns messages in chronological order by default. + return replies.slice(-limit); + } catch { + return []; + } +} diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 8a2a9e111..f58be6328 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -44,7 +44,11 @@ import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-li import { resolveSlackEffectiveAllowFrom } from "../auth.js"; import { resolveSlackChannelConfig } from "../channel-config.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; -import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js"; +import { + resolveSlackMedia, + resolveSlackThreadReplies, + resolveSlackThreadStarter, +} from "../media.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -452,6 +456,7 @@ export async function prepareSlackMessage(params: { systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; let threadStarterBody: string | undefined; + let threadRepliesBody: string | undefined; let threadLabel: string | undefined; let threadStarterMedia: Awaited> = null; if (isThreadReply && threadTs) { @@ -489,6 +494,34 @@ export async function prepareSlackMessage(params: { } else { threadLabel = `Slack thread ${roomLabel}`; } + + // Fetch recent thread replies (excluding starter and current message). + const threadReplies = await resolveSlackThreadReplies({ + channelId: message.channel, + threadTs, + client: ctx.app.client, + excludeTs: message.ts, + limit: ctx.historyLimit > 0 ? ctx.historyLimit : 10, + }); + if (threadReplies.length > 0) { + const formattedReplies = await Promise.all( + threadReplies.map(async (reply) => { + const replyUser = reply.userId ? await ctx.resolveUserName(reply.userId) : null; + const replyName = replyUser?.name ?? reply.userId ?? "Unknown"; + const replyWithId = `${reply.text}\n[slack message id: ${reply.ts ?? "unknown"} channel: ${message.channel}]`; + return formatInboundEnvelope({ + channel: "Slack", + from: roomLabel, + timestamp: reply.ts ? Math.round(Number(reply.ts) * 1000) : undefined, + body: replyWithId, + chatType: "channel", + senderLabel: replyName, + envelope: envelopeOptions, + }); + }), + ); + threadRepliesBody = formattedReplies.join("\n\n"); + } } // Use thread starter media if current message has none @@ -516,6 +549,7 @@ export async function prepareSlackMessage(params: { MessageThreadId: threadContext.messageThreadId, ParentSessionKey: threadKeys.parentSessionKey, ThreadStarterBody: threadStarterBody, + ThreadRepliesBody: threadRepliesBody, ThreadLabel: threadLabel, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined, From dd4da8ec3e94ad7813a765847cfcc4b0c913a26c Mon Sep 17 00:00:00 2001 From: Yoshihiro Takahara Date: Thu, 29 Jan 2026 04:00:11 +0000 Subject: [PATCH 4/4] fix(slack): respect historyLimit=0 when fetching thread replies Skip thread replies fetch when historyLimit <= 0 to honor user config that disables context injection (extra tokens or privacy concerns). --- src/slack/monitor/message-handler/prepare.ts | 53 +++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index f58be6328..d8c45d0f7 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -496,31 +496,34 @@ export async function prepareSlackMessage(params: { } // Fetch recent thread replies (excluding starter and current message). - const threadReplies = await resolveSlackThreadReplies({ - channelId: message.channel, - threadTs, - client: ctx.app.client, - excludeTs: message.ts, - limit: ctx.historyLimit > 0 ? ctx.historyLimit : 10, - }); - if (threadReplies.length > 0) { - const formattedReplies = await Promise.all( - threadReplies.map(async (reply) => { - const replyUser = reply.userId ? await ctx.resolveUserName(reply.userId) : null; - const replyName = replyUser?.name ?? reply.userId ?? "Unknown"; - const replyWithId = `${reply.text}\n[slack message id: ${reply.ts ?? "unknown"} channel: ${message.channel}]`; - return formatInboundEnvelope({ - channel: "Slack", - from: roomLabel, - timestamp: reply.ts ? Math.round(Number(reply.ts) * 1000) : undefined, - body: replyWithId, - chatType: "channel", - senderLabel: replyName, - envelope: envelopeOptions, - }); - }), - ); - threadRepliesBody = formattedReplies.join("\n\n"); + // Respect historyLimit=0 to disable context injection. + if (ctx.historyLimit > 0) { + const threadReplies = await resolveSlackThreadReplies({ + channelId: message.channel, + threadTs, + client: ctx.app.client, + excludeTs: message.ts, + limit: ctx.historyLimit, + }); + if (threadReplies.length > 0) { + const formattedReplies = await Promise.all( + threadReplies.map(async (reply) => { + const replyUser = reply.userId ? await ctx.resolveUserName(reply.userId) : null; + const replyName = replyUser?.name ?? reply.userId ?? "Unknown"; + const replyWithId = `${reply.text}\n[slack message id: ${reply.ts ?? "unknown"} channel: ${message.channel}]`; + return formatInboundEnvelope({ + channel: "Slack", + from: roomLabel, + timestamp: reply.ts ? Math.round(Number(reply.ts) * 1000) : undefined, + body: replyWithId, + chatType: "channel", + senderLabel: replyName, + envelope: envelopeOptions, + }); + }), + ); + threadRepliesBody = formattedReplies.join("\n\n"); + } } }