From 7bebc8bd4d0f86817b8bfa445344a9472a24c50e Mon Sep 17 00:00:00 2001 From: Argenis Date: Fri, 20 Mar 2026 15:00:31 -0400 Subject: [PATCH] fix: preserve Slack session context when thread_replies=false (#4084) * fix: preserve session context across Slack messages when thread_replies=false When thread_replies=false, inbound_thread_ts() was falling back to each message's own ts, giving every message a unique conversation key and breaking multi-turn context. Now top-level messages get thread_ts=None when threading is disabled, so all messages from the same user in the same channel share one session. Closes #4052 * chore: ignore RUSTSEC-2024-0384 (unmaintained instant crate via nostr) --- src/channels/slack.rs | 97 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src/channels/slack.rs b/src/channels/slack.rs index 6cd366700..d13079a27 100644 --- a/src/channels/slack.rs +++ b/src/channels/slack.rs @@ -165,6 +165,17 @@ impl SlackChannel { .map(str::to_string) } + /// Like `inbound_thread_ts`, but only returns a value when Slack's own + /// `thread_ts` field is present (genuine thread reply). Does **not** fall + /// back to the message's `ts`, so top-level messages get `None`. Used when + /// `thread_replies=false` so that all top-level messages from the same user + /// share a single conversation session key. + fn inbound_thread_ts_genuine_only(msg: &serde_json::Value) -> Option { + msg.get("thread_ts") + .and_then(|t| t.as_str()) + .map(str::to_string) + } + /// Returns the interruption scope identifier for a Slack message. /// /// Returns `Some(thread_ts)` only when the message is a genuine thread reply @@ -1808,7 +1819,11 @@ impl SlackChannel { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(), - thread_ts: Self::inbound_thread_ts(event, ts), + thread_ts: if self.thread_replies { + Self::inbound_thread_ts(event, ts) + } else { + Self::inbound_thread_ts_genuine_only(event) + }, interruption_scope_id: Self::inbound_interruption_scope_id(event, ts), }; @@ -2373,7 +2388,11 @@ impl Channel for SlackChannel { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(), - thread_ts: Self::inbound_thread_ts(msg, ts), + thread_ts: if self.thread_replies { + Self::inbound_thread_ts(msg, ts) + } else { + Self::inbound_thread_ts_genuine_only(msg) + }, interruption_scope_id: Self::inbound_interruption_scope_id(msg, ts), }; @@ -3287,4 +3306,78 @@ mod tests { let thread_ts = SlackChannel::inbound_thread_ts(&reply, "200.000"); assert_eq!(thread_ts.as_deref(), Some("100.000")); } + + #[test] + fn inbound_thread_ts_genuine_only_returns_none_for_top_level() { + // Top-level messages don't have thread_ts in Slack's API. + let msg = serde_json::json!({ + "ts": "100.000", + "text": "hello" + }); + assert_eq!(SlackChannel::inbound_thread_ts_genuine_only(&msg), None); + } + + #[test] + fn inbound_thread_ts_genuine_only_returns_thread_ts_for_replies() { + // Thread replies have thread_ts pointing to the parent message. + let reply = serde_json::json!({ + "ts": "200.000", + "thread_ts": "100.000", + "text": "a reply" + }); + assert_eq!( + SlackChannel::inbound_thread_ts_genuine_only(&reply).as_deref(), + Some("100.000") + ); + } + + #[test] + fn session_key_stable_without_thread_replies() { + // When thread_replies=false, top-level messages from the same user should + // produce the same conversation_history_key (thread_ts=None). + use crate::channels::traits::ChannelMessage; + + let make_msg = |ts: &str| ChannelMessage { + id: format!("slack_C123_{ts}"), + sender: "U_alice".into(), + reply_target: "C123".into(), + content: "text".into(), + channel: "slack".into(), + timestamp: 0, + thread_ts: None, // thread_replies=false → no fallback to ts + interruption_scope_id: None, + }; + + let msg1 = make_msg("100.000"); + let msg2 = make_msg("200.000"); + + let key1 = super::super::conversation_history_key(&msg1); + let key2 = super::super::conversation_history_key(&msg2); + assert_eq!(key1, key2, "session key should be stable across messages"); + } + + #[test] + fn session_key_varies_with_thread_replies() { + // When thread_replies=true, top-level messages get thread_ts=Some(ts), + // giving each its own session key (thread isolation). + use crate::channels::traits::ChannelMessage; + + let make_msg = |ts: &str| ChannelMessage { + id: format!("slack_C123_{ts}"), + sender: "U_alice".into(), + reply_target: "C123".into(), + content: "text".into(), + channel: "slack".into(), + timestamp: 0, + thread_ts: Some(ts.to_string()), // thread_replies=true → ts as thread_ts + interruption_scope_id: None, + }; + + let msg1 = make_msg("100.000"); + let msg2 = make_msg("200.000"); + + let key1 = super::super::conversation_history_key(&msg1); + let key2 = super::super::conversation_history_key(&msg2); + assert_ne!(key1, key2, "session key should differ per thread"); + } }