From 0548b473655b45a14f0b6ad70fec30fc9125e0ab Mon Sep 17 00:00:00 2001 From: "m.shino" Date: Fri, 30 Jan 2026 18:33:05 +0900 Subject: [PATCH] feat(slack): populate thread session with existing thread history When a new session is created for a Slack thread, fetch and inject the full thread history as context. This preserves conversation continuity so the bot knows what it previously said in the thread. - Add resolveSlackThreadHistory() to fetch all thread messages - Add ThreadHistoryBody to context payload - Use thread history instead of just thread starter for new sessions Fixes #4470 --- src/auto-reply/reply/get-reply-run.ts | 14 +++-- src/auto-reply/reply/inbound-context.ts | 1 + src/auto-reply/templating.ts | 2 + src/slack/monitor/media.ts | 57 ++++++++++++++++++++ src/slack/monitor/message-handler/prepare.ts | 46 +++++++++++++++- 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 19c4df49b..59bb30eb4 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -227,10 +227,14 @@ export async function runPreparedReply( prefixedBodyBase, }); const threadStarterBody = ctx.ThreadStarterBody?.trim(); - const threadStarterNote = - isNewSession && threadStarterBody - ? `[Thread starter - for context]\n${threadStarterBody}` - : undefined; + const threadHistoryBody = ctx.ThreadHistoryBody?.trim(); + // If we have full thread history, use it instead of just the starter + const threadContextNote = + isNewSession && threadHistoryBody + ? `[Thread history - for context]\n${threadHistoryBody}` + : isNewSession && threadStarterBody + ? `[Thread starter - for context]\n${threadStarterBody}` + : undefined; const skillResult = await ensureSkillSnapshot({ sessionEntry, sessionStore, @@ -245,7 +249,7 @@ 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 = [threadContextNote, 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..801283c61 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.ThreadHistoryBody = normalizeTextField(normalized.ThreadHistoryBody); 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 1e07f6a32..810dc7a65 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; + /** Full thread history when starting a new thread session. */ + ThreadHistoryBody?: string; ThreadLabel?: string; MediaPath?: string; MediaUrl?: string; diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 2674e2d50..aaab04e54 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -122,3 +122,60 @@ export async function resolveSlackThreadStarter(params: { return null; } } + +export type SlackThreadMessage = { + text: string; + userId?: string; + ts?: string; + botId?: string; + files?: SlackFile[]; +}; + +/** + * Fetches all messages in a Slack thread (excluding the current message). + * Used to populate thread context when a new thread session starts. + */ +export async function resolveSlackThreadHistory(params: { + channelId: string; + threadTs: string; + client: SlackWebClient; + currentMessageTs?: string; + limit?: number; +}): Promise { + const maxMessages = params.limit ?? 20; + try { + const response = (await params.client.conversations.replies({ + channel: params.channelId, + ts: params.threadTs, + limit: maxMessages + 1, // +1 to account for filtering current message + inclusive: true, + })) as { + messages?: Array<{ + text?: string; + user?: string; + bot_id?: string; + ts?: string; + files?: SlackFile[]; + }>; + }; + + const messages = response?.messages ?? []; + // Filter out the current message and empty messages + return messages + .filter((msg) => { + if (!msg.text?.trim()) return false; + if (params.currentMessageTs && msg.ts === params.currentMessageTs) return false; + return true; + }) + .slice(0, maxMessages) + .map((msg) => ({ + text: msg.text ?? "", + userId: msg.user, + botId: msg.bot_id, + ts: msg.ts, + files: msg.files, + })); + } catch { + return []; + } +} diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index 8a2a9e111..55da56eac 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, + resolveSlackThreadHistory, + 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 threadHistoryBody: string | undefined; let threadLabel: string | undefined; let threadStarterMedia: Awaited> = null; if (isThreadReply && threadTs) { @@ -489,6 +494,44 @@ export async function prepareSlackMessage(params: { } else { threadLabel = `Slack thread ${roomLabel}`; } + + // Fetch full thread history for new thread sessions + // This provides context of previous messages (including bot replies) in the thread + if (!previousTimestamp) { + const threadHistory = await resolveSlackThreadHistory({ + channelId: message.channel, + threadTs, + client: ctx.app.client, + currentMessageTs: message.ts, + limit: 20, + }); + + if (threadHistory.length > 0) { + const historyParts: string[] = []; + for (const historyMsg of threadHistory) { + const msgUser = historyMsg.userId ? await ctx.resolveUserName(historyMsg.userId) : null; + const msgSenderName = + msgUser?.name ?? (historyMsg.botId ? `Bot (${historyMsg.botId})` : "Unknown"); + const isBot = Boolean(historyMsg.botId); + const role = isBot ? "assistant" : "user"; + const msgWithId = `${historyMsg.text}\n[slack message id: ${historyMsg.ts ?? "unknown"} channel: ${message.channel}]`; + historyParts.push( + formatInboundEnvelope({ + channel: "Slack", + from: `${msgSenderName} (${role})`, + timestamp: historyMsg.ts ? Math.round(Number(historyMsg.ts) * 1000) : undefined, + body: msgWithId, + chatType: "channel", + envelope: envelopeOptions, + }), + ); + } + threadHistoryBody = historyParts.join("\n\n"); + logVerbose( + `slack: populated thread history with ${threadHistory.length} messages for new session`, + ); + } + } } // Use thread starter media if current message has none @@ -516,6 +559,7 @@ export async function prepareSlackMessage(params: { MessageThreadId: threadContext.messageThreadId, ParentSessionKey: threadKeys.parentSessionKey, ThreadStarterBody: threadStarterBody, + ThreadHistoryBody: threadHistoryBody, ThreadLabel: threadLabel, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined,