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