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