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