fix(slack): normalize conversation ID to prevent thread splits#406
fix(slack): normalize conversation ID to prevent thread splits#406
Conversation
Slack thread replies included thread_ts in the conversation ID, producing a
different key than the original top-level message. This caused the main event
loop to create a second channel for the same Slack conversation, orphaning the
original worker results and outbound response routing.
Always use slack:{team}:{channel} as the conversation ID regardless of thread
context. Thread targeting for outbound replies still works via slack_thread_ts
in message metadata.
|
Caution Review failedPull request was closed or merged during review WalkthroughIntroduces routed outbound envelopes (RoutedResponse/RoutedSender) and propagates them through agent/channel, tools, cron, main, and tests. Slack message handling now uses a channel-level conversation ID; broadcast and outbound paths parse and attach optional Slack thread_ts to outgoing messages. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| } else { | ||
| format!("slack:{}:{}", team_id_str, channel_id) | ||
| }; | ||
| let base_conversation_id = format!("slack:{}:{}", team_id_str, channel_id); |
There was a problem hiding this comment.
Now that conversation_id is per-channel, watch out for cross-thread reply misrouting if multiple threads interleave: outbound routing that pulls slack_thread_ts from the current latest_message can end up replying into the wrong thread when a worker finishes later. If this shows up in practice, consider pinning the outbound target (thread_ts) from the triggering inbound message at enqueue time instead of reading it from latest_message at send time.
…ad misrouting The previous latest_message approach used a shared mutable reference that could be overwritten by a newer message from a different thread before the worker's response was delivered. With conversation IDs now per-channel (not per-thread), this race becomes possible when multiple Slack threads interleave. Introduces RoutedResponse which pairs each OutboundResponse with the InboundMessage that triggered it. The channel captures current_inbound at the start of each turn and all send sites (channel, reply, react, skip, send_file) use it to pin the outbound target. The outbound routing task in main.rs reads the paired target instead of a shared latest_message.
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/tools.rs (1)
348-359:⚠️ Potential issue | 🟠 Major
conversation_idis no longer enough for thread-aware cron defaults.After Slack IDs were normalized to
slack:{team}:{channel}, this API no longer has access to the originating thread when it wires up per-turn tools.default_delivery_target_for_conversation()can now only persist the channel root, andrun_cron_job()later broadcasts using only that stored delivery target, so reminders created inside a thread will post top-level. Pass the pinned inbound target—or at leastslack_thread_ts—throughadd_channel_tools()as well.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/tools.rs` around lines 348 - 359, The add_channel_tools function currently only receives conversation_id which has been normalized to slack:{team}:{channel}, losing thread context so default_delivery_target_for_conversation() and run_cron_job() end up posting reminders at channel root; modify the add_channel_tools signature to accept the pinned inbound delivery target or at minimum a slack_thread_ts (e.g., add a slack_thread_ts: Option<String> parameter or inbound_target: Option<DeliveryTarget>) and thread that value through to where default_delivery_target_for_conversation() is called and persisted so that run_cron_job() can use the full thread-aware delivery target when scheduling and broadcasting cron messages. Ensure callers that construct ChannelState or call add_channel_tools (the call sites creating per-turn tools) pass the thread ts/target along, and update any types (e.g., ChannelState handling or ToolServer wiring) to carry the new optional thread info.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/agent/channel.rs`:
- Around line 2246-2251: The RoutedSender is being constructed from the mutable
channel-wide self.current_inbound (and may fall back to
InboundMessage::empty()), which pins replies to a stale target; instead capture
and attach the originating inbound target to the queued batch/pending result at
enqueue time (when building the batch or pending result), and then use that
captured inbound target to construct RoutedSender and pass it into
run_agent_turn() (rather than reading self.current_inbound inside
handle_message_batch()/retrigger paths). Update the code paths around
handle_message_batch(), the queue/enqueue logic, and run_agent_turn() to accept
an InboundMessage parameter and create
RoutedSender::new(self.response_tx.clone(), captured_inbound.clone()) using that
captured value.
- Around line 2289-2291: The send currently uses a discarded Result via "let _ =
self.send_routed(OutboundResponse::Status(crate::StatusUpdate::Thinking)).await;"
which violates the repo rule—replace it with the sanctioned dropped-send form by
calling .ok() on the awaited Result: invoke
self.send_routed(OutboundResponse::Status(crate::StatusUpdate::Thinking)).await.ok();
so the send_routed call (and its OutboundResponse::Status/StatusUpdate::Thinking
payload) remains best-effort while conforming to the repository pattern.
---
Outside diff comments:
In `@src/tools.rs`:
- Around line 348-359: The add_channel_tools function currently only receives
conversation_id which has been normalized to slack:{team}:{channel}, losing
thread context so default_delivery_target_for_conversation() and run_cron_job()
end up posting reminders at channel root; modify the add_channel_tools signature
to accept the pinned inbound delivery target or at minimum a slack_thread_ts
(e.g., add a slack_thread_ts: Option<String> parameter or inbound_target:
Option<DeliveryTarget>) and thread that value through to where
default_delivery_target_for_conversation() is called and persisted so that
run_cron_job() can use the full thread-aware delivery target when scheduling and
broadcasting cron messages. Ensure callers that construct ChannelState or call
add_channel_tools (the call sites creating per-turn tools) pass the thread
ts/target along, and update any types (e.g., ChannelState handling or ToolServer
wiring) to carry the new optional thread info.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ad79f104-a684-47b0-b539-270e730454c1
📒 Files selected for processing (9)
src/agent/channel.rssrc/cron/scheduler.rssrc/lib.rssrc/main.rssrc/tools.rssrc/tools/react.rssrc/tools/reply.rssrc/tools/send_file.rssrc/tools/skip.rs
✅ Files skipped from review due to trivial changes (1)
- src/main.rs
Update context_dump tests to use RoutedSender. Fix collapsible_if warnings in send_message_to_another_channel.rs.
- Set current_inbound in handle_message_batch() from the last non-system message so the RoutedSender carries correct routing metadata (e.g. Slack thread_ts) instead of stale/empty data. - Replace `let _ =` with `.ok()` on best-effort send_routed calls for Thinking and StopTyping status updates per repo conventions. - Thread slack_thread_ts through add_channel_tools into the cron default delivery target so cron jobs created inside a Slack thread post results back into that thread. Encodes thread_ts as a #thread:<ts> suffix in the broadcast target string, parsed by Slack's broadcast() method.
Summary
thread_tsin the conversation ID (slack:TEAM:CHANNEL:THREAD_TS), which differed from the top-level form (slack:TEAM:CHANNEL). This caused the main event loop to create a second, disconnected channel for the same Slack conversation — orphaning the original worker results and outbound response routing.slack:{team}:{channel}as the conversation ID. Thread targeting for outbound replies is unaffected sinceslack_thread_tsis read from message metadata inextract_thread_ts().Bug behavior
latest_messagewhich has noslack_thread_tsmetadata → reply either posted as top-level message or lost?in the thread → different conversation ID → new channel created → immediate response from fresh contextReported by Tyler and Ohmna in #debug — worker dashboard showed the response was generated but it never reached Slack.
Changes
Three sites in
slack.rswhere conversation IDs are constructed:handle_message_eventhandle_app_mention_eventhandle_block_actionsAll now use the stable
slack:{team}:{channel}form.Note
This collapses all threads in a single Slack channel into one conversation context. If per-thread isolation is needed later, the conversation ID can be re-keyed with thread_ts but would need reconciliation logic in the main loop to avoid the orphaning problem.
Note
This fix removes thread-specific conversation ID splitting across three event handlers in
slack.rs, ensuring all interactions in a channel route to the same conversation context. Thread targeting for replies is preserved via existing metadata extraction, resolving worker result orphaning observed in thread responses.Written by Tembo for commit 8f20589.