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