Skip to main content

orch/channels/
transport.rs

1//! Transport layer — connects channels to tmux agent sessions.
2//!
3//! The transport is the glue between external channels and local tmux sessions.
4//! When a user sends a message on Telegram, Discord, or GitHub, the transport:
5//! 1. Routes it to the correct tmux session (or creates a task)
6//! 2. Captures agent output from tmux and broadcasts to all connected channels
7//!
8//! Architecture:
9//!
10//!   User (Telegram) ─────┐
11//!   User (Discord) ──────┤
12//!   User (GitHub Issue) ─┤
13//!                        ▼
14//!                  ┌───────────┐
15//!                  │ Transport │  ← routes messages, manages sessions
16//!                  └─────┬─────┘
17//!                        │
18//!          ┌─────────────┼─────────────┐
19//!          ▼             ▼             ▼
20//!   tmux:orch-42   tmux:orch-43   tmux:main
21//!   (task agent)   (task agent)   (chat session)
22//!          │             │             │
23//!          └─────────────┼─────────────┘
24//!                        ▼
25//!                  ┌───────────┐
26//!                  │ Broadcast │  ← fans out output to all connected channels
27//!                  └───────────┘
28
29use super::notification::TaskNotification;
30use super::{split_utf8, IncomingMessage, OutputChunk};
31use std::collections::HashMap;
32use std::sync::Arc;
33use tokio::sync::{broadcast, RwLock};
34
35const MAX_OUTPUT_CHUNK_BYTES: usize = 64 * 1024;
36
37/// Build a globally unique session key from `(repo, task_id)`.
38///
39/// External task IDs (e.g. `"42"` for GitHub issue #42) are only unique
40/// within a repo. Internal tasks (`"internal:<id>"`) are globally unique.
41/// This function prefixes external task IDs with the repo slug to avoid
42/// collisions across repos while keeping internal task keys unchanged.
43pub fn session_key(repo: &str, task_id: &str) -> String {
44    if task_id.starts_with("internal:") {
45        task_id.to_string()
46    } else {
47        format!("{repo}:{task_id}")
48    }
49}
50
51/// Parse a session key created by [`session_key`] back into `(repo, task_id)`.
52///
53/// Returns `None` if the key is malformed.
54pub fn parse_session_key(key: &str) -> Option<(&str, &str)> {
55    if key.starts_with("internal:") {
56        // Internal tasks don't have a repo component in the key.
57        // Callers that need repo must track it separately.
58        None
59    } else {
60        // Format is "repo:task_id" — repo itself may contain "/" but not ":"
61        // The first ":" separates repo from task_id.
62        let (repo, task_id) = key.split_once(':')?;
63        Some((repo, task_id))
64    }
65}
66
67/// A live connection between a channel thread and a tmux session.
68#[derive(Debug, Clone)]
69pub struct SessionBinding {
70    /// tmux session name (e.g. "orch-myproject-42")
71    pub tmux_session: String,
72    /// Channel threads connected to this session.
73    ///
74    /// Each entry is a canonical conversation key produced by
75    /// [`conversation_key`].  The format is either:
76    ///   - `"channel:thread_id"` (no topic), or
77    ///   - `"channel:thread_id|topic_id"` (with topic/thread)
78    ///
79    /// Use [`parse_conversation_key`] to decompose a key back into its parts.
80    pub connected_threads: Vec<String>,
81    /// Broadcast sender for output streaming
82    pub output_tx: broadcast::Sender<OutputChunk>,
83}
84
85/// Build a canonical conversation key that is unique per topic/thread when
86/// `topic_id` is present, and falls back to `channel:thread_id` otherwise.
87///
88/// Telegram and Discord encode the forum-topic / thread identity in `topic_id`
89/// while `thread_id` carries the parent chat / channel id.  Two different topics
90/// inside the same parent would share the same `"channel:thread_id"` key —
91/// causing bindings to collide.  Including `topic_id` in the key avoids that.
92///
93/// `|` is used as the topic separator because it cannot appear in Telegram or
94/// Discord snowflake IDs (they are numeric / alphanumeric).
95pub fn conversation_key(channel: &str, thread_id: &str, topic_id: Option<&str>) -> String {
96    match topic_id {
97        Some(tid) if !tid.is_empty() => format!("{channel}:{thread_id}|{tid}"),
98        _ => format!("{channel}:{thread_id}"),
99    }
100}
101
102/// Decompose a conversation key created by [`conversation_key`] back into
103/// `(channel, thread_id, topic_id)`.
104///
105/// Returns `None` if the key is malformed (no `':'` separator).
106pub fn parse_conversation_key(key: &str) -> Option<(&str, &str, Option<&str>)> {
107    let (channel, rest) = key.split_once(':')?;
108    let (thread_id, topic_id) = match rest.split_once('|') {
109        Some((tid, topic)) => (tid, Some(topic)),
110        None => (rest, None),
111    };
112    Some((channel, thread_id, topic_id))
113}
114
115/// The transport layer manages all session bindings and routes messages.
116pub struct Transport {
117    /// Active session bindings, keyed by session_key(repo, task_id)
118    bindings: Arc<RwLock<HashMap<String, SessionBinding>>>,
119    /// Reverse lookup: conversation_key → session_key(repo, task_id)
120    thread_to_task: Arc<RwLock<HashMap<String, String>>>,
121    /// Broadcast sender for task completion notifications
122    notification_tx: broadcast::Sender<TaskNotification>,
123}
124
125impl Transport {
126    pub fn new() -> Self {
127        let (notification_tx, _) = broadcast::channel(64);
128        Self {
129            bindings: Arc::new(RwLock::new(HashMap::new())),
130            thread_to_task: Arc::new(RwLock::new(HashMap::new())),
131            notification_tx,
132        }
133    }
134
135    /// Bind a channel thread to a task's tmux session.
136    ///
137    /// `repo` is required to build a globally unique session key, preventing
138    /// collisions when multiple repos share the same external task ID.
139    ///
140    /// `topic_id` should be set for topic-aware channels (Telegram forum topics,
141    /// Discord threads) so that bindings for different topics inside the same
142    /// parent chat / channel do not collide.
143    pub async fn bind(
144        &self,
145        repo: &str,
146        task_id: &str,
147        tmux_session: &str,
148        channel: &str,
149        thread_id: &str,
150        topic_id: Option<&str>,
151    ) {
152        let key = conversation_key(channel, thread_id, topic_id);
153        let skey = session_key(repo, task_id);
154        // Update bindings synchronously (no await while lock held)
155        {
156            let mut bindings = self.bindings.write().await;
157            let binding = bindings.entry(skey.clone()).or_insert_with(|| {
158                let (tx, _) = broadcast::channel(256);
159                SessionBinding {
160                    tmux_session: tmux_session.to_string(),
161                    connected_threads: Vec::new(),
162                    output_tx: tx,
163                }
164            });
165            // Always update tmux_session — task retries get a new session
166            binding.tmux_session = tmux_session.to_string();
167            if !binding.connected_threads.contains(&key) {
168                binding.connected_threads.push(key.clone());
169            }
170        } // bindings lock released here
171          // Update thread_to_task after releasing bindings lock
172        self.thread_to_task.write().await.insert(key, skey);
173    }
174
175    /// Get the broadcast receiver for a task's output stream.
176    pub async fn subscribe(
177        &self,
178        repo: &str,
179        task_id: &str,
180    ) -> Option<broadcast::Receiver<OutputChunk>> {
181        let skey = session_key(repo, task_id);
182        let bindings = self.bindings.read().await;
183        bindings.get(&skey).map(|b| b.output_tx.subscribe())
184    }
185
186    /// Push an output chunk to all subscribers of a task.
187    pub async fn push_output(&self, repo: &str, task_id: &str, chunk: OutputChunk) {
188        let skey = session_key(repo, task_id);
189        if chunk.content.is_empty() {
190            let bindings = self.bindings.read().await;
191            if let Some(binding) = bindings.get(&skey) {
192                let _ = binding.output_tx.send(chunk.clone());
193            }
194            return;
195        }
196
197        let parts = split_utf8(&chunk.content, MAX_OUTPUT_CHUNK_BYTES);
198        let last_index = parts.len().saturating_sub(1);
199
200        for (idx, part) in parts.into_iter().enumerate() {
201            let part_chunk = OutputChunk {
202                content: part,
203                is_final: chunk.is_final && idx == last_index,
204            };
205            let bindings = self.bindings.read().await;
206            if let Some(binding) = bindings.get(&skey) {
207                let _ = binding.output_tx.send(part_chunk.clone());
208            }
209        }
210    }
211
212    /// Get the session binding for a specific task, if any.
213    pub async fn get_binding(&self, repo: &str, task_id: &str) -> Option<SessionBinding> {
214        let skey = session_key(repo, task_id);
215        self.bindings.read().await.get(&skey).cloned()
216    }
217
218    /// Route an incoming message to the appropriate handler.
219    /// Returns the session_key if this message maps to an existing session.
220    pub async fn route(&self, msg: &IncomingMessage) -> MessageRoute {
221        let key = conversation_key(&msg.channel, &msg.thread_id, msg.topic_id.as_deref());
222
223        // Check if this thread is bound to a task
224        if let Some(session_key) = self.thread_to_task.read().await.get(&key) {
225            return MessageRoute::TaskSession {
226                session_key: session_key.clone(),
227            };
228        }
229
230        // Check if this looks like a command
231        let body = msg.body.trim();
232        if body.starts_with('/') || body.starts_with("orch ") {
233            return MessageRoute::Command {
234                raw: body.to_string(),
235            };
236        }
237
238        // New conversation — could become a task
239        MessageRoute::NewTask
240    }
241
242    /// Unbind a task session, removing all associated entries.
243    ///
244    /// Removes the entry from `bindings` and all reverse-lookup entries from
245    /// `thread_to_task` whose value matches the session key. Call this when a
246    /// task session ends so memory does not grow indefinitely.
247    pub async fn unbind(&self, repo: &str, task_id: &str) {
248        let skey = session_key(repo, task_id);
249
250        // Remove the binding and collect its connected threads.
251        let connected_threads = {
252            let mut bindings = self.bindings.write().await;
253            bindings
254                .remove(&skey)
255                .map(|b| b.connected_threads)
256                .unwrap_or_default()
257        };
258
259        // Remove reverse-lookup entries for all connected threads.
260        if !connected_threads.is_empty() {
261            let mut t2t = self.thread_to_task.write().await;
262            for key in &connected_threads {
263                t2t.remove(key);
264            }
265        }
266    }
267
268    /// List all active bindings.
269    pub async fn active_sessions(&self) -> Vec<SessionBinding> {
270        self.bindings.read().await.values().cloned().collect()
271    }
272
273    /// Push a task completion notification to all subscribers.
274    pub fn push_notification(&self, notification: TaskNotification) {
275        // Ignore send errors (no active receivers)
276        let _ = self.notification_tx.send(notification);
277    }
278
279    /// Subscribe to task completion notifications.
280    pub fn subscribe_notifications(&self) -> broadcast::Receiver<TaskNotification> {
281        self.notification_tx.subscribe()
282    }
283}
284
285/// How an incoming message should be handled.
286#[derive(Debug)]
287pub enum MessageRoute {
288    /// Message belongs to an existing task session — forward to tmux.
289    /// `session_key` is the globally unique key (repo:task_id or internal:id).
290    TaskSession { session_key: String },
291    /// Message is a command (e.g. "/status", "orch task list")
292    Command { raw: String },
293    /// Message is in the configured control session channel — route to control agent
294    ControlSession,
295    /// Message is new — should create a task
296    NewTask,
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[tokio::test]
304    async fn push_notification_reaches_subscriber() {
305        let transport = Transport::new();
306        let mut rx = transport.subscribe_notifications();
307
308        let notification = TaskNotification {
309            task_id: "42".to_string(),
310            title: "Test task".to_string(),
311            status: "done".to_string(),
312            agent: "claude".to_string(),
313            duration_seconds: 60.0,
314            summary: "Completed successfully".to_string(),
315            repo: None,
316            notify_target: None,
317            pr_number: None,
318        };
319
320        transport.push_notification(notification.clone());
321
322        let received = rx.recv().await.unwrap();
323        assert_eq!(received.task_id, "42");
324        assert_eq!(received.status, "done");
325        assert_eq!(received.agent, "claude");
326        assert_eq!(received.summary, "Completed successfully");
327    }
328
329    #[tokio::test]
330    async fn push_notification_multiple_subscribers() {
331        let transport = Transport::new();
332        let mut rx1 = transport.subscribe_notifications();
333        let mut rx2 = transport.subscribe_notifications();
334
335        transport.push_notification(TaskNotification {
336            task_id: "1".to_string(),
337            title: "Task".to_string(),
338            status: "done".to_string(),
339            agent: "codex".to_string(),
340            duration_seconds: 10.0,
341            summary: "Done".to_string(),
342            repo: None,
343            notify_target: None,
344            pr_number: None,
345        });
346
347        let n1 = rx1.recv().await.unwrap();
348        let n2 = rx2.recv().await.unwrap();
349        assert_eq!(n1.task_id, "1");
350        assert_eq!(n2.task_id, "1");
351    }
352
353    #[test]
354    fn split_chunks_multibyte_at_boundary_does_not_panic() {
355        // 4-byte emoji at the chunk boundary: the old code set end = start + 1
356        // and then sliced content[start..end], which panics on multi-byte chars.
357        let content = "x".repeat(97) + "😀"; // 101 bytes; with max=100 the cut is at byte 100 (mid-emoji)
358        let chunks = split_utf8(&content, 100);
359        assert_eq!(chunks.join(""), content);
360        for c in &chunks {
361            assert!(std::str::from_utf8(c.as_bytes()).is_ok());
362        }
363    }
364
365    #[test]
366    fn split_chunks_only_emojis() {
367        // Every boundary falls inside a 4-byte character.
368        let content = "😀".repeat(50); // 200 bytes
369        let chunks = split_utf8(&content, 10); // 10 bytes per chunk, emoji is 4 bytes
370        assert_eq!(chunks.join(""), content);
371        for c in &chunks {
372            assert!(std::str::from_utf8(c.as_bytes()).is_ok());
373        }
374    }
375
376    #[test]
377    fn push_notification_no_subscribers_does_not_panic() {
378        let transport = Transport::new();
379        // No subscribers — should not panic
380        transport.push_notification(TaskNotification {
381            task_id: "1".to_string(),
382            title: "Task".to_string(),
383            status: "done".to_string(),
384            agent: "claude".to_string(),
385            duration_seconds: 0.0,
386            summary: "Done".to_string(),
387            repo: None,
388            notify_target: None,
389            pr_number: None,
390        });
391    }
392
393    #[tokio::test]
394    async fn bind_and_route_to_session() {
395        let transport = Transport::new();
396        transport
397            .bind(
398                "owner/repo",
399                "42",
400                "orch-myproject-42",
401                "telegram",
402                "12345",
403                None,
404            )
405            .await;
406
407        let msg = IncomingMessage {
408            channel: "telegram".to_string(),
409            id: "msg1".to_string(),
410            thread_id: "12345".to_string(),
411            author: "user".to_string(),
412            body: "hello".to_string(),
413            timestamp: chrono::Utc::now(),
414            metadata: serde_json::json!({}),
415            topic_id: None,
416        };
417
418        match transport.route(&msg).await {
419            MessageRoute::TaskSession { session_key } => {
420                assert_eq!(session_key, "owner/repo:42");
421            }
422            _ => panic!("expected TaskSession"),
423        }
424    }
425
426    #[tokio::test]
427    async fn route_command() {
428        let transport = Transport::new();
429
430        let msg = IncomingMessage {
431            channel: "telegram".to_string(),
432            id: "msg1".to_string(),
433            thread_id: "99".to_string(),
434            author: "user".to_string(),
435            body: "/status".to_string(),
436            timestamp: chrono::Utc::now(),
437            metadata: serde_json::json!({}),
438            topic_id: None,
439        };
440
441        match transport.route(&msg).await {
442            MessageRoute::Command { raw } => assert_eq!(raw, "/status"),
443            _ => panic!("expected Command"),
444        }
445    }
446
447    // ── session_key ──────────────────────────────────────────────────────────
448
449    #[test]
450    fn session_key_external_is_prefixed_with_repo() {
451        assert_eq!(session_key("owner/repo", "42"), "owner/repo:42");
452    }
453
454    #[test]
455    fn session_key_internal_is_unchanged() {
456        assert_eq!(session_key("owner/repo", "internal:99"), "internal:99");
457    }
458
459    #[test]
460    fn session_key_same_id_different_repos_are_unique() {
461        let k1 = session_key("owner/repo-a", "42");
462        let k2 = session_key("owner/repo-b", "42");
463        assert_ne!(k1, k2);
464        assert_eq!(k1, "owner/repo-a:42");
465        assert_eq!(k2, "owner/repo-b:42");
466    }
467
468    // ── cross-repo collision regression ──────────────────────────────────────
469
470    /// Two repos with the same external task ID must not collide in bindings.
471    #[tokio::test]
472    async fn same_external_id_different_repos_do_not_collide() {
473        let transport = Transport::new();
474
475        // Repo A binds task "42"
476        transport
477            .bind(
478                "owner/repo-a",
479                "42",
480                "orch-repo-a-42",
481                "telegram",
482                "111",
483                None,
484            )
485            .await;
486        // Repo B binds task "42" (same external ID, different repo)
487        transport
488            .bind(
489                "owner/repo-b",
490                "42",
491                "orch-repo-b-42",
492                "telegram",
493                "222",
494                None,
495            )
496            .await;
497
498        // Each binding has its own session
499        let binding_a = transport.get_binding("owner/repo-a", "42").await.unwrap();
500        let binding_b = transport.get_binding("owner/repo-b", "42").await.unwrap();
501        assert_eq!(binding_a.tmux_session, "orch-repo-a-42");
502        assert_eq!(binding_b.tmux_session, "orch-repo-b-42");
503        assert_ne!(binding_a.tmux_session, binding_b.tmux_session);
504    }
505
506    /// Same external task ID in two repos: pushing output to one must not
507    /// reach the other's subscribers.
508    #[tokio::test]
509    async fn push_output_isolated_across_repos_with_same_task_id() {
510        let transport = Transport::new();
511
512        // Subscribe to repo-a's task 42
513        transport
514            .bind("owner/repo-a", "42", "orch-a-42", "cli", "stream-a", None)
515            .await;
516        let mut rx_a = transport.subscribe("owner/repo-a", "42").await.unwrap();
517
518        // Subscribe to repo-b's task 42
519        transport
520            .bind("owner/repo-b", "42", "orch-b-42", "cli", "stream-b", None)
521            .await;
522        let mut rx_b = transport.subscribe("owner/repo-b", "42").await.unwrap();
523
524        // Push output to repo-a's task 42
525        transport
526            .push_output(
527                "owner/repo-a",
528                "42",
529                OutputChunk {
530                    content: "hello from repo-a".to_string(),
531                    is_final: false,
532                },
533            )
534            .await;
535
536        // repo-a receives it
537        let chunk_a = tokio::time::timeout(std::time::Duration::from_millis(100), rx_a.recv())
538            .await
539            .unwrap()
540            .unwrap();
541        assert_eq!(chunk_a.content, "hello from repo-a");
542
543        // repo-b should NOT receive anything (timeout)
544        let result_b =
545            tokio::time::timeout(std::time::Duration::from_millis(100), rx_b.recv()).await;
546        assert!(
547            result_b.is_err(),
548            "repo-b should not receive repo-a's output"
549        );
550    }
551
552    // ── conversation_key ──────────────────────────────────────────────────────
553
554    #[test]
555    fn conversation_key_without_topic() {
556        let key = conversation_key("telegram", "12345", None);
557        assert_eq!(key, "telegram:12345");
558    }
559
560    #[test]
561    fn conversation_key_with_topic() {
562        let key = conversation_key("telegram", "12345", Some("678"));
563        assert_eq!(key, "telegram:12345|678");
564    }
565
566    #[test]
567    fn conversation_key_empty_topic_treated_as_none() {
568        // An empty topic_id should produce the same key as None.
569        let key_empty = conversation_key("discord", "abc", Some(""));
570        let key_none = conversation_key("discord", "abc", None);
571        assert_eq!(key_empty, key_none);
572    }
573
574    // ── parse_conversation_key ────────────────────────────────────────────────
575
576    #[test]
577    fn parse_key_without_topic() {
578        let (channel, thread_id, topic_id) =
579            parse_conversation_key("telegram:12345").expect("valid key");
580        assert_eq!(channel, "telegram");
581        assert_eq!(thread_id, "12345");
582        assert!(topic_id.is_none());
583    }
584
585    #[test]
586    fn parse_key_with_topic() {
587        let (channel, thread_id, topic_id) =
588            parse_conversation_key("telegram:12345|678").expect("valid key");
589        assert_eq!(channel, "telegram");
590        assert_eq!(thread_id, "12345");
591        assert_eq!(topic_id, Some("678"));
592    }
593
594    #[test]
595    fn parse_key_malformed_returns_none() {
596        assert!(parse_conversation_key("no-colon-here").is_none());
597    }
598
599    #[test]
600    fn conversation_key_round_trips() {
601        // Build a key and parse it back; values must match what went in.
602        for (ch, tid, topic) in [
603            ("telegram", "111", Some("222")),
604            ("discord", "abc", None),
605            ("slack", "x", Some("y")),
606        ] {
607            let key = conversation_key(ch, tid, topic);
608            let (ch2, tid2, topic2) = parse_conversation_key(&key).expect("round-trip valid");
609            assert_eq!(ch2, ch);
610            assert_eq!(tid2, tid);
611            assert_eq!(topic2, topic);
612        }
613    }
614
615    // ── topic collision prevention ────────────────────────────────────────────
616
617    /// Two different Telegram forum topics inside the same chat must bind to
618    /// different keys and not collide.
619    #[tokio::test]
620    async fn different_topics_do_not_collide() {
621        let transport = Transport::new();
622
623        // Task 10 is in topic 100 of chat 999
624        transport
625            .bind(
626                "owner/repo",
627                "10",
628                "orch-proj-10",
629                "telegram",
630                "999",
631                Some("100"),
632            )
633            .await;
634        // Task 20 is in topic 200 of the same chat 999
635        transport
636            .bind(
637                "owner/repo",
638                "20",
639                "orch-proj-20",
640                "telegram",
641                "999",
642                Some("200"),
643            )
644            .await;
645
646        let msg_topic_100 = IncomingMessage {
647            channel: "telegram".to_string(),
648            id: "m1".to_string(),
649            thread_id: "999".to_string(),
650            author: "user".to_string(),
651            body: "reply for task 10".to_string(),
652            timestamp: chrono::Utc::now(),
653            metadata: serde_json::json!({}),
654            topic_id: Some("100".to_string()),
655        };
656
657        let msg_topic_200 = IncomingMessage {
658            channel: "telegram".to_string(),
659            id: "m2".to_string(),
660            thread_id: "999".to_string(),
661            author: "user".to_string(),
662            body: "reply for task 20".to_string(),
663            timestamp: chrono::Utc::now(),
664            metadata: serde_json::json!({}),
665            topic_id: Some("200".to_string()),
666        };
667
668        match transport.route(&msg_topic_100).await {
669            MessageRoute::TaskSession { session_key } => {
670                assert_eq!(session_key, "owner/repo:10");
671            }
672            other => panic!("expected TaskSession for task 10, got {other:?}"),
673        }
674
675        match transport.route(&msg_topic_200).await {
676            MessageRoute::TaskSession { session_key } => {
677                assert_eq!(session_key, "owner/repo:20");
678            }
679            other => panic!("expected TaskSession for task 20, got {other:?}"),
680        }
681    }
682
683    // ── unbind ────────────────────────────────────────────────────────────────
684
685    /// After unbinding, the binding and reverse-lookup entries are removed.
686    #[tokio::test]
687    async fn unbind_removes_binding_and_reverse_lookup() {
688        let transport = Transport::new();
689        transport
690            .bind("owner/repo", "99", "orch-proj-99", "telegram", "555", None)
691            .await;
692
693        // Verify bound
694        assert!(transport.get_binding("owner/repo", "99").await.is_some());
695
696        // Unbind
697        transport.unbind("owner/repo", "99").await;
698
699        // Binding gone
700        assert!(transport.get_binding("owner/repo", "99").await.is_none());
701
702        // Reverse lookup gone — message should not route to the old session
703        let msg = IncomingMessage {
704            channel: "telegram".to_string(),
705            id: "m1".to_string(),
706            thread_id: "555".to_string(),
707            author: "user".to_string(),
708            body: "hello".to_string(),
709            timestamp: chrono::Utc::now(),
710            metadata: serde_json::json!({}),
711            topic_id: None,
712        };
713        match transport.route(&msg).await {
714            MessageRoute::NewTask => {} // correct
715            other => panic!("expected NewTask after unbind, got {other:?}"),
716        }
717    }
718
719    /// Unbinding a session that was never bound must not panic.
720    #[tokio::test]
721    async fn unbind_nonexistent_does_not_panic() {
722        let transport = Transport::new();
723        // Should complete without error
724        transport.unbind("owner/repo", "nonexistent").await;
725    }
726
727    /// A message arriving in a *different* topic must not be routed to a task
728    /// that was bound to another topic in the same chat.
729    #[tokio::test]
730    async fn wrong_topic_does_not_route_to_bound_task() {
731        let transport = Transport::new();
732
733        // Task 5 bound to topic 10 of chat 42
734        transport
735            .bind(
736                "owner/repo",
737                "5",
738                "orch-proj-5",
739                "telegram",
740                "42",
741                Some("10"),
742            )
743            .await;
744
745        // Message arrives in topic 99 of the same chat — should NOT route to task 5
746        let msg = IncomingMessage {
747            channel: "telegram".to_string(),
748            id: "m1".to_string(),
749            thread_id: "42".to_string(),
750            author: "user".to_string(),
751            body: "hello from a different topic".to_string(),
752            timestamp: chrono::Utc::now(),
753            metadata: serde_json::json!({}),
754            topic_id: Some("99".to_string()),
755        };
756
757        match transport.route(&msg).await {
758            MessageRoute::NewTask => {} // correct — not routed to task 5
759            MessageRoute::TaskSession { session_key } => {
760                panic!("should not have routed to session {session_key}")
761            }
762            other => panic!("unexpected route result: {other:?}"),
763        }
764    }
765}