1use 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
38fn split_for_platform(content: &str, channel_name: &str) -> Vec<String> {
40 split_utf8(content, platform_max_bytes(channel_name))
41}
42
43async 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
86pub 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 let mut last_send: HashMap<String, Instant> = HashMap::new();
112 let mut buffers: HashMap<String, String> = HashMap::new();
114
115 loop {
116 match rx.recv().await {
118 Ok(chunk) => {
119 let is_final = chunk.is_final;
120
121 if !chunk.content.is_empty() {
122 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 buffers
135 .entry(thread_key.clone())
136 .or_default()
137 .push_str(&chunk.content);
138
139 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 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 if let Some(_binding) = transport.get_binding(&repo, &task_id).await {
172 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 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 !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 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 if let Some(_binding) = transport.get_binding(&repo, &task_id).await {
247 let now = Instant::now();
248 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 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 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 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 let content = "x".repeat(1997) + "😀"; let parts = split_for_platform(&content, "discord");
343 assert_eq!(parts.join(""), content);
345 for p in &parts {
346 assert!(std::str::from_utf8(p.as_bytes()).is_ok());
348 }
349 }
350
351 #[test]
352 fn split_single_multibyte_char_exceeding_limit() {
353 let content = "😀".repeat(600); 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 #[test]
371 fn split_telegram_html_content_escaped_before_split() {
372 let raw = "a".repeat(4096) + "<code>123</code>"; assert_eq!(raw.len(), 4112);
376 let escaped = html_escape(&raw);
377 assert_eq!(escaped.len(), 4112 + 12); assert!(escaped.len() > raw.len());
380
381 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 assert_eq!(parts.join(""), escaped);
399 }
400
401 #[test]
405 fn split_then_escape_exceeds_limit_when_boundary_inside_html_tag() {
406 let html_tag = "<code>123</code>";
408 assert_eq!(html_tag.len(), 16);
409
410 let raw = "a".repeat(4093) + html_tag; assert_eq!(raw.len(), 4109);
417
418 let buggy_parts: Vec<String> = split_for_platform(&raw, "telegram")
420 .into_iter()
421 .map(|p| html_escape(&p))
422 .collect();
423
424 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 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 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>"), "<test>");
449 assert_eq!(html_escape("a & b"), "a & b");
450 assert_eq!(html_escape("<>&"), "<>&");
451 }
452
453 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 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 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 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 let mut registry = crate::channels::ChannelRegistry::new();
500 registry.register(Box::new(test_ch));
501 let registry = std::sync::Arc::new(registry);
502
503 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 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 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
528
529 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 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 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 #[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 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}