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
This commit is contained in:
SocialNerd42069 2026-01-21 16:41:44 -06:00
parent 246216b1f8
commit 40d2842443
4 changed files with 68 additions and 3 deletions

View File

@ -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

View File

@ -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 };

View File

@ -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<string | undefined> {
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<PreparedSlackMessage | null> {
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;

View File

@ -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"}`);
}
}