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#[non_exhaustive]
37#[derive(Debug, Clone)]
48pub enum ElicitationFieldType {
49 String,
50 Integer,
51 Number,
52 Boolean,
53 Enum(Vec<String>),
55}
56
57#[derive(Debug, Clone)]
82pub struct ElicitationRequest {
83 pub server_name: String,
85 pub message: String,
87 pub fields: Vec<ElicitationField>,
89}
90
91#[non_exhaustive]
92#[derive(Debug, Clone)]
110pub enum ElicitationResponse {
111 Accepted(serde_json::Value),
113 Declined,
115 Cancelled,
117}
118
119#[derive(Debug, thiserror::Error)]
121#[non_exhaustive]
122pub enum ChannelError {
123 #[error("I/O error: {0}")]
125 Io(#[from] std::io::Error),
126
127 #[error("channel closed")]
129 ChannelClosed,
130
131 #[error("confirmation cancelled")]
133 ConfirmCancelled,
134
135 #[error("no active session")]
140 NoActiveSession,
141
142 #[error("telegram error: {0}")]
148 Telegram(String),
149
150 #[error("{0}")]
152 Other(String),
153}
154
155impl ChannelError {
156 pub fn telegram(e: impl std::fmt::Display) -> Self {
167 Self::Telegram(e.to_string())
168 }
169
170 pub fn other(e: impl std::fmt::Display) -> Self {
175 Self::Other(e.to_string())
176 }
177}
178
179#[non_exhaustive]
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum AttachmentKind {
183 Audio,
184 Image,
185 Video,
186 File,
187}
188
189#[derive(Debug, Clone)]
191pub struct Attachment {
192 pub kind: AttachmentKind,
193 pub data: Vec<u8>,
194 pub filename: Option<String>,
195}
196
197#[derive(Debug, Clone)]
199pub struct ChannelMessage {
200 pub text: String,
201 pub attachments: Vec<Attachment>,
202 pub is_guest_context: bool,
204 pub is_from_bot: bool,
206}
207
208pub trait Channel: Send {
225 fn recv(&mut self)
231 -> impl Future<Output = Result<Option<ChannelMessage>, ChannelError>> + Send;
232
233 fn try_recv(&mut self) -> Option<ChannelMessage> {
235 None
236 }
237
238 fn supports_exit(&self) -> bool {
243 true
244 }
245
246 fn send(&mut self, text: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
252
253 fn send_chunk(&mut self, chunk: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
259
260 fn flush_chunks(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send;
266
267 fn send_typing(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send {
273 async { Ok(()) }
274 }
275
276 fn send_status(
282 &mut self,
283 _text: &str,
284 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
285 async { Ok(()) }
286 }
287
288 fn send_thinking_chunk(
294 &mut self,
295 _chunk: &str,
296 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
297 async { Ok(()) }
298 }
299
300 fn send_queue_count(
306 &mut self,
307 _count: usize,
308 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
309 async { Ok(()) }
310 }
311
312 fn send_context_estimate(
320 &mut self,
321 _tokens: usize,
322 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
323 async { Ok(()) }
324 }
325
326 fn send_usage(
335 &mut self,
336 _input_tokens: u64,
337 _output_tokens: u64,
338 _context_window: u64,
339 _cache_read_tokens: u64,
340 _cache_write_tokens: u64,
341 _cost_cents: f64,
342 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
343 async { Ok(()) }
344 }
345
346 fn send_diff(
355 &mut self,
356 _diff: crate::DiffData,
357 _tool_call_id: &str,
358 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
359 async { Ok(()) }
360 }
361
362 fn send_tool_start(
372 &mut self,
373 _event: ToolStartEvent,
374 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
375 async { Ok(()) }
376 }
377
378 fn send_tool_output(
388 &mut self,
389 event: ToolOutputEvent,
390 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
391 let formatted = crate::agent::format_tool_output(event.tool_name.as_str(), &event.display);
392 async move { self.send(&formatted).await }
393 }
394
395 fn confirm(
402 &mut self,
403 _prompt: &str,
404 ) -> impl Future<Output = Result<bool, ChannelError>> + Send {
405 async { Ok(true) }
406 }
407
408 fn elicit(
417 &mut self,
418 _request: ElicitationRequest,
419 ) -> impl Future<Output = Result<ElicitationResponse, ChannelError>> + Send {
420 async { Ok(ElicitationResponse::Declined) }
421 }
422
423 fn send_stop_hint(
432 &mut self,
433 _hint: StopHint,
434 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
435 async { Ok(()) }
436 }
437
438 fn notify_foreground_subagent_started(
448 &mut self,
449 _id: &str,
450 _name: &str,
451 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
452 async { Ok(()) }
453 }
454
455 fn notify_foreground_subagent_completed(
465 &mut self,
466 _id: &str,
467 _name: &str,
468 _success: bool,
469 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
470 async { Ok(()) }
471 }
472}
473
474pub use zeph_common::StopHint;
475
476#[derive(Debug, Clone)]
481pub struct ToolStartEvent {
482 pub tool_name: zeph_common::ToolName,
484 pub tool_call_id: String,
486 pub params: Option<serde_json::Value>,
488 pub parent_tool_use_id: Option<String>,
490 pub started_at: std::time::Instant,
492 pub speculative: bool,
496 pub sandbox_profile: Option<zeph_tools::SandboxProfile>,
500}
501
502#[derive(Debug, Clone)]
507pub struct ToolOutputEvent {
508 pub tool_name: zeph_common::ToolName,
510 pub display: String,
512 pub diff: Option<crate::DiffData>,
514 pub filter_stats: Option<String>,
516 pub kept_lines: Option<Vec<usize>>,
518 pub locations: Option<Vec<String>>,
520 pub tool_call_id: String,
522 pub is_error: bool,
524 pub terminal_id: Option<String>,
526 pub parent_tool_use_id: Option<String>,
528 pub raw_response: Option<serde_json::Value>,
530 pub started_at: Option<std::time::Instant>,
532}
533
534pub type ToolStartData = ToolStartEvent;
538
539pub type ToolOutputData = ToolOutputEvent;
543
544#[non_exhaustive]
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 {
563 input_tokens: u64,
564 output_tokens: u64,
565 context_window: u64,
566 cache_read_tokens: u64,
568 cache_write_tokens: u64,
570 cost_cents: f64,
572 },
573 SessionTitle(String),
575 Plan(Vec<(String, PlanItemStatus)>),
577 ThinkingChunk(String),
579 Stop(StopHint),
583}
584
585#[non_exhaustive]
586#[derive(Debug, Clone)]
588pub enum PlanItemStatus {
589 Pending,
590 InProgress,
591 Completed,
592}
593
594pub struct LoopbackHandle {
596 pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
597 pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
598 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
600}
601
602pub struct LoopbackChannel {
604 input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
605 output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
606}
607
608impl LoopbackChannel {
609 #[must_use]
611 pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
612 let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
613 let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
614 let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
615 (
616 Self {
617 input_rx,
618 output_tx,
619 },
620 LoopbackHandle {
621 input_tx,
622 output_rx,
623 cancel_signal,
624 },
625 )
626 }
627}
628
629impl Channel for LoopbackChannel {
630 fn supports_exit(&self) -> bool {
631 false
632 }
633
634 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
635 Ok(self.input_rx.recv().await)
636 }
637
638 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
639 self.output_tx
640 .send(LoopbackEvent::FullMessage(text.to_owned()))
641 .await
642 .map_err(|_| ChannelError::ChannelClosed)
643 }
644
645 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
646 self.output_tx
647 .send(LoopbackEvent::Chunk(chunk.to_owned()))
648 .await
649 .map_err(|_| ChannelError::ChannelClosed)
650 }
651
652 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
653 self.output_tx
654 .send(LoopbackEvent::Flush)
655 .await
656 .map_err(|_| ChannelError::ChannelClosed)
657 }
658
659 async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
660 self.output_tx
661 .send(LoopbackEvent::Status(text.to_owned()))
662 .await
663 .map_err(|_| ChannelError::ChannelClosed)
664 }
665
666 async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
667 self.output_tx
668 .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
669 .await
670 .map_err(|_| ChannelError::ChannelClosed)
671 }
672
673 async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
674 self.output_tx
675 .send(LoopbackEvent::ToolStart(Box::new(event)))
676 .await
677 .map_err(|_| ChannelError::ChannelClosed)
678 }
679
680 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
681 self.output_tx
682 .send(LoopbackEvent::ToolOutput(Box::new(event)))
683 .await
684 .map_err(|_| ChannelError::ChannelClosed)
685 }
686
687 async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
688 Ok(true)
689 }
690
691 async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
692 self.output_tx
693 .send(LoopbackEvent::Stop(hint))
694 .await
695 .map_err(|_| ChannelError::ChannelClosed)
696 }
697
698 async fn send_usage(
699 &mut self,
700 input_tokens: u64,
701 output_tokens: u64,
702 context_window: u64,
703 cache_read_tokens: u64,
704 cache_write_tokens: u64,
705 cost_cents: f64,
706 ) -> Result<(), ChannelError> {
707 self.output_tx
708 .send(LoopbackEvent::Usage {
709 input_tokens,
710 output_tokens,
711 context_window,
712 cache_read_tokens,
713 cache_write_tokens,
714 cost_cents,
715 })
716 .await
717 .map_err(|_| ChannelError::ChannelClosed)
718 }
719}
720
721pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
726
727impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
728 fn send<'a>(
729 &'a mut self,
730 msg: &'a str,
731 ) -> std::pin::Pin<
732 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
733 > {
734 Box::pin(async move {
735 self.0
736 .send(msg)
737 .await
738 .map_err(zeph_commands::CommandError::new)
739 })
740 }
741
742 fn flush_chunks<'a>(
743 &'a mut self,
744 ) -> std::pin::Pin<
745 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
746 > {
747 Box::pin(async move {
748 self.0
749 .flush_chunks()
750 .await
751 .map_err(zeph_commands::CommandError::new)
752 })
753 }
754
755 fn send_queue_count<'a>(
756 &'a mut self,
757 count: usize,
758 ) -> std::pin::Pin<
759 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
760 > {
761 Box::pin(async move {
762 self.0
763 .send_queue_count(count)
764 .await
765 .map_err(zeph_commands::CommandError::new)
766 })
767 }
768
769 fn supports_exit(&self) -> bool {
770 self.0.supports_exit()
771 }
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777
778 #[test]
779 fn channel_message_creation() {
780 let msg = ChannelMessage {
781 text: "hello".to_string(),
782 attachments: vec![],
783 is_guest_context: false,
784 is_from_bot: false,
785 };
786 assert_eq!(msg.text, "hello");
787 assert!(msg.attachments.is_empty());
788 }
789
790 struct StubChannel;
791
792 impl Channel for StubChannel {
793 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
794 Ok(None)
795 }
796
797 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
798 Ok(())
799 }
800
801 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
802 Ok(())
803 }
804
805 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
806 Ok(())
807 }
808 }
809
810 #[tokio::test]
811 async fn send_chunk_default_is_noop() {
812 let mut ch = StubChannel;
813 ch.send_chunk("partial").await.unwrap();
814 }
815
816 #[tokio::test]
817 async fn flush_chunks_default_is_noop() {
818 let mut ch = StubChannel;
819 ch.flush_chunks().await.unwrap();
820 }
821
822 #[tokio::test]
823 async fn stub_channel_confirm_auto_approves() {
824 let mut ch = StubChannel;
825 let result = ch.confirm("Delete everything?").await.unwrap();
826 assert!(result);
827 }
828
829 #[tokio::test]
830 async fn stub_channel_send_typing_default() {
831 let mut ch = StubChannel;
832 ch.send_typing().await.unwrap();
833 }
834
835 #[tokio::test]
836 async fn stub_channel_recv_returns_none() {
837 let mut ch = StubChannel;
838 let msg = ch.recv().await.unwrap();
839 assert!(msg.is_none());
840 }
841
842 #[tokio::test]
843 async fn stub_channel_send_ok() {
844 let mut ch = StubChannel;
845 ch.send("hello").await.unwrap();
846 }
847
848 #[test]
849 fn channel_message_clone() {
850 let msg = ChannelMessage {
851 text: "test".to_string(),
852 attachments: vec![],
853 is_guest_context: false,
854 is_from_bot: false,
855 };
856 let cloned = msg.clone();
857 assert_eq!(cloned.text, "test");
858 }
859
860 #[test]
861 fn channel_message_debug() {
862 let msg = ChannelMessage {
863 text: "debug".to_string(),
864 attachments: vec![],
865 is_guest_context: false,
866 is_from_bot: false,
867 };
868 let debug = format!("{msg:?}");
869 assert!(debug.contains("debug"));
870 }
871
872 #[test]
873 fn attachment_kind_equality() {
874 assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
875 assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
876 }
877
878 #[test]
879 fn attachment_construction() {
880 let a = Attachment {
881 kind: AttachmentKind::Audio,
882 data: vec![0, 1, 2],
883 filename: Some("test.wav".into()),
884 };
885 assert_eq!(a.kind, AttachmentKind::Audio);
886 assert_eq!(a.data.len(), 3);
887 assert_eq!(a.filename.as_deref(), Some("test.wav"));
888 }
889
890 #[test]
891 fn channel_message_with_attachments() {
892 let msg = ChannelMessage {
893 text: String::new(),
894 attachments: vec![Attachment {
895 kind: AttachmentKind::Audio,
896 data: vec![42],
897 filename: None,
898 }],
899 is_guest_context: false,
900 is_from_bot: false,
901 };
902 assert_eq!(msg.attachments.len(), 1);
903 assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
904 }
905
906 #[test]
907 fn stub_channel_try_recv_returns_none() {
908 let mut ch = StubChannel;
909 assert!(ch.try_recv().is_none());
910 }
911
912 #[tokio::test]
913 async fn stub_channel_send_queue_count_noop() {
914 let mut ch = StubChannel;
915 ch.send_queue_count(5).await.unwrap();
916 }
917
918 #[test]
921 fn loopback_pair_returns_linked_handles() {
922 let (channel, handle) = LoopbackChannel::pair(8);
923 drop(channel);
925 drop(handle);
926 }
927
928 #[tokio::test]
929 async fn loopback_cancel_signal_can_be_notified_and_awaited() {
930 let (_channel, handle) = LoopbackChannel::pair(8);
931 let signal = std::sync::Arc::clone(&handle.cancel_signal);
932 let notified = signal.notified();
934 handle.cancel_signal.notify_one();
935 notified.await; }
937
938 #[tokio::test]
939 async fn loopback_cancel_signal_shared_across_clones() {
940 let (_channel, handle) = LoopbackChannel::pair(8);
941 let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
942 let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
943 let notified = signal_b.notified();
944 signal_a.notify_one();
945 notified.await;
946 }
947
948 #[tokio::test]
949 async fn loopback_send_recv_round_trip() {
950 let (mut channel, handle) = LoopbackChannel::pair(8);
951 handle
952 .input_tx
953 .send(ChannelMessage {
954 text: "hello".to_owned(),
955 attachments: vec![],
956 is_guest_context: false,
957 is_from_bot: false,
958 })
959 .await
960 .unwrap();
961 let msg = channel.recv().await.unwrap().unwrap();
962 assert_eq!(msg.text, "hello");
963 }
964
965 #[tokio::test]
966 async fn loopback_recv_returns_none_when_handle_dropped() {
967 let (mut channel, handle) = LoopbackChannel::pair(8);
968 drop(handle);
969 let result = channel.recv().await.unwrap();
970 assert!(result.is_none());
971 }
972
973 #[tokio::test]
974 async fn loopback_send_produces_full_message_event() {
975 let (mut channel, mut handle) = LoopbackChannel::pair(8);
976 channel.send("world").await.unwrap();
977 let event = handle.output_rx.recv().await.unwrap();
978 assert!(matches!(event, LoopbackEvent::FullMessage(t) if t == "world"));
979 }
980
981 #[tokio::test]
982 async fn loopback_send_chunk_then_flush() {
983 let (mut channel, mut handle) = LoopbackChannel::pair(8);
984 channel.send_chunk("part1").await.unwrap();
985 channel.flush_chunks().await.unwrap();
986 let ev1 = handle.output_rx.recv().await.unwrap();
987 let ev2 = handle.output_rx.recv().await.unwrap();
988 assert!(matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1"));
989 assert!(matches!(ev2, LoopbackEvent::Flush));
990 }
991
992 #[tokio::test]
993 async fn loopback_send_tool_output() {
994 let (mut channel, mut handle) = LoopbackChannel::pair(8);
995 channel
996 .send_tool_output(ToolOutputEvent {
997 tool_name: "bash".into(),
998 display: "exit 0".into(),
999 diff: None,
1000 filter_stats: None,
1001 kept_lines: None,
1002 locations: None,
1003 tool_call_id: String::new(),
1004 terminal_id: None,
1005 is_error: false,
1006 parent_tool_use_id: None,
1007 raw_response: None,
1008 started_at: None,
1009 })
1010 .await
1011 .unwrap();
1012 let event = handle.output_rx.recv().await.unwrap();
1013 match event {
1014 LoopbackEvent::ToolOutput(data) => {
1015 assert_eq!(data.tool_name, "bash");
1016 assert_eq!(data.display, "exit 0");
1017 assert!(data.diff.is_none());
1018 assert!(data.filter_stats.is_none());
1019 assert!(data.kept_lines.is_none());
1020 assert!(data.locations.is_none());
1021 assert_eq!(data.tool_call_id, "");
1022 assert!(!data.is_error);
1023 assert!(data.terminal_id.is_none());
1024 assert!(data.parent_tool_use_id.is_none());
1025 assert!(data.raw_response.is_none());
1026 }
1027 _ => panic!("expected ToolOutput event"),
1028 }
1029 }
1030
1031 #[tokio::test]
1032 async fn loopback_confirm_auto_approves() {
1033 let (mut channel, _handle) = LoopbackChannel::pair(8);
1034 let result = channel.confirm("are you sure?").await.unwrap();
1035 assert!(result);
1036 }
1037
1038 #[tokio::test]
1039 async fn loopback_send_error_when_output_closed() {
1040 let (mut channel, handle) = LoopbackChannel::pair(8);
1041 drop(handle);
1043 let result = channel.send("too late").await;
1044 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1045 }
1046
1047 #[tokio::test]
1048 async fn loopback_send_chunk_error_when_output_closed() {
1049 let (mut channel, handle) = LoopbackChannel::pair(8);
1050 drop(handle);
1051 let result = channel.send_chunk("chunk").await;
1052 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1053 }
1054
1055 #[tokio::test]
1056 async fn loopback_flush_error_when_output_closed() {
1057 let (mut channel, handle) = LoopbackChannel::pair(8);
1058 drop(handle);
1059 let result = channel.flush_chunks().await;
1060 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1061 }
1062
1063 #[tokio::test]
1064 async fn loopback_send_status_event() {
1065 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1066 channel.send_status("working...").await.unwrap();
1067 let event = handle.output_rx.recv().await.unwrap();
1068 assert!(matches!(event, LoopbackEvent::Status(s) if s == "working..."));
1069 }
1070
1071 #[tokio::test]
1072 async fn loopback_send_usage_produces_usage_event() {
1073 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1074 channel
1075 .send_usage(100, 50, 200_000, 10, 5, 1.5)
1076 .await
1077 .unwrap();
1078 let event = handle.output_rx.recv().await.unwrap();
1079 match event {
1080 LoopbackEvent::Usage {
1081 input_tokens,
1082 output_tokens,
1083 context_window,
1084 cache_read_tokens,
1085 cache_write_tokens,
1086 cost_cents,
1087 } => {
1088 assert_eq!(input_tokens, 100);
1089 assert_eq!(output_tokens, 50);
1090 assert_eq!(context_window, 200_000);
1091 assert_eq!(cache_read_tokens, 10);
1092 assert_eq!(cache_write_tokens, 5);
1093 assert!((cost_cents - 1.5).abs() < f64::EPSILON);
1094 }
1095 _ => panic!("expected Usage event"),
1096 }
1097 }
1098
1099 #[tokio::test]
1100 async fn loopback_send_usage_error_when_closed() {
1101 let (mut channel, handle) = LoopbackChannel::pair(8);
1102 drop(handle);
1103 let result = channel.send_usage(1, 2, 3, 0, 0, 0.0).await;
1104 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1105 }
1106
1107 #[test]
1108 fn plan_item_status_variants_are_distinct() {
1109 assert!(!matches!(
1110 PlanItemStatus::Pending,
1111 PlanItemStatus::InProgress
1112 ));
1113 assert!(!matches!(
1114 PlanItemStatus::InProgress,
1115 PlanItemStatus::Completed
1116 ));
1117 assert!(!matches!(
1118 PlanItemStatus::Completed,
1119 PlanItemStatus::Pending
1120 ));
1121 }
1122
1123 #[test]
1124 fn loopback_event_session_title_carries_string() {
1125 let event = LoopbackEvent::SessionTitle("hello".to_owned());
1126 assert!(matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello"));
1127 }
1128
1129 #[test]
1130 fn loopback_event_plan_carries_entries() {
1131 let entries = vec![
1132 ("step 1".to_owned(), PlanItemStatus::Pending),
1133 ("step 2".to_owned(), PlanItemStatus::InProgress),
1134 ];
1135 let event = LoopbackEvent::Plan(entries);
1136 match event {
1137 LoopbackEvent::Plan(e) => {
1138 assert_eq!(e.len(), 2);
1139 assert!(matches!(e[0].1, PlanItemStatus::Pending));
1140 assert!(matches!(e[1].1, PlanItemStatus::InProgress));
1141 }
1142 _ => panic!("expected Plan event"),
1143 }
1144 }
1145
1146 #[tokio::test]
1147 async fn loopback_send_tool_start_produces_tool_start_event() {
1148 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1149 channel
1150 .send_tool_start(ToolStartEvent {
1151 tool_name: "shell".into(),
1152 tool_call_id: "tc-001".into(),
1153 params: Some(serde_json::json!({"command": "ls"})),
1154 parent_tool_use_id: None,
1155 started_at: std::time::Instant::now(),
1156 speculative: false,
1157 sandbox_profile: None,
1158 })
1159 .await
1160 .unwrap();
1161 let event = handle.output_rx.recv().await.unwrap();
1162 match event {
1163 LoopbackEvent::ToolStart(data) => {
1164 assert_eq!(data.tool_name.as_str(), "shell");
1165 assert_eq!(data.tool_call_id.as_str(), "tc-001");
1166 assert!(data.params.is_some());
1167 assert!(data.parent_tool_use_id.is_none());
1168 }
1169 _ => panic!("expected ToolStart event"),
1170 }
1171 }
1172
1173 #[tokio::test]
1174 async fn loopback_send_tool_start_with_parent_id() {
1175 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1176 channel
1177 .send_tool_start(ToolStartEvent {
1178 tool_name: "web".into(),
1179 tool_call_id: "tc-002".into(),
1180 params: None,
1181 parent_tool_use_id: Some("parent-123".into()),
1182 started_at: std::time::Instant::now(),
1183 speculative: false,
1184 sandbox_profile: None,
1185 })
1186 .await
1187 .unwrap();
1188 let event = handle.output_rx.recv().await.unwrap();
1189 assert!(matches!(
1190 event,
1191 LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
1192 ));
1193 }
1194
1195 #[tokio::test]
1196 async fn loopback_send_tool_start_error_when_output_closed() {
1197 let (mut channel, handle) = LoopbackChannel::pair(8);
1198 drop(handle);
1199 let result = channel
1200 .send_tool_start(ToolStartEvent {
1201 tool_name: "shell".into(),
1202 tool_call_id: "tc-003".into(),
1203 params: None,
1204 parent_tool_use_id: None,
1205 started_at: std::time::Instant::now(),
1206 speculative: false,
1207 sandbox_profile: None,
1208 })
1209 .await;
1210 assert!(matches!(result, Err(ChannelError::ChannelClosed)));
1211 }
1212
1213 #[tokio::test]
1214 async fn default_send_tool_output_formats_message() {
1215 let mut ch = StubChannel;
1216 ch.send_tool_output(ToolOutputEvent {
1218 tool_name: "bash".into(),
1219 display: "hello".into(),
1220 diff: None,
1221 filter_stats: None,
1222 kept_lines: None,
1223 locations: None,
1224 tool_call_id: "id".into(),
1225 terminal_id: None,
1226 is_error: false,
1227 parent_tool_use_id: None,
1228 raw_response: None,
1229 started_at: None,
1230 })
1231 .await
1232 .unwrap();
1233 }
1234}