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 fn notify_foreground_subagent_started(
424 &mut self,
425 _id: &str,
426 _name: &str,
427 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
428 async { Ok(()) }
429 }
430
431 fn notify_foreground_subagent_completed(
441 &mut self,
442 _id: &str,
443 _name: &str,
444 _success: bool,
445 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
446 async { Ok(()) }
447 }
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum StopHint {
457 MaxTokens,
459 MaxTurnRequests,
461}
462
463#[derive(Debug, Clone)]
468pub struct ToolStartEvent {
469 pub tool_name: zeph_common::ToolName,
471 pub tool_call_id: String,
473 pub params: Option<serde_json::Value>,
475 pub parent_tool_use_id: Option<String>,
477 pub started_at: std::time::Instant,
479 pub speculative: bool,
483 pub sandbox_profile: Option<zeph_tools::SandboxProfile>,
487}
488
489#[derive(Debug, Clone)]
494pub struct ToolOutputEvent {
495 pub tool_name: zeph_common::ToolName,
497 pub display: String,
499 pub diff: Option<crate::DiffData>,
501 pub filter_stats: Option<String>,
503 pub kept_lines: Option<Vec<usize>>,
505 pub locations: Option<Vec<String>>,
507 pub tool_call_id: String,
509 pub is_error: bool,
511 pub terminal_id: Option<String>,
513 pub parent_tool_use_id: Option<String>,
515 pub raw_response: Option<serde_json::Value>,
517 pub started_at: Option<std::time::Instant>,
519}
520
521pub type ToolStartData = ToolStartEvent;
525
526pub type ToolOutputData = ToolOutputEvent;
530
531#[derive(Debug, Clone)]
533pub enum LoopbackEvent {
534 Chunk(String),
535 Flush,
536 FullMessage(String),
537 Status(String),
538 ToolStart(Box<ToolStartEvent>),
540 ToolOutput(Box<ToolOutputEvent>),
541 Usage {
543 input_tokens: u64,
544 output_tokens: u64,
545 context_window: u64,
546 },
547 SessionTitle(String),
549 Plan(Vec<(String, PlanItemStatus)>),
551 ThinkingChunk(String),
553 Stop(StopHint),
557}
558
559#[derive(Debug, Clone)]
561pub enum PlanItemStatus {
562 Pending,
563 InProgress,
564 Completed,
565}
566
567pub struct LoopbackHandle {
569 pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
570 pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
571 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
573}
574
575pub struct LoopbackChannel {
577 input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
578 output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
579}
580
581impl LoopbackChannel {
582 #[must_use]
584 pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
585 let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
586 let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
587 let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
588 (
589 Self {
590 input_rx,
591 output_tx,
592 },
593 LoopbackHandle {
594 input_tx,
595 output_rx,
596 cancel_signal,
597 },
598 )
599 }
600}
601
602impl Channel for LoopbackChannel {
603 fn supports_exit(&self) -> bool {
604 false
605 }
606
607 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
608 Ok(self.input_rx.recv().await)
609 }
610
611 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
612 self.output_tx
613 .send(LoopbackEvent::FullMessage(text.to_owned()))
614 .await
615 .map_err(|_| ChannelError::ChannelClosed)
616 }
617
618 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
619 self.output_tx
620 .send(LoopbackEvent::Chunk(chunk.to_owned()))
621 .await
622 .map_err(|_| ChannelError::ChannelClosed)
623 }
624
625 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
626 self.output_tx
627 .send(LoopbackEvent::Flush)
628 .await
629 .map_err(|_| ChannelError::ChannelClosed)
630 }
631
632 async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
633 self.output_tx
634 .send(LoopbackEvent::Status(text.to_owned()))
635 .await
636 .map_err(|_| ChannelError::ChannelClosed)
637 }
638
639 async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
640 self.output_tx
641 .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
642 .await
643 .map_err(|_| ChannelError::ChannelClosed)
644 }
645
646 async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
647 self.output_tx
648 .send(LoopbackEvent::ToolStart(Box::new(event)))
649 .await
650 .map_err(|_| ChannelError::ChannelClosed)
651 }
652
653 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
654 self.output_tx
655 .send(LoopbackEvent::ToolOutput(Box::new(event)))
656 .await
657 .map_err(|_| ChannelError::ChannelClosed)
658 }
659
660 async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
661 Ok(true)
662 }
663
664 async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
665 self.output_tx
666 .send(LoopbackEvent::Stop(hint))
667 .await
668 .map_err(|_| ChannelError::ChannelClosed)
669 }
670
671 async fn send_usage(
672 &mut self,
673 input_tokens: u64,
674 output_tokens: u64,
675 context_window: u64,
676 ) -> Result<(), ChannelError> {
677 self.output_tx
678 .send(LoopbackEvent::Usage {
679 input_tokens,
680 output_tokens,
681 context_window,
682 })
683 .await
684 .map_err(|_| ChannelError::ChannelClosed)
685 }
686}
687
688pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
693
694impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
695 fn send<'a>(
696 &'a mut self,
697 msg: &'a str,
698 ) -> std::pin::Pin<
699 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
700 > {
701 Box::pin(async move {
702 self.0
703 .send(msg)
704 .await
705 .map_err(zeph_commands::CommandError::new)
706 })
707 }
708
709 fn flush_chunks<'a>(
710 &'a mut self,
711 ) -> std::pin::Pin<
712 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
713 > {
714 Box::pin(async move {
715 self.0
716 .flush_chunks()
717 .await
718 .map_err(zeph_commands::CommandError::new)
719 })
720 }
721
722 fn send_queue_count<'a>(
723 &'a mut self,
724 count: usize,
725 ) -> std::pin::Pin<
726 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
727 > {
728 Box::pin(async move {
729 self.0
730 .send_queue_count(count)
731 .await
732 .map_err(zeph_commands::CommandError::new)
733 })
734 }
735
736 fn supports_exit(&self) -> bool {
737 self.0.supports_exit()
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn channel_message_creation() {
747 let msg = ChannelMessage {
748 text: "hello".to_string(),
749 attachments: vec![],
750 is_guest_context: false,
751 is_from_bot: false,
752 };
753 assert_eq!(msg.text, "hello");
754 assert!(msg.attachments.is_empty());
755 }
756
757 struct StubChannel;
758
759 impl Channel for StubChannel {
760 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
761 Ok(None)
762 }
763
764 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
765 Ok(())
766 }
767
768 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
769 Ok(())
770 }
771
772 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
773 Ok(())
774 }
775 }
776
777 #[tokio::test]
778 async fn send_chunk_default_is_noop() {
779 let mut ch = StubChannel;
780 ch.send_chunk("partial").await.unwrap();
781 }
782
783 #[tokio::test]
784 async fn flush_chunks_default_is_noop() {
785 let mut ch = StubChannel;
786 ch.flush_chunks().await.unwrap();
787 }
788
789 #[tokio::test]
790 async fn stub_channel_confirm_auto_approves() {
791 let mut ch = StubChannel;
792 let result = ch.confirm("Delete everything?").await.unwrap();
793 assert!(result);
794 }
795
796 #[tokio::test]
797 async fn stub_channel_send_typing_default() {
798 let mut ch = StubChannel;
799 ch.send_typing().await.unwrap();
800 }
801
802 #[tokio::test]
803 async fn stub_channel_recv_returns_none() {
804 let mut ch = StubChannel;
805 let msg = ch.recv().await.unwrap();
806 assert!(msg.is_none());
807 }
808
809 #[tokio::test]
810 async fn stub_channel_send_ok() {
811 let mut ch = StubChannel;
812 ch.send("hello").await.unwrap();
813 }
814
815 #[test]
816 fn channel_message_clone() {
817 let msg = ChannelMessage {
818 text: "test".to_string(),
819 attachments: vec![],
820 is_guest_context: false,
821 is_from_bot: false,
822 };
823 let cloned = msg.clone();
824 assert_eq!(cloned.text, "test");
825 }
826
827 #[test]
828 fn channel_message_debug() {
829 let msg = ChannelMessage {
830 text: "debug".to_string(),
831 attachments: vec![],
832 is_guest_context: false,
833 is_from_bot: false,
834 };
835 let debug = format!("{msg:?}");
836 assert!(debug.contains("debug"));
837 }
838
839 #[test]
840 fn attachment_kind_equality() {
841 assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
842 assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
843 }
844
845 #[test]
846 fn attachment_construction() {
847 let a = Attachment {
848 kind: AttachmentKind::Audio,
849 data: vec![0, 1, 2],
850 filename: Some("test.wav".into()),
851 };
852 assert_eq!(a.kind, AttachmentKind::Audio);
853 assert_eq!(a.data.len(), 3);
854 assert_eq!(a.filename.as_deref(), Some("test.wav"));
855 }
856
857 #[test]
858 fn channel_message_with_attachments() {
859 let msg = ChannelMessage {
860 text: String::new(),
861 attachments: vec![Attachment {
862 kind: AttachmentKind::Audio,
863 data: vec![42],
864 filename: None,
865 }],
866 is_guest_context: false,
867 is_from_bot: false,
868 };
869 assert_eq!(msg.attachments.len(), 1);
870 assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
871 }
872
873 #[test]
874 fn stub_channel_try_recv_returns_none() {
875 let mut ch = StubChannel;
876 assert!(ch.try_recv().is_none());
877 }
878
879 #[tokio::test]
880 async fn stub_channel_send_queue_count_noop() {
881 let mut ch = StubChannel;
882 ch.send_queue_count(5).await.unwrap();
883 }
884
885 #[test]
888 fn loopback_pair_returns_linked_handles() {
889 let (channel, handle) = LoopbackChannel::pair(8);
890 drop(channel);
892 drop(handle);
893 }
894
895 #[tokio::test]
896 async fn loopback_cancel_signal_can_be_notified_and_awaited() {
897 let (_channel, handle) = LoopbackChannel::pair(8);
898 let signal = std::sync::Arc::clone(&handle.cancel_signal);
899 let notified = signal.notified();
901 handle.cancel_signal.notify_one();
902 notified.await; }
904
905 #[tokio::test]
906 async fn loopback_cancel_signal_shared_across_clones() {
907 let (_channel, handle) = LoopbackChannel::pair(8);
908 let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
909 let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
910 let notified = signal_b.notified();
911 signal_a.notify_one();
912 notified.await;
913 }
914
915 #[tokio::test]
916 async fn loopback_send_recv_round_trip() {
917 let (mut channel, handle) = LoopbackChannel::pair(8);
918 handle
919 .input_tx
920 .send(ChannelMessage {
921 text: "hello".to_owned(),
922 attachments: vec![],
923 is_guest_context: false,
924 is_from_bot: false,
925 })
926 .await
927 .unwrap();
928 let msg = channel.recv().await.unwrap().unwrap();
929 assert_eq!(msg.text, "hello");
930 }
931
932 #[tokio::test]
933 async fn loopback_recv_returns_none_when_handle_dropped() {
934 let (mut channel, handle) = LoopbackChannel::pair(8);
935 drop(handle);
936 let result = channel.recv().await.unwrap();
937 assert!(result.is_none());
938 }
939
940 #[tokio::test]
941 async fn loopback_send_produces_full_message_event() {
942 let (mut channel, mut handle) = LoopbackChannel::pair(8);
943 channel.send("world").await.unwrap();
944 let event = handle.output_rx.recv().await.unwrap();
945 assert!(matches!(event, LoopbackEvent::FullMessage(t) if t == "world"));
946 }
947
948 #[tokio::test]
949 async fn loopback_send_chunk_then_flush() {
950 let (mut channel, mut handle) = LoopbackChannel::pair(8);
951 channel.send_chunk("part1").await.unwrap();
952 channel.flush_chunks().await.unwrap();
953 let ev1 = handle.output_rx.recv().await.unwrap();
954 let ev2 = handle.output_rx.recv().await.unwrap();
955 assert!(matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1"));
956 assert!(matches!(ev2, LoopbackEvent::Flush));
957 }
958
959 #[tokio::test]
960 async fn loopback_send_tool_output() {
961 let (mut channel, mut handle) = LoopbackChannel::pair(8);
962 channel
963 .send_tool_output(ToolOutputEvent {
964 tool_name: "bash".into(),
965 display: "exit 0".into(),
966 diff: None,
967 filter_stats: None,
968 kept_lines: None,
969 locations: None,
970 tool_call_id: String::new(),
971 terminal_id: None,
972 is_error: false,
973 parent_tool_use_id: None,
974 raw_response: None,
975 started_at: None,
976 })
977 .await
978 .unwrap();
979 let event = handle.output_rx.recv().await.unwrap();
980 match event {
981 LoopbackEvent::ToolOutput(data) => {
982 assert_eq!(data.tool_name, "bash");
983 assert_eq!(data.display, "exit 0");
984 assert!(data.diff.is_none());
985 assert!(data.filter_stats.is_none());
986 assert!(data.kept_lines.is_none());
987 assert!(data.locations.is_none());
988 assert_eq!(data.tool_call_id, "");
989 assert!(!data.is_error);
990 assert!(data.terminal_id.is_none());
991 assert!(data.parent_tool_use_id.is_none());
992 assert!(data.raw_response.is_none());
993 }
994 _ => panic!("expected ToolOutput event"),
995 }
996 }
997
998 #[tokio::test]
999 async fn loopback_confirm_auto_approves() {
1000 let (mut channel, _handle) = LoopbackChannel::pair(8);
1001 let result = channel.confirm("are you sure?").await.unwrap();
1002 assert!(result);
1003 }
1004
1005 #[tokio::test]
1006 async fn loopback_send_error_when_output_closed() {
1007 let (mut channel, handle) = LoopbackChannel::pair(8);
1008 drop(handle);
1010 let result = channel.send("too late").await;
1011 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1012 }
1013
1014 #[tokio::test]
1015 async fn loopback_send_chunk_error_when_output_closed() {
1016 let (mut channel, handle) = LoopbackChannel::pair(8);
1017 drop(handle);
1018 let result = channel.send_chunk("chunk").await;
1019 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1020 }
1021
1022 #[tokio::test]
1023 async fn loopback_flush_error_when_output_closed() {
1024 let (mut channel, handle) = LoopbackChannel::pair(8);
1025 drop(handle);
1026 let result = channel.flush_chunks().await;
1027 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1028 }
1029
1030 #[tokio::test]
1031 async fn loopback_send_status_event() {
1032 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1033 channel.send_status("working...").await.unwrap();
1034 let event = handle.output_rx.recv().await.unwrap();
1035 assert!(matches!(event, LoopbackEvent::Status(s) if s == "working..."));
1036 }
1037
1038 #[tokio::test]
1039 async fn loopback_send_usage_produces_usage_event() {
1040 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1041 channel.send_usage(100, 50, 200_000).await.unwrap();
1042 let event = handle.output_rx.recv().await.unwrap();
1043 match event {
1044 LoopbackEvent::Usage {
1045 input_tokens,
1046 output_tokens,
1047 context_window,
1048 } => {
1049 assert_eq!(input_tokens, 100);
1050 assert_eq!(output_tokens, 50);
1051 assert_eq!(context_window, 200_000);
1052 }
1053 _ => panic!("expected Usage event"),
1054 }
1055 }
1056
1057 #[tokio::test]
1058 async fn loopback_send_usage_error_when_closed() {
1059 let (mut channel, handle) = LoopbackChannel::pair(8);
1060 drop(handle);
1061 let result = channel.send_usage(1, 2, 3).await;
1062 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1063 }
1064
1065 #[test]
1066 fn plan_item_status_variants_are_distinct() {
1067 assert!(!matches!(
1068 PlanItemStatus::Pending,
1069 PlanItemStatus::InProgress
1070 ));
1071 assert!(!matches!(
1072 PlanItemStatus::InProgress,
1073 PlanItemStatus::Completed
1074 ));
1075 assert!(!matches!(
1076 PlanItemStatus::Completed,
1077 PlanItemStatus::Pending
1078 ));
1079 }
1080
1081 #[test]
1082 fn loopback_event_session_title_carries_string() {
1083 let event = LoopbackEvent::SessionTitle("hello".to_owned());
1084 assert!(matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello"));
1085 }
1086
1087 #[test]
1088 fn loopback_event_plan_carries_entries() {
1089 let entries = vec![
1090 ("step 1".to_owned(), PlanItemStatus::Pending),
1091 ("step 2".to_owned(), PlanItemStatus::InProgress),
1092 ];
1093 let event = LoopbackEvent::Plan(entries);
1094 match event {
1095 LoopbackEvent::Plan(e) => {
1096 assert_eq!(e.len(), 2);
1097 assert!(matches!(e[0].1, PlanItemStatus::Pending));
1098 assert!(matches!(e[1].1, PlanItemStatus::InProgress));
1099 }
1100 _ => panic!("expected Plan event"),
1101 }
1102 }
1103
1104 #[tokio::test]
1105 async fn loopback_send_tool_start_produces_tool_start_event() {
1106 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1107 channel
1108 .send_tool_start(ToolStartEvent {
1109 tool_name: "shell".into(),
1110 tool_call_id: "tc-001".into(),
1111 params: Some(serde_json::json!({"command": "ls"})),
1112 parent_tool_use_id: None,
1113 started_at: std::time::Instant::now(),
1114 speculative: false,
1115 sandbox_profile: None,
1116 })
1117 .await
1118 .unwrap();
1119 let event = handle.output_rx.recv().await.unwrap();
1120 match event {
1121 LoopbackEvent::ToolStart(data) => {
1122 assert_eq!(data.tool_name.as_str(), "shell");
1123 assert_eq!(data.tool_call_id.as_str(), "tc-001");
1124 assert!(data.params.is_some());
1125 assert!(data.parent_tool_use_id.is_none());
1126 }
1127 _ => panic!("expected ToolStart event"),
1128 }
1129 }
1130
1131 #[tokio::test]
1132 async fn loopback_send_tool_start_with_parent_id() {
1133 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1134 channel
1135 .send_tool_start(ToolStartEvent {
1136 tool_name: "web".into(),
1137 tool_call_id: "tc-002".into(),
1138 params: None,
1139 parent_tool_use_id: Some("parent-123".into()),
1140 started_at: std::time::Instant::now(),
1141 speculative: false,
1142 sandbox_profile: None,
1143 })
1144 .await
1145 .unwrap();
1146 let event = handle.output_rx.recv().await.unwrap();
1147 assert!(matches!(
1148 event,
1149 LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
1150 ));
1151 }
1152
1153 #[tokio::test]
1154 async fn loopback_send_tool_start_error_when_output_closed() {
1155 let (mut channel, handle) = LoopbackChannel::pair(8);
1156 drop(handle);
1157 let result = channel
1158 .send_tool_start(ToolStartEvent {
1159 tool_name: "shell".into(),
1160 tool_call_id: "tc-003".into(),
1161 params: None,
1162 parent_tool_use_id: None,
1163 started_at: std::time::Instant::now(),
1164 speculative: false,
1165 sandbox_profile: None,
1166 })
1167 .await;
1168 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1169 }
1170
1171 #[tokio::test]
1172 async fn default_send_tool_output_formats_message() {
1173 let mut ch = StubChannel;
1174 ch.send_tool_output(ToolOutputEvent {
1176 tool_name: "bash".into(),
1177 display: "hello".into(),
1178 diff: None,
1179 filter_stats: None,
1180 kept_lines: None,
1181 locations: None,
1182 tool_call_id: "id".into(),
1183 terminal_id: None,
1184 is_error: false,
1185 parent_tool_use_id: None,
1186 raw_response: None,
1187 started_at: None,
1188 })
1189 .await
1190 .unwrap();
1191 }
1192}