From 578ac9f1a992be567352d2b983b329bce1d66a5d Mon Sep 17 00:00:00 2001 From: Travis Irby Date: Thu, 22 Jan 2026 21:55:03 -0500 Subject: [PATCH] hydrate files from thread root message on replies When replying to a Slack thread, files attached to the root message were not being fetched. The existing `resolveSlackThreadStarter()` fetched the root message text via `conversations.replies` but ignored the `files[]` array in the response. Changes: - Add `files` to `SlackThreadStarter` type and extract from API response - Download thread starter files when the reply message has no attachments - Add verbose log for thread starter file hydration Fixes issue where asking about a PDF in a thread reply would fail because the model never received the file content from the root message. --- src/slack/monitor/media.ts | 4 +++- src/slack/monitor/message-handler/prepare.ts | 23 +++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 4a51e9abd..143d6b36f 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -53,6 +53,7 @@ export type SlackThreadStarter = { text: string; userId?: string; ts?: string; + files?: SlackFile[]; }; const THREAD_STARTER_CACHE = new Map(); @@ -71,7 +72,7 @@ export async function resolveSlackThreadStarter(params: { ts: params.threadTs, limit: 1, inclusive: true, - })) as { messages?: Array<{ text?: string; user?: string; ts?: string }> }; + })) as { messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }> }; const message = response?.messages?.[0]; const text = (message?.text ?? "").trim(); if (!message || !text) return null; @@ -79,6 +80,7 @@ export async function resolveSlackThreadStarter(params: { text, userId: message.user, ts: message.ts, + files: message.files, }; THREAD_STARTER_CACHE.set(cacheKey, starter); return starter; diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index c76ae4285..767ecea89 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -434,6 +434,7 @@ export async function prepareSlackMessage(params: { let threadStarterBody: string | undefined; let threadLabel: string | undefined; + let threadStarterMedia: Awaited> = null; if (isThreadReply && threadTs) { const starter = await resolveSlackThreadStarter({ channelId: message.channel, @@ -453,11 +454,27 @@ export async function prepareSlackMessage(params: { }); const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80); threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`; + // If current message has no files but thread starter does, fetch starter's files + if (!media && starter.files && starter.files.length > 0) { + threadStarterMedia = await resolveSlackMedia({ + files: starter.files, + token: ctx.botToken, + maxBytes: ctx.mediaMaxBytes, + }); + if (threadStarterMedia) { + logVerbose( + `slack: hydrated thread starter file ${threadStarterMedia.placeholder} from root message`, + ); + } + } } else { threadLabel = `Slack thread ${roomLabel}`; } } + // Use thread starter media if current message has none + const effectiveMedia = media ?? threadStarterMedia; + const ctxPayload = finalizeInboundContext({ Body: combinedBody, RawBody: rawBody, @@ -483,9 +500,9 @@ export async function prepareSlackMessage(params: { ThreadLabel: threadLabel, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, WasMentioned: isRoomish ? effectiveWasMentioned : undefined, - MediaPath: media?.path, - MediaType: media?.contentType, - MediaUrl: media?.path, + MediaPath: effectiveMedia?.path, + MediaType: effectiveMedia?.contentType, + MediaUrl: effectiveMedia?.path, CommandAuthorized: commandAuthorized, OriginatingChannel: "slack" as const, OriginatingTo: slackTo,