1use std::sync::Arc;
22use std::time::Duration;
23
24use tokio::sync::broadcast;
25use tokio::time::Instant;
26
27use bamboo_agent_core::AgentEvent;
28
29use super::platform::{MessageRef, OutboundMessage, Platform, ReplyCtx};
30
31pub const MAX_MESSAGE_CHARS: usize = 4096;
35
36const TOOL_LINE_MAX_CHARS: usize = 300;
40
41const EDIT_MIN_INTERVAL: Duration = Duration::from_millis(1500);
44const EDIT_MIN_NEW_CHARS: usize = 30;
47
48#[derive(Debug)]
50pub enum RunOutcome {
51 Terminal,
54 Paused {
57 ask: PendingAsk,
58 stream_state: Option<Box<StreamState>>,
67 },
68}
69
70#[derive(Debug)]
74pub struct StreamState {
75 tool_lines: Vec<String>,
76 assistant_text: String,
77 status_ref: Option<MessageRef>,
78 last_edit_at: Option<Instant>,
79 chars_since_edit: usize,
80}
81
82#[derive(Debug, Clone)]
86pub struct PendingAsk {
87 pub tool_call_id: String,
88 pub tool_name: String,
89 pub question: String,
90 pub options: Vec<String>,
91 pub allow_custom: bool,
92}
93
94pub fn chunk_message(text: &str, limit: usize) -> Vec<String> {
98 if text.is_empty() {
99 return Vec::new();
100 }
101 let chars: Vec<char> = text.chars().collect();
102 chars
103 .chunks(limit.max(1))
104 .map(|chunk| chunk.iter().collect())
105 .collect()
106}
107
108fn truncate_chars(text: &str, max: usize) -> String {
110 if text.chars().count() <= max {
111 return text.to_string();
112 }
113 let mut out: String = text.chars().take(max).collect();
114 out.push('…');
115 out
116}
117
118fn tail_chars(text: &str, max: usize) -> String {
123 let chars: Vec<char> = text.chars().collect();
124 if chars.len() <= max {
125 return text.to_string();
126 }
127 let start = chars.len() - max;
128 chars[start..].iter().collect()
129}
130
131fn summarize_arguments(arguments: &serde_json::Value) -> String {
135 let Some(obj) = arguments.as_object() else {
136 return arguments.to_string();
137 };
138 for key in ["command", "file_path", "path", "query", "pattern", "url"] {
139 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
140 return value.to_string();
141 }
142 }
143 serde_json::to_string(arguments).unwrap_or_default()
144}
145
146fn format_tool_line(tool_name: &str, arguments: &serde_json::Value) -> String {
149 let summary = summarize_arguments(arguments);
150 truncate_chars(&format!("⚙ {tool_name}: {summary}"), TOOL_LINE_MAX_CHARS)
151}
152
153async fn send_chunks(platform: &Arc<dyn Platform>, ctx: &ReplyCtx, text: &str) {
154 for chunk in chunk_message(text, MAX_MESSAGE_CHARS) {
155 if let Err(error) = platform.reply(ctx, OutboundMessage::text(chunk)).await {
156 tracing::warn!("connect: failed to deliver reply: {error}");
157 }
158 }
159}
160
161fn pending_ask_from_event(
162 question: String,
163 options: Option<Vec<String>>,
164 tool_call_id: Option<String>,
165 tool_name: Option<String>,
166 allow_custom: bool,
167) -> PendingAsk {
168 PendingAsk {
169 tool_call_id: tool_call_id.unwrap_or_default(),
170 tool_name: tool_name.unwrap_or_default(),
171 question,
172 options: options.unwrap_or_default(),
173 allow_custom,
174 }
175}
176
177pub async fn stream_execution(
188 platform: Arc<dyn Platform>,
189 reply_ctx: ReplyCtx,
190 rx: broadcast::Receiver<AgentEvent>,
191 prior: Option<Box<StreamState>>,
192) -> RunOutcome {
193 if platform.capabilities().edit_message {
194 stream_execution_streaming(platform, reply_ctx, rx, prior).await
195 } else {
196 stream_execution_legacy(platform, reply_ctx, rx).await
197 }
198}
199
200async fn stream_execution_legacy(
205 platform: Arc<dyn Platform>,
206 reply_ctx: ReplyCtx,
207 mut rx: broadcast::Receiver<AgentEvent>,
208) -> RunOutcome {
209 let mut final_text = String::new();
210 let mut terminal_note: Option<String> = None;
211
212 loop {
213 match rx.recv().await {
214 Ok(AgentEvent::ToolStart {
215 tool_name,
216 arguments,
217 ..
218 }) => {
219 let line = format_tool_line(&tool_name, &arguments);
220 send_chunks(&platform, &reply_ctx, &line).await;
221 }
222 Ok(AgentEvent::Token { content }) => final_text.push_str(&content),
223 Ok(AgentEvent::NeedClarification {
224 question,
225 options,
226 tool_call_id,
227 tool_name,
228 allow_custom,
229 }) => {
230 return RunOutcome::Paused {
231 ask: pending_ask_from_event(
232 question,
233 options,
234 tool_call_id,
235 tool_name,
236 allow_custom,
237 ),
238 stream_state: None,
239 };
240 }
241 Ok(AgentEvent::Complete { .. }) => break,
242 Ok(AgentEvent::Cancelled { message }) => {
243 terminal_note = Some(message.unwrap_or_else(|| "Cancelled.".to_string()));
244 break;
245 }
246 Ok(AgentEvent::Error { message }) => {
247 terminal_note = Some(format!("Error: {message}"));
248 break;
249 }
250 Ok(_) => continue,
251 Err(broadcast::error::RecvError::Lagged(_)) => continue,
253 Err(broadcast::error::RecvError::Closed) => break,
256 }
257 }
258
259 let body = terminal_note.unwrap_or(final_text);
260 if !body.trim().is_empty() {
261 send_chunks(&platform, &reply_ctx, &body).await;
262 }
263 RunOutcome::Terminal
264}
265
266struct StreamingRenderer {
269 platform: Arc<dyn Platform>,
270 reply_ctx: ReplyCtx,
271 tool_lines: Vec<String>,
272 assistant_text: String,
273 status_ref: Option<MessageRef>,
274 last_edit_at: Option<Instant>,
275 chars_since_edit: usize,
276}
277
278impl StreamingRenderer {
279 fn new(platform: Arc<dyn Platform>, reply_ctx: ReplyCtx) -> Self {
280 Self {
281 platform,
282 reply_ctx,
283 tool_lines: Vec::new(),
284 assistant_text: String::new(),
285 status_ref: None,
286 last_edit_at: None,
287 chars_since_edit: 0,
288 }
289 }
290
291 fn resume(platform: Arc<dyn Platform>, reply_ctx: ReplyCtx, state: StreamState) -> Self {
294 let StreamState {
295 tool_lines,
296 assistant_text,
297 status_ref,
298 last_edit_at,
299 chars_since_edit,
300 } = state;
301 Self {
302 platform,
303 reply_ctx,
304 tool_lines,
305 assistant_text,
306 status_ref,
307 last_edit_at,
308 chars_since_edit,
309 }
310 }
311
312 fn into_state(self) -> StreamState {
315 StreamState {
316 tool_lines: self.tool_lines,
317 assistant_text: self.assistant_text,
318 status_ref: self.status_ref,
319 last_edit_at: self.last_edit_at,
320 chars_since_edit: self.chars_since_edit,
321 }
322 }
323
324 async fn send_initial(&mut self) {
325 match self
326 .platform
327 .reply(&self.reply_ctx, OutboundMessage::text("⏳ Working…"))
328 .await
329 {
330 Ok(msg_ref) => self.status_ref = Some(msg_ref),
331 Err(error) => {
332 tracing::warn!("connect: failed to send initial status message: {error}")
333 }
334 }
335 }
336
337 fn full_body(&self) -> String {
340 let mut body = String::new();
341 for line in &self.tool_lines {
342 body.push_str(line);
343 body.push('\n');
344 }
345 if !self.assistant_text.is_empty() {
346 if !body.is_empty() {
347 body.push('\n');
348 }
349 body.push_str(&self.assistant_text);
350 }
351 body
352 }
353
354 fn display_tail(&self) -> String {
357 tail_chars(&self.full_body(), MAX_MESSAGE_CHARS)
358 }
359
360 async fn note_growth(&mut self, added_chars: usize) {
363 self.chars_since_edit += added_chars;
364 let now = Instant::now();
365 let interval_ok = self
366 .last_edit_at
367 .map(|at| now.duration_since(at) >= EDIT_MIN_INTERVAL)
368 .unwrap_or(true);
369 if !interval_ok || self.chars_since_edit < EDIT_MIN_NEW_CHARS {
370 return;
371 }
372 let text = self.display_tail();
373 self.apply_edit(text).await;
374 self.last_edit_at = Some(now);
375 self.chars_since_edit = 0;
376 }
377
378 async fn apply_edit(&mut self, text: String) {
384 if text.trim().is_empty() {
385 return;
386 }
387 let Some(msg_ref) = self.status_ref.clone() else {
388 match self
389 .platform
390 .reply(&self.reply_ctx, OutboundMessage::text(text))
391 .await
392 {
393 Ok(new_ref) => self.status_ref = Some(new_ref),
394 Err(error) => {
395 tracing::warn!("connect: failed to send status message: {error}")
396 }
397 }
398 return;
399 };
400 if let Err(error) = self
401 .platform
402 .edit(&msg_ref, OutboundMessage::text(text.clone()))
403 .await
404 {
405 tracing::warn!("connect: status edit failed, degrading to a fresh message: {error}");
406 match self
407 .platform
408 .reply(&self.reply_ctx, OutboundMessage::text(text))
409 .await
410 {
411 Ok(new_ref) => self.status_ref = Some(new_ref),
412 Err(error) => tracing::warn!("connect: fallback send also failed: {error}"),
413 }
414 }
415 }
416
417 async fn finalize_success(&mut self) {
422 let full = self.full_body();
423 if full.trim().is_empty() {
424 self.apply_edit("✅ Done.".to_string()).await;
425 return;
426 }
427 if full.chars().count() <= MAX_MESSAGE_CHARS {
428 self.apply_edit(format!("✅ {full}")).await;
429 } else {
430 self.apply_edit("✅ done".to_string()).await;
431 send_chunks(&self.platform, &self.reply_ctx, &full).await;
432 }
433 }
434
435 async fn finalize_terminal_note(&mut self, icon: &str, note: &str) {
438 self.apply_edit(format!("{icon} {note}")).await;
439 }
440
441 async fn finalize_paused(&mut self) {
444 let mut text = self.display_tail();
445 if !text.is_empty() {
446 text.push_str("\n\n");
447 }
448 text.push_str("⏸ Waiting for your input…");
449 self.apply_edit(text).await;
450 }
451}
452
453async fn stream_execution_streaming(
457 platform: Arc<dyn Platform>,
458 reply_ctx: ReplyCtx,
459 mut rx: broadcast::Receiver<AgentEvent>,
460 prior: Option<Box<StreamState>>,
461) -> RunOutcome {
462 let mut renderer = match prior {
463 Some(state) => StreamingRenderer::resume(platform, reply_ctx, *state),
464 None => {
465 let mut renderer = StreamingRenderer::new(platform, reply_ctx);
466 renderer.send_initial().await;
467 renderer
468 }
469 };
470
471 loop {
472 match rx.recv().await {
473 Ok(AgentEvent::ToolStart {
474 tool_name,
475 arguments,
476 ..
477 }) => {
478 let line = format_tool_line(&tool_name, &arguments);
479 let added = line.chars().count();
480 renderer.tool_lines.push(line);
481 renderer.note_growth(added).await;
482 }
483 Ok(AgentEvent::Token { content }) => {
484 let added = content.chars().count();
485 renderer.assistant_text.push_str(&content);
486 renderer.note_growth(added).await;
487 }
488 Ok(AgentEvent::NeedClarification {
489 question,
490 options,
491 tool_call_id,
492 tool_name,
493 allow_custom,
494 }) => {
495 renderer.finalize_paused().await;
496 return RunOutcome::Paused {
497 ask: pending_ask_from_event(
498 question,
499 options,
500 tool_call_id,
501 tool_name,
502 allow_custom,
503 ),
504 stream_state: Some(Box::new(renderer.into_state())),
505 };
506 }
507 Ok(AgentEvent::Complete { .. }) => {
508 renderer.finalize_success().await;
509 return RunOutcome::Terminal;
510 }
511 Ok(AgentEvent::Cancelled { message }) => {
512 renderer
513 .finalize_terminal_note(
514 "⏹",
515 &message.unwrap_or_else(|| "Cancelled.".to_string()),
516 )
517 .await;
518 return RunOutcome::Terminal;
519 }
520 Ok(AgentEvent::Error { message }) => {
521 renderer.finalize_terminal_note("❌ Error:", &message).await;
522 return RunOutcome::Terminal;
523 }
524 Ok(_) => continue,
525 Err(broadcast::error::RecvError::Lagged(_)) => continue,
526 Err(broadcast::error::RecvError::Closed) => return RunOutcome::Terminal,
531 }
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::connect::platform::{Capabilities, InboundMessage};
539
540 #[test]
541 fn chunk_message_splits_on_char_boundaries_not_bytes() {
542 let text = "\u{4e2d}\u{6587}\u{6d4b}\u{8bd5}"; let chunks = chunk_message(text, 2);
546 assert_eq!(chunks, vec!["\u{4e2d}\u{6587}", "\u{6d4b}\u{8bd5}"]);
547 }
548
549 #[test]
550 fn chunk_message_respects_the_4096_limit() {
551 let text = "a".repeat(10_000);
552 let chunks = chunk_message(&text, MAX_MESSAGE_CHARS);
553 assert_eq!(chunks.len(), 3);
554 assert_eq!(chunks[0].chars().count(), MAX_MESSAGE_CHARS);
555 assert_eq!(chunks[1].chars().count(), MAX_MESSAGE_CHARS);
556 assert_eq!(chunks[2].chars().count(), 10_000 - 2 * MAX_MESSAGE_CHARS);
557 }
558
559 #[test]
560 fn chunk_message_empty_text_yields_no_chunks() {
561 assert!(chunk_message("", MAX_MESSAGE_CHARS).is_empty());
562 }
563
564 #[test]
565 fn tail_chars_keeps_the_last_n_characters() {
566 let text = "0123456789";
567 assert_eq!(tail_chars(text, 4), "6789");
568 assert_eq!(tail_chars(text, 100), text);
569 }
570
571 #[test]
572 fn format_tool_line_prefers_command_field_and_truncates() {
573 let args = serde_json::json!({ "command": "cargo test --workspace" });
574 let line = format_tool_line("Bash", &args);
575 assert_eq!(line, "⚙ Bash: cargo test --workspace");
576 }
577
578 #[test]
579 fn format_tool_line_truncates_long_summaries() {
580 let long_command = "x".repeat(1000);
581 let args = serde_json::json!({ "command": long_command });
582 let line = format_tool_line("Bash", &args);
583 assert!(line.chars().count() <= TOOL_LINE_MAX_CHARS + 1);
584 assert!(line.ends_with('…'));
585 }
586
587 #[test]
588 fn format_tool_line_falls_back_to_json_for_unknown_shape() {
589 let args = serde_json::json!({ "foo": "bar" });
590 let line = format_tool_line("CustomTool", &args);
591 assert!(line.starts_with("⚙ CustomTool: "));
592 assert!(line.contains("foo"));
593 }
594
595 struct RecordingPlatform {
599 edit_message: bool,
600 sent: tokio::sync::Mutex<Vec<String>>,
601 edits: tokio::sync::Mutex<Vec<String>>,
602 edit_should_fail: std::sync::atomic::AtomicBool,
603 }
604
605 impl RecordingPlatform {
606 fn new(edit_message: bool) -> Arc<Self> {
607 Arc::new(Self {
608 edit_message,
609 sent: tokio::sync::Mutex::new(Vec::new()),
610 edits: tokio::sync::Mutex::new(Vec::new()),
611 edit_should_fail: std::sync::atomic::AtomicBool::new(false),
612 })
613 }
614 }
615
616 #[async_trait::async_trait]
617 impl Platform for RecordingPlatform {
618 fn name(&self) -> &str {
619 "recording"
620 }
621 fn capabilities(&self) -> Capabilities {
622 Capabilities {
623 buttons: false,
624 edit_message: self.edit_message,
625 images: false,
626 files: false,
627 }
628 }
629 async fn start(
630 &self,
631 _inbound: tokio::sync::mpsc::Sender<super::super::platform::Inbound>,
632 ) -> super::super::platform::PlatformResult<()> {
633 Ok(())
634 }
635 async fn reply(
636 &self,
637 _ctx: &ReplyCtx,
638 msg: OutboundMessage,
639 ) -> super::super::platform::PlatformResult<super::super::platform::MessageRef> {
640 self.sent.lock().await.push(msg.text);
641 Ok(super::super::platform::MessageRef(serde_json::json!({
642 "id": self.sent.lock().await.len()
643 })))
644 }
645 async fn edit(
646 &self,
647 _msg_ref: &super::super::platform::MessageRef,
648 new: OutboundMessage,
649 ) -> super::super::platform::PlatformResult<()> {
650 if self
651 .edit_should_fail
652 .load(std::sync::atomic::Ordering::SeqCst)
653 {
654 return Err(super::super::platform::PlatformError::other("edit failed"));
655 }
656 self.edits.lock().await.push(new.text);
657 Ok(())
658 }
659 async fn stop(&self) -> super::super::platform::PlatformResult<()> {
660 Ok(())
661 }
662 }
663
664 fn ask_event(question: &str, options: Vec<&str>, allow_custom: bool) -> AgentEvent {
665 AgentEvent::NeedClarification {
666 question: question.to_string(),
667 options: Some(options.into_iter().map(str::to_string).collect()),
668 tool_call_id: Some("call-1".to_string()),
669 tool_name: Some("conclusion_with_options".to_string()),
670 allow_custom,
671 }
672 }
673
674 #[tokio::test]
677 async fn stream_execution_renders_tool_lines_and_final_text() {
678 let (tx, rx) = broadcast::channel(16);
679 let platform = RecordingPlatform::new(false);
680 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
681
682 tx.send(AgentEvent::ToolStart {
683 tool_call_id: "1".to_string(),
684 tool_name: "Bash".to_string(),
685 arguments: serde_json::json!({ "command": "cargo test" }),
686 })
687 .unwrap();
688 tx.send(AgentEvent::Token {
689 content: "Hello ".to_string(),
690 })
691 .unwrap();
692 tx.send(AgentEvent::Token {
693 content: "world.".to_string(),
694 })
695 .unwrap();
696 tx.send(AgentEvent::Complete {
697 usage: bamboo_agent_core::TokenUsage {
698 prompt_tokens: 1,
699 completion_tokens: 1,
700 total_tokens: 2,
701 },
702 })
703 .unwrap();
704
705 let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
706 assert!(matches!(outcome, RunOutcome::Terminal));
707
708 let sent = platform.sent.lock().await;
709 assert_eq!(sent.len(), 2);
710 assert_eq!(sent[0], "⚙ Bash: cargo test");
711 assert_eq!(sent[1], "Hello world.");
712 }
713
714 #[tokio::test]
715 async fn stream_execution_renders_error_note_instead_of_partial_text() {
716 let (tx, rx) = broadcast::channel(16);
717 let platform = RecordingPlatform::new(false);
718 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
719
720 tx.send(AgentEvent::Token {
721 content: "partial".to_string(),
722 })
723 .unwrap();
724 tx.send(AgentEvent::Error {
725 message: "boom".to_string(),
726 })
727 .unwrap();
728
729 stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
730
731 let sent = platform.sent.lock().await;
732 assert_eq!(sent.len(), 1);
733 assert_eq!(sent[0], "Error: boom");
734 }
735
736 #[tokio::test]
737 async fn stream_execution_returns_when_channel_closes_without_terminal_event() {
738 let (tx, rx) = broadcast::channel(16);
739 let platform = RecordingPlatform::new(false);
740 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
741 drop(tx);
742
743 tokio::time::timeout(
745 std::time::Duration::from_secs(5),
746 stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None),
747 )
748 .await
749 .expect("stream_execution must not hang on a closed channel");
750
751 assert!(platform.sent.lock().await.is_empty());
752 }
753
754 #[tokio::test]
755 async fn stream_execution_legacy_pauses_on_need_clarification() {
756 let (tx, rx) = broadcast::channel(16);
757 let platform = RecordingPlatform::new(false);
758 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
759
760 tx.send(ask_event("Pick one", vec!["A", "B"], false))
761 .unwrap();
762
763 let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
764 match outcome {
765 RunOutcome::Paused { ask, stream_state } => {
766 assert_eq!(ask.question, "Pick one");
767 assert_eq!(ask.options, vec!["A".to_string(), "B".to_string()]);
768 assert_eq!(ask.tool_call_id, "call-1");
769 assert!(!ask.allow_custom);
770 assert!(stream_state.is_none());
772 }
773 RunOutcome::Terminal => panic!("expected Paused"),
774 }
775 assert!(platform.sent.lock().await.is_empty());
778 }
779
780 #[tokio::test]
783 async fn streaming_mode_sends_one_initial_status_message() {
784 let (_tx, rx) = broadcast::channel(16);
785 let platform = RecordingPlatform::new(true);
786 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
787 drop(_tx);
788
789 stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
790
791 let sent = platform.sent.lock().await;
792 assert_eq!(sent.len(), 1);
793 assert_eq!(sent[0], "⏳ Working…");
794 }
795
796 #[tokio::test]
797 async fn streaming_mode_final_success_edits_status_with_checkmark() {
798 let (tx, rx) = broadcast::channel(16);
799 let platform = RecordingPlatform::new(true);
800 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
801
802 tx.send(AgentEvent::Token {
803 content: "All done.".to_string(),
804 })
805 .unwrap();
806 tx.send(AgentEvent::Complete {
807 usage: bamboo_agent_core::TokenUsage {
808 prompt_tokens: 1,
809 completion_tokens: 1,
810 total_tokens: 2,
811 },
812 })
813 .unwrap();
814
815 let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
816 assert!(matches!(outcome, RunOutcome::Terminal));
817
818 let edits = platform.edits.lock().await;
821 assert_eq!(edits.len(), 1);
822 assert_eq!(edits[0], "✅ All done.");
823 assert_eq!(platform.sent.lock().await.len(), 1);
825 }
826
827 #[tokio::test]
828 async fn streaming_mode_final_error_edits_status_with_cross() {
829 let (tx, rx) = broadcast::channel(16);
830 let platform = RecordingPlatform::new(true);
831 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
832
833 tx.send(AgentEvent::Error {
834 message: "boom".to_string(),
835 })
836 .unwrap();
837
838 stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
839
840 let edits = platform.edits.lock().await;
841 assert_eq!(edits.last().unwrap(), "❌ Error: boom");
842 }
843
844 #[tokio::test]
845 async fn streaming_mode_final_cancel_edits_status_with_stop_icon() {
846 let (tx, rx) = broadcast::channel(16);
847 let platform = RecordingPlatform::new(true);
848 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
849
850 tx.send(AgentEvent::Cancelled {
851 message: Some("user requested /stop".to_string()),
852 })
853 .unwrap();
854
855 stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
856
857 let edits = platform.edits.lock().await;
858 assert_eq!(edits.last().unwrap(), "⏹ user requested /stop");
859 }
860
861 #[tokio::test]
862 async fn streaming_mode_pauses_on_need_clarification_with_courtesy_edit() {
863 let (tx, rx) = broadcast::channel(16);
864 let platform = RecordingPlatform::new(true);
865 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
866
867 tx.send(AgentEvent::Token {
868 content: "Working on it".to_string(),
869 })
870 .unwrap();
871 tx.send(ask_event("Approve?", vec!["Approve", "Deny"], false))
872 .unwrap();
873
874 let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
875 match outcome {
876 RunOutcome::Paused { ask, stream_state } => {
877 assert_eq!(ask.question, "Approve?");
878 assert!(stream_state.is_some());
880 }
881 RunOutcome::Terminal => panic!("expected Paused"),
882 }
883
884 let edits = platform.edits.lock().await;
885 assert!(edits.last().unwrap().contains("Waiting for your input"));
886 }
887
888 #[tokio::test]
889 async fn streaming_mode_resume_keeps_editing_the_same_status_message() {
890 let platform = RecordingPlatform::new(true);
891 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
892
893 let (tx1, rx1) = broadcast::channel(16);
895 tx1.send(AgentEvent::Token {
896 content: "Before the question. ".to_string(),
897 })
898 .unwrap();
899 tx1.send(ask_event("Approve?", vec!["Approve", "Deny"], false))
900 .unwrap();
901 let outcome = stream_execution(
902 platform.clone() as Arc<dyn Platform>,
903 ctx.clone(),
904 rx1,
905 None,
906 )
907 .await;
908 let state = match outcome {
909 RunOutcome::Paused { stream_state, .. } => stream_state,
910 RunOutcome::Terminal => panic!("expected Paused"),
911 };
912 assert!(state.is_some());
913
914 let (tx2, rx2) = broadcast::channel(16);
917 tx2.send(AgentEvent::Token {
918 content: "After the answer.".to_string(),
919 })
920 .unwrap();
921 tx2.send(AgentEvent::Complete {
922 usage: bamboo_agent_core::TokenUsage {
923 prompt_tokens: 1,
924 completion_tokens: 1,
925 total_tokens: 2,
926 },
927 })
928 .unwrap();
929 stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx2, state).await;
930
931 let sent = platform.sent.lock().await;
935 assert_eq!(sent.len(), 1, "expected a single status message: {sent:?}");
936 assert_eq!(sent[0], "⏳ Working…");
937 let edits = platform.edits.lock().await;
940 let last = edits.last().expect("expected a final edit");
941 assert!(last.starts_with('✅'), "final edit not a success: {last}");
942 assert!(last.contains("Before the question."));
943 assert!(last.contains("After the answer."));
944 }
945
946 #[tokio::test]
947 async fn streaming_mode_throttle_skips_edits_below_the_char_threshold() {
948 let (tx, rx) = broadcast::channel(16);
949 let platform = RecordingPlatform::new(true);
950 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
951
952 for i in 0..5 {
955 tx.send(AgentEvent::Token {
956 content: format!("t{i} "),
957 })
958 .unwrap();
959 }
960 tx.send(AgentEvent::Complete {
961 usage: bamboo_agent_core::TokenUsage {
962 prompt_tokens: 1,
963 completion_tokens: 1,
964 total_tokens: 2,
965 },
966 })
967 .unwrap();
968
969 stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
970
971 assert_eq!(platform.edits.lock().await.len(), 1);
973 }
974
975 #[tokio::test]
976 async fn streaming_mode_long_final_text_chunks_instead_of_editing_in_full() {
977 let (tx, rx) = broadcast::channel(16);
978 let platform = RecordingPlatform::new(true);
979 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
980
981 let long_text = "a".repeat(5000);
982 tx.send(AgentEvent::Token {
983 content: long_text.clone(),
984 })
985 .unwrap();
986 tx.send(AgentEvent::Complete {
987 usage: bamboo_agent_core::TokenUsage {
988 prompt_tokens: 1,
989 completion_tokens: 1,
990 total_tokens: 2,
991 },
992 })
993 .unwrap();
994
995 stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
996
997 let edits = platform.edits.lock().await;
999 assert_eq!(edits.last().unwrap(), "✅ done");
1000 let sent = platform.sent.lock().await;
1003 assert_eq!(sent.len(), 3);
1004 }
1005
1006 #[tokio::test]
1007 async fn streaming_mode_edit_failure_degrades_to_a_fresh_send() {
1008 let (tx, rx) = broadcast::channel(16);
1009 let platform = RecordingPlatform::new(true);
1010 platform
1011 .edit_should_fail
1012 .store(true, std::sync::atomic::Ordering::SeqCst);
1013 let ctx = ReplyCtx(serde_json::json!({"chat_id": "1"}));
1014
1015 tx.send(AgentEvent::Complete {
1016 usage: bamboo_agent_core::TokenUsage {
1017 prompt_tokens: 1,
1018 completion_tokens: 1,
1019 total_tokens: 2,
1020 },
1021 })
1022 .unwrap();
1023
1024 let outcome = stream_execution(platform.clone() as Arc<dyn Platform>, ctx, rx, None).await;
1025 assert!(matches!(outcome, RunOutcome::Terminal));
1026
1027 assert!(platform.edits.lock().await.is_empty());
1030 assert_eq!(platform.sent.lock().await.len(), 2);
1032 }
1033
1034 #[test]
1038 fn inbound_message_is_still_constructible() {
1039 let _ = InboundMessage {
1040 platform: "telegram".to_string(),
1041 chat_id: "1".to_string(),
1042 user_id: "1".to_string(),
1043 message_id: "1".to_string(),
1044 sent_at: chrono::Utc::now(),
1045 text: "hi".to_string(),
1046 reply_ctx: ReplyCtx(serde_json::Value::Null),
1047 };
1048 }
1049}