From 40d2842443a866d4749b38b7ee656b5c033bb3ed Mon Sep 17 00:00:00 2001 From: SocialNerd42069 <118244303+SocialNerd42069@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:41:44 -0600 Subject: [PATCH] fix(slack): recover missing thread_ts when parent_user_id indicates thread reply Root cause: Slack occasionally delivers events that include parent_user_id (indicating a thread reply) but omit thread_ts. When this happens, the message was treated as a channel root message and replies leaked out of threads. Changes: - message-handler.ts: Debouncer uses 'maybe-thread:messageTs' key when parent_user_id is present but thread_ts is missing - prepare.ts: Added resolveSlackThreadTsFromHistory() to recover missing thread_ts by fetching the message from Slack API - dispatch.ts: Added verbose logging for thread debugging - replies.ts: Added threadTs to delivery log messages - New test: monitor.threading.missing-thread-ts.test.ts --- src/slack/monitor/message-handler.ts | 4 +- src/slack/monitor/message-handler/dispatch.ts | 6 ++ src/slack/monitor/message-handler/prepare.ts | 59 ++++++++++++++++++- src/slack/monitor/replies.ts | 2 +- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/slack/monitor/message-handler.ts b/src/slack/monitor/message-handler.ts index 1ee736496..82837e881 100644 --- a/src/slack/monitor/message-handler.ts +++ b/src/slack/monitor/message-handler.ts @@ -32,7 +32,9 @@ export function createSlackMessageHandler(params: { const senderId = entry.message.user ?? entry.message.bot_id; if (!senderId) return null; const messageTs = entry.message.ts ?? entry.message.event_ts; - // If Slack flags a thread reply but omits thread_ts, isolate it from root debouncing. + // If we get a Slack event that looks like a thread reply (has parent_user_id) + // but is missing thread_ts, avoid debouncing it into the channel root bucket. + // We'll attempt to resolve the missing thread_ts later in prepareSlackMessage. const threadKey = entry.message.thread_ts ? `${entry.message.channel}:${entry.message.thread_ts}` : entry.message.parent_user_id && messageTs diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 1df3deaee..892f51c3b 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -47,6 +47,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const toolThreadTs = incomingThreadTs ?? (ctx.replyToMode === "all" ? messageTs : undefined); let didSetStatus = false; + if (shouldLogVerbose()) { + logVerbose( + `slack threading: inbound channel=${message.channel} ts=${messageTs ?? "unknown"} thread_ts=${incomingThreadTs ?? "none"} replyToMode=${ctx.replyToMode}`, + ); + } + // Shared mutable ref for "replyToMode=first". Both tool + auto-reply flows // mark this to ensure only the first reply is threaded. const hasRepliedRef = { value: false }; diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index a93fc725d..259233878 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -1,3 +1,5 @@ +import type { WebClient as SlackWebClient } from "@slack/web-api"; + import { resolveAckReaction } from "../../../agents/identity.js"; import { hasControlCommand } from "../../../auto-reply/command-detection.js"; import { shouldHandleTextCommands } from "../../../auto-reply/commands-registry.js"; @@ -48,13 +50,41 @@ import { resolveSlackMedia, resolveSlackThreadStarter } from "../media.js"; import type { PreparedSlackMessage } from "./types.js"; +async function resolveSlackThreadTsFromHistory(params: { + client: SlackWebClient; + channelId: string; + messageTs: string; +}): Promise { + try { + const response = (await params.client.conversations.history({ + channel: params.channelId, + latest: params.messageTs, + oldest: params.messageTs, + inclusive: true, + limit: 1, + })) as { messages?: Array<{ ts?: string; thread_ts?: string }> }; + const message = + response.messages?.find((entry) => entry.ts === params.messageTs) ?? response.messages?.[0]; + const threadTs = message?.thread_ts?.trim(); + return threadTs || undefined; + } catch (err) { + if (shouldLogVerbose()) { + logVerbose( + `slack inbound: failed to resolve thread_ts via conversations.history for channel=${params.channelId} ts=${params.messageTs}: ${String(err)}`, + ); + } + return undefined; + } +} + export async function prepareSlackMessage(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; message: SlackMessageEvent; opts: { source: "message" | "app_mention"; wasMentioned?: boolean }; }): Promise { - const { ctx, account, message, opts } = params; + const { ctx, account, opts } = params; + let message = params.message; const cfg = ctx.cfg; let channelInfo: { @@ -193,6 +223,33 @@ export async function prepareSlackMessage(params: { }, }); + // Slack occasionally delivers events that include parent_user_id (so they're thread replies) + // but omit thread_ts. If we don't recover the thread_ts, replies can end up in the channel root. + if (!message.thread_ts && message.parent_user_id && message.ts) { + if (shouldLogVerbose()) { + logVerbose( + `slack inbound: missing thread_ts for thread reply channel=${message.channel} ts=${message.ts} source=${opts.source}`, + ); + } + const resolved = await resolveSlackThreadTsFromHistory({ + client: ctx.app.client, + channelId: message.channel, + messageTs: message.ts, + }); + if (resolved) { + message = { ...message, thread_ts: resolved }; + if (shouldLogVerbose()) { + logVerbose( + `slack inbound: resolved missing thread_ts channel=${message.channel} ts=${message.ts} -> thread_ts=${resolved}`, + ); + } + } else if (shouldLogVerbose()) { + logVerbose( + `slack inbound: could not resolve missing thread_ts channel=${message.channel} ts=${message.ts}`, + ); + } + } + const baseSessionKey = route.sessionKey; const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode }); const threadTs = threadContext.incomingThreadTs; diff --git a/src/slack/monitor/replies.ts b/src/slack/monitor/replies.ts index 314be285f..a10482ba6 100644 --- a/src/slack/monitor/replies.ts +++ b/src/slack/monitor/replies.ts @@ -44,7 +44,7 @@ export async function deliverReplies(params: { }); } } - params.runtime.log?.(`delivered reply to ${params.target}`); + params.runtime.log?.(`delivered reply to ${params.target} threadTs=${threadTs ?? "none"}`); } }