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 super::*;
298
299    #[tokio::test]
300    async fn push_notification_reaches_subscriber() {
301        let transport = Transport::new();
302        let mut rx = transport.subscribe_notifications();
303
304        let notification = TaskNotification {
305            task_id: "42".to_string(),
306            title: "Test task".to_string(),
307            status: "done".to_string(),
308            agent: "claude".to_string(),
309            duration_seconds: 60.0,
310            summary: "Completed successfully".to_string(),
311            repo: None,
312            notify_target: None,
313            pr_number: None,
314        };
315
316        transport.push_notification(notification.clone());
317
318        let received = rx.recv().await.unwrap();
319        assert_eq!(received.task_id, "42");
320        assert_eq!(received.status, "done");
321        assert_eq!(received.agent, "claude");
322        assert_eq!(received.summary, "Completed successfully");
323    }
324
325    #[tokio::test]
326    async fn push_notification_multiple_subscribers() {
327        let transport = Transport::new();
328        let mut rx1 = transport.subscribe_notifications();
329        let mut rx2 = transport.subscribe_notifications();
330
331        transport.push_notification(TaskNotification {
332            task_id: "1".to_string(),
333            title: "Task".to_string(),
334            status: "done".to_string(),
335            agent: "codex".to_string(),
336            duration_seconds: 10.0,
337            summary: "Done".to_string(),
338            repo: None,
339            notify_target: None,
340            pr_number: None,
341        });
342
343        let n1 = rx1.recv().await.unwrap();
344        let n2 = rx2.recv().await.unwrap();
345        assert_eq!(n1.task_id, "1");
346        assert_eq!(n2.task_id, "1");
347    }
348
349    #[test]
350    fn split_chunks_multibyte_at_boundary_does_not_panic() {
351        // 4-byte emoji at the chunk boundary: the old code set end = start + 1
352        // and then sliced content[start..end], which panics on multi-byte chars.
353        let content = format!("{}😀", "x".repeat(97)); // 101 bytes; with max=100 the cut is at byte 100 (mid-emoji)
354        let chunks = split_utf8(&content, 100);
355        assert_eq!(chunks.join(""), content);
356        for c in &chunks {
357            assert!(std::str::from_utf8(c.as_bytes()).is_ok());
358        }
359    }
360
361    #[test]
362    fn split_chunks_only_emojis() {
363        // Every boundary falls inside a 4-byte character.
364        let content = "😀".repeat(50); // 200 bytes
365        let chunks = split_utf8(&content, 10); // 10 bytes per chunk, emoji is 4 bytes
366        assert_eq!(chunks.join(""), content);
367        for c in &chunks {
368            assert!(std::str::from_utf8(c.as_bytes()).is_ok());
369        }
370    }
371
372    #[test]
373    fn push_notification_no_subscribers_does_not_panic() {
374        let transport = Transport::new();
375        // No subscribers — should not panic
376        transport.push_notification(TaskNotification {
377            task_id: "1".to_string(),
378            title: "Task".to_string(),
379            status: "done".to_string(),
380            agent: "claude".to_string(),
381            duration_seconds: 0.0,
382            summary: "Done".to_string(),
383            repo: None,
384            notify_target: None,
385            pr_number: None,
386        });
387    }
388
389    #[tokio::test]
390    async fn bind_and_route_to_session() {
391        let transport = Transport::new();
392        transport
393            .bind(
394                "owner/repo",
395                "42",
396                "orch-myproject-42",
397                "telegram",
398                "12345",
399                None,
400            )
401            .await;
402
403        let msg = IncomingMessage {
404            channel: "telegram".to_string(),
405            id: "msg1".to_string(),
406            thread_id: "12345".to_string(),
407            author: "user".to_string(),
408            body: "hello".to_string(),
409            timestamp: chrono::Utc::now(),
410            metadata: serde_json::json!({}),
411            topic_id: None,
412        };
413
414        match transport.route(&msg).await {
415            MessageRoute::TaskSession { session_key } => {
416                assert_eq!(session_key, "owner/repo:42");
417            }
418            _ => panic!("expected TaskSession"),
419        }
420    }
421
422    #[tokio::test]
423    async fn route_command() {
424        let transport = Transport::new();
425
426        let msg = IncomingMessage {
427            channel: "telegram".to_string(),
428            id: "msg1".to_string(),
429            thread_id: "99".to_string(),
430            author: "user".to_string(),
431            body: "/status".to_string(),
432            timestamp: chrono::Utc::now(),
433            metadata: serde_json::json!({}),
434            topic_id: None,
435        };
436
437        match transport.route(&msg).await {
438            MessageRoute::Command { raw } => assert_eq!(raw, "/status"),
439            _ => panic!("expected Command"),
440        }
441    }
442
443    // ── session_key ──────────────────────────────────────────────────────────
444
445    #[test]
446    fn session_key_external_is_prefixed_with_repo() {
447        assert_eq!(session_key("owner/repo", "42"), "owner/repo:42");
448    }
449
450    #[test]
451    fn session_key_internal_is_unchanged() {
452        assert_eq!(session_key("owner/repo", "internal:99"), "internal:99");
453    }
454
455    #[test]
456    fn session_key_same_id_different_repos_are_unique() {
457        let k1 = session_key("owner/repo-a", "42");
458        let k2 = session_key("owner/repo-b", "42");
459        assert_ne!(k1, k2);
460        assert_eq!(k1, "owner/repo-a:42");
461        assert_eq!(k2, "owner/repo-b:42");
462    }
463
464    // ── cross-repo collision regression ──────────────────────────────────────
465
466    /// Two repos with the same external task ID must not collide in bindings.
467    #[tokio::test]
468    async fn same_external_id_different_repos_do_not_collide() {
469        let transport = Transport::new();
470
471        // Repo A binds task "42"
472        transport
473            .bind(
474                "owner/repo-a",
475                "42",
476                "orch-repo-a-42",
477                "telegram",
478                "111",
479                None,
480            )
481            .await;
482        // Repo B binds task "42" (same external ID, different repo)
483        transport
484            .bind(
485                "owner/repo-b",
486                "42",
487                "orch-repo-b-42",
488                "telegram",
489                "222",
490                None,
491            )
492            .await;
493
494        // Each binding has its own session
495        let binding_a = transport.get_binding("owner/repo-a", "42").await.unwrap();
496        let binding_b = transport.get_binding("owner/repo-b", "42").await.unwrap();
497        assert_eq!(binding_a.tmux_session, "orch-repo-a-42");
498        assert_eq!(binding_b.tmux_session, "orch-repo-b-42");
499        assert_ne!(binding_a.tmux_session, binding_b.tmux_session);
500    }
501
502    /// Same external task ID in two repos: pushing output to one must not
503    /// reach the other's subscribers.
504    #[tokio::test]
505    async fn push_output_isolated_across_repos_with_same_task_id() {
506        let transport = Transport::new();
507
508        // Subscribe to repo-a's task 42
509        transport
510            .bind("owner/repo-a", "42", "orch-a-42", "cli", "stream-a", None)
511            .await;
512        let mut rx_a = transport.subscribe("owner/repo-a", "42").await.unwrap();
513
514        // Subscribe to repo-b's task 42
515        transport
516            .bind("owner/repo-b", "42", "orch-b-42", "cli", "stream-b", None)
517            .await;
518        let mut rx_b = transport.subscribe("owner/repo-b", "42").await.unwrap();
519
520        // Push output to repo-a's task 42
521        transport
522            .push_output(
523                "owner/repo-a",
524                "42",
525                OutputChunk {
526                    content: "hello from repo-a".to_string(),
527                    is_final: false,
528                },
529            )
530            .await;
531
532        // repo-a receives it
533        let chunk_a = tokio::time::timeout(std::time::Duration::from_millis(100), rx_a.recv())
534            .await
535            .unwrap()
536            .unwrap();
537        assert_eq!(chunk_a.content, "hello from repo-a");
538
539        // repo-b should NOT receive anything (timeout)
540        let result_b =
541            tokio::time::timeout(std::time::Duration::from_millis(100), rx_b.recv()).await;
542        assert!(
543            result_b.is_err(),
544            "repo-b should not receive repo-a's output"
545        );
546    }
547
548    // ── conversation_key ──────────────────────────────────────────────────────
549
550    #[test]
551    fn conversation_key_without_topic() {
552        let key = conversation_key("telegram", "12345", None);
553        assert_eq!(key, "telegram:12345");
554    }
555
556    #[test]
557    fn conversation_key_with_topic() {
558        let key = conversation_key("telegram", "12345", Some("678"));
559        assert_eq!(key, "telegram:12345|678");
560    }
561
562    #[test]
563    fn conversation_key_empty_topic_treated_as_none() {
564        // An empty topic_id should produce the same key as None.
565        let key_empty = conversation_key("discord", "abc", Some(""));
566        let key_none = conversation_key("discord", "abc", None);
567        assert_eq!(key_empty, key_none);
568    }
569
570    // ── parse_conversation_key ────────────────────────────────────────────────
571
572    #[test]
573    fn parse_key_without_topic() {
574        let (channel, thread_id, topic_id) =
575            parse_conversation_key("telegram:12345").expect("valid key");
576        assert_eq!(channel, "telegram");
577        assert_eq!(thread_id, "12345");
578        assert!(topic_id.is_none());
579    }
580
581    #[test]
582    fn parse_key_with_topic() {
583        let (channel, thread_id, topic_id) =
584            parse_conversation_key("telegram:12345|678").expect("valid key");
585        assert_eq!(channel, "telegram");
586        assert_eq!(thread_id, "12345");
587        assert_eq!(topic_id, Some("678"));
588    }
589
590    #[test]
591    fn parse_key_malformed_returns_none() {
592        assert!(parse_conversation_key("no-colon-here").is_none());
593    }
594
595    #[test]
596    fn conversation_key_round_trips() {
597        // Build a key and parse it back; values must match what went in.
598        for (ch, tid, topic) in [
599            ("telegram", "111", Some("222")),
600            ("discord", "abc", None),
601            ("slack", "x", Some("y")),
602        ] {
603            let key = conversation_key(ch, tid, topic);
604            let (ch2, tid2, topic2) = parse_conversation_key(&key).expect("round-trip valid");
605            assert_eq!(ch2, ch);
606            assert_eq!(tid2, tid);
607            assert_eq!(topic2, topic);
608        }
609    }
610
611    // ── topic collision prevention ────────────────────────────────────────────
612
613    /// Two different Telegram forum topics inside the same chat must bind to
614    /// different keys and not collide.
615    #[tokio::test]
616    async fn different_topics_do_not_collide() {
617        let transport = Transport::new();
618
619        // Task 10 is in topic 100 of chat 999
620        transport
621            .bind(
622                "owner/repo",
623                "10",
624                "orch-proj-10",
625                "telegram",
626                "999",
627                Some("100"),
628            )
629            .await;
630        // Task 20 is in topic 200 of the same chat 999
631        transport
632            .bind(
633                "owner/repo",
634                "20",
635                "orch-proj-20",
636                "telegram",
637                "999",
638                Some("200"),
639            )
640            .await;
641
642        let msg_topic_100 = IncomingMessage {
643            channel: "telegram".to_string(),
644            id: "m1".to_string(),
645            thread_id: "999".to_string(),
646            author: "user".to_string(),
647            body: "reply for task 10".to_string(),
648            timestamp: chrono::Utc::now(),
649            metadata: serde_json::json!({}),
650            topic_id: Some("100".to_string()),
651        };
652
653        let msg_topic_200 = IncomingMessage {
654            channel: "telegram".to_string(),
655            id: "m2".to_string(),
656            thread_id: "999".to_string(),
657            author: "user".to_string(),
658            body: "reply for task 20".to_string(),
659            timestamp: chrono::Utc::now(),
660            metadata: serde_json::json!({}),
661            topic_id: Some("200".to_string()),
662        };
663
664        match transport.route(&msg_topic_100).await {
665            MessageRoute::TaskSession { session_key } => {
666                assert_eq!(session_key, "owner/repo:10");
667            }
668            other => panic!("expected TaskSession for task 10, got {other:?}"),
669        }
670
671        match transport.route(&msg_topic_200).await {
672            MessageRoute::TaskSession { session_key } => {
673                assert_eq!(session_key, "owner/repo:20");
674            }
675            other => panic!("expected TaskSession for task 20, got {other:?}"),
676        }
677    }
678
679    // ── unbind ────────────────────────────────────────────────────────────────
680
681    /// After unbinding, the binding and reverse-lookup entries are removed.
682    #[tokio::test]
683    async fn unbind_removes_binding_and_reverse_lookup() {
684        let transport = Transport::new();
685        transport
686            .bind("owner/repo", "99", "orch-proj-99", "telegram", "555", None)
687            .await;
688
689        // Verify bound
690        assert!(transport.get_binding("owner/repo", "99").await.is_some());
691
692        // Unbind
693        transport.unbind("owner/repo", "99").await;
694
695        // Binding gone
696        assert!(transport.get_binding("owner/repo", "99").await.is_none());
697
698        // Reverse lookup gone — message should not route to the old session
699        let msg = IncomingMessage {
700            channel: "telegram".to_string(),
701            id: "m1".to_string(),
702            thread_id: "555".to_string(),
703            author: "user".to_string(),
704            body: "hello".to_string(),
705            timestamp: chrono::Utc::now(),
706            metadata: serde_json::json!({}),
707            topic_id: None,
708        };
709        match transport.route(&msg).await {
710            MessageRoute::NewTask => {} // correct
711            other => panic!("expected NewTask after unbind, got {other:?}"),
712        }
713    }
714
715    /// Unbinding a session that was never bound must not panic.
716    #[tokio::test]
717    async fn unbind_nonexistent_does_not_panic() {
718        let transport = Transport::new();
719        // Should complete without error
720        transport.unbind("owner/repo", "nonexistent").await;
721    }
722
723    /// A message arriving in a *different* topic must not be routed to a task
724    /// that was bound to another topic in the same chat.
725    #[tokio::test]
726    async fn wrong_topic_does_not_route_to_bound_task() {
727        let transport = Transport::new();
728
729        // Task 5 bound to topic 10 of chat 42
730        transport
731            .bind(
732                "owner/repo",
733                "5",
734                "orch-proj-5",
735                "telegram",
736                "42",
737                Some("10"),
738            )
739            .await;
740
741        // Message arrives in topic 99 of the same chat — should NOT route to task 5
742        let msg = IncomingMessage {
743            channel: "telegram".to_string(),
744            id: "m1".to_string(),
745            thread_id: "42".to_string(),
746            author: "user".to_string(),
747            body: "hello from a different topic".to_string(),
748            timestamp: chrono::Utc::now(),
749            metadata: serde_json::json!({}),
750            topic_id: Some("99".to_string()),
751        };
752
753        match transport.route(&msg).await {
754            MessageRoute::NewTask => {} // correct — not routed to task 5
755            MessageRoute::TaskSession { session_key } => {
756                panic!("should not have routed to session {session_key}")
757            }
758            other => panic!("unexpected route result: {other:?}"),
759        }
760    }
761}