From cd7b2814afa2ff308e5dd6e7b379017d594bcd28 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 22 Feb 2026 13:26:31 -0500 Subject: [PATCH] fix(slack): preserve string thread context in queue + DM route (#23804) * fix(slack): preserve thread_ts in queue drain and deliveryContext Two related fixes for Slack thread reply routing: 1. Queue drain drops string thread_ts (#11195) - `typeof threadId === "number"` in drain.ts only matches Telegram numeric topic IDs. Slack thread_ts is a string like "1770474140.187459" which fails the check, causing threadKey to become empty. - Changed to `threadId != null && threadId !== ""` to accept both number and string thread IDs. - Applies to all 3 occurrences in drain.ts: cross-channel detection, thread key building, and collected originatingThreadId extraction. 2. DM deliveryContext missing thread_ts (#10837) - updateLastRoute calls for Slack DMs in both prepare.ts and dispatch.ts built deliveryContext without threadId, so the session's delivery context never included thread_ts for DM threads. - Added threadId from threadContext.messageThreadId / ctxPayload.MessageThreadId to both updateLastRoute call sites. Tests: 3 new cases in queue.collect-routing.test.ts - Collects messages with matching string thread_ts (same Slack thread) - Separates messages with different string thread_ts (different threads) - Treats empty string threadId same as absent Closes #10837, closes #11195 * fix(slack): preserve string thread context in queue + DM route updates --------- Co-authored-by: RobClawd --- CHANGELOG.md | 1 + src/auto-reply/reply/queue/drain.ts | 10 ++++++---- src/slack/monitor/message-handler/dispatch.ts | 1 + src/slack/monitor/message-handler/prepare.ts | 1 + 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db9df49cc..7ac3703aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai - Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic. - Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. - Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13. +- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2. - Telegram/Native commands: set `ctx.Provider="telegram"` for native slash-command context so elevated gate checks resolve provider correctly (fixes `provider (ctx.Provider)` failures in `/elevated` flows). (#23748) Thanks @serhii12. - Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester. - Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr. diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index c0bcc2c20..75e6ffa07 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -30,7 +30,7 @@ export function scheduleFollowupDrain( // Once the batch is mixed, never collect again within this drain. // Prevents “collect after shift” collapsing different targets. // - // Debug: `pnpm test src/auto-reply/reply/queue.collect-routing.test.ts` + // Debug: `pnpm test src/auto-reply/reply/reply-flow.test.ts` // Check if messages span multiple channels. // If so, process individually to preserve per-message routing. const isCrossChannel = hasCrossChannelItems(queue.items, (item) => { @@ -38,13 +38,14 @@ export function scheduleFollowupDrain( const to = item.originatingTo; const accountId = item.originatingAccountId; const threadId = item.originatingThreadId; - if (!channel && !to && !accountId && threadId == null) { + if (!channel && !to && !accountId && (threadId == null || threadId === "")) { return {}; } if (!isRoutableChannel(channel) || !to) { return { cross: true }; } - const threadKey = threadId != null ? String(threadId) : ""; + // Support both number (Telegram topic IDs) and string (Slack thread_ts) thread IDs. + const threadKey = threadId != null && threadId !== "" ? String(threadId) : ""; return { key: [channel, to, accountId || "", threadKey].join("|"), }; @@ -76,8 +77,9 @@ export function scheduleFollowupDrain( const originatingAccountId = items.find( (i) => i.originatingAccountId, )?.originatingAccountId; + // Support both number (Telegram topic) and string (Slack thread_ts) thread IDs. const originatingThreadId = items.find( - (i) => i.originatingThreadId != null, + (i) => i.originatingThreadId != null && i.originatingThreadId !== "", )?.originatingThreadId; const prompt = buildCollectPrompt({ diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 6fa51f993..afcfdd626 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -80,6 +80,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag channel: "slack", to: `user:${message.user}`, accountId: route.accountId, + threadId: prepared.ctxPayload.MessageThreadId, }, ctx: prepared.ctxPayload, }); diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index e0cf23aee..7f6100469 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -642,6 +642,7 @@ export async function prepareSlackMessage(params: { channel: "slack", to: `user:${message.user}`, accountId: route.accountId, + threadId: threadContext.messageThreadId, } : undefined, onRecordError: (err) => {