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}
199
200pub trait Channel: Send {
217 fn recv(&mut self)
223 -> impl Future<Output = Result<Option<ChannelMessage>, ChannelError>> + Send;
224
225 fn try_recv(&mut self) -> Option<ChannelMessage> {
227 None
228 }
229
230 fn supports_exit(&self) -> bool {
235 true
236 }
237
238 fn send(&mut self, text: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
244
245 fn send_chunk(&mut self, chunk: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
251
252 fn flush_chunks(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send;
258
259 fn send_typing(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send {
265 async { Ok(()) }
266 }
267
268 fn send_status(
274 &mut self,
275 _text: &str,
276 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
277 async { Ok(()) }
278 }
279
280 fn send_thinking_chunk(
286 &mut self,
287 _chunk: &str,
288 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
289 async { Ok(()) }
290 }
291
292 fn send_queue_count(
298 &mut self,
299 _count: usize,
300 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
301 async { Ok(()) }
302 }
303
304 fn send_usage(
310 &mut self,
311 _input_tokens: u64,
312 _output_tokens: u64,
313 _context_window: u64,
314 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
315 async { Ok(()) }
316 }
317
318 fn send_diff(
324 &mut self,
325 _diff: crate::DiffData,
326 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
327 async { Ok(()) }
328 }
329
330 fn send_tool_start(
340 &mut self,
341 _event: ToolStartEvent,
342 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
343 async { Ok(()) }
344 }
345
346 fn send_tool_output(
356 &mut self,
357 event: ToolOutputEvent,
358 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
359 let formatted = crate::agent::format_tool_output(event.tool_name.as_str(), &event.display);
360 async move { self.send(&formatted).await }
361 }
362
363 fn confirm(
370 &mut self,
371 _prompt: &str,
372 ) -> impl Future<Output = Result<bool, ChannelError>> + Send {
373 async { Ok(true) }
374 }
375
376 fn elicit(
385 &mut self,
386 _request: ElicitationRequest,
387 ) -> impl Future<Output = Result<ElicitationResponse, ChannelError>> + Send {
388 async { Ok(ElicitationResponse::Declined) }
389 }
390
391 fn send_stop_hint(
400 &mut self,
401 _hint: StopHint,
402 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
403 async { Ok(()) }
404 }
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413pub enum StopHint {
414 MaxTokens,
416 MaxTurnRequests,
418}
419
420#[derive(Debug, Clone)]
425pub struct ToolStartEvent {
426 pub tool_name: zeph_common::ToolName,
428 pub tool_call_id: String,
430 pub params: Option<serde_json::Value>,
432 pub parent_tool_use_id: Option<String>,
434 pub started_at: std::time::Instant,
436 pub speculative: bool,
440 pub sandbox_profile: Option<zeph_tools::SandboxProfile>,
444}
445
446#[derive(Debug, Clone)]
451pub struct ToolOutputEvent {
452 pub tool_name: zeph_common::ToolName,
454 pub display: String,
456 pub diff: Option<crate::DiffData>,
458 pub filter_stats: Option<String>,
460 pub kept_lines: Option<Vec<usize>>,
462 pub locations: Option<Vec<String>>,
464 pub tool_call_id: String,
466 pub is_error: bool,
468 pub terminal_id: Option<String>,
470 pub parent_tool_use_id: Option<String>,
472 pub raw_response: Option<serde_json::Value>,
474 pub started_at: Option<std::time::Instant>,
476}
477
478pub type ToolStartData = ToolStartEvent;
482
483pub type ToolOutputData = ToolOutputEvent;
487
488#[derive(Debug, Clone)]
490pub enum LoopbackEvent {
491 Chunk(String),
492 Flush,
493 FullMessage(String),
494 Status(String),
495 ToolStart(Box<ToolStartEvent>),
497 ToolOutput(Box<ToolOutputEvent>),
498 Usage {
500 input_tokens: u64,
501 output_tokens: u64,
502 context_window: u64,
503 },
504 SessionTitle(String),
506 Plan(Vec<(String, PlanItemStatus)>),
508 ThinkingChunk(String),
510 Stop(StopHint),
514}
515
516#[derive(Debug, Clone)]
518pub enum PlanItemStatus {
519 Pending,
520 InProgress,
521 Completed,
522}
523
524pub struct LoopbackHandle {
526 pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
527 pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
528 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
530}
531
532pub struct LoopbackChannel {
534 input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
535 output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
536}
537
538impl LoopbackChannel {
539 #[must_use]
541 pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
542 let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
543 let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
544 let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
545 (
546 Self {
547 input_rx,
548 output_tx,
549 },
550 LoopbackHandle {
551 input_tx,
552 output_rx,
553 cancel_signal,
554 },
555 )
556 }
557}
558
559impl Channel for LoopbackChannel {
560 fn supports_exit(&self) -> bool {
561 false
562 }
563
564 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
565 Ok(self.input_rx.recv().await)
566 }
567
568 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
569 self.output_tx
570 .send(LoopbackEvent::FullMessage(text.to_owned()))
571 .await
572 .map_err(|_| ChannelError::ChannelClosed)
573 }
574
575 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
576 self.output_tx
577 .send(LoopbackEvent::Chunk(chunk.to_owned()))
578 .await
579 .map_err(|_| ChannelError::ChannelClosed)
580 }
581
582 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
583 self.output_tx
584 .send(LoopbackEvent::Flush)
585 .await
586 .map_err(|_| ChannelError::ChannelClosed)
587 }
588
589 async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
590 self.output_tx
591 .send(LoopbackEvent::Status(text.to_owned()))
592 .await
593 .map_err(|_| ChannelError::ChannelClosed)
594 }
595
596 async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
597 self.output_tx
598 .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
599 .await
600 .map_err(|_| ChannelError::ChannelClosed)
601 }
602
603 async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
604 self.output_tx
605 .send(LoopbackEvent::ToolStart(Box::new(event)))
606 .await
607 .map_err(|_| ChannelError::ChannelClosed)
608 }
609
610 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
611 self.output_tx
612 .send(LoopbackEvent::ToolOutput(Box::new(event)))
613 .await
614 .map_err(|_| ChannelError::ChannelClosed)
615 }
616
617 async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
618 Ok(true)
619 }
620
621 async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
622 self.output_tx
623 .send(LoopbackEvent::Stop(hint))
624 .await
625 .map_err(|_| ChannelError::ChannelClosed)
626 }
627
628 async fn send_usage(
629 &mut self,
630 input_tokens: u64,
631 output_tokens: u64,
632 context_window: u64,
633 ) -> Result<(), ChannelError> {
634 self.output_tx
635 .send(LoopbackEvent::Usage {
636 input_tokens,
637 output_tokens,
638 context_window,
639 })
640 .await
641 .map_err(|_| ChannelError::ChannelClosed)
642 }
643}
644
645pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
650
651impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
652 fn send<'a>(
653 &'a mut self,
654 msg: &'a str,
655 ) -> std::pin::Pin<
656 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
657 > {
658 Box::pin(async move {
659 self.0
660 .send(msg)
661 .await
662 .map_err(zeph_commands::CommandError::new)
663 })
664 }
665
666 fn flush_chunks<'a>(
667 &'a mut self,
668 ) -> std::pin::Pin<
669 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
670 > {
671 Box::pin(async move {
672 self.0
673 .flush_chunks()
674 .await
675 .map_err(zeph_commands::CommandError::new)
676 })
677 }
678
679 fn send_queue_count<'a>(
680 &'a mut self,
681 count: usize,
682 ) -> std::pin::Pin<
683 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
684 > {
685 Box::pin(async move {
686 self.0
687 .send_queue_count(count)
688 .await
689 .map_err(zeph_commands::CommandError::new)
690 })
691 }
692
693 fn supports_exit(&self) -> bool {
694 self.0.supports_exit()
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701
702 #[test]
703 fn channel_message_creation() {
704 let msg = ChannelMessage {
705 text: "hello".to_string(),
706 attachments: vec![],
707 };
708 assert_eq!(msg.text, "hello");
709 assert!(msg.attachments.is_empty());
710 }
711
712 struct StubChannel;
713
714 impl Channel for StubChannel {
715 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
716 Ok(None)
717 }
718
719 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
720 Ok(())
721 }
722
723 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
724 Ok(())
725 }
726
727 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
728 Ok(())
729 }
730 }
731
732 #[tokio::test]
733 async fn send_chunk_default_is_noop() {
734 let mut ch = StubChannel;
735 ch.send_chunk("partial").await.unwrap();
736 }
737
738 #[tokio::test]
739 async fn flush_chunks_default_is_noop() {
740 let mut ch = StubChannel;
741 ch.flush_chunks().await.unwrap();
742 }
743
744 #[tokio::test]
745 async fn stub_channel_confirm_auto_approves() {
746 let mut ch = StubChannel;
747 let result = ch.confirm("Delete everything?").await.unwrap();
748 assert!(result);
749 }
750
751 #[tokio::test]
752 async fn stub_channel_send_typing_default() {
753 let mut ch = StubChannel;
754 ch.send_typing().await.unwrap();
755 }
756
757 #[tokio::test]
758 async fn stub_channel_recv_returns_none() {
759 let mut ch = StubChannel;
760 let msg = ch.recv().await.unwrap();
761 assert!(msg.is_none());
762 }
763
764 #[tokio::test]
765 async fn stub_channel_send_ok() {
766 let mut ch = StubChannel;
767 ch.send("hello").await.unwrap();
768 }
769
770 #[test]
771 fn channel_message_clone() {
772 let msg = ChannelMessage {
773 text: "test".to_string(),
774 attachments: vec![],
775 };
776 let cloned = msg.clone();
777 assert_eq!(cloned.text, "test");
778 }
779
780 #[test]
781 fn channel_message_debug() {
782 let msg = ChannelMessage {
783 text: "debug".to_string(),
784 attachments: vec![],
785 };
786 let debug = format!("{msg:?}");
787 assert!(debug.contains("debug"));
788 }
789
790 #[test]
791 fn attachment_kind_equality() {
792 assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
793 assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
794 }
795
796 #[test]
797 fn attachment_construction() {
798 let a = Attachment {
799 kind: AttachmentKind::Audio,
800 data: vec![0, 1, 2],
801 filename: Some("test.wav".into()),
802 };
803 assert_eq!(a.kind, AttachmentKind::Audio);
804 assert_eq!(a.data.len(), 3);
805 assert_eq!(a.filename.as_deref(), Some("test.wav"));
806 }
807
808 #[test]
809 fn channel_message_with_attachments() {
810 let msg = ChannelMessage {
811 text: String::new(),
812 attachments: vec![Attachment {
813 kind: AttachmentKind::Audio,
814 data: vec![42],
815 filename: None,
816 }],
817 };
818 assert_eq!(msg.attachments.len(), 1);
819 assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
820 }
821
822 #[test]
823 fn stub_channel_try_recv_returns_none() {
824 let mut ch = StubChannel;
825 assert!(ch.try_recv().is_none());
826 }
827
828 #[tokio::test]
829 async fn stub_channel_send_queue_count_noop() {
830 let mut ch = StubChannel;
831 ch.send_queue_count(5).await.unwrap();
832 }
833
834 #[test]
837 fn loopback_pair_returns_linked_handles() {
838 let (channel, handle) = LoopbackChannel::pair(8);
839 drop(channel);
841 drop(handle);
842 }
843
844 #[tokio::test]
845 async fn loopback_cancel_signal_can_be_notified_and_awaited() {
846 let (_channel, handle) = LoopbackChannel::pair(8);
847 let signal = std::sync::Arc::clone(&handle.cancel_signal);
848 let notified = signal.notified();
850 handle.cancel_signal.notify_one();
851 notified.await; }
853
854 #[tokio::test]
855 async fn loopback_cancel_signal_shared_across_clones() {
856 let (_channel, handle) = LoopbackChannel::pair(8);
857 let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
858 let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
859 let notified = signal_b.notified();
860 signal_a.notify_one();
861 notified.await;
862 }
863
864 #[tokio::test]
865 async fn loopback_send_recv_round_trip() {
866 let (mut channel, handle) = LoopbackChannel::pair(8);
867 handle
868 .input_tx
869 .send(ChannelMessage {
870 text: "hello".to_owned(),
871 attachments: vec![],
872 })
873 .await
874 .unwrap();
875 let msg = channel.recv().await.unwrap().unwrap();
876 assert_eq!(msg.text, "hello");
877 }
878
879 #[tokio::test]
880 async fn loopback_recv_returns_none_when_handle_dropped() {
881 let (mut channel, handle) = LoopbackChannel::pair(8);
882 drop(handle);
883 let result = channel.recv().await.unwrap();
884 assert!(result.is_none());
885 }
886
887 #[tokio::test]
888 async fn loopback_send_produces_full_message_event() {
889 let (mut channel, mut handle) = LoopbackChannel::pair(8);
890 channel.send("world").await.unwrap();
891 let event = handle.output_rx.recv().await.unwrap();
892 assert!(matches!(event, LoopbackEvent::FullMessage(t) if t == "world"));
893 }
894
895 #[tokio::test]
896 async fn loopback_send_chunk_then_flush() {
897 let (mut channel, mut handle) = LoopbackChannel::pair(8);
898 channel.send_chunk("part1").await.unwrap();
899 channel.flush_chunks().await.unwrap();
900 let ev1 = handle.output_rx.recv().await.unwrap();
901 let ev2 = handle.output_rx.recv().await.unwrap();
902 assert!(matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1"));
903 assert!(matches!(ev2, LoopbackEvent::Flush));
904 }
905
906 #[tokio::test]
907 async fn loopback_send_tool_output() {
908 let (mut channel, mut handle) = LoopbackChannel::pair(8);
909 channel
910 .send_tool_output(ToolOutputEvent {
911 tool_name: "bash".into(),
912 display: "exit 0".into(),
913 diff: None,
914 filter_stats: None,
915 kept_lines: None,
916 locations: None,
917 tool_call_id: String::new(),
918 terminal_id: None,
919 is_error: false,
920 parent_tool_use_id: None,
921 raw_response: None,
922 started_at: None,
923 })
924 .await
925 .unwrap();
926 let event = handle.output_rx.recv().await.unwrap();
927 match event {
928 LoopbackEvent::ToolOutput(data) => {
929 assert_eq!(data.tool_name, "bash");
930 assert_eq!(data.display, "exit 0");
931 assert!(data.diff.is_none());
932 assert!(data.filter_stats.is_none());
933 assert!(data.kept_lines.is_none());
934 assert!(data.locations.is_none());
935 assert_eq!(data.tool_call_id, "");
936 assert!(!data.is_error);
937 assert!(data.terminal_id.is_none());
938 assert!(data.parent_tool_use_id.is_none());
939 assert!(data.raw_response.is_none());
940 }
941 _ => panic!("expected ToolOutput event"),
942 }
943 }
944
945 #[tokio::test]
946 async fn loopback_confirm_auto_approves() {
947 let (mut channel, _handle) = LoopbackChannel::pair(8);
948 let result = channel.confirm("are you sure?").await.unwrap();
949 assert!(result);
950 }
951
952 #[tokio::test]
953 async fn loopback_send_error_when_output_closed() {
954 let (mut channel, handle) = LoopbackChannel::pair(8);
955 drop(handle);
957 let result = channel.send("too late").await;
958 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
959 }
960
961 #[tokio::test]
962 async fn loopback_send_chunk_error_when_output_closed() {
963 let (mut channel, handle) = LoopbackChannel::pair(8);
964 drop(handle);
965 let result = channel.send_chunk("chunk").await;
966 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
967 }
968
969 #[tokio::test]
970 async fn loopback_flush_error_when_output_closed() {
971 let (mut channel, handle) = LoopbackChannel::pair(8);
972 drop(handle);
973 let result = channel.flush_chunks().await;
974 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
975 }
976
977 #[tokio::test]
978 async fn loopback_send_status_event() {
979 let (mut channel, mut handle) = LoopbackChannel::pair(8);
980 channel.send_status("working...").await.unwrap();
981 let event = handle.output_rx.recv().await.unwrap();
982 assert!(matches!(event, LoopbackEvent::Status(s) if s == "working..."));
983 }
984
985 #[tokio::test]
986 async fn loopback_send_usage_produces_usage_event() {
987 let (mut channel, mut handle) = LoopbackChannel::pair(8);
988 channel.send_usage(100, 50, 200_000).await.unwrap();
989 let event = handle.output_rx.recv().await.unwrap();
990 match event {
991 LoopbackEvent::Usage {
992 input_tokens,
993 output_tokens,
994 context_window,
995 } => {
996 assert_eq!(input_tokens, 100);
997 assert_eq!(output_tokens, 50);
998 assert_eq!(context_window, 200_000);
999 }
1000 _ => panic!("expected Usage event"),
1001 }
1002 }
1003
1004 #[tokio::test]
1005 async fn loopback_send_usage_error_when_closed() {
1006 let (mut channel, handle) = LoopbackChannel::pair(8);
1007 drop(handle);
1008 let result = channel.send_usage(1, 2, 3).await;
1009 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1010 }
1011
1012 #[test]
1013 fn plan_item_status_variants_are_distinct() {
1014 assert!(!matches!(
1015 PlanItemStatus::Pending,
1016 PlanItemStatus::InProgress
1017 ));
1018 assert!(!matches!(
1019 PlanItemStatus::InProgress,
1020 PlanItemStatus::Completed
1021 ));
1022 assert!(!matches!(
1023 PlanItemStatus::Completed,
1024 PlanItemStatus::Pending
1025 ));
1026 }
1027
1028 #[test]
1029 fn loopback_event_session_title_carries_string() {
1030 let event = LoopbackEvent::SessionTitle("hello".to_owned());
1031 assert!(matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello"));
1032 }
1033
1034 #[test]
1035 fn loopback_event_plan_carries_entries() {
1036 let entries = vec![
1037 ("step 1".to_owned(), PlanItemStatus::Pending),
1038 ("step 2".to_owned(), PlanItemStatus::InProgress),
1039 ];
1040 let event = LoopbackEvent::Plan(entries);
1041 match event {
1042 LoopbackEvent::Plan(e) => {
1043 assert_eq!(e.len(), 2);
1044 assert!(matches!(e[0].1, PlanItemStatus::Pending));
1045 assert!(matches!(e[1].1, PlanItemStatus::InProgress));
1046 }
1047 _ => panic!("expected Plan event"),
1048 }
1049 }
1050
1051 #[tokio::test]
1052 async fn loopback_send_tool_start_produces_tool_start_event() {
1053 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1054 channel
1055 .send_tool_start(ToolStartEvent {
1056 tool_name: "shell".into(),
1057 tool_call_id: "tc-001".into(),
1058 params: Some(serde_json::json!({"command": "ls"})),
1059 parent_tool_use_id: None,
1060 started_at: std::time::Instant::now(),
1061 speculative: false,
1062 sandbox_profile: None,
1063 })
1064 .await
1065 .unwrap();
1066 let event = handle.output_rx.recv().await.unwrap();
1067 match event {
1068 LoopbackEvent::ToolStart(data) => {
1069 assert_eq!(data.tool_name.as_str(), "shell");
1070 assert_eq!(data.tool_call_id.as_str(), "tc-001");
1071 assert!(data.params.is_some());
1072 assert!(data.parent_tool_use_id.is_none());
1073 }
1074 _ => panic!("expected ToolStart event"),
1075 }
1076 }
1077
1078 #[tokio::test]
1079 async fn loopback_send_tool_start_with_parent_id() {
1080 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1081 channel
1082 .send_tool_start(ToolStartEvent {
1083 tool_name: "web".into(),
1084 tool_call_id: "tc-002".into(),
1085 params: None,
1086 parent_tool_use_id: Some("parent-123".into()),
1087 started_at: std::time::Instant::now(),
1088 speculative: false,
1089 sandbox_profile: None,
1090 })
1091 .await
1092 .unwrap();
1093 let event = handle.output_rx.recv().await.unwrap();
1094 assert!(matches!(
1095 event,
1096 LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
1097 ));
1098 }
1099
1100 #[tokio::test]
1101 async fn loopback_send_tool_start_error_when_output_closed() {
1102 let (mut channel, handle) = LoopbackChannel::pair(8);
1103 drop(handle);
1104 let result = channel
1105 .send_tool_start(ToolStartEvent {
1106 tool_name: "shell".into(),
1107 tool_call_id: "tc-003".into(),
1108 params: None,
1109 parent_tool_use_id: None,
1110 started_at: std::time::Instant::now(),
1111 speculative: false,
1112 sandbox_profile: None,
1113 })
1114 .await;
1115 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1116 }
1117
1118 #[tokio::test]
1119 async fn default_send_tool_output_formats_message() {
1120 let mut ch = StubChannel;
1121 ch.send_tool_output(ToolOutputEvent {
1123 tool_name: "bash".into(),
1124 display: "hello".into(),
1125 diff: None,
1126 filter_stats: None,
1127 kept_lines: None,
1128 locations: None,
1129 tool_call_id: "id".into(),
1130 terminal_id: None,
1131 is_error: false,
1132 parent_tool_use_id: None,
1133 raw_response: None,
1134 started_at: None,
1135 })
1136 .await
1137 .unwrap();
1138 }
1139}