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
208const STATUS_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
222
223pub trait Channel: Send {
240 fn recv(&mut self)
246 -> impl Future<Output = Result<Option<ChannelMessage>, ChannelError>> + Send;
247
248 fn try_recv(&mut self) -> Option<ChannelMessage> {
250 None
251 }
252
253 fn supports_exit(&self) -> bool {
258 true
259 }
260
261 fn requires_input_sanitization(&self) -> bool {
275 false
276 }
277
278 fn send(&mut self, text: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
284
285 fn send_chunk(&mut self, chunk: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
291
292 fn flush_chunks(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send;
298
299 fn send_typing(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send {
305 async { Ok(()) }
306 }
307
308 fn send_status(
314 &mut self,
315 _text: &str,
316 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
317 async { Ok(()) }
318 }
319
320 fn send_status_best_effort(&mut self, text: &str) -> impl Future<Output = ()> + Send {
329 async move {
330 match tokio::time::timeout(STATUS_SEND_TIMEOUT, self.send_status(text)).await {
331 Ok(Ok(())) => tracing::debug!(text, "channel status sent"),
332 Ok(Err(error)) => tracing::warn!(%error, text, "channel status send failed"),
333 Err(_) => tracing::warn!(
334 text,
335 timeout_secs = STATUS_SEND_TIMEOUT.as_secs(),
336 "channel status send timed out"
337 ),
338 }
339 }
340 }
341
342 fn send_thinking_chunk(
348 &mut self,
349 _chunk: &str,
350 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
351 async { Ok(()) }
352 }
353
354 fn send_queue_count(
360 &mut self,
361 _count: usize,
362 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
363 async { Ok(()) }
364 }
365
366 fn send_context_estimate(
374 &mut self,
375 _tokens: usize,
376 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
377 async { Ok(()) }
378 }
379
380 fn send_usage(
389 &mut self,
390 _input_tokens: u64,
391 _output_tokens: u64,
392 _context_window: u64,
393 _cache_read_tokens: u64,
394 _cache_write_tokens: u64,
395 _cost_cents: f64,
396 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
397 async { Ok(()) }
398 }
399
400 fn send_diff(
409 &mut self,
410 _diff: crate::DiffData,
411 _tool_call_id: &str,
412 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
413 async { Ok(()) }
414 }
415
416 fn send_tool_start(
426 &mut self,
427 _event: ToolStartEvent,
428 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
429 async { Ok(()) }
430 }
431
432 fn send_tool_output(
442 &mut self,
443 event: ToolOutputEvent,
444 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
445 let formatted = crate::agent::format_tool_output(event.tool_name.as_str(), &event.display);
446 async move { self.send(&formatted).await }
447 }
448
449 fn confirm(
456 &mut self,
457 _prompt: &str,
458 ) -> impl Future<Output = Result<bool, ChannelError>> + Send {
459 async { Ok(true) }
460 }
461
462 fn elicit(
471 &mut self,
472 _request: ElicitationRequest,
473 ) -> impl Future<Output = Result<ElicitationResponse, ChannelError>> + Send {
474 async { Ok(ElicitationResponse::Declined) }
475 }
476
477 fn send_stop_hint(
486 &mut self,
487 _hint: StopHint,
488 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
489 async { Ok(()) }
490 }
491
492 fn notify_foreground_subagent_started(
502 &mut self,
503 _id: &str,
504 _name: &str,
505 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
506 async { Ok(()) }
507 }
508
509 fn notify_foreground_subagent_completed(
519 &mut self,
520 _id: &str,
521 _name: &str,
522 _success: bool,
523 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
524 async { Ok(()) }
525 }
526}
527
528pub use zeph_common::StopHint;
529
530#[derive(Debug, Clone)]
535pub struct ToolStartEvent {
536 pub tool_name: zeph_common::ToolName,
538 pub tool_call_id: String,
540 pub params: Option<serde_json::Value>,
542 pub parent_tool_use_id: Option<String>,
544 pub started_at: std::time::Instant,
546 pub speculative: bool,
550 pub sandbox_profile: Option<zeph_tools::SandboxProfile>,
554 pub is_mcp: bool,
556}
557
558#[derive(Debug, Clone)]
563pub struct ToolOutputEvent {
564 pub tool_name: zeph_common::ToolName,
566 pub display: String,
568 pub diff: Option<crate::DiffData>,
570 pub filter_stats: Option<String>,
572 pub kept_lines: Option<Vec<usize>>,
574 pub locations: Option<Vec<String>>,
576 pub tool_call_id: String,
578 pub is_error: bool,
580 pub terminal_id: Option<String>,
582 pub parent_tool_use_id: Option<String>,
584 pub raw_response: Option<serde_json::Value>,
586 pub started_at: Option<std::time::Instant>,
588}
589
590pub type ToolStartData = ToolStartEvent;
594
595pub type ToolOutputData = ToolOutputEvent;
599
600#[non_exhaustive]
601#[derive(Debug, Clone)]
603pub enum LoopbackEvent {
604 Chunk(String),
605 Flush,
606 FullMessage(String),
607 Status(String),
608 ToolStart(Box<ToolStartEvent>),
610 ToolOutput(Box<ToolOutputEvent>),
611 Usage {
619 input_tokens: u64,
620 output_tokens: u64,
621 context_window: u64,
622 cache_read_tokens: u64,
624 cache_write_tokens: u64,
626 cost_cents: f64,
628 },
629 SessionTitle(String),
631 Plan(Vec<(String, PlanItemStatus)>),
633 ThinkingChunk(String),
635 Stop(StopHint),
639}
640
641#[non_exhaustive]
642#[derive(Debug, Clone)]
644pub enum PlanItemStatus {
645 Pending,
646 InProgress,
647 Completed,
648}
649
650pub struct LoopbackHandle {
652 pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
653 pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
654 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
656}
657
658pub struct LoopbackChannel {
660 input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
661 output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
662}
663
664impl LoopbackChannel {
665 #[must_use]
667 pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
668 let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
669 let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
670 let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
671 (
672 Self {
673 input_rx,
674 output_tx,
675 },
676 LoopbackHandle {
677 input_tx,
678 output_rx,
679 cancel_signal,
680 },
681 )
682 }
683}
684
685impl Channel for LoopbackChannel {
686 fn supports_exit(&self) -> bool {
687 false
688 }
689
690 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
691 Ok(self.input_rx.recv().await)
692 }
693
694 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
695 self.output_tx
696 .send(LoopbackEvent::FullMessage(text.to_owned()))
697 .await
698 .map_err(|_| ChannelError::ChannelClosed)
699 }
700
701 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
702 self.output_tx
703 .send(LoopbackEvent::Chunk(chunk.to_owned()))
704 .await
705 .map_err(|_| ChannelError::ChannelClosed)
706 }
707
708 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
709 self.output_tx
710 .send(LoopbackEvent::Flush)
711 .await
712 .map_err(|_| ChannelError::ChannelClosed)
713 }
714
715 async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
716 self.output_tx
717 .send(LoopbackEvent::Status(text.to_owned()))
718 .await
719 .map_err(|_| ChannelError::ChannelClosed)
720 }
721
722 async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
723 self.output_tx
724 .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
725 .await
726 .map_err(|_| ChannelError::ChannelClosed)
727 }
728
729 async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
730 self.output_tx
731 .send(LoopbackEvent::ToolStart(Box::new(event)))
732 .await
733 .map_err(|_| ChannelError::ChannelClosed)
734 }
735
736 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
737 self.output_tx
738 .send(LoopbackEvent::ToolOutput(Box::new(event)))
739 .await
740 .map_err(|_| ChannelError::ChannelClosed)
741 }
742
743 async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
744 Ok(true)
745 }
746
747 async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
748 self.output_tx
749 .send(LoopbackEvent::Stop(hint))
750 .await
751 .map_err(|_| ChannelError::ChannelClosed)
752 }
753
754 async fn send_usage(
755 &mut self,
756 input_tokens: u64,
757 output_tokens: u64,
758 context_window: u64,
759 cache_read_tokens: u64,
760 cache_write_tokens: u64,
761 cost_cents: f64,
762 ) -> Result<(), ChannelError> {
763 self.output_tx
764 .send(LoopbackEvent::Usage {
765 input_tokens,
766 output_tokens,
767 context_window,
768 cache_read_tokens,
769 cache_write_tokens,
770 cost_cents,
771 })
772 .await
773 .map_err(|_| ChannelError::ChannelClosed)
774 }
775}
776
777pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
782
783impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
784 fn send<'a>(
785 &'a mut self,
786 msg: &'a str,
787 ) -> std::pin::Pin<
788 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
789 > {
790 Box::pin(async move {
791 self.0
792 .send(msg)
793 .await
794 .map_err(zeph_commands::CommandError::new)
795 })
796 }
797
798 fn flush_chunks<'a>(
799 &'a mut self,
800 ) -> std::pin::Pin<
801 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
802 > {
803 Box::pin(async move {
804 self.0
805 .flush_chunks()
806 .await
807 .map_err(zeph_commands::CommandError::new)
808 })
809 }
810
811 fn send_queue_count<'a>(
812 &'a mut self,
813 count: usize,
814 ) -> std::pin::Pin<
815 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
816 > {
817 Box::pin(async move {
818 self.0
819 .send_queue_count(count)
820 .await
821 .map_err(zeph_commands::CommandError::new)
822 })
823 }
824
825 fn supports_exit(&self) -> bool {
826 self.0.supports_exit()
827 }
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use std::assert_matches;
834
835 #[test]
836 fn channel_message_creation() {
837 let msg = ChannelMessage {
838 text: "hello".to_string(),
839 attachments: vec![],
840 is_guest_context: false,
841 is_from_bot: false,
842 };
843 assert_eq!(msg.text, "hello");
844 assert!(msg.attachments.is_empty());
845 }
846
847 struct StubChannel;
848
849 impl Channel for StubChannel {
850 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
851 Ok(None)
852 }
853
854 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
855 Ok(())
856 }
857
858 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
859 Ok(())
860 }
861
862 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
863 Ok(())
864 }
865 }
866
867 #[tokio::test]
868 async fn send_chunk_default_is_noop() {
869 let mut ch = StubChannel;
870 ch.send_chunk("partial").await.unwrap();
871 }
872
873 #[tokio::test]
874 async fn flush_chunks_default_is_noop() {
875 let mut ch = StubChannel;
876 ch.flush_chunks().await.unwrap();
877 }
878
879 #[tokio::test]
880 async fn stub_channel_confirm_auto_approves() {
881 let mut ch = StubChannel;
882 let result = ch.confirm("Delete everything?").await.unwrap();
883 assert!(result);
884 }
885
886 #[tokio::test]
887 async fn stub_channel_send_typing_default() {
888 let mut ch = StubChannel;
889 ch.send_typing().await.unwrap();
890 }
891
892 #[tokio::test]
893 async fn stub_channel_recv_returns_none() {
894 let mut ch = StubChannel;
895 let msg = ch.recv().await.unwrap();
896 assert!(msg.is_none());
897 }
898
899 #[tokio::test]
900 async fn stub_channel_send_ok() {
901 let mut ch = StubChannel;
902 ch.send("hello").await.unwrap();
903 }
904
905 #[tokio::test]
906 async fn send_status_best_effort_succeeds_silently() {
907 let mut ch = StubChannel;
908 ch.send_status_best_effort("hello").await;
910 }
911
912 struct ErroringStatusChannel;
913
914 impl Channel for ErroringStatusChannel {
915 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
916 Ok(None)
917 }
918
919 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
920 Ok(())
921 }
922
923 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
924 Ok(())
925 }
926
927 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
928 Ok(())
929 }
930
931 async fn send_status(&mut self, _text: &str) -> Result<(), ChannelError> {
932 Err(ChannelError::ChannelClosed)
933 }
934 }
935
936 #[tokio::test]
937 async fn send_status_best_effort_swallows_errors() {
938 let mut ch = ErroringStatusChannel;
939 ch.send_status_best_effort("hello").await;
941 }
942
943 #[tokio::test]
948 #[tracing_test::traced_test]
949 async fn send_status_best_effort_warns_on_error() {
950 let mut ch = ErroringStatusChannel;
951 ch.send_status_best_effort("hello").await;
952 assert!(
953 logs_contain("channel status send failed"),
954 "expected a tracing::warn! logging the send_status error"
955 );
956 }
957
958 #[tokio::test]
959 #[tracing_test::traced_test]
960 async fn send_status_best_effort_debug_logs_on_success() {
961 let mut ch = StubChannel;
962 ch.send_status_best_effort("hello").await;
963 assert!(
964 logs_contain("channel status sent"),
965 "expected a tracing::debug! logging the successful send_status"
966 );
967 }
968
969 struct HangingStatusChannel;
970
971 impl Channel for HangingStatusChannel {
972 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
973 Ok(None)
974 }
975
976 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
977 Ok(())
978 }
979
980 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
981 Ok(())
982 }
983
984 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
985 Ok(())
986 }
987
988 async fn send_status(&mut self, _text: &str) -> Result<(), ChannelError> {
989 std::future::pending().await
990 }
991 }
992
993 #[tokio::test(start_paused = true)]
997 async fn send_status_best_effort_times_out_instead_of_hanging() {
998 let mut ch = HangingStatusChannel;
999 let call = ch.send_status_best_effort("hello");
1000 tokio::pin!(call);
1001
1002 assert!(
1004 futures::poll!(&mut call).is_pending(),
1005 "expected send_status_best_effort to still be pending immediately"
1006 );
1007
1008 tokio::time::advance(STATUS_SEND_TIMEOUT + std::time::Duration::from_secs(1)).await;
1009
1010 tokio::time::timeout(std::time::Duration::from_secs(1), call)
1012 .await
1013 .expect("send_status_best_effort must resolve once STATUS_SEND_TIMEOUT elapses");
1014 }
1015
1016 #[tokio::test(start_paused = true)]
1017 #[tracing_test::traced_test]
1018 async fn send_status_best_effort_warns_on_timeout() {
1019 let mut ch = HangingStatusChannel;
1020 let call = ch.send_status_best_effort("hello");
1021 tokio::pin!(call);
1022 let _ = futures::poll!(&mut call);
1023
1024 tokio::time::advance(STATUS_SEND_TIMEOUT + std::time::Duration::from_secs(1)).await;
1025 call.await;
1026
1027 assert!(
1028 logs_contain("channel status send timed out"),
1029 "expected a tracing::warn! logging the send_status timeout"
1030 );
1031 }
1032
1033 #[test]
1034 fn channel_message_clone() {
1035 let msg = ChannelMessage {
1036 text: "test".to_string(),
1037 attachments: vec![],
1038 is_guest_context: false,
1039 is_from_bot: false,
1040 };
1041 let cloned = msg.clone();
1042 assert_eq!(cloned.text, "test");
1043 }
1044
1045 #[test]
1046 fn channel_message_debug() {
1047 let msg = ChannelMessage {
1048 text: "debug".to_string(),
1049 attachments: vec![],
1050 is_guest_context: false,
1051 is_from_bot: false,
1052 };
1053 let debug = format!("{msg:?}");
1054 assert!(debug.contains("debug"));
1055 }
1056
1057 #[test]
1058 fn attachment_kind_equality() {
1059 assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
1060 assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
1061 }
1062
1063 #[test]
1064 fn attachment_construction() {
1065 let a = Attachment {
1066 kind: AttachmentKind::Audio,
1067 data: vec![0, 1, 2],
1068 filename: Some("test.wav".into()),
1069 };
1070 assert_eq!(a.kind, AttachmentKind::Audio);
1071 assert_eq!(a.data.len(), 3);
1072 assert_eq!(a.filename.as_deref(), Some("test.wav"));
1073 }
1074
1075 #[test]
1076 fn channel_message_with_attachments() {
1077 let msg = ChannelMessage {
1078 text: String::new(),
1079 attachments: vec![Attachment {
1080 kind: AttachmentKind::Audio,
1081 data: vec![42],
1082 filename: None,
1083 }],
1084 is_guest_context: false,
1085 is_from_bot: false,
1086 };
1087 assert_eq!(msg.attachments.len(), 1);
1088 assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
1089 }
1090
1091 #[test]
1092 fn stub_channel_try_recv_returns_none() {
1093 let mut ch = StubChannel;
1094 assert!(ch.try_recv().is_none());
1095 }
1096
1097 #[tokio::test]
1098 async fn stub_channel_send_queue_count_noop() {
1099 let mut ch = StubChannel;
1100 ch.send_queue_count(5).await.unwrap();
1101 }
1102
1103 #[test]
1106 fn loopback_pair_returns_linked_handles() {
1107 let (channel, handle) = LoopbackChannel::pair(8);
1108 drop(channel);
1110 drop(handle);
1111 }
1112
1113 #[tokio::test]
1114 async fn loopback_cancel_signal_can_be_notified_and_awaited() {
1115 let (_channel, handle) = LoopbackChannel::pair(8);
1116 let signal = std::sync::Arc::clone(&handle.cancel_signal);
1117 let notified = signal.notified();
1119 handle.cancel_signal.notify_one();
1120 notified.await; }
1122
1123 #[tokio::test]
1124 async fn loopback_cancel_signal_shared_across_clones() {
1125 let (_channel, handle) = LoopbackChannel::pair(8);
1126 let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
1127 let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
1128 let notified = signal_b.notified();
1129 signal_a.notify_one();
1130 notified.await;
1131 }
1132
1133 #[tokio::test]
1134 async fn loopback_send_recv_round_trip() {
1135 let (mut channel, handle) = LoopbackChannel::pair(8);
1136 handle
1137 .input_tx
1138 .send(ChannelMessage {
1139 text: "hello".to_owned(),
1140 attachments: vec![],
1141 is_guest_context: false,
1142 is_from_bot: false,
1143 })
1144 .await
1145 .unwrap();
1146 let msg = channel.recv().await.unwrap().unwrap();
1147 assert_eq!(msg.text, "hello");
1148 }
1149
1150 #[tokio::test]
1151 async fn loopback_recv_returns_none_when_handle_dropped() {
1152 let (mut channel, handle) = LoopbackChannel::pair(8);
1153 drop(handle);
1154 let result = channel.recv().await.unwrap();
1155 assert!(result.is_none());
1156 }
1157
1158 #[tokio::test]
1159 async fn loopback_send_produces_full_message_event() {
1160 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1161 channel.send("world").await.unwrap();
1162 let event = handle.output_rx.recv().await.unwrap();
1163 assert_matches!(event, LoopbackEvent::FullMessage(t) if t == "world");
1164 }
1165
1166 #[tokio::test]
1167 async fn loopback_send_chunk_then_flush() {
1168 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1169 channel.send_chunk("part1").await.unwrap();
1170 channel.flush_chunks().await.unwrap();
1171 let ev1 = handle.output_rx.recv().await.unwrap();
1172 let ev2 = handle.output_rx.recv().await.unwrap();
1173 assert_matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1");
1174 assert_matches!(ev2, LoopbackEvent::Flush);
1175 }
1176
1177 #[tokio::test]
1178 async fn loopback_send_tool_output() {
1179 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1180 channel
1181 .send_tool_output(ToolOutputEvent {
1182 tool_name: "bash".into(),
1183 display: "exit 0".into(),
1184 diff: None,
1185 filter_stats: None,
1186 kept_lines: None,
1187 locations: None,
1188 tool_call_id: String::new(),
1189 terminal_id: None,
1190 is_error: false,
1191 parent_tool_use_id: None,
1192 raw_response: None,
1193 started_at: None,
1194 })
1195 .await
1196 .unwrap();
1197 let event = handle.output_rx.recv().await.unwrap();
1198 match event {
1199 LoopbackEvent::ToolOutput(data) => {
1200 assert_eq!(data.tool_name, "bash");
1201 assert_eq!(data.display, "exit 0");
1202 assert!(data.diff.is_none());
1203 assert!(data.filter_stats.is_none());
1204 assert!(data.kept_lines.is_none());
1205 assert!(data.locations.is_none());
1206 assert_eq!(data.tool_call_id, "");
1207 assert!(!data.is_error);
1208 assert!(data.terminal_id.is_none());
1209 assert!(data.parent_tool_use_id.is_none());
1210 assert!(data.raw_response.is_none());
1211 }
1212 _ => panic!("expected ToolOutput event"),
1213 }
1214 }
1215
1216 #[tokio::test]
1217 async fn loopback_confirm_auto_approves() {
1218 let (mut channel, _handle) = LoopbackChannel::pair(8);
1219 let result = channel.confirm("are you sure?").await.unwrap();
1220 assert!(result);
1221 }
1222
1223 #[tokio::test]
1224 async fn loopback_send_error_when_output_closed() {
1225 let (mut channel, handle) = LoopbackChannel::pair(8);
1226 drop(handle);
1228 let result = channel.send("too late").await;
1229 assert_matches!(result, Err(ChannelError::ChannelClosed));
1230 }
1231
1232 #[tokio::test]
1233 async fn loopback_send_chunk_error_when_output_closed() {
1234 let (mut channel, handle) = LoopbackChannel::pair(8);
1235 drop(handle);
1236 let result = channel.send_chunk("chunk").await;
1237 assert_matches!(result, Err(ChannelError::ChannelClosed));
1238 }
1239
1240 #[tokio::test]
1241 async fn loopback_flush_error_when_output_closed() {
1242 let (mut channel, handle) = LoopbackChannel::pair(8);
1243 drop(handle);
1244 let result = channel.flush_chunks().await;
1245 assert_matches!(result, Err(ChannelError::ChannelClosed));
1246 }
1247
1248 #[tokio::test]
1249 async fn loopback_send_status_event() {
1250 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1251 channel.send_status("working...").await.unwrap();
1252 let event = handle.output_rx.recv().await.unwrap();
1253 assert_matches!(event, LoopbackEvent::Status(s) if s == "working...");
1254 }
1255
1256 #[tokio::test]
1257 async fn loopback_send_usage_produces_usage_event() {
1258 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1259 channel
1260 .send_usage(100, 50, 200_000, 10, 5, 1.5)
1261 .await
1262 .unwrap();
1263 let event = handle.output_rx.recv().await.unwrap();
1264 match event {
1265 LoopbackEvent::Usage {
1266 input_tokens,
1267 output_tokens,
1268 context_window,
1269 cache_read_tokens,
1270 cache_write_tokens,
1271 cost_cents,
1272 } => {
1273 assert_eq!(input_tokens, 100);
1274 assert_eq!(output_tokens, 50);
1275 assert_eq!(context_window, 200_000);
1276 assert_eq!(cache_read_tokens, 10);
1277 assert_eq!(cache_write_tokens, 5);
1278 assert!((cost_cents - 1.5).abs() < f64::EPSILON);
1279 }
1280 _ => panic!("expected Usage event"),
1281 }
1282 }
1283
1284 #[tokio::test]
1285 async fn loopback_send_usage_error_when_closed() {
1286 let (mut channel, handle) = LoopbackChannel::pair(8);
1287 drop(handle);
1288 let result = channel.send_usage(1, 2, 3, 0, 0, 0.0).await;
1289 assert_matches!(result, Err(ChannelError::ChannelClosed));
1290 }
1291
1292 #[test]
1293 fn plan_item_status_variants_are_distinct() {
1294 assert!(!matches!(
1295 PlanItemStatus::Pending,
1296 PlanItemStatus::InProgress
1297 ));
1298 assert!(!matches!(
1299 PlanItemStatus::InProgress,
1300 PlanItemStatus::Completed
1301 ));
1302 assert!(!matches!(
1303 PlanItemStatus::Completed,
1304 PlanItemStatus::Pending
1305 ));
1306 }
1307
1308 #[test]
1309 fn loopback_event_session_title_carries_string() {
1310 let event = LoopbackEvent::SessionTitle("hello".to_owned());
1311 assert_matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello");
1312 }
1313
1314 #[test]
1315 fn loopback_event_plan_carries_entries() {
1316 let entries = vec![
1317 ("step 1".to_owned(), PlanItemStatus::Pending),
1318 ("step 2".to_owned(), PlanItemStatus::InProgress),
1319 ];
1320 let event = LoopbackEvent::Plan(entries);
1321 match event {
1322 LoopbackEvent::Plan(e) => {
1323 assert_eq!(e.len(), 2);
1324 assert_matches!(e[0].1, PlanItemStatus::Pending);
1325 assert_matches!(e[1].1, PlanItemStatus::InProgress);
1326 }
1327 _ => panic!("expected Plan event"),
1328 }
1329 }
1330
1331 #[tokio::test]
1332 async fn loopback_send_tool_start_produces_tool_start_event() {
1333 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1334 channel
1335 .send_tool_start(ToolStartEvent {
1336 tool_name: "shell".into(),
1337 tool_call_id: "tc-001".into(),
1338 params: Some(serde_json::json!({"command": "ls"})),
1339 parent_tool_use_id: None,
1340 started_at: std::time::Instant::now(),
1341 speculative: false,
1342 sandbox_profile: None,
1343 is_mcp: false,
1344 })
1345 .await
1346 .unwrap();
1347 let event = handle.output_rx.recv().await.unwrap();
1348 match event {
1349 LoopbackEvent::ToolStart(data) => {
1350 assert_eq!(data.tool_name.as_str(), "shell");
1351 assert_eq!(data.tool_call_id.as_str(), "tc-001");
1352 assert!(data.params.is_some());
1353 assert!(data.parent_tool_use_id.is_none());
1354 }
1355 _ => panic!("expected ToolStart event"),
1356 }
1357 }
1358
1359 #[tokio::test]
1360 async fn loopback_send_tool_start_with_parent_id() {
1361 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1362 channel
1363 .send_tool_start(ToolStartEvent {
1364 tool_name: "web".into(),
1365 tool_call_id: "tc-002".into(),
1366 params: None,
1367 parent_tool_use_id: Some("parent-123".into()),
1368 started_at: std::time::Instant::now(),
1369 speculative: false,
1370 sandbox_profile: None,
1371 is_mcp: false,
1372 })
1373 .await
1374 .unwrap();
1375 let event = handle.output_rx.recv().await.unwrap();
1376 assert_matches!(
1377 event,
1378 LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
1379 );
1380 }
1381
1382 #[tokio::test]
1383 async fn loopback_send_tool_start_error_when_output_closed() {
1384 let (mut channel, handle) = LoopbackChannel::pair(8);
1385 drop(handle);
1386 let result = channel
1387 .send_tool_start(ToolStartEvent {
1388 tool_name: "shell".into(),
1389 tool_call_id: "tc-003".into(),
1390 params: None,
1391 parent_tool_use_id: None,
1392 started_at: std::time::Instant::now(),
1393 speculative: false,
1394 sandbox_profile: None,
1395 is_mcp: false,
1396 })
1397 .await;
1398 assert_matches!(result, Err(ChannelError::ChannelClosed));
1399 }
1400
1401 #[tokio::test]
1402 async fn default_send_tool_output_formats_message() {
1403 let mut ch = StubChannel;
1404 ch.send_tool_output(ToolOutputEvent {
1406 tool_name: "bash".into(),
1407 display: "hello".into(),
1408 diff: None,
1409 filter_stats: None,
1410 kept_lines: None,
1411 locations: None,
1412 tool_call_id: "id".into(),
1413 terminal_id: None,
1414 is_error: false,
1415 parent_tool_use_id: None,
1416 raw_response: None,
1417 started_at: None,
1418 })
1419 .await
1420 .unwrap();
1421 }
1422}