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::{IncomingMessage, OutputChunk, split_utf8};
31use std::collections::HashMap;
32use std::sync::Arc;
33use tokio::sync::{RwLock, broadcast};
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.
116#[derive(Debug)]
117pub struct Transport {
118    /// Active session bindings, keyed by session_key(repo, task_id)
119    bindings: Arc<RwLock<HashMap<String, SessionBinding>>>,
120    /// Reverse lookup: conversation_key → session_key(repo, task_id)
121    thread_to_task: Arc<RwLock<HashMap<String, String>>>,
122    /// Broadcast sender for task completion notifications
123    notification_tx: broadcast::Sender<TaskNotification>,
124}
125
126impl Transport {
127    pub fn new() -> Self {
128        let (notification_tx, _) = broadcast::channel(64);
129        Self {
130            bindings: Arc::new(RwLock::new(HashMap::new())),
131            thread_to_task: Arc::new(RwLock::new(HashMap::new())),
132            notification_tx,
133        }
134    }
135
136    /// Bind a channel thread to a task's tmux session.
137    ///
138    /// `repo` is required to build a globally unique session key, preventing
139    /// collisions when multiple repos share the same external task ID.
140    ///
141    /// `topic_id` should be set for topic-aware channels (Telegram forum topics,
142    /// Discord threads) so that bindings for different topics inside the same
143    /// parent chat / channel do not collide.
144    pub async fn bind(
145        &self,
146        repo: &str,
147        task_id: &str,
148        tmux_session: &str,
149        channel: &str,
150        thread_id: &str,
151        topic_id: Option<&str>,
152    ) {
153        let key = conversation_key(channel, thread_id, topic_id);
154        let skey = session_key(repo, task_id);
155        // Update bindings synchronously (no await while lock held)
156        {
157            let mut bindings = self.bindings.write().await;
158            let binding = bindings.entry(skey.clone()).or_insert_with(|| {
159                let (tx, _) = broadcast::channel(256);
160                SessionBinding {
161                    tmux_session: tmux_session.to_string(),
162                    connected_threads: Vec::new(),
163                    output_tx: tx,
164                }
165            });
166            // Always update tmux_session — task retries get a new session
167            binding.tmux_session = tmux_session.to_string();
168            if !binding.connected_threads.contains(&key) {
169                binding.connected_threads.push(key.clone());
170            }
171        } // bindings lock released here
172        // Update thread_to_task after releasing bindings lock
173        self.thread_to_task.write().await.insert(key, skey);
174    }
175
176    /// Get the broadcast receiver for a task's output stream.
177    pub async fn subscribe(
178        &self,
179        repo: &str,
180        task_id: &str,
181    ) -> Option<broadcast::Receiver<OutputChunk>> {
182        let skey = session_key(repo, task_id);
183        let bindings = self.bindings.read().await;
184        bindings.get(&skey).map(|b| b.output_tx.subscribe())
185    }
186
187    /// Push an output chunk to all subscribers of a task.
188    pub async fn push_output(&self, repo: &str, task_id: &str, chunk: OutputChunk) {
189        let skey = session_key(repo, task_id);
190        if chunk.content.is_empty() {
191            let bindings = self.bindings.read().await;
192            if let Some(binding) = bindings.get(&skey) {
193                let _ = binding.output_tx.send(chunk.clone());
194            }
195            return;
196        }
197
198        let parts = split_utf8(&chunk.content, MAX_OUTPUT_CHUNK_BYTES);
199        let last_index = parts.len().saturating_sub(1);
200
201        for (idx, part) in parts.into_iter().enumerate() {
202            let part_chunk = OutputChunk {
203                content: part,
204                is_final: chunk.is_final && idx == last_index,
205            };
206            let bindings = self.bindings.read().await;
207            if let Some(binding) = bindings.get(&skey) {
208                let _ = binding.output_tx.send(part_chunk.clone());
209            }
210        }
211    }
212
213    /// Get the session binding for a specific task, if any.
214    pub async fn get_binding(&self, repo: &str, task_id: &str) -> Option<SessionBinding> {
215        let skey = session_key(repo, task_id);
216        self.bindings.read().await.get(&skey).cloned()
217    }
218
219    /// Route an incoming message to the appropriate handler.
220    /// Returns the session_key if this message maps to an existing session.
221    pub async fn route(&self, msg: &IncomingMessage) -> MessageRoute {
222        let key = conversation_key(&msg.channel, &msg.thread_id, msg.topic_id.as_deref());
223
224        // Check if this thread is bound to a task
225        if let Some(session_key) = self.thread_to_task.read().await.get(&key) {
226            return MessageRoute::TaskSession {
227                session_key: session_key.clone(),
228            };
229        }
230
231        // Check if this looks like a command
232        let body = msg.body.trim();
233        if body.starts_with('/') || body.starts_with("orch ") {
234            return MessageRoute::Command {
235                raw: body.to_string(),
236            };
237        }
238
239        // New conversation — could become a task
240        MessageRoute::NewTask
241    }
242
243    /// Unbind a task session, removing all associated entries.
244    ///
245    /// Removes the entry from `bindings` and all reverse-lookup entries from
246    /// `thread_to_task` whose value matches the session key. Call this when a
247    /// task session ends so memory does not grow indefinitely.
248    pub async fn unbind(&self, repo: &str, task_id: &str) {
249        let skey = session_key(repo, task_id);
250
251        // Remove the binding and collect its connected threads.
252        let connected_threads = {
253            let mut bindings = self.bindings.write().await;
254            bindings
255                .remove(&skey)
256                .map(|b| b.connected_threads)
257                .unwrap_or_default()
258        };
259
260        // Remove reverse-lookup entries for all connected threads.
261        if !connected_threads.is_empty() {
262            let mut t2t = self.thread_to_task.write().await;
263            for key in &connected_threads {
264                t2t.remove(key);
265            }
266        }
267    }
268
269    /// Push a task completion notification to all subscribers.
270    pub fn push_notification(&self, notification: TaskNotification) {
271        // Ignore send errors (no active receivers)
272        let _ = self.notification_tx.send(notification);
273    }
274
275    /// Subscribe to task completion notifications.
276    pub fn subscribe_notifications(&self) -> broadcast::Receiver<TaskNotification> {
277        self.notification_tx.subscribe()
278    }
279}
280
281/// How an incoming message should be handled.
282#[derive(Debug)]
283pub enum MessageRoute {
284    /// Message belongs to an existing task session — forward to tmux.
285    /// `session_key` is the globally unique key (repo:task_id or internal:id).
286    TaskSession { session_key: String },
287    /// Message is a command (e.g. "/status", "orch task list")
288    Command { raw: String },
289    /// Message is in the configured control session channel — route to control agent
290    ControlSession,
291    /// Message is new — should create a task
292    NewTask,
293}
294
295#[cfg(test)]
296mod tests {
297    use tokio::time;
298
299    use std::time::Duration;
300
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 = format!("{}😀", "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!(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!(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 = time::timeout(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 = time::timeout(Duration::from_millis(100), rx_b.recv()).await;
545        assert!(
546            result_b.is_err(),
547            "repo-b should not receive repo-a's output"
548        );
549    }
550
551    // ── conversation_key ──────────────────────────────────────────────────────
552
553    #[test]
554    fn conversation_key_without_topic() {
555        let key = conversation_key("telegram", "12345", None);
556        assert_eq!(key, "telegram:12345");
557    }
558
559    #[test]
560    fn conversation_key_with_topic() {
561        let key = conversation_key("telegram", "12345", Some("678"));
562        assert_eq!(key, "telegram:12345|678");
563    }
564
565    #[test]
566    fn conversation_key_empty_topic_treated_as_none() {
567        // An empty topic_id should produce the same key as None.
568        let key_empty = conversation_key("discord", "abc", Some(""));
569        let key_none = conversation_key("discord", "abc", None);
570        assert_eq!(key_empty, key_none);
571    }
572
573    // ── parse_conversation_key ────────────────────────────────────────────────
574
575    #[test]
576    fn parse_key_without_topic() {
577        let (channel, thread_id, topic_id) =
578            parse_conversation_key("telegram:12345").expect("valid key");
579        assert_eq!(channel, "telegram");
580        assert_eq!(thread_id, "12345");
581        assert!(topic_id.is_none());
582    }
583
584    #[test]
585    fn parse_key_with_topic() {
586        let (channel, thread_id, topic_id) =
587            parse_conversation_key("telegram:12345|678").expect("valid key");
588        assert_eq!(channel, "telegram");
589        assert_eq!(thread_id, "12345");
590        assert_eq!(topic_id, Some("678"));
591    }
592
593    #[test]
594    fn parse_key_malformed_returns_none() {
595        assert!(parse_conversation_key("no-colon-here").is_none());
596    }
597
598    #[test]
599    fn conversation_key_round_trips() {
600        // Build a key and parse it back; values must match what went in.
601        for (ch, tid, topic) in [
602            ("telegram", "111", Some("222")),
603            ("discord", "abc", None),
604            ("slack", "x", Some("y")),
605        ] {
606            let key = conversation_key(ch, tid, topic);
607            let (ch2, tid2, topic2) = parse_conversation_key(&key).expect("round-trip valid");
608            assert_eq!(ch2, ch);
609            assert_eq!(tid2, tid);
610            assert_eq!(topic2, topic);
611        }
612    }
613
614    // ── topic collision prevention ────────────────────────────────────────────
615
616    /// Two different Telegram forum topics inside the same chat must bind to
617    /// different keys and not collide.
618    #[tokio::test]
619    async fn different_topics_do_not_collide() {
620        let transport = Transport::new();
621
622        // Task 10 is in topic 100 of chat 999
623        transport
624            .bind(
625                "owner/repo",
626                "10",
627                "orch-proj-10",
628                "telegram",
629                "999",
630                Some("100"),
631            )
632            .await;
633        // Task 20 is in topic 200 of the same chat 999
634        transport
635            .bind(
636                "owner/repo",
637                "20",
638                "orch-proj-20",
639                "telegram",
640                "999",
641                Some("200"),
642            )
643            .await;
644
645        let msg_topic_100 = IncomingMessage {
646            channel: "telegram".to_string(),
647            id: "m1".to_string(),
648            thread_id: "999".to_string(),
649            author: "user".to_string(),
650            body: "reply for task 10".to_string(),
651            timestamp: chrono::Utc::now(),
652            metadata: serde_json::json!({}),
653            topic_id: Some("100".to_string()),
654        };
655
656        let msg_topic_200 = IncomingMessage {
657            channel: "telegram".to_string(),
658            id: "m2".to_string(),
659            thread_id: "999".to_string(),
660            author: "user".to_string(),
661            body: "reply for task 20".to_string(),
662            timestamp: chrono::Utc::now(),
663            metadata: serde_json::json!({}),
664            topic_id: Some("200".to_string()),
665        };
666
667        match transport.route(&msg_topic_100).await {
668            MessageRoute::TaskSession { session_key } => {
669                assert_eq!(session_key, "owner/repo:10");
670            }
671            other => panic!("expected TaskSession for task 10, got {other:?}"),
672        }
673
674        match transport.route(&msg_topic_200).await {
675            MessageRoute::TaskSession { session_key } => {
676                assert_eq!(session_key, "owner/repo:20");
677            }
678            other => panic!("expected TaskSession for task 20, got {other:?}"),
679        }
680    }
681
682    // ── unbind ────────────────────────────────────────────────────────────────
683
684    /// After unbinding, the binding and reverse-lookup entries are removed.
685    #[tokio::test]
686    async fn unbind_removes_binding_and_reverse_lookup() {
687        let transport = Transport::new();
688        transport
689            .bind("owner/repo", "99", "orch-proj-99", "telegram", "555", None)
690            .await;
691
692        // Verify bound
693        assert!(transport.get_binding("owner/repo", "99").await.is_some());
694
695        // Unbind
696        transport.unbind("owner/repo", "99").await;
697
698        // Binding gone
699        assert!(transport.get_binding("owner/repo", "99").await.is_none());
700
701        // Reverse lookup gone — message should not route to the old session
702        let msg = IncomingMessage {
703            channel: "telegram".to_string(),
704            id: "m1".to_string(),
705            thread_id: "555".to_string(),
706            author: "user".to_string(),
707            body: "hello".to_string(),
708            timestamp: chrono::Utc::now(),
709            metadata: serde_json::json!({}),
710            topic_id: None,
711        };
712        match transport.route(&msg).await {
713            MessageRoute::NewTask => {} // correct
714            other => panic!("expected NewTask after unbind, got {other:?}"),
715        }
716    }
717
718    /// Unbinding a session that was never bound must not panic.
719    #[tokio::test]
720    async fn unbind_nonexistent_does_not_panic() {
721        let transport = Transport::new();
722        // Should complete without error
723        transport.unbind("owner/repo", "nonexistent").await;
724    }
725
726    /// A message arriving in a *different* topic must not be routed to a task
727    /// that was bound to another topic in the same chat.
728    #[tokio::test]
729    async fn wrong_topic_does_not_route_to_bound_task() {
730        let transport = Transport::new();
731
732        // Task 5 bound to topic 10 of chat 42
733        transport
734            .bind(
735                "owner/repo",
736                "5",
737                "orch-proj-5",
738                "telegram",
739                "42",
740                Some("10"),
741            )
742            .await;
743
744        // Message arrives in topic 99 of the same chat — should NOT route to task 5
745        let msg = IncomingMessage {
746            channel: "telegram".to_string(),
747            id: "m1".to_string(),
748            thread_id: "42".to_string(),
749            author: "user".to_string(),
750            body: "hello from a different topic".to_string(),
751            timestamp: chrono::Utc::now(),
752            metadata: serde_json::json!({}),
753            topic_id: Some("99".to_string()),
754        };
755
756        match transport.route(&msg).await {
757            MessageRoute::NewTask => {} // correct — not routed to task 5
758            MessageRoute::TaskSession { session_key } => {
759                panic!("should not have routed to session {session_key}")
760            }
761            other => panic!("unexpected route result: {other:?}"),
762        }
763    }
764}