1#[derive(Debug, Clone)]
25pub struct ElicitationField {
26 pub name: String,
28 pub description: Option<String>,
30 pub field_type: ElicitationFieldType,
32 pub required: bool,
34}
35
36#[derive(Debug, Clone)]
47pub enum ElicitationFieldType {
48 String,
49 Integer,
50 Number,
51 Boolean,
52 Enum(Vec<String>),
54}
55
56#[derive(Debug, Clone)]
81pub struct ElicitationRequest {
82 pub server_name: String,
84 pub message: String,
86 pub fields: Vec<ElicitationField>,
88}
89
90#[derive(Debug, Clone)]
108pub enum ElicitationResponse {
109 Accepted(serde_json::Value),
111 Declined,
113 Cancelled,
115}
116
117#[derive(Debug, thiserror::Error)]
119pub enum ChannelError {
120 #[error("I/O error: {0}")]
122 Io(#[from] std::io::Error),
123
124 #[error("channel closed")]
126 ChannelClosed,
127
128 #[error("confirmation cancelled")]
130 ConfirmCancelled,
131
132 #[error("no active session")]
137 NoActiveSession,
138
139 #[error("telegram error: {0}")]
145 Telegram(String),
146
147 #[error("{0}")]
149 Other(String),
150}
151
152impl ChannelError {
153 pub fn telegram(e: impl std::fmt::Display) -> Self {
164 Self::Telegram(e.to_string())
165 }
166
167 pub fn other(e: impl std::fmt::Display) -> Self {
172 Self::Other(e.to_string())
173 }
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum AttachmentKind {
179 Audio,
180 Image,
181 Video,
182 File,
183}
184
185#[derive(Debug, Clone)]
187pub struct Attachment {
188 pub kind: AttachmentKind,
189 pub data: Vec<u8>,
190 pub filename: Option<String>,
191}
192
193#[derive(Debug, Clone)]
195pub struct ChannelMessage {
196 pub text: String,
197 pub attachments: Vec<Attachment>,
198 pub is_guest_context: bool,
200 pub is_from_bot: bool,
202}
203
204pub trait Channel: Send {
221 fn recv(&mut self)
227 -> impl Future<Output = Result<Option<ChannelMessage>, ChannelError>> + Send;
228
229 fn try_recv(&mut self) -> Option<ChannelMessage> {
231 None
232 }
233
234 fn supports_exit(&self) -> bool {
239 true
240 }
241
242 fn send(&mut self, text: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
248
249 fn send_chunk(&mut self, chunk: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
255
256 fn flush_chunks(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send;
262
263 fn send_typing(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send {
269 async { Ok(()) }
270 }
271
272 fn send_status(
278 &mut self,
279 _text: &str,
280 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
281 async { Ok(()) }
282 }
283
284 fn send_thinking_chunk(
290 &mut self,
291 _chunk: &str,
292 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
293 async { Ok(()) }
294 }
295
296 fn send_queue_count(
302 &mut self,
303 _count: usize,
304 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
305 async { Ok(()) }
306 }
307
308 fn send_usage(
314 &mut self,
315 _input_tokens: u64,
316 _output_tokens: u64,
317 _context_window: u64,
318 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
319 async { Ok(()) }
320 }
321
322 fn send_diff(
331 &mut self,
332 _diff: crate::DiffData,
333 _tool_call_id: &str,
334 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
335 async { Ok(()) }
336 }
337
338 fn send_tool_start(
348 &mut self,
349 _event: ToolStartEvent,
350 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
351 async { Ok(()) }
352 }
353
354 fn send_tool_output(
364 &mut self,
365 event: ToolOutputEvent,
366 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
367 let formatted = crate::agent::format_tool_output(event.tool_name.as_str(), &event.display);
368 async move { self.send(&formatted).await }
369 }
370
371 fn confirm(
378 &mut self,
379 _prompt: &str,
380 ) -> impl Future<Output = Result<bool, ChannelError>> + Send {
381 async { Ok(true) }
382 }
383
384 fn elicit(
393 &mut self,
394 _request: ElicitationRequest,
395 ) -> impl Future<Output = Result<ElicitationResponse, ChannelError>> + Send {
396 async { Ok(ElicitationResponse::Declined) }
397 }
398
399 fn send_stop_hint(
408 &mut self,
409 _hint: StopHint,
410 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
411 async { Ok(()) }
412 }
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub enum StopHint {
422 MaxTokens,
424 MaxTurnRequests,
426}
427
428#[derive(Debug, Clone)]
433pub struct ToolStartEvent {
434 pub tool_name: zeph_common::ToolName,
436 pub tool_call_id: String,
438 pub params: Option<serde_json::Value>,
440 pub parent_tool_use_id: Option<String>,
442 pub started_at: std::time::Instant,
444 pub speculative: bool,
448 pub sandbox_profile: Option<zeph_tools::SandboxProfile>,
452}
453
454#[derive(Debug, Clone)]
459pub struct ToolOutputEvent {
460 pub tool_name: zeph_common::ToolName,
462 pub display: String,
464 pub diff: Option<crate::DiffData>,
466 pub filter_stats: Option<String>,
468 pub kept_lines: Option<Vec<usize>>,
470 pub locations: Option<Vec<String>>,
472 pub tool_call_id: String,
474 pub is_error: bool,
476 pub terminal_id: Option<String>,
478 pub parent_tool_use_id: Option<String>,
480 pub raw_response: Option<serde_json::Value>,
482 pub started_at: Option<std::time::Instant>,
484}
485
486pub type ToolStartData = ToolStartEvent;
490
491pub type ToolOutputData = ToolOutputEvent;
495
496#[derive(Debug, Clone)]
498pub enum LoopbackEvent {
499 Chunk(String),
500 Flush,
501 FullMessage(String),
502 Status(String),
503 ToolStart(Box<ToolStartEvent>),
505 ToolOutput(Box<ToolOutputEvent>),
506 Usage {
508 input_tokens: u64,
509 output_tokens: u64,
510 context_window: u64,
511 },
512 SessionTitle(String),
514 Plan(Vec<(String, PlanItemStatus)>),
516 ThinkingChunk(String),
518 Stop(StopHint),
522}
523
524#[derive(Debug, Clone)]
526pub enum PlanItemStatus {
527 Pending,
528 InProgress,
529 Completed,
530}
531
532pub struct LoopbackHandle {
534 pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
535 pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
536 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
538}
539
540pub struct LoopbackChannel {
542 input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
543 output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
544}
545
546impl LoopbackChannel {
547 #[must_use]
549 pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
550 let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
551 let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
552 let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
553 (
554 Self {
555 input_rx,
556 output_tx,
557 },
558 LoopbackHandle {
559 input_tx,
560 output_rx,
561 cancel_signal,
562 },
563 )
564 }
565}
566
567impl Channel for LoopbackChannel {
568 fn supports_exit(&self) -> bool {
569 false
570 }
571
572 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
573 Ok(self.input_rx.recv().await)
574 }
575
576 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
577 self.output_tx
578 .send(LoopbackEvent::FullMessage(text.to_owned()))
579 .await
580 .map_err(|_| ChannelError::ChannelClosed)
581 }
582
583 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
584 self.output_tx
585 .send(LoopbackEvent::Chunk(chunk.to_owned()))
586 .await
587 .map_err(|_| ChannelError::ChannelClosed)
588 }
589
590 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
591 self.output_tx
592 .send(LoopbackEvent::Flush)
593 .await
594 .map_err(|_| ChannelError::ChannelClosed)
595 }
596
597 async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
598 self.output_tx
599 .send(LoopbackEvent::Status(text.to_owned()))
600 .await
601 .map_err(|_| ChannelError::ChannelClosed)
602 }
603
604 async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
605 self.output_tx
606 .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
607 .await
608 .map_err(|_| ChannelError::ChannelClosed)
609 }
610
611 async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
612 self.output_tx
613 .send(LoopbackEvent::ToolStart(Box::new(event)))
614 .await
615 .map_err(|_| ChannelError::ChannelClosed)
616 }
617
618 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
619 self.output_tx
620 .send(LoopbackEvent::ToolOutput(Box::new(event)))
621 .await
622 .map_err(|_| ChannelError::ChannelClosed)
623 }
624
625 async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
626 Ok(true)
627 }
628
629 async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
630 self.output_tx
631 .send(LoopbackEvent::Stop(hint))
632 .await
633 .map_err(|_| ChannelError::ChannelClosed)
634 }
635
636 async fn send_usage(
637 &mut self,
638 input_tokens: u64,
639 output_tokens: u64,
640 context_window: u64,
641 ) -> Result<(), ChannelError> {
642 self.output_tx
643 .send(LoopbackEvent::Usage {
644 input_tokens,
645 output_tokens,
646 context_window,
647 })
648 .await
649 .map_err(|_| ChannelError::ChannelClosed)
650 }
651}
652
653pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
658
659impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
660 fn send<'a>(
661 &'a mut self,
662 msg: &'a str,
663 ) -> std::pin::Pin<
664 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
665 > {
666 Box::pin(async move {
667 self.0
668 .send(msg)
669 .await
670 .map_err(zeph_commands::CommandError::new)
671 })
672 }
673
674 fn flush_chunks<'a>(
675 &'a mut self,
676 ) -> std::pin::Pin<
677 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
678 > {
679 Box::pin(async move {
680 self.0
681 .flush_chunks()
682 .await
683 .map_err(zeph_commands::CommandError::new)
684 })
685 }
686
687 fn send_queue_count<'a>(
688 &'a mut self,
689 count: usize,
690 ) -> std::pin::Pin<
691 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
692 > {
693 Box::pin(async move {
694 self.0
695 .send_queue_count(count)
696 .await
697 .map_err(zeph_commands::CommandError::new)
698 })
699 }
700
701 fn supports_exit(&self) -> bool {
702 self.0.supports_exit()
703 }
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn channel_message_creation() {
712 let msg = ChannelMessage {
713 text: "hello".to_string(),
714 attachments: vec![],
715 is_guest_context: false,
716 is_from_bot: false,
717 };
718 assert_eq!(msg.text, "hello");
719 assert!(msg.attachments.is_empty());
720 }
721
722 struct StubChannel;
723
724 impl Channel for StubChannel {
725 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
726 Ok(None)
727 }
728
729 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
730 Ok(())
731 }
732
733 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
734 Ok(())
735 }
736
737 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
738 Ok(())
739 }
740 }
741
742 #[tokio::test]
743 async fn send_chunk_default_is_noop() {
744 let mut ch = StubChannel;
745 ch.send_chunk("partial").await.unwrap();
746 }
747
748 #[tokio::test]
749 async fn flush_chunks_default_is_noop() {
750 let mut ch = StubChannel;
751 ch.flush_chunks().await.unwrap();
752 }
753
754 #[tokio::test]
755 async fn stub_channel_confirm_auto_approves() {
756 let mut ch = StubChannel;
757 let result = ch.confirm("Delete everything?").await.unwrap();
758 assert!(result);
759 }
760
761 #[tokio::test]
762 async fn stub_channel_send_typing_default() {
763 let mut ch = StubChannel;
764 ch.send_typing().await.unwrap();
765 }
766
767 #[tokio::test]
768 async fn stub_channel_recv_returns_none() {
769 let mut ch = StubChannel;
770 let msg = ch.recv().await.unwrap();
771 assert!(msg.is_none());
772 }
773
774 #[tokio::test]
775 async fn stub_channel_send_ok() {
776 let mut ch = StubChannel;
777 ch.send("hello").await.unwrap();
778 }
779
780 #[test]
781 fn channel_message_clone() {
782 let msg = ChannelMessage {
783 text: "test".to_string(),
784 attachments: vec![],
785 is_guest_context: false,
786 is_from_bot: false,
787 };
788 let cloned = msg.clone();
789 assert_eq!(cloned.text, "test");
790 }
791
792 #[test]
793 fn channel_message_debug() {
794 let msg = ChannelMessage {
795 text: "debug".to_string(),
796 attachments: vec![],
797 is_guest_context: false,
798 is_from_bot: false,
799 };
800 let debug = format!("{msg:?}");
801 assert!(debug.contains("debug"));
802 }
803
804 #[test]
805 fn attachment_kind_equality() {
806 assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
807 assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
808 }
809
810 #[test]
811 fn attachment_construction() {
812 let a = Attachment {
813 kind: AttachmentKind::Audio,
814 data: vec![0, 1, 2],
815 filename: Some("test.wav".into()),
816 };
817 assert_eq!(a.kind, AttachmentKind::Audio);
818 assert_eq!(a.data.len(), 3);
819 assert_eq!(a.filename.as_deref(), Some("test.wav"));
820 }
821
822 #[test]
823 fn channel_message_with_attachments() {
824 let msg = ChannelMessage {
825 text: String::new(),
826 attachments: vec![Attachment {
827 kind: AttachmentKind::Audio,
828 data: vec![42],
829 filename: None,
830 }],
831 is_guest_context: false,
832 is_from_bot: false,
833 };
834 assert_eq!(msg.attachments.len(), 1);
835 assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
836 }
837
838 #[test]
839 fn stub_channel_try_recv_returns_none() {
840 let mut ch = StubChannel;
841 assert!(ch.try_recv().is_none());
842 }
843
844 #[tokio::test]
845 async fn stub_channel_send_queue_count_noop() {
846 let mut ch = StubChannel;
847 ch.send_queue_count(5).await.unwrap();
848 }
849
850 #[test]
853 fn loopback_pair_returns_linked_handles() {
854 let (channel, handle) = LoopbackChannel::pair(8);
855 drop(channel);
857 drop(handle);
858 }
859
860 #[tokio::test]
861 async fn loopback_cancel_signal_can_be_notified_and_awaited() {
862 let (_channel, handle) = LoopbackChannel::pair(8);
863 let signal = std::sync::Arc::clone(&handle.cancel_signal);
864 let notified = signal.notified();
866 handle.cancel_signal.notify_one();
867 notified.await; }
869
870 #[tokio::test]
871 async fn loopback_cancel_signal_shared_across_clones() {
872 let (_channel, handle) = LoopbackChannel::pair(8);
873 let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
874 let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
875 let notified = signal_b.notified();
876 signal_a.notify_one();
877 notified.await;
878 }
879
880 #[tokio::test]
881 async fn loopback_send_recv_round_trip() {
882 let (mut channel, handle) = LoopbackChannel::pair(8);
883 handle
884 .input_tx
885 .send(ChannelMessage {
886 text: "hello".to_owned(),
887 attachments: vec![],
888 is_guest_context: false,
889 is_from_bot: false,
890 })
891 .await
892 .unwrap();
893 let msg = channel.recv().await.unwrap().unwrap();
894 assert_eq!(msg.text, "hello");
895 }
896
897 #[tokio::test]
898 async fn loopback_recv_returns_none_when_handle_dropped() {
899 let (mut channel, handle) = LoopbackChannel::pair(8);
900 drop(handle);
901 let result = channel.recv().await.unwrap();
902 assert!(result.is_none());
903 }
904
905 #[tokio::test]
906 async fn loopback_send_produces_full_message_event() {
907 let (mut channel, mut handle) = LoopbackChannel::pair(8);
908 channel.send("world").await.unwrap();
909 let event = handle.output_rx.recv().await.unwrap();
910 assert!(matches!(event, LoopbackEvent::FullMessage(t) if t == "world"));
911 }
912
913 #[tokio::test]
914 async fn loopback_send_chunk_then_flush() {
915 let (mut channel, mut handle) = LoopbackChannel::pair(8);
916 channel.send_chunk("part1").await.unwrap();
917 channel.flush_chunks().await.unwrap();
918 let ev1 = handle.output_rx.recv().await.unwrap();
919 let ev2 = handle.output_rx.recv().await.unwrap();
920 assert!(matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1"));
921 assert!(matches!(ev2, LoopbackEvent::Flush));
922 }
923
924 #[tokio::test]
925 async fn loopback_send_tool_output() {
926 let (mut channel, mut handle) = LoopbackChannel::pair(8);
927 channel
928 .send_tool_output(ToolOutputEvent {
929 tool_name: "bash".into(),
930 display: "exit 0".into(),
931 diff: None,
932 filter_stats: None,
933 kept_lines: None,
934 locations: None,
935 tool_call_id: String::new(),
936 terminal_id: None,
937 is_error: false,
938 parent_tool_use_id: None,
939 raw_response: None,
940 started_at: None,
941 })
942 .await
943 .unwrap();
944 let event = handle.output_rx.recv().await.unwrap();
945 match event {
946 LoopbackEvent::ToolOutput(data) => {
947 assert_eq!(data.tool_name, "bash");
948 assert_eq!(data.display, "exit 0");
949 assert!(data.diff.is_none());
950 assert!(data.filter_stats.is_none());
951 assert!(data.kept_lines.is_none());
952 assert!(data.locations.is_none());
953 assert_eq!(data.tool_call_id, "");
954 assert!(!data.is_error);
955 assert!(data.terminal_id.is_none());
956 assert!(data.parent_tool_use_id.is_none());
957 assert!(data.raw_response.is_none());
958 }
959 _ => panic!("expected ToolOutput event"),
960 }
961 }
962
963 #[tokio::test]
964 async fn loopback_confirm_auto_approves() {
965 let (mut channel, _handle) = LoopbackChannel::pair(8);
966 let result = channel.confirm("are you sure?").await.unwrap();
967 assert!(result);
968 }
969
970 #[tokio::test]
971 async fn loopback_send_error_when_output_closed() {
972 let (mut channel, handle) = LoopbackChannel::pair(8);
973 drop(handle);
975 let result = channel.send("too late").await;
976 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
977 }
978
979 #[tokio::test]
980 async fn loopback_send_chunk_error_when_output_closed() {
981 let (mut channel, handle) = LoopbackChannel::pair(8);
982 drop(handle);
983 let result = channel.send_chunk("chunk").await;
984 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
985 }
986
987 #[tokio::test]
988 async fn loopback_flush_error_when_output_closed() {
989 let (mut channel, handle) = LoopbackChannel::pair(8);
990 drop(handle);
991 let result = channel.flush_chunks().await;
992 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
993 }
994
995 #[tokio::test]
996 async fn loopback_send_status_event() {
997 let (mut channel, mut handle) = LoopbackChannel::pair(8);
998 channel.send_status("working...").await.unwrap();
999 let event = handle.output_rx.recv().await.unwrap();
1000 assert!(matches!(event, LoopbackEvent::Status(s) if s == "working..."));
1001 }
1002
1003 #[tokio::test]
1004 async fn loopback_send_usage_produces_usage_event() {
1005 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1006 channel.send_usage(100, 50, 200_000).await.unwrap();
1007 let event = handle.output_rx.recv().await.unwrap();
1008 match event {
1009 LoopbackEvent::Usage {
1010 input_tokens,
1011 output_tokens,
1012 context_window,
1013 } => {
1014 assert_eq!(input_tokens, 100);
1015 assert_eq!(output_tokens, 50);
1016 assert_eq!(context_window, 200_000);
1017 }
1018 _ => panic!("expected Usage event"),
1019 }
1020 }
1021
1022 #[tokio::test]
1023 async fn loopback_send_usage_error_when_closed() {
1024 let (mut channel, handle) = LoopbackChannel::pair(8);
1025 drop(handle);
1026 let result = channel.send_usage(1, 2, 3).await;
1027 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1028 }
1029
1030 #[test]
1031 fn plan_item_status_variants_are_distinct() {
1032 assert!(!matches!(
1033 PlanItemStatus::Pending,
1034 PlanItemStatus::InProgress
1035 ));
1036 assert!(!matches!(
1037 PlanItemStatus::InProgress,
1038 PlanItemStatus::Completed
1039 ));
1040 assert!(!matches!(
1041 PlanItemStatus::Completed,
1042 PlanItemStatus::Pending
1043 ));
1044 }
1045
1046 #[test]
1047 fn loopback_event_session_title_carries_string() {
1048 let event = LoopbackEvent::SessionTitle("hello".to_owned());
1049 assert!(matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello"));
1050 }
1051
1052 #[test]
1053 fn loopback_event_plan_carries_entries() {
1054 let entries = vec![
1055 ("step 1".to_owned(), PlanItemStatus::Pending),
1056 ("step 2".to_owned(), PlanItemStatus::InProgress),
1057 ];
1058 let event = LoopbackEvent::Plan(entries);
1059 match event {
1060 LoopbackEvent::Plan(e) => {
1061 assert_eq!(e.len(), 2);
1062 assert!(matches!(e[0].1, PlanItemStatus::Pending));
1063 assert!(matches!(e[1].1, PlanItemStatus::InProgress));
1064 }
1065 _ => panic!("expected Plan event"),
1066 }
1067 }
1068
1069 #[tokio::test]
1070 async fn loopback_send_tool_start_produces_tool_start_event() {
1071 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1072 channel
1073 .send_tool_start(ToolStartEvent {
1074 tool_name: "shell".into(),
1075 tool_call_id: "tc-001".into(),
1076 params: Some(serde_json::json!({"command": "ls"})),
1077 parent_tool_use_id: None,
1078 started_at: std::time::Instant::now(),
1079 speculative: false,
1080 sandbox_profile: None,
1081 })
1082 .await
1083 .unwrap();
1084 let event = handle.output_rx.recv().await.unwrap();
1085 match event {
1086 LoopbackEvent::ToolStart(data) => {
1087 assert_eq!(data.tool_name.as_str(), "shell");
1088 assert_eq!(data.tool_call_id.as_str(), "tc-001");
1089 assert!(data.params.is_some());
1090 assert!(data.parent_tool_use_id.is_none());
1091 }
1092 _ => panic!("expected ToolStart event"),
1093 }
1094 }
1095
1096 #[tokio::test]
1097 async fn loopback_send_tool_start_with_parent_id() {
1098 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1099 channel
1100 .send_tool_start(ToolStartEvent {
1101 tool_name: "web".into(),
1102 tool_call_id: "tc-002".into(),
1103 params: None,
1104 parent_tool_use_id: Some("parent-123".into()),
1105 started_at: std::time::Instant::now(),
1106 speculative: false,
1107 sandbox_profile: None,
1108 })
1109 .await
1110 .unwrap();
1111 let event = handle.output_rx.recv().await.unwrap();
1112 assert!(matches!(
1113 event,
1114 LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
1115 ));
1116 }
1117
1118 #[tokio::test]
1119 async fn loopback_send_tool_start_error_when_output_closed() {
1120 let (mut channel, handle) = LoopbackChannel::pair(8);
1121 drop(handle);
1122 let result = channel
1123 .send_tool_start(ToolStartEvent {
1124 tool_name: "shell".into(),
1125 tool_call_id: "tc-003".into(),
1126 params: None,
1127 parent_tool_use_id: None,
1128 started_at: std::time::Instant::now(),
1129 speculative: false,
1130 sandbox_profile: None,
1131 })
1132 .await;
1133 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1134 }
1135
1136 #[tokio::test]
1137 async fn default_send_tool_output_formats_message() {
1138 let mut ch = StubChannel;
1139 ch.send_tool_output(ToolOutputEvent {
1141 tool_name: "bash".into(),
1142 display: "hello".into(),
1143 diff: None,
1144 filter_stats: None,
1145 kept_lines: None,
1146 locations: None,
1147 tool_call_id: "id".into(),
1148 terminal_id: None,
1149 is_error: false,
1150 parent_tool_use_id: None,
1151 raw_response: None,
1152 started_at: None,
1153 })
1154 .await
1155 .unwrap();
1156 }
1157}