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 pub owner_key: Option<String>,
213}
214
215const STATUS_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
229
230pub trait Channel: Send {
247 fn recv(&mut self)
253 -> impl Future<Output = Result<Option<ChannelMessage>, ChannelError>> + Send;
254
255 fn try_recv(&mut self) -> Option<ChannelMessage> {
257 None
258 }
259
260 fn supports_exit(&self) -> bool {
265 true
266 }
267
268 fn requires_input_sanitization(&self) -> bool {
290 false
291 }
292
293 fn send(&mut self, text: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
299
300 fn send_chunk(&mut self, chunk: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
306
307 fn flush_chunks(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send;
313
314 fn send_typing(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send {
320 async { Ok(()) }
321 }
322
323 fn send_status(
329 &mut self,
330 _text: &str,
331 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
332 async { Ok(()) }
333 }
334
335 fn send_transcript_backfill(
348 &mut self,
349 entries: &[zeph_commands::TranscriptEntry],
350 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
351 let text = zeph_commands::TranscriptFormatter::render_flat(entries);
352 async move { self.send(&text).await }
353 }
354
355 fn send_resume_banner(
368 &mut self,
369 text: &str,
370 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
371 async move { self.send(text).await }
372 }
373
374 fn send_status_best_effort(&mut self, text: &str) -> impl Future<Output = ()> + Send {
383 async move {
384 match tokio::time::timeout(STATUS_SEND_TIMEOUT, self.send_status(text)).await {
385 Ok(Ok(())) => tracing::debug!(text, "channel status sent"),
386 Ok(Err(error)) => tracing::warn!(%error, text, "channel status send failed"),
387 Err(_) => tracing::warn!(
388 text,
389 timeout_secs = STATUS_SEND_TIMEOUT.as_secs(),
390 "channel status send timed out"
391 ),
392 }
393 }
394 }
395
396 fn send_thinking_chunk(
402 &mut self,
403 _chunk: &str,
404 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
405 async { Ok(()) }
406 }
407
408 fn send_queue_count(
414 &mut self,
415 _count: usize,
416 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
417 async { Ok(()) }
418 }
419
420 fn send_context_estimate(
428 &mut self,
429 _tokens: usize,
430 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
431 async { Ok(()) }
432 }
433
434 fn send_usage(
443 &mut self,
444 _input_tokens: u64,
445 _output_tokens: u64,
446 _context_window: u64,
447 _cache_read_tokens: u64,
448 _cache_write_tokens: u64,
449 _cost_cents: f64,
450 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
451 async { Ok(()) }
452 }
453
454 fn send_diff(
463 &mut self,
464 _diff: crate::DiffData,
465 _tool_call_id: &str,
466 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
467 async { Ok(()) }
468 }
469
470 fn send_tool_start(
480 &mut self,
481 _event: ToolStartEvent,
482 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
483 async { Ok(()) }
484 }
485
486 fn send_tool_output(
496 &mut self,
497 event: ToolOutputEvent,
498 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
499 let formatted = crate::agent::format_tool_output(event.tool_name.as_str(), &event.display);
500 async move { self.send(&formatted).await }
501 }
502
503 fn confirm(
510 &mut self,
511 _prompt: &str,
512 ) -> impl Future<Output = Result<bool, ChannelError>> + Send {
513 async { Ok(true) }
514 }
515
516 fn elicit(
525 &mut self,
526 _request: ElicitationRequest,
527 ) -> impl Future<Output = Result<ElicitationResponse, ChannelError>> + Send {
528 async { Ok(ElicitationResponse::Declined) }
529 }
530
531 fn send_stop_hint(
540 &mut self,
541 _hint: StopHint,
542 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
543 async { Ok(()) }
544 }
545
546 fn notify_foreground_subagent_started(
556 &mut self,
557 _id: &str,
558 _name: &str,
559 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
560 async { Ok(()) }
561 }
562
563 fn notify_foreground_subagent_completed(
573 &mut self,
574 _id: &str,
575 _name: &str,
576 _success: bool,
577 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
578 async { Ok(()) }
579 }
580}
581
582pub use zeph_common::StopHint;
583
584#[derive(Debug, Clone)]
589pub struct ToolStartEvent {
590 pub tool_name: zeph_common::ToolName,
592 pub tool_call_id: String,
594 pub params: Option<serde_json::Value>,
596 pub parent_tool_use_id: Option<String>,
598 pub started_at: std::time::Instant,
600 pub speculative: bool,
604 pub sandbox_profile: Option<zeph_tools::SandboxProfile>,
608 pub is_mcp: bool,
610}
611
612#[derive(Debug, Clone)]
617pub struct ToolOutputEvent {
618 pub tool_name: zeph_common::ToolName,
620 pub display: String,
622 pub diff: Option<crate::DiffData>,
624 pub filter_stats: Option<String>,
626 pub kept_lines: Option<Vec<usize>>,
628 pub locations: Option<Vec<String>>,
630 pub tool_call_id: String,
632 pub is_error: bool,
634 pub terminal_id: Option<String>,
636 pub parent_tool_use_id: Option<String>,
638 pub raw_response: Option<serde_json::Value>,
640 pub started_at: Option<std::time::Instant>,
642}
643
644pub type ToolStartData = ToolStartEvent;
648
649pub type ToolOutputData = ToolOutputEvent;
653
654#[non_exhaustive]
655#[derive(Debug, Clone)]
657pub enum LoopbackEvent {
658 Chunk(String),
659 Flush,
660 FullMessage(String),
661 Status(String),
662 ToolStart(Box<ToolStartEvent>),
664 ToolOutput(Box<ToolOutputEvent>),
665 Usage {
673 input_tokens: u64,
674 output_tokens: u64,
675 context_window: u64,
676 cache_read_tokens: u64,
678 cache_write_tokens: u64,
680 cost_cents: f64,
682 },
683 SessionTitle(String),
685 Plan(Vec<(String, PlanItemStatus)>),
687 ThinkingChunk(String),
689 Stop(StopHint),
693}
694
695#[non_exhaustive]
696#[derive(Debug, Clone)]
698pub enum PlanItemStatus {
699 Pending,
700 InProgress,
701 Completed,
702}
703
704pub struct LoopbackHandle {
706 pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
707 pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
708 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
710}
711
712pub struct LoopbackChannel {
714 input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
715 output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
716}
717
718impl LoopbackChannel {
719 #[must_use]
721 pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
722 let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
723 let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
724 let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
725 (
726 Self {
727 input_rx,
728 output_tx,
729 },
730 LoopbackHandle {
731 input_tx,
732 output_rx,
733 cancel_signal,
734 },
735 )
736 }
737}
738
739impl Channel for LoopbackChannel {
740 fn supports_exit(&self) -> bool {
741 false
742 }
743
744 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
745 Ok(self.input_rx.recv().await)
746 }
747
748 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
749 self.output_tx
750 .send(LoopbackEvent::FullMessage(text.to_owned()))
751 .await
752 .map_err(|_| ChannelError::ChannelClosed)
753 }
754
755 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
756 self.output_tx
757 .send(LoopbackEvent::Chunk(chunk.to_owned()))
758 .await
759 .map_err(|_| ChannelError::ChannelClosed)
760 }
761
762 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
763 self.output_tx
764 .send(LoopbackEvent::Flush)
765 .await
766 .map_err(|_| ChannelError::ChannelClosed)
767 }
768
769 async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
770 self.output_tx
771 .send(LoopbackEvent::Status(text.to_owned()))
772 .await
773 .map_err(|_| ChannelError::ChannelClosed)
774 }
775
776 async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
777 self.output_tx
778 .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
779 .await
780 .map_err(|_| ChannelError::ChannelClosed)
781 }
782
783 async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
784 self.output_tx
785 .send(LoopbackEvent::ToolStart(Box::new(event)))
786 .await
787 .map_err(|_| ChannelError::ChannelClosed)
788 }
789
790 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
791 self.output_tx
792 .send(LoopbackEvent::ToolOutput(Box::new(event)))
793 .await
794 .map_err(|_| ChannelError::ChannelClosed)
795 }
796
797 async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
798 Ok(true)
799 }
800
801 async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
802 self.output_tx
803 .send(LoopbackEvent::Stop(hint))
804 .await
805 .map_err(|_| ChannelError::ChannelClosed)
806 }
807
808 async fn send_usage(
809 &mut self,
810 input_tokens: u64,
811 output_tokens: u64,
812 context_window: u64,
813 cache_read_tokens: u64,
814 cache_write_tokens: u64,
815 cost_cents: f64,
816 ) -> Result<(), ChannelError> {
817 self.output_tx
818 .send(LoopbackEvent::Usage {
819 input_tokens,
820 output_tokens,
821 context_window,
822 cache_read_tokens,
823 cache_write_tokens,
824 cost_cents,
825 })
826 .await
827 .map_err(|_| ChannelError::ChannelClosed)
828 }
829}
830
831pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
836
837impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
838 fn send<'a>(
839 &'a mut self,
840 msg: &'a str,
841 ) -> std::pin::Pin<
842 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
843 > {
844 Box::pin(async move {
845 self.0
846 .send(msg)
847 .await
848 .map_err(zeph_commands::CommandError::new)
849 })
850 }
851
852 fn flush_chunks<'a>(
853 &'a mut self,
854 ) -> std::pin::Pin<
855 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
856 > {
857 Box::pin(async move {
858 self.0
859 .flush_chunks()
860 .await
861 .map_err(zeph_commands::CommandError::new)
862 })
863 }
864
865 fn send_queue_count<'a>(
866 &'a mut self,
867 count: usize,
868 ) -> std::pin::Pin<
869 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
870 > {
871 Box::pin(async move {
872 self.0
873 .send_queue_count(count)
874 .await
875 .map_err(zeph_commands::CommandError::new)
876 })
877 }
878
879 fn supports_exit(&self) -> bool {
880 self.0.supports_exit()
881 }
882
883 fn send_transcript<'a>(
884 &'a mut self,
885 entries: &'a [zeph_commands::TranscriptEntry],
886 ) -> std::pin::Pin<
887 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
888 > {
889 Box::pin(async move {
890 self.0
891 .send_transcript_backfill(entries)
892 .await
893 .map_err(zeph_commands::CommandError::new)
894 })
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901 use std::assert_matches;
902
903 #[test]
904 fn channel_message_creation() {
905 let msg = ChannelMessage {
906 text: "hello".to_string(),
907 attachments: vec![],
908 is_guest_context: false,
909 is_from_bot: false,
910 owner_key: None,
911 };
912 assert_eq!(msg.text, "hello");
913 assert!(msg.attachments.is_empty());
914 }
915
916 struct StubChannel;
917
918 impl Channel for StubChannel {
919 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
920 Ok(None)
921 }
922
923 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
924 Ok(())
925 }
926
927 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
928 Ok(())
929 }
930
931 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
932 Ok(())
933 }
934 }
935
936 #[tokio::test]
937 async fn send_chunk_default_is_noop() {
938 let mut ch = StubChannel;
939 ch.send_chunk("partial").await.unwrap();
940 }
941
942 #[tokio::test]
943 async fn flush_chunks_default_is_noop() {
944 let mut ch = StubChannel;
945 ch.flush_chunks().await.unwrap();
946 }
947
948 #[tokio::test]
949 async fn stub_channel_confirm_auto_approves() {
950 let mut ch = StubChannel;
951 let result = ch.confirm("Delete everything?").await.unwrap();
952 assert!(result);
953 }
954
955 #[tokio::test]
956 async fn stub_channel_send_typing_default() {
957 let mut ch = StubChannel;
958 ch.send_typing().await.unwrap();
959 }
960
961 #[tokio::test]
962 async fn stub_channel_recv_returns_none() {
963 let mut ch = StubChannel;
964 let msg = ch.recv().await.unwrap();
965 assert!(msg.is_none());
966 }
967
968 #[tokio::test]
969 async fn stub_channel_send_ok() {
970 let mut ch = StubChannel;
971 ch.send("hello").await.unwrap();
972 }
973
974 #[tokio::test]
975 async fn send_status_best_effort_succeeds_silently() {
976 let mut ch = StubChannel;
977 ch.send_status_best_effort("hello").await;
979 }
980
981 struct ErroringStatusChannel;
982
983 impl Channel for ErroringStatusChannel {
984 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
985 Ok(None)
986 }
987
988 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
989 Ok(())
990 }
991
992 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
993 Ok(())
994 }
995
996 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
997 Ok(())
998 }
999
1000 async fn send_status(&mut self, _text: &str) -> Result<(), ChannelError> {
1001 Err(ChannelError::ChannelClosed)
1002 }
1003 }
1004
1005 #[tokio::test]
1006 async fn send_status_best_effort_swallows_errors() {
1007 let mut ch = ErroringStatusChannel;
1008 ch.send_status_best_effort("hello").await;
1010 }
1011
1012 #[tokio::test]
1017 #[tracing_test::traced_test]
1018 async fn send_status_best_effort_warns_on_error() {
1019 let mut ch = ErroringStatusChannel;
1020 ch.send_status_best_effort("hello").await;
1021 assert!(
1022 logs_contain("channel status send failed"),
1023 "expected a tracing::warn! logging the send_status error"
1024 );
1025 }
1026
1027 #[tokio::test]
1028 #[tracing_test::traced_test]
1029 async fn send_status_best_effort_debug_logs_on_success() {
1030 let mut ch = StubChannel;
1031 ch.send_status_best_effort("hello").await;
1032 assert!(
1033 logs_contain("channel status sent"),
1034 "expected a tracing::debug! logging the successful send_status"
1035 );
1036 }
1037
1038 struct HangingStatusChannel;
1039
1040 impl Channel for HangingStatusChannel {
1041 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
1042 Ok(None)
1043 }
1044
1045 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
1046 Ok(())
1047 }
1048
1049 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
1050 Ok(())
1051 }
1052
1053 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
1054 Ok(())
1055 }
1056
1057 async fn send_status(&mut self, _text: &str) -> Result<(), ChannelError> {
1058 std::future::pending().await
1059 }
1060 }
1061
1062 #[tokio::test(start_paused = true)]
1066 async fn send_status_best_effort_times_out_instead_of_hanging() {
1067 let mut ch = HangingStatusChannel;
1068 let call = ch.send_status_best_effort("hello");
1069 tokio::pin!(call);
1070
1071 assert!(
1073 futures::poll!(&mut call).is_pending(),
1074 "expected send_status_best_effort to still be pending immediately"
1075 );
1076
1077 tokio::time::advance(STATUS_SEND_TIMEOUT + std::time::Duration::from_secs(1)).await;
1078
1079 tokio::time::timeout(std::time::Duration::from_secs(1), call)
1081 .await
1082 .expect("send_status_best_effort must resolve once STATUS_SEND_TIMEOUT elapses");
1083 }
1084
1085 #[tokio::test(start_paused = true)]
1086 #[tracing_test::traced_test]
1087 async fn send_status_best_effort_warns_on_timeout() {
1088 let mut ch = HangingStatusChannel;
1089 let call = ch.send_status_best_effort("hello");
1090 tokio::pin!(call);
1091 let _ = futures::poll!(&mut call);
1092
1093 tokio::time::advance(STATUS_SEND_TIMEOUT + std::time::Duration::from_secs(1)).await;
1094 call.await;
1095
1096 assert!(
1097 logs_contain("channel status send timed out"),
1098 "expected a tracing::warn! logging the send_status timeout"
1099 );
1100 }
1101
1102 #[test]
1103 fn channel_message_clone() {
1104 let msg = ChannelMessage {
1105 text: "test".to_string(),
1106 attachments: vec![],
1107 is_guest_context: false,
1108 is_from_bot: false,
1109 owner_key: None,
1110 };
1111 let cloned = msg.clone();
1112 assert_eq!(cloned.text, "test");
1113 }
1114
1115 #[test]
1116 fn channel_message_debug() {
1117 let msg = ChannelMessage {
1118 text: "debug".to_string(),
1119 attachments: vec![],
1120 is_guest_context: false,
1121 is_from_bot: false,
1122 owner_key: None,
1123 };
1124 let debug = format!("{msg:?}");
1125 assert!(debug.contains("debug"));
1126 }
1127
1128 #[test]
1129 fn attachment_kind_equality() {
1130 assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
1131 assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
1132 }
1133
1134 #[test]
1135 fn attachment_construction() {
1136 let a = Attachment {
1137 kind: AttachmentKind::Audio,
1138 data: vec![0, 1, 2],
1139 filename: Some("test.wav".into()),
1140 };
1141 assert_eq!(a.kind, AttachmentKind::Audio);
1142 assert_eq!(a.data.len(), 3);
1143 assert_eq!(a.filename.as_deref(), Some("test.wav"));
1144 }
1145
1146 #[test]
1147 fn channel_message_with_attachments() {
1148 let msg = ChannelMessage {
1149 text: String::new(),
1150 attachments: vec![Attachment {
1151 kind: AttachmentKind::Audio,
1152 data: vec![42],
1153 filename: None,
1154 }],
1155 is_guest_context: false,
1156 is_from_bot: false,
1157 owner_key: None,
1158 };
1159 assert_eq!(msg.attachments.len(), 1);
1160 assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
1161 }
1162
1163 #[test]
1164 fn stub_channel_try_recv_returns_none() {
1165 let mut ch = StubChannel;
1166 assert!(ch.try_recv().is_none());
1167 }
1168
1169 #[tokio::test]
1170 async fn stub_channel_send_queue_count_noop() {
1171 let mut ch = StubChannel;
1172 ch.send_queue_count(5).await.unwrap();
1173 }
1174
1175 #[test]
1178 fn loopback_pair_returns_linked_handles() {
1179 let (channel, handle) = LoopbackChannel::pair(8);
1180 drop(channel);
1182 drop(handle);
1183 }
1184
1185 #[tokio::test]
1186 async fn loopback_cancel_signal_can_be_notified_and_awaited() {
1187 let (_channel, handle) = LoopbackChannel::pair(8);
1188 let signal = std::sync::Arc::clone(&handle.cancel_signal);
1189 let notified = signal.notified();
1191 handle.cancel_signal.notify_one();
1192 notified.await; }
1194
1195 #[tokio::test]
1196 async fn loopback_cancel_signal_shared_across_clones() {
1197 let (_channel, handle) = LoopbackChannel::pair(8);
1198 let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
1199 let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
1200 let notified = signal_b.notified();
1201 signal_a.notify_one();
1202 notified.await;
1203 }
1204
1205 #[tokio::test]
1206 async fn loopback_send_recv_round_trip() {
1207 let (mut channel, handle) = LoopbackChannel::pair(8);
1208 handle
1209 .input_tx
1210 .send(ChannelMessage {
1211 text: "hello".to_owned(),
1212 attachments: vec![],
1213 is_guest_context: false,
1214 is_from_bot: false,
1215 owner_key: None,
1216 })
1217 .await
1218 .unwrap();
1219 let msg = channel.recv().await.unwrap().unwrap();
1220 assert_eq!(msg.text, "hello");
1221 }
1222
1223 #[tokio::test]
1224 async fn loopback_recv_returns_none_when_handle_dropped() {
1225 let (mut channel, handle) = LoopbackChannel::pair(8);
1226 drop(handle);
1227 let result = channel.recv().await.unwrap();
1228 assert!(result.is_none());
1229 }
1230
1231 #[tokio::test]
1232 async fn loopback_send_produces_full_message_event() {
1233 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1234 channel.send("world").await.unwrap();
1235 let event = handle.output_rx.recv().await.unwrap();
1236 assert_matches!(event, LoopbackEvent::FullMessage(t) if t == "world");
1237 }
1238
1239 #[tokio::test]
1240 async fn loopback_send_chunk_then_flush() {
1241 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1242 channel.send_chunk("part1").await.unwrap();
1243 channel.flush_chunks().await.unwrap();
1244 let ev1 = handle.output_rx.recv().await.unwrap();
1245 let ev2 = handle.output_rx.recv().await.unwrap();
1246 assert_matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1");
1247 assert_matches!(ev2, LoopbackEvent::Flush);
1248 }
1249
1250 #[tokio::test]
1251 async fn loopback_send_tool_output() {
1252 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1253 channel
1254 .send_tool_output(ToolOutputEvent {
1255 tool_name: "bash".into(),
1256 display: "exit 0".into(),
1257 diff: None,
1258 filter_stats: None,
1259 kept_lines: None,
1260 locations: None,
1261 tool_call_id: String::new(),
1262 terminal_id: None,
1263 is_error: false,
1264 parent_tool_use_id: None,
1265 raw_response: None,
1266 started_at: None,
1267 })
1268 .await
1269 .unwrap();
1270 let event = handle.output_rx.recv().await.unwrap();
1271 match event {
1272 LoopbackEvent::ToolOutput(data) => {
1273 assert_eq!(data.tool_name, "bash");
1274 assert_eq!(data.display, "exit 0");
1275 assert!(data.diff.is_none());
1276 assert!(data.filter_stats.is_none());
1277 assert!(data.kept_lines.is_none());
1278 assert!(data.locations.is_none());
1279 assert_eq!(data.tool_call_id, "");
1280 assert!(!data.is_error);
1281 assert!(data.terminal_id.is_none());
1282 assert!(data.parent_tool_use_id.is_none());
1283 assert!(data.raw_response.is_none());
1284 }
1285 _ => panic!("expected ToolOutput event"),
1286 }
1287 }
1288
1289 #[tokio::test]
1290 async fn loopback_confirm_auto_approves() {
1291 let (mut channel, _handle) = LoopbackChannel::pair(8);
1292 let result = channel.confirm("are you sure?").await.unwrap();
1293 assert!(result);
1294 }
1295
1296 #[tokio::test]
1297 async fn loopback_send_error_when_output_closed() {
1298 let (mut channel, handle) = LoopbackChannel::pair(8);
1299 drop(handle);
1301 let result = channel.send("too late").await;
1302 assert_matches!(result, Err(ChannelError::ChannelClosed));
1303 }
1304
1305 #[tokio::test]
1306 async fn loopback_send_chunk_error_when_output_closed() {
1307 let (mut channel, handle) = LoopbackChannel::pair(8);
1308 drop(handle);
1309 let result = channel.send_chunk("chunk").await;
1310 assert_matches!(result, Err(ChannelError::ChannelClosed));
1311 }
1312
1313 #[tokio::test]
1314 async fn loopback_flush_error_when_output_closed() {
1315 let (mut channel, handle) = LoopbackChannel::pair(8);
1316 drop(handle);
1317 let result = channel.flush_chunks().await;
1318 assert_matches!(result, Err(ChannelError::ChannelClosed));
1319 }
1320
1321 #[tokio::test]
1322 async fn loopback_send_status_event() {
1323 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1324 channel.send_status("working...").await.unwrap();
1325 let event = handle.output_rx.recv().await.unwrap();
1326 assert_matches!(event, LoopbackEvent::Status(s) if s == "working...");
1327 }
1328
1329 #[tokio::test]
1330 async fn loopback_send_usage_produces_usage_event() {
1331 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1332 channel
1333 .send_usage(100, 50, 200_000, 10, 5, 1.5)
1334 .await
1335 .unwrap();
1336 let event = handle.output_rx.recv().await.unwrap();
1337 match event {
1338 LoopbackEvent::Usage {
1339 input_tokens,
1340 output_tokens,
1341 context_window,
1342 cache_read_tokens,
1343 cache_write_tokens,
1344 cost_cents,
1345 } => {
1346 assert_eq!(input_tokens, 100);
1347 assert_eq!(output_tokens, 50);
1348 assert_eq!(context_window, 200_000);
1349 assert_eq!(cache_read_tokens, 10);
1350 assert_eq!(cache_write_tokens, 5);
1351 assert!((cost_cents - 1.5).abs() < f64::EPSILON);
1352 }
1353 _ => panic!("expected Usage event"),
1354 }
1355 }
1356
1357 #[tokio::test]
1358 async fn loopback_send_usage_error_when_closed() {
1359 let (mut channel, handle) = LoopbackChannel::pair(8);
1360 drop(handle);
1361 let result = channel.send_usage(1, 2, 3, 0, 0, 0.0).await;
1362 assert_matches!(result, Err(ChannelError::ChannelClosed));
1363 }
1364
1365 #[test]
1366 fn plan_item_status_variants_are_distinct() {
1367 assert!(!matches!(
1368 PlanItemStatus::Pending,
1369 PlanItemStatus::InProgress
1370 ));
1371 assert!(!matches!(
1372 PlanItemStatus::InProgress,
1373 PlanItemStatus::Completed
1374 ));
1375 assert!(!matches!(
1376 PlanItemStatus::Completed,
1377 PlanItemStatus::Pending
1378 ));
1379 }
1380
1381 #[test]
1382 fn loopback_event_session_title_carries_string() {
1383 let event = LoopbackEvent::SessionTitle("hello".to_owned());
1384 assert_matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello");
1385 }
1386
1387 #[test]
1388 fn loopback_event_plan_carries_entries() {
1389 let entries = vec![
1390 ("step 1".to_owned(), PlanItemStatus::Pending),
1391 ("step 2".to_owned(), PlanItemStatus::InProgress),
1392 ];
1393 let event = LoopbackEvent::Plan(entries);
1394 match event {
1395 LoopbackEvent::Plan(e) => {
1396 assert_eq!(e.len(), 2);
1397 assert_matches!(e[0].1, PlanItemStatus::Pending);
1398 assert_matches!(e[1].1, PlanItemStatus::InProgress);
1399 }
1400 _ => panic!("expected Plan event"),
1401 }
1402 }
1403
1404 #[tokio::test]
1405 async fn loopback_send_tool_start_produces_tool_start_event() {
1406 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1407 channel
1408 .send_tool_start(ToolStartEvent {
1409 tool_name: "shell".into(),
1410 tool_call_id: "tc-001".into(),
1411 params: Some(serde_json::json!({"command": "ls"})),
1412 parent_tool_use_id: None,
1413 started_at: std::time::Instant::now(),
1414 speculative: false,
1415 sandbox_profile: None,
1416 is_mcp: false,
1417 })
1418 .await
1419 .unwrap();
1420 let event = handle.output_rx.recv().await.unwrap();
1421 match event {
1422 LoopbackEvent::ToolStart(data) => {
1423 assert_eq!(data.tool_name.as_str(), "shell");
1424 assert_eq!(data.tool_call_id.as_str(), "tc-001");
1425 assert!(data.params.is_some());
1426 assert!(data.parent_tool_use_id.is_none());
1427 }
1428 _ => panic!("expected ToolStart event"),
1429 }
1430 }
1431
1432 #[tokio::test]
1433 async fn loopback_send_tool_start_with_parent_id() {
1434 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1435 channel
1436 .send_tool_start(ToolStartEvent {
1437 tool_name: "web".into(),
1438 tool_call_id: "tc-002".into(),
1439 params: None,
1440 parent_tool_use_id: Some("parent-123".into()),
1441 started_at: std::time::Instant::now(),
1442 speculative: false,
1443 sandbox_profile: None,
1444 is_mcp: false,
1445 })
1446 .await
1447 .unwrap();
1448 let event = handle.output_rx.recv().await.unwrap();
1449 assert_matches!(
1450 event,
1451 LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
1452 );
1453 }
1454
1455 #[tokio::test]
1456 async fn loopback_send_tool_start_error_when_output_closed() {
1457 let (mut channel, handle) = LoopbackChannel::pair(8);
1458 drop(handle);
1459 let result = channel
1460 .send_tool_start(ToolStartEvent {
1461 tool_name: "shell".into(),
1462 tool_call_id: "tc-003".into(),
1463 params: None,
1464 parent_tool_use_id: None,
1465 started_at: std::time::Instant::now(),
1466 speculative: false,
1467 sandbox_profile: None,
1468 is_mcp: false,
1469 })
1470 .await;
1471 assert_matches!(result, Err(ChannelError::ChannelClosed));
1472 }
1473
1474 #[tokio::test]
1475 async fn default_send_tool_output_formats_message() {
1476 let mut ch = StubChannel;
1477 ch.send_tool_output(ToolOutputEvent {
1479 tool_name: "bash".into(),
1480 display: "hello".into(),
1481 diff: None,
1482 filter_stats: None,
1483 kept_lines: None,
1484 locations: None,
1485 tool_call_id: "id".into(),
1486 terminal_id: None,
1487 is_error: false,
1488 parent_tool_use_id: None,
1489 raw_response: None,
1490 started_at: None,
1491 })
1492 .await
1493 .unwrap();
1494 }
1495}