1#[derive(Debug, Clone)]
6pub struct ElicitationField {
7 pub name: String,
8 pub description: Option<String>,
9 pub field_type: ElicitationFieldType,
10 pub required: bool,
11}
12
13#[derive(Debug, Clone)]
15pub enum ElicitationFieldType {
16 String,
17 Integer,
18 Number,
19 Boolean,
20 Enum(Vec<String>),
22}
23
24#[derive(Debug, Clone)]
26pub struct ElicitationRequest {
27 pub server_name: String,
29 pub message: String,
31 pub fields: Vec<ElicitationField>,
33}
34
35#[derive(Debug, Clone)]
37pub enum ElicitationResponse {
38 Accepted(serde_json::Value),
40 Declined,
42 Cancelled,
44}
45
46#[derive(Debug, thiserror::Error)]
48pub enum ChannelError {
49 #[error("I/O error: {0}")]
51 Io(#[from] std::io::Error),
52
53 #[error("channel closed")]
55 ChannelClosed,
56
57 #[error("confirmation cancelled")]
59 ConfirmCancelled,
60
61 #[error("{0}")]
63 Other(String),
64}
65
66impl ChannelError {
67 pub fn other(e: impl std::fmt::Display) -> Self {
72 Self::Other(e.to_string())
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum AttachmentKind {
79 Audio,
80 Image,
81 Video,
82 File,
83}
84
85#[derive(Debug, Clone)]
87pub struct Attachment {
88 pub kind: AttachmentKind,
89 pub data: Vec<u8>,
90 pub filename: Option<String>,
91}
92
93#[derive(Debug, Clone)]
95pub struct ChannelMessage {
96 pub text: String,
97 pub attachments: Vec<Attachment>,
98}
99
100pub trait Channel: Send {
102 fn recv(&mut self)
108 -> impl Future<Output = Result<Option<ChannelMessage>, ChannelError>> + Send;
109
110 fn try_recv(&mut self) -> Option<ChannelMessage> {
112 None
113 }
114
115 fn supports_exit(&self) -> bool {
120 true
121 }
122
123 fn send(&mut self, text: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
129
130 fn send_chunk(&mut self, chunk: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
136
137 fn flush_chunks(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send;
143
144 fn send_typing(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send {
150 async { Ok(()) }
151 }
152
153 fn send_status(
159 &mut self,
160 _text: &str,
161 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
162 async { Ok(()) }
163 }
164
165 fn send_thinking_chunk(
171 &mut self,
172 _chunk: &str,
173 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
174 async { Ok(()) }
175 }
176
177 fn send_queue_count(
183 &mut self,
184 _count: usize,
185 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
186 async { Ok(()) }
187 }
188
189 fn send_usage(
195 &mut self,
196 _input_tokens: u64,
197 _output_tokens: u64,
198 _context_window: u64,
199 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
200 async { Ok(()) }
201 }
202
203 fn send_diff(
209 &mut self,
210 _diff: crate::DiffData,
211 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
212 async { Ok(()) }
213 }
214
215 fn send_tool_start(
225 &mut self,
226 _event: ToolStartEvent,
227 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
228 async { Ok(()) }
229 }
230
231 fn send_tool_output(
241 &mut self,
242 event: ToolOutputEvent,
243 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
244 let formatted = crate::agent::format_tool_output(event.tool_name.as_str(), &event.display);
245 async move { self.send(&formatted).await }
246 }
247
248 fn confirm(
255 &mut self,
256 _prompt: &str,
257 ) -> impl Future<Output = Result<bool, ChannelError>> + Send {
258 async { Ok(true) }
259 }
260
261 fn elicit(
270 &mut self,
271 _request: ElicitationRequest,
272 ) -> impl Future<Output = Result<ElicitationResponse, ChannelError>> + Send {
273 async { Ok(ElicitationResponse::Declined) }
274 }
275
276 fn send_stop_hint(
285 &mut self,
286 _hint: StopHint,
287 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
288 async { Ok(()) }
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum StopHint {
299 MaxTokens,
301 MaxTurnRequests,
303}
304
305#[derive(Debug, Clone)]
310pub struct ToolStartEvent {
311 pub tool_name: zeph_common::ToolName,
313 pub tool_call_id: String,
315 pub params: Option<serde_json::Value>,
317 pub parent_tool_use_id: Option<String>,
319 pub started_at: std::time::Instant,
321}
322
323#[derive(Debug, Clone)]
328pub struct ToolOutputEvent {
329 pub tool_name: zeph_common::ToolName,
331 pub display: String,
333 pub diff: Option<crate::DiffData>,
335 pub filter_stats: Option<String>,
337 pub kept_lines: Option<Vec<usize>>,
339 pub locations: Option<Vec<String>>,
341 pub tool_call_id: String,
343 pub is_error: bool,
345 pub terminal_id: Option<String>,
347 pub parent_tool_use_id: Option<String>,
349 pub raw_response: Option<serde_json::Value>,
351 pub started_at: Option<std::time::Instant>,
353}
354
355pub type ToolStartData = ToolStartEvent;
359
360pub type ToolOutputData = ToolOutputEvent;
364
365#[derive(Debug, Clone)]
367pub enum LoopbackEvent {
368 Chunk(String),
369 Flush,
370 FullMessage(String),
371 Status(String),
372 ToolStart(Box<ToolStartEvent>),
374 ToolOutput(Box<ToolOutputEvent>),
375 Usage {
377 input_tokens: u64,
378 output_tokens: u64,
379 context_window: u64,
380 },
381 SessionTitle(String),
383 Plan(Vec<(String, PlanItemStatus)>),
385 ThinkingChunk(String),
387 Stop(StopHint),
391}
392
393#[derive(Debug, Clone)]
395pub enum PlanItemStatus {
396 Pending,
397 InProgress,
398 Completed,
399}
400
401pub struct LoopbackHandle {
403 pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
404 pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
405 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
407}
408
409pub struct LoopbackChannel {
411 input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
412 output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
413}
414
415impl LoopbackChannel {
416 #[must_use]
418 pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
419 let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
420 let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
421 let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
422 (
423 Self {
424 input_rx,
425 output_tx,
426 },
427 LoopbackHandle {
428 input_tx,
429 output_rx,
430 cancel_signal,
431 },
432 )
433 }
434}
435
436impl Channel for LoopbackChannel {
437 fn supports_exit(&self) -> bool {
438 false
439 }
440
441 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
442 Ok(self.input_rx.recv().await)
443 }
444
445 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
446 self.output_tx
447 .send(LoopbackEvent::FullMessage(text.to_owned()))
448 .await
449 .map_err(|_| ChannelError::ChannelClosed)
450 }
451
452 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
453 self.output_tx
454 .send(LoopbackEvent::Chunk(chunk.to_owned()))
455 .await
456 .map_err(|_| ChannelError::ChannelClosed)
457 }
458
459 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
460 self.output_tx
461 .send(LoopbackEvent::Flush)
462 .await
463 .map_err(|_| ChannelError::ChannelClosed)
464 }
465
466 async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
467 self.output_tx
468 .send(LoopbackEvent::Status(text.to_owned()))
469 .await
470 .map_err(|_| ChannelError::ChannelClosed)
471 }
472
473 async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
474 self.output_tx
475 .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
476 .await
477 .map_err(|_| ChannelError::ChannelClosed)
478 }
479
480 async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
481 self.output_tx
482 .send(LoopbackEvent::ToolStart(Box::new(event)))
483 .await
484 .map_err(|_| ChannelError::ChannelClosed)
485 }
486
487 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
488 self.output_tx
489 .send(LoopbackEvent::ToolOutput(Box::new(event)))
490 .await
491 .map_err(|_| ChannelError::ChannelClosed)
492 }
493
494 async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
495 Ok(true)
496 }
497
498 async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
499 self.output_tx
500 .send(LoopbackEvent::Stop(hint))
501 .await
502 .map_err(|_| ChannelError::ChannelClosed)
503 }
504
505 async fn send_usage(
506 &mut self,
507 input_tokens: u64,
508 output_tokens: u64,
509 context_window: u64,
510 ) -> Result<(), ChannelError> {
511 self.output_tx
512 .send(LoopbackEvent::Usage {
513 input_tokens,
514 output_tokens,
515 context_window,
516 })
517 .await
518 .map_err(|_| ChannelError::ChannelClosed)
519 }
520}
521
522pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
527
528impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
529 fn send<'a>(
530 &'a mut self,
531 msg: &'a str,
532 ) -> std::pin::Pin<
533 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
534 > {
535 Box::pin(async move {
536 self.0
537 .send(msg)
538 .await
539 .map_err(zeph_commands::CommandError::new)
540 })
541 }
542
543 fn flush_chunks<'a>(
544 &'a mut self,
545 ) -> std::pin::Pin<
546 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
547 > {
548 Box::pin(async move {
549 self.0
550 .flush_chunks()
551 .await
552 .map_err(zeph_commands::CommandError::new)
553 })
554 }
555
556 fn send_queue_count<'a>(
557 &'a mut self,
558 count: usize,
559 ) -> std::pin::Pin<
560 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
561 > {
562 Box::pin(async move {
563 self.0
564 .send_queue_count(count)
565 .await
566 .map_err(zeph_commands::CommandError::new)
567 })
568 }
569
570 fn supports_exit(&self) -> bool {
571 self.0.supports_exit()
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn channel_message_creation() {
581 let msg = ChannelMessage {
582 text: "hello".to_string(),
583 attachments: vec![],
584 };
585 assert_eq!(msg.text, "hello");
586 assert!(msg.attachments.is_empty());
587 }
588
589 struct StubChannel;
590
591 impl Channel for StubChannel {
592 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
593 Ok(None)
594 }
595
596 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
597 Ok(())
598 }
599
600 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
601 Ok(())
602 }
603
604 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
605 Ok(())
606 }
607 }
608
609 #[tokio::test]
610 async fn send_chunk_default_is_noop() {
611 let mut ch = StubChannel;
612 ch.send_chunk("partial").await.unwrap();
613 }
614
615 #[tokio::test]
616 async fn flush_chunks_default_is_noop() {
617 let mut ch = StubChannel;
618 ch.flush_chunks().await.unwrap();
619 }
620
621 #[tokio::test]
622 async fn stub_channel_confirm_auto_approves() {
623 let mut ch = StubChannel;
624 let result = ch.confirm("Delete everything?").await.unwrap();
625 assert!(result);
626 }
627
628 #[tokio::test]
629 async fn stub_channel_send_typing_default() {
630 let mut ch = StubChannel;
631 ch.send_typing().await.unwrap();
632 }
633
634 #[tokio::test]
635 async fn stub_channel_recv_returns_none() {
636 let mut ch = StubChannel;
637 let msg = ch.recv().await.unwrap();
638 assert!(msg.is_none());
639 }
640
641 #[tokio::test]
642 async fn stub_channel_send_ok() {
643 let mut ch = StubChannel;
644 ch.send("hello").await.unwrap();
645 }
646
647 #[test]
648 fn channel_message_clone() {
649 let msg = ChannelMessage {
650 text: "test".to_string(),
651 attachments: vec![],
652 };
653 let cloned = msg.clone();
654 assert_eq!(cloned.text, "test");
655 }
656
657 #[test]
658 fn channel_message_debug() {
659 let msg = ChannelMessage {
660 text: "debug".to_string(),
661 attachments: vec![],
662 };
663 let debug = format!("{msg:?}");
664 assert!(debug.contains("debug"));
665 }
666
667 #[test]
668 fn attachment_kind_equality() {
669 assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
670 assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
671 }
672
673 #[test]
674 fn attachment_construction() {
675 let a = Attachment {
676 kind: AttachmentKind::Audio,
677 data: vec![0, 1, 2],
678 filename: Some("test.wav".into()),
679 };
680 assert_eq!(a.kind, AttachmentKind::Audio);
681 assert_eq!(a.data.len(), 3);
682 assert_eq!(a.filename.as_deref(), Some("test.wav"));
683 }
684
685 #[test]
686 fn channel_message_with_attachments() {
687 let msg = ChannelMessage {
688 text: String::new(),
689 attachments: vec![Attachment {
690 kind: AttachmentKind::Audio,
691 data: vec![42],
692 filename: None,
693 }],
694 };
695 assert_eq!(msg.attachments.len(), 1);
696 assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
697 }
698
699 #[test]
700 fn stub_channel_try_recv_returns_none() {
701 let mut ch = StubChannel;
702 assert!(ch.try_recv().is_none());
703 }
704
705 #[tokio::test]
706 async fn stub_channel_send_queue_count_noop() {
707 let mut ch = StubChannel;
708 ch.send_queue_count(5).await.unwrap();
709 }
710
711 #[test]
714 fn loopback_pair_returns_linked_handles() {
715 let (channel, handle) = LoopbackChannel::pair(8);
716 drop(channel);
718 drop(handle);
719 }
720
721 #[tokio::test]
722 async fn loopback_cancel_signal_can_be_notified_and_awaited() {
723 let (_channel, handle) = LoopbackChannel::pair(8);
724 let signal = std::sync::Arc::clone(&handle.cancel_signal);
725 let notified = signal.notified();
727 handle.cancel_signal.notify_one();
728 notified.await; }
730
731 #[tokio::test]
732 async fn loopback_cancel_signal_shared_across_clones() {
733 let (_channel, handle) = LoopbackChannel::pair(8);
734 let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
735 let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
736 let notified = signal_b.notified();
737 signal_a.notify_one();
738 notified.await;
739 }
740
741 #[tokio::test]
742 async fn loopback_send_recv_round_trip() {
743 let (mut channel, handle) = LoopbackChannel::pair(8);
744 handle
745 .input_tx
746 .send(ChannelMessage {
747 text: "hello".to_owned(),
748 attachments: vec![],
749 })
750 .await
751 .unwrap();
752 let msg = channel.recv().await.unwrap().unwrap();
753 assert_eq!(msg.text, "hello");
754 }
755
756 #[tokio::test]
757 async fn loopback_recv_returns_none_when_handle_dropped() {
758 let (mut channel, handle) = LoopbackChannel::pair(8);
759 drop(handle);
760 let result = channel.recv().await.unwrap();
761 assert!(result.is_none());
762 }
763
764 #[tokio::test]
765 async fn loopback_send_produces_full_message_event() {
766 let (mut channel, mut handle) = LoopbackChannel::pair(8);
767 channel.send("world").await.unwrap();
768 let event = handle.output_rx.recv().await.unwrap();
769 assert!(matches!(event, LoopbackEvent::FullMessage(t) if t == "world"));
770 }
771
772 #[tokio::test]
773 async fn loopback_send_chunk_then_flush() {
774 let (mut channel, mut handle) = LoopbackChannel::pair(8);
775 channel.send_chunk("part1").await.unwrap();
776 channel.flush_chunks().await.unwrap();
777 let ev1 = handle.output_rx.recv().await.unwrap();
778 let ev2 = handle.output_rx.recv().await.unwrap();
779 assert!(matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1"));
780 assert!(matches!(ev2, LoopbackEvent::Flush));
781 }
782
783 #[tokio::test]
784 async fn loopback_send_tool_output() {
785 let (mut channel, mut handle) = LoopbackChannel::pair(8);
786 channel
787 .send_tool_output(ToolOutputEvent {
788 tool_name: "bash".into(),
789 display: "exit 0".into(),
790 diff: None,
791 filter_stats: None,
792 kept_lines: None,
793 locations: None,
794 tool_call_id: String::new(),
795 terminal_id: None,
796 is_error: false,
797 parent_tool_use_id: None,
798 raw_response: None,
799 started_at: None,
800 })
801 .await
802 .unwrap();
803 let event = handle.output_rx.recv().await.unwrap();
804 match event {
805 LoopbackEvent::ToolOutput(data) => {
806 assert_eq!(data.tool_name, "bash");
807 assert_eq!(data.display, "exit 0");
808 assert!(data.diff.is_none());
809 assert!(data.filter_stats.is_none());
810 assert!(data.kept_lines.is_none());
811 assert!(data.locations.is_none());
812 assert_eq!(data.tool_call_id, "");
813 assert!(!data.is_error);
814 assert!(data.terminal_id.is_none());
815 assert!(data.parent_tool_use_id.is_none());
816 assert!(data.raw_response.is_none());
817 }
818 _ => panic!("expected ToolOutput event"),
819 }
820 }
821
822 #[tokio::test]
823 async fn loopback_confirm_auto_approves() {
824 let (mut channel, _handle) = LoopbackChannel::pair(8);
825 let result = channel.confirm("are you sure?").await.unwrap();
826 assert!(result);
827 }
828
829 #[tokio::test]
830 async fn loopback_send_error_when_output_closed() {
831 let (mut channel, handle) = LoopbackChannel::pair(8);
832 drop(handle);
834 let result = channel.send("too late").await;
835 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
836 }
837
838 #[tokio::test]
839 async fn loopback_send_chunk_error_when_output_closed() {
840 let (mut channel, handle) = LoopbackChannel::pair(8);
841 drop(handle);
842 let result = channel.send_chunk("chunk").await;
843 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
844 }
845
846 #[tokio::test]
847 async fn loopback_flush_error_when_output_closed() {
848 let (mut channel, handle) = LoopbackChannel::pair(8);
849 drop(handle);
850 let result = channel.flush_chunks().await;
851 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
852 }
853
854 #[tokio::test]
855 async fn loopback_send_status_event() {
856 let (mut channel, mut handle) = LoopbackChannel::pair(8);
857 channel.send_status("working...").await.unwrap();
858 let event = handle.output_rx.recv().await.unwrap();
859 assert!(matches!(event, LoopbackEvent::Status(s) if s == "working..."));
860 }
861
862 #[tokio::test]
863 async fn loopback_send_usage_produces_usage_event() {
864 let (mut channel, mut handle) = LoopbackChannel::pair(8);
865 channel.send_usage(100, 50, 200_000).await.unwrap();
866 let event = handle.output_rx.recv().await.unwrap();
867 match event {
868 LoopbackEvent::Usage {
869 input_tokens,
870 output_tokens,
871 context_window,
872 } => {
873 assert_eq!(input_tokens, 100);
874 assert_eq!(output_tokens, 50);
875 assert_eq!(context_window, 200_000);
876 }
877 _ => panic!("expected Usage event"),
878 }
879 }
880
881 #[tokio::test]
882 async fn loopback_send_usage_error_when_closed() {
883 let (mut channel, handle) = LoopbackChannel::pair(8);
884 drop(handle);
885 let result = channel.send_usage(1, 2, 3).await;
886 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
887 }
888
889 #[test]
890 fn plan_item_status_variants_are_distinct() {
891 assert!(!matches!(
892 PlanItemStatus::Pending,
893 PlanItemStatus::InProgress
894 ));
895 assert!(!matches!(
896 PlanItemStatus::InProgress,
897 PlanItemStatus::Completed
898 ));
899 assert!(!matches!(
900 PlanItemStatus::Completed,
901 PlanItemStatus::Pending
902 ));
903 }
904
905 #[test]
906 fn loopback_event_session_title_carries_string() {
907 let event = LoopbackEvent::SessionTitle("hello".to_owned());
908 assert!(matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello"));
909 }
910
911 #[test]
912 fn loopback_event_plan_carries_entries() {
913 let entries = vec![
914 ("step 1".to_owned(), PlanItemStatus::Pending),
915 ("step 2".to_owned(), PlanItemStatus::InProgress),
916 ];
917 let event = LoopbackEvent::Plan(entries);
918 match event {
919 LoopbackEvent::Plan(e) => {
920 assert_eq!(e.len(), 2);
921 assert!(matches!(e[0].1, PlanItemStatus::Pending));
922 assert!(matches!(e[1].1, PlanItemStatus::InProgress));
923 }
924 _ => panic!("expected Plan event"),
925 }
926 }
927
928 #[tokio::test]
929 async fn loopback_send_tool_start_produces_tool_start_event() {
930 let (mut channel, mut handle) = LoopbackChannel::pair(8);
931 channel
932 .send_tool_start(ToolStartEvent {
933 tool_name: "shell".into(),
934 tool_call_id: "tc-001".into(),
935 params: Some(serde_json::json!({"command": "ls"})),
936 parent_tool_use_id: None,
937 started_at: std::time::Instant::now(),
938 })
939 .await
940 .unwrap();
941 let event = handle.output_rx.recv().await.unwrap();
942 match event {
943 LoopbackEvent::ToolStart(data) => {
944 assert_eq!(data.tool_name.as_str(), "shell");
945 assert_eq!(data.tool_call_id.as_str(), "tc-001");
946 assert!(data.params.is_some());
947 assert!(data.parent_tool_use_id.is_none());
948 }
949 _ => panic!("expected ToolStart event"),
950 }
951 }
952
953 #[tokio::test]
954 async fn loopback_send_tool_start_with_parent_id() {
955 let (mut channel, mut handle) = LoopbackChannel::pair(8);
956 channel
957 .send_tool_start(ToolStartEvent {
958 tool_name: "web".into(),
959 tool_call_id: "tc-002".into(),
960 params: None,
961 parent_tool_use_id: Some("parent-123".into()),
962 started_at: std::time::Instant::now(),
963 })
964 .await
965 .unwrap();
966 let event = handle.output_rx.recv().await.unwrap();
967 assert!(matches!(
968 event,
969 LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
970 ));
971 }
972
973 #[tokio::test]
974 async fn loopback_send_tool_start_error_when_output_closed() {
975 let (mut channel, handle) = LoopbackChannel::pair(8);
976 drop(handle);
977 let result = channel
978 .send_tool_start(ToolStartEvent {
979 tool_name: "shell".into(),
980 tool_call_id: "tc-003".into(),
981 params: None,
982 parent_tool_use_id: None,
983 started_at: std::time::Instant::now(),
984 })
985 .await;
986 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
987 }
988
989 #[tokio::test]
990 async fn default_send_tool_output_formats_message() {
991 let mut ch = StubChannel;
992 ch.send_tool_output(ToolOutputEvent {
994 tool_name: "bash".into(),
995 display: "hello".into(),
996 diff: None,
997 filter_stats: None,
998 kept_lines: None,
999 locations: None,
1000 tool_call_id: "id".into(),
1001 terminal_id: None,
1002 is_error: false,
1003 parent_tool_use_id: None,
1004 raw_response: None,
1005 started_at: None,
1006 })
1007 .await
1008 .unwrap();
1009 }
1010}