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