Skip to main content

orch/channels/
stream.rs

1//! Output streaming — fans out session output to bound channel threads.
2//!
3//! When an agent session is bound to one or more channel threads (Telegram,
4//! Discord, Slack), this module streams `OutputChunk` broadcasts to those
5//! threads in real-time.
6//!
7//! Platform limits and rate limiting:
8//! - Telegram: 4 KB per message, 100 ms between sends
9//! - Discord:  2 KB per message, 500 ms between sends
10//! - Slack:    4 KB per message, 200 ms between sends
11
12use crate::channels::transport::{parse_conversation_key, Transport};
13use crate::channels::{html_escape, split_utf8, ChannelRegistry, OutgoingMessage};
14use std::collections::HashMap;
15use std::sync::Arc;
16use tokio::time::{Duration, Instant};
17
18const TELEGRAM_MAX_BYTES: usize = 4096;
19const DISCORD_MAX_BYTES: usize = 2000;
20const SLACK_MAX_BYTES: usize = 4000;
21
22fn platform_max_bytes(channel_name: &str) -> usize {
23    match channel_name {
24        "discord" => DISCORD_MAX_BYTES,
25        "slack" => SLACK_MAX_BYTES,
26        _ => TELEGRAM_MAX_BYTES,
27    }
28}
29
30fn platform_rate(channel_name: &str) -> Duration {
31    match channel_name {
32        "telegram" => Duration::from_millis(100),
33        "slack" => Duration::from_millis(200),
34        _ => Duration::from_millis(500),
35    }
36}
37
38/// Split content into chunks that fit within the platform's message size limit.
39fn split_for_platform(content: &str, channel_name: &str) -> Vec<String> {
40    split_utf8(content, platform_max_bytes(channel_name))
41}
42
43/// Send a message to a specific channel thread, finding the channel by name.
44///
45/// `topic_id` is forwarded to the channel so that topic-aware channels
46/// (Telegram forum topics, Discord threads) deliver the message to the
47/// correct sub-conversation rather than the parent chat/channel.
48///
49/// `body` is assumed to already be in the correct format for the channel:
50/// - For Telegram: already HTML-escaped (caller escapes before splitting)
51/// - For other channels: raw text
52async fn send_to_thread(
53    channels: &Arc<ChannelRegistry>,
54    channel_name: &str,
55    thread_id: &str,
56    topic_id: Option<&str>,
57    body: String,
58) {
59    let metadata = if channel_name == "telegram" {
60        serde_json::json!({ "preformatted_html": true })
61    } else {
62        serde_json::json!({})
63    };
64
65    for ch in channels.iter() {
66        if ch.name() == channel_name {
67            let msg = OutgoingMessage {
68                thread_id: thread_id.to_string(),
69                body,
70                reply_to: None,
71                metadata,
72                topic_id: topic_id.map(String::from),
73            };
74            if let Err(e) = ch.send(&msg).await {
75                tracing::warn!(channel = channel_name, thread_id, ?e, "stream: send failed");
76            }
77            return;
78        }
79    }
80    tracing::debug!(
81        channel = channel_name,
82        "stream: channel not found in registry"
83    );
84}
85
86/// Fan out output chunks from a task to all bound channel threads.
87///
88/// Subscribes to the task's output broadcast in `transport`, accumulates
89/// content per channel thread, applies rate limiting, and terminates when
90/// the stream receives a final chunk or is closed.
91///
92/// This function should be spawned as a background task when a channel
93/// thread is bound to an agent session.
94pub async fn fanout_output(
95    repo: String,
96    task_id: String,
97    transport: Arc<Transport>,
98    channels: Arc<ChannelRegistry>,
99) {
100    let mut rx = match transport.subscribe(&repo, &task_id).await {
101        Some(r) => r,
102        None => {
103            tracing::debug!(task_id, "fanout: no binding for task, cannot subscribe");
104            return;
105        }
106    };
107
108    tracing::debug!(task_id, "fanout: started output streaming");
109
110    // Per channel-name: last send time (for rate limiting)
111    let mut last_send: HashMap<String, Instant> = HashMap::new();
112    // Per thread-key ("channel:thread_id"): buffered content pending send
113    let mut buffers: HashMap<String, String> = HashMap::new();
114
115    loop {
116        // Wait for either a new chunk or the next scheduled flush
117        match rx.recv().await {
118            Ok(chunk) => {
119                let is_final = chunk.is_final;
120
121                if !chunk.content.is_empty() {
122                    // Look up current binding to find connected threads
123                    if let Some(binding) = transport.get_binding(&repo, &task_id).await {
124                        let now = Instant::now();
125
126                        for thread_key in &binding.connected_threads {
127                            let (ch_name, thread_id, topic_id) =
128                                match parse_conversation_key(thread_key) {
129                                    Some(p) => p,
130                                    None => continue,
131                                };
132
133                            // Append new content to this thread's buffer
134                            buffers
135                                .entry(thread_key.clone())
136                                .or_default()
137                                .push_str(&chunk.content);
138
139                            // Check if enough time has passed since last send
140                            let rate = platform_rate(ch_name);
141                            let can_send = last_send
142                                .get(ch_name)
143                                .map(|t| now.duration_since(*t) >= rate)
144                                .unwrap_or(true);
145
146                            if can_send {
147                                if let Some(buffered) = buffers.remove(thread_key) {
148                                    // Escape HTML for Telegram before splitting so chunk
149                                    // boundaries are computed on the actual API payload size.
150                                    let to_send = if ch_name == "telegram" {
151                                        html_escape(&buffered)
152                                    } else {
153                                        buffered
154                                    };
155                                    let parts = split_for_platform(&to_send, ch_name);
156                                    for part in parts {
157                                        send_to_thread(
158                                            &channels, ch_name, thread_id, topic_id, part,
159                                        )
160                                        .await;
161                                    }
162                                    last_send.insert(ch_name.to_string(), now);
163                                }
164                            }
165                        }
166                    }
167                }
168
169                if is_final {
170                    // Flush all remaining buffers immediately regardless of rate limits
171                    if let Some(_binding) = transport.get_binding(&repo, &task_id).await {
172                        // Drain buffers map and send everything
173                        for (thread_key, buffered) in buffers.drain() {
174                            if buffered.is_empty() {
175                                continue;
176                            }
177                            let (ch_name, thread_id, topic_id) =
178                                match parse_conversation_key(&thread_key) {
179                                    Some(p) => p,
180                                    None => continue,
181                                };
182                            // Escape HTML for Telegram before splitting (same as above).
183                            let to_send = if ch_name == "telegram" {
184                                html_escape(&buffered)
185                            } else {
186                                buffered
187                            };
188                            let parts = split_for_platform(&to_send, ch_name);
189                            for part in parts {
190                                send_to_thread(&channels, ch_name, thread_id, topic_id, part).await;
191                            }
192                        }
193                    }
194                    tracing::debug!(task_id, "fanout: final chunk received, stream complete");
195                    break;
196                }
197            }
198            Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
199                tracing::warn!(
200                    task_id,
201                    missed = n,
202                    "fanout: output receiver lagged, some output dropped"
203                );
204            }
205            Err(tokio::sync::broadcast::error::RecvError::Closed) => {
206                tracing::debug!(task_id, "fanout: output broadcast closed");
207                break;
208            }
209        }
210
211        // If there are buffered messages pending due to rate limiting, compute
212        // the soonest time we can send and wait (non-busy) so future loop
213        // iterations can flush them even if no new chunks arrive.
214        if !buffers.is_empty() {
215            let now = Instant::now();
216            let mut earliest = None::<Instant>;
217            for thread_key in buffers.keys() {
218                if let Some((ch_name, _, _)) = parse_conversation_key(thread_key) {
219                    let rate = platform_rate(ch_name);
220                    if let Some(last) = last_send.get(ch_name) {
221                        let when = *last + rate;
222                        if when > now {
223                            earliest = Some(match earliest {
224                                Some(e) if e <= when => e,
225                                _ => when,
226                            });
227                        } else {
228                            earliest = Some(now);
229                        }
230                    } else {
231                        earliest = Some(now);
232                    }
233                }
234            }
235
236            if let Some(wake_at) = earliest {
237                // Sleep until earliest send time or 50ms minimum to avoid busy-looping
238                let sleep_dur = if wake_at > now {
239                    wake_at - now
240                } else {
241                    Duration::from_millis(50)
242                };
243                tokio::time::sleep(sleep_dur).await;
244
245                // After waiting, attempt to flush any buffers whose rate limit expired
246                if let Some(_binding) = transport.get_binding(&repo, &task_id).await {
247                    let now = Instant::now();
248                    // Collect keys to flush to avoid borrowing issues
249                    let keys: Vec<String> = buffers.keys().cloned().collect();
250                    for thread_key in keys {
251                        if let Some(buffered) = buffers.remove(&thread_key) {
252                            let (ch_name, thread_id, topic_id) =
253                                match parse_conversation_key(&thread_key) {
254                                    Some(p) => p,
255                                    None => continue,
256                                };
257                            let rate = platform_rate(ch_name);
258                            let can_send = last_send
259                                .get(ch_name)
260                                .map(|t| now.duration_since(*t) >= rate)
261                                .unwrap_or(true);
262                            if can_send {
263                                // Escape HTML for Telegram before splitting (same as above).
264                                let to_send = if ch_name == "telegram" {
265                                    html_escape(&buffered)
266                                } else {
267                                    buffered
268                                };
269                                let parts = split_for_platform(&to_send, ch_name);
270                                for part in parts {
271                                    send_to_thread(&channels, ch_name, thread_id, topic_id, part)
272                                        .await;
273                                }
274                                last_send.insert(ch_name.to_string(), now);
275                            } else {
276                                // Put it back for next round
277                                buffers.insert(thread_key, buffered);
278                            }
279                        }
280                    }
281                }
282            }
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn split_short_content_unchanged() {
293        let parts = split_for_platform("hello", "telegram");
294        assert_eq!(parts, vec!["hello"]);
295    }
296
297    #[test]
298    fn split_long_content_telegram() {
299        let content = "a".repeat(5000);
300        let parts = split_for_platform(&content, "telegram");
301        assert!(parts.len() >= 2);
302        for p in &parts {
303            assert!(p.len() <= TELEGRAM_MAX_BYTES);
304        }
305        // Reassembled content equals original
306        assert_eq!(parts.join(""), content);
307    }
308
309    #[test]
310    fn split_long_content_discord() {
311        let content = "x".repeat(3000);
312        let parts = split_for_platform(&content, "discord");
313        assert!(parts.len() >= 2);
314        for p in &parts {
315            assert!(p.len() <= DISCORD_MAX_BYTES);
316        }
317        assert_eq!(parts.join(""), content);
318    }
319
320    #[test]
321    fn platform_max_bytes_known_channels() {
322        assert_eq!(platform_max_bytes("telegram"), 4096);
323        assert_eq!(platform_max_bytes("discord"), 2000);
324        assert_eq!(platform_max_bytes("slack"), 4000);
325        assert_eq!(platform_max_bytes("unknown"), 4096);
326    }
327
328    #[test]
329    fn platform_rate_known_and_default_channels() {
330        assert_eq!(platform_rate("telegram"), Duration::from_millis(100));
331        assert_eq!(platform_rate("discord"), Duration::from_millis(500));
332        assert_eq!(platform_rate("slack"), Duration::from_millis(200));
333        assert_eq!(platform_rate("unknown"), Duration::from_millis(500));
334    }
335
336    #[test]
337    fn split_multibyte_char_at_boundary_does_not_panic() {
338        // 4-byte emoji: each "😀" is 4 bytes. With max=3, the first chunk boundary
339        // falls in the middle of the character — the old code would panic here.
340        // Build content so the cut at byte 2000 falls inside the trailing 4-byte emoji.
341        let content = "x".repeat(1997) + "😀"; // 2001 bytes, discord limit is 2000
342        let parts = split_for_platform(&content, "discord");
343        // Must not panic and reassembled content must equal original
344        assert_eq!(parts.join(""), content);
345        for p in &parts {
346            // Each part must be valid UTF-8 (Rust strings always are, but slice must be sound)
347            assert!(std::str::from_utf8(p.as_bytes()).is_ok());
348        }
349    }
350
351    #[test]
352    fn split_single_multibyte_char_exceeding_limit() {
353        // Directly exercise split_for_platform where the only content is a single
354        // character whose byte length exceeds the chunk limit.
355        // We can't change platform limits, but we can test that a string of emojis
356        // where every boundary falls mid-char is handled correctly.
357        let content = "😀".repeat(600); // 2400 bytes, exceeds discord limit of 2000
358        let parts = split_for_platform(&content, "discord");
359        assert_eq!(parts.join(""), content);
360        for p in &parts {
361            assert!(std::str::from_utf8(p.as_bytes()).is_ok());
362        }
363    }
364
365    // Regression test for #1897: content must be HTML-escaped BEFORE splitting so
366    // that chunk boundaries are computed on the actual payload that reaches the
367    // Telegram API. The old code split on raw content, then escaped each chunk —
368    // if a chunk boundary fell inside an HTML tag, the escaped result could exceed
369    // 4096 bytes and be rejected by Telegram.
370    #[test]
371    fn split_telegram_html_content_escaped_before_split() {
372        // Build content where the 4096-byte boundary falls inside an HTML tag.
373        // After escaping, each chunk (computed on escaped content) must be ≤ 4096.
374        let raw = "a".repeat(4096) + "<code>123</code>"; // 4096 + 16 = 4112 bytes raw
375        assert_eq!(raw.len(), 4112);
376        let escaped = html_escape(&raw);
377        // 2 `<` + 2 `>` each gain 3 bytes when escaped (4 vs 1)
378        assert_eq!(escaped.len(), 4112 + 12); // 4124 bytes
379        assert!(escaped.len() > raw.len());
380
381        // Split the ESCAPED content — this is what fanout_output now does.
382        let parts = split_for_platform(&escaped, "telegram");
383        assert!(
384            parts.len() >= 2,
385            "escaped content ({}) must split into ≥2 parts",
386            escaped.len()
387        );
388        for (i, p) in parts.iter().enumerate() {
389            assert!(
390                p.len() <= TELEGRAM_MAX_BYTES,
391                "part {} ({} bytes) exceeds Telegram limit {}",
392                i,
393                p.len(),
394                TELEGRAM_MAX_BYTES
395            );
396        }
397        // Reassembled parts must equal fully-escaped content
398        assert_eq!(parts.join(""), escaped);
399    }
400
401    // Documents the old buggy behavior that caused #1897.
402    // Splitting on raw content then escaping each chunk causes the escaped
403    // chunk to exceed 4096 bytes when a boundary falls inside an HTML tag.
404    #[test]
405    fn split_then_escape_exceeds_limit_when_boundary_inside_html_tag() {
406        // `<code>123</code>` is 16 bytes: 2×`<>`, 3 digits, 5 letters, 1×`/`
407        let html_tag = "<code>123</code>";
408        assert_eq!(html_tag.len(), 16);
409
410        // Build content where raw boundary lands mid-HTML-tag.
411        // The escaped version of `<code>123</code>` is 28 bytes (each `<` and `>`
412        // becomes `&lt;` / `&gt;`, a +3 byte expansion).
413        // If the boundary splits in the middle of `</code>`, the raw chunk end
414        // (containing `>`) expands by 3 bytes when escaped — enough to overflow.
415        let raw = "a".repeat(4093) + html_tag; // 4093 + 16 = 4109 bytes
416        assert_eq!(raw.len(), 4109);
417
418        // Old buggy approach: split raw, then escape each
419        let buggy_parts: Vec<String> = split_for_platform(&raw, "telegram")
420            .into_iter()
421            .map(|p| html_escape(&p))
422            .collect();
423
424        // At least one chunk exceeds the limit after escaping
425        let has_overflow = buggy_parts.iter().any(|p| p.len() > TELEGRAM_MAX_BYTES);
426        assert!(
427            has_overflow,
428            "expected old buggy approach to produce an oversized chunk"
429        );
430
431        // Fixed approach: escape first, then split on escaped content
432        let escaped = html_escape(&raw);
433        let fixed_parts = split_for_platform(&escaped, "telegram");
434        for (i, p) in fixed_parts.iter().enumerate() {
435            assert!(
436                p.len() <= TELEGRAM_MAX_BYTES,
437                "fixed part {} ({} bytes) must not exceed limit",
438                i,
439                p.len()
440            );
441        }
442        // Fixed approach preserves all content
443        assert_eq!(fixed_parts.join(""), escaped);
444    }
445
446    #[test]
447    fn telegram_html_escape_produces_valid_html_entities() {
448        assert_eq!(html_escape("<test>"), "&lt;test&gt;");
449        assert_eq!(html_escape("a & b"), "a &amp; b");
450        assert_eq!(html_escape("<>&"), "&lt;&gt;&amp;");
451    }
452
453    // A lightweight test channel implementation used to assert that
454    // fanout_output sends OutgoingMessage bodies to registered channels.
455    struct TestChannel {
456        name: String,
457        tx: tokio::sync::mpsc::Sender<crate::channels::OutgoingMessage>,
458    }
459
460    #[async_trait::async_trait]
461    impl crate::channels::Channel for TestChannel {
462        fn name(&self) -> &str {
463            &self.name
464        }
465
466        async fn start(
467            &self,
468        ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::channels::IncomingMessage>> {
469            // Not used in this test — return a dummy receiver.
470            let (_tx, rx) = tokio::sync::mpsc::channel(1);
471            Ok(rx)
472        }
473
474        async fn send(&self, msg: &crate::channels::OutgoingMessage) -> anyhow::Result<()> {
475            // Forward the message to the test harness channel so assertions can observe it.
476            let _ = self.tx.send(msg.clone()).await;
477            Ok(())
478        }
479
480        async fn health_check(&self) -> anyhow::Result<()> {
481            Ok(())
482        }
483    }
484
485    #[tokio::test]
486    async fn fanout_sends_to_registered_channel() {
487        use crate::channels::transport::Transport;
488
489        let transport = std::sync::Arc::new(Transport::new());
490
491        // Prepare a test channel that will collect OutgoingMessage values
492        let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::channels::OutgoingMessage>(16);
493        let test_ch = TestChannel {
494            name: "telegram".to_string(),
495            tx,
496        };
497
498        // Register channel in a ChannelRegistry
499        let mut registry = crate::channels::ChannelRegistry::new();
500        registry.register(Box::new(test_ch));
501        let registry = std::sync::Arc::new(registry);
502
503        // Bind a task to the channel:thread so fanout knows where to send
504        let task_id = "fanout-test-task";
505        let repo = "owner/test-repo";
506        transport
507            .bind(
508                repo,
509                task_id,
510                "orch-fanout-test",
511                "telegram",
512                "thread-1",
513                None,
514            )
515            .await;
516
517        // Spawn the fanout_output task
518        let transport_clone = transport.clone();
519        let registry_clone = registry.clone();
520        let task_id_str = task_id.to_string();
521        let repo_str = repo.to_string();
522        tokio::spawn(async move {
523            fanout_output(repo_str, task_id_str, transport_clone, registry_clone).await
524        });
525
526        // Give fanout time to subscribe to the broadcast
527        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
528
529        // Push some output into the transport; the fanout should forward it
530        transport
531            .push_output(
532                repo,
533                task_id,
534                crate::channels::OutputChunk {
535                    content: "hello from agent".to_string(),
536                    is_final: false,
537                },
538            )
539            .await;
540
541        // Push a final chunk to force flush
542        transport
543            .push_output(
544                repo,
545                task_id,
546                crate::channels::OutputChunk {
547                    content: "".to_string(),
548                    is_final: true,
549                },
550            )
551            .await;
552
553        // Expect at least one outgoing message to be sent to the test channel
554        let mut got = false;
555        for _ in 0..10 {
556            if let Ok(Some(msg)) =
557                tokio::time::timeout(std::time::Duration::from_millis(200), async {
558                    rx.recv().await
559                })
560                .await
561            {
562                if msg.body.contains("hello from agent") {
563                    got = true;
564                    break;
565                }
566            }
567        }
568
569        assert!(
570            got,
571            "fanout did not send expected outgoing message to test channel"
572        );
573    }
574
575    /// When a task is bound with a `topic_id`, the outgoing `OutgoingMessage`
576    /// forwarded by fanout must carry the same `topic_id` so that the channel
577    /// delivers the reply to the correct forum topic / thread.
578    #[tokio::test]
579    async fn fanout_forwards_topic_id() {
580        let transport = std::sync::Arc::new(Transport::new());
581
582        let (tx, mut rx) = tokio::sync::mpsc::channel::<crate::channels::OutgoingMessage>(16);
583        let test_ch = TestChannel {
584            name: "telegram".to_string(),
585            tx,
586        };
587
588        let mut registry = crate::channels::ChannelRegistry::new();
589        registry.register(Box::new(test_ch));
590        let registry = std::sync::Arc::new(registry);
591
592        let task_id = "topic-forward-task";
593        let repo = "owner/test-repo";
594        // Bind with a specific topic_id
595        transport
596            .bind(
597                repo,
598                task_id,
599                "orch-topic-test",
600                "telegram",
601                "chat-111",
602                Some("topic-42"),
603            )
604            .await;
605
606        let transport_clone = transport.clone();
607        let registry_clone = registry.clone();
608        let task_id_str = task_id.to_string();
609        let repo_str = repo.to_string();
610        tokio::spawn(async move {
611            fanout_output(repo_str, task_id_str, transport_clone, registry_clone).await
612        });
613
614        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
615
616        transport
617            .push_output(
618                repo,
619                task_id,
620                crate::channels::OutputChunk {
621                    content: "topic reply".to_string(),
622                    is_final: true,
623                },
624            )
625            .await;
626
627        let mut got_topic = false;
628        for _ in 0..10 {
629            if let Ok(Some(msg)) =
630                tokio::time::timeout(std::time::Duration::from_millis(200), async {
631                    rx.recv().await
632                })
633                .await
634            {
635                if msg.body.contains("topic reply") {
636                    assert_eq!(
637                        msg.topic_id.as_deref(),
638                        Some("topic-42"),
639                        "outgoing message must carry the bound topic_id"
640                    );
641                    got_topic = true;
642                    break;
643                }
644            }
645        }
646
647        assert!(
648            got_topic,
649            "fanout did not forward topic_id in outgoing message"
650        );
651    }
652}