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 fn notify_background_subagent_completed(
596 &mut self,
597 _id: &str,
598 _name: &str,
599 _success: bool,
600 ) -> impl Future<Output = Result<(), ChannelError>> + Send {
601 async { Ok(()) }
602 }
603}
604
605pub use zeph_common::StopHint;
606
607#[derive(Debug, Clone)]
612pub struct ToolStartEvent {
613 pub tool_name: zeph_common::ToolName,
615 pub tool_call_id: String,
617 pub params: Option<serde_json::Value>,
619 pub parent_tool_use_id: Option<String>,
621 pub started_at: std::time::Instant,
623 pub speculative: bool,
627 pub sandbox_profile: Option<zeph_tools::SandboxProfile>,
631 pub is_mcp: bool,
633}
634
635#[derive(Debug, Clone)]
640pub struct ToolOutputEvent {
641 pub tool_name: zeph_common::ToolName,
643 pub display: String,
645 pub diff: Option<crate::DiffData>,
647 pub filter_stats: Option<String>,
649 pub kept_lines: Option<Vec<usize>>,
651 pub locations: Option<Vec<String>>,
653 pub tool_call_id: String,
655 pub is_error: bool,
657 pub terminal_id: Option<String>,
659 pub parent_tool_use_id: Option<String>,
661 pub raw_response: Option<serde_json::Value>,
663 pub started_at: Option<std::time::Instant>,
665}
666
667pub type ToolStartData = ToolStartEvent;
671
672pub type ToolOutputData = ToolOutputEvent;
676
677#[non_exhaustive]
678#[derive(Debug, Clone)]
680pub enum LoopbackEvent {
681 Chunk(String),
682 Flush,
683 FullMessage(String),
684 Status(String),
685 ToolStart(Box<ToolStartEvent>),
687 ToolOutput(Box<ToolOutputEvent>),
688 Usage {
696 input_tokens: u64,
697 output_tokens: u64,
698 context_window: u64,
699 cache_read_tokens: u64,
701 cache_write_tokens: u64,
703 cost_cents: f64,
705 },
706 SessionTitle(String),
708 Plan(Vec<(String, PlanItemStatus)>),
710 ThinkingChunk(String),
712 Stop(StopHint),
716}
717
718#[non_exhaustive]
719#[derive(Debug, Clone)]
721pub enum PlanItemStatus {
722 Pending,
723 InProgress,
724 Completed,
725}
726
727pub struct LoopbackHandle {
729 pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
730 pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
731 pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
733}
734
735pub struct LoopbackChannel {
737 input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
738 output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
739}
740
741impl LoopbackChannel {
742 #[must_use]
744 pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
745 let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
746 let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
747 let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
748 (
749 Self {
750 input_rx,
751 output_tx,
752 },
753 LoopbackHandle {
754 input_tx,
755 output_rx,
756 cancel_signal,
757 },
758 )
759 }
760}
761
762impl Channel for LoopbackChannel {
763 fn supports_exit(&self) -> bool {
764 false
765 }
766
767 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
768 Ok(self.input_rx.recv().await)
769 }
770
771 async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
772 self.output_tx
773 .send(LoopbackEvent::FullMessage(text.to_owned()))
774 .await
775 .map_err(|_| ChannelError::ChannelClosed)
776 }
777
778 async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
779 self.output_tx
780 .send(LoopbackEvent::Chunk(chunk.to_owned()))
781 .await
782 .map_err(|_| ChannelError::ChannelClosed)
783 }
784
785 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
786 self.output_tx
787 .send(LoopbackEvent::Flush)
788 .await
789 .map_err(|_| ChannelError::ChannelClosed)
790 }
791
792 async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
793 self.output_tx
794 .send(LoopbackEvent::Status(text.to_owned()))
795 .await
796 .map_err(|_| ChannelError::ChannelClosed)
797 }
798
799 async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
800 self.output_tx
801 .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
802 .await
803 .map_err(|_| ChannelError::ChannelClosed)
804 }
805
806 async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
807 self.output_tx
808 .send(LoopbackEvent::ToolStart(Box::new(event)))
809 .await
810 .map_err(|_| ChannelError::ChannelClosed)
811 }
812
813 async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
814 self.output_tx
815 .send(LoopbackEvent::ToolOutput(Box::new(event)))
816 .await
817 .map_err(|_| ChannelError::ChannelClosed)
818 }
819
820 async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
821 Ok(true)
822 }
823
824 async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
825 self.output_tx
826 .send(LoopbackEvent::Stop(hint))
827 .await
828 .map_err(|_| ChannelError::ChannelClosed)
829 }
830
831 async fn send_usage(
832 &mut self,
833 input_tokens: u64,
834 output_tokens: u64,
835 context_window: u64,
836 cache_read_tokens: u64,
837 cache_write_tokens: u64,
838 cost_cents: f64,
839 ) -> Result<(), ChannelError> {
840 self.output_tx
841 .send(LoopbackEvent::Usage {
842 input_tokens,
843 output_tokens,
844 context_window,
845 cache_read_tokens,
846 cache_write_tokens,
847 cost_cents,
848 })
849 .await
850 .map_err(|_| ChannelError::ChannelClosed)
851 }
852}
853
854pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
859
860impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
861 fn send<'a>(
862 &'a mut self,
863 msg: &'a str,
864 ) -> std::pin::Pin<
865 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
866 > {
867 Box::pin(async move {
868 self.0
869 .send(msg)
870 .await
871 .map_err(zeph_commands::CommandError::new)
872 })
873 }
874
875 fn flush_chunks<'a>(
876 &'a mut self,
877 ) -> std::pin::Pin<
878 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
879 > {
880 Box::pin(async move {
881 self.0
882 .flush_chunks()
883 .await
884 .map_err(zeph_commands::CommandError::new)
885 })
886 }
887
888 fn send_queue_count<'a>(
889 &'a mut self,
890 count: usize,
891 ) -> std::pin::Pin<
892 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
893 > {
894 Box::pin(async move {
895 self.0
896 .send_queue_count(count)
897 .await
898 .map_err(zeph_commands::CommandError::new)
899 })
900 }
901
902 fn supports_exit(&self) -> bool {
903 self.0.supports_exit()
904 }
905
906 fn send_transcript<'a>(
907 &'a mut self,
908 entries: &'a [zeph_commands::TranscriptEntry],
909 ) -> std::pin::Pin<
910 Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
911 > {
912 Box::pin(async move {
913 self.0
914 .send_transcript_backfill(entries)
915 .await
916 .map_err(zeph_commands::CommandError::new)
917 })
918 }
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924 use std::assert_matches;
925
926 #[test]
927 fn channel_message_creation() {
928 let msg = ChannelMessage {
929 text: "hello".to_string(),
930 attachments: vec![],
931 is_guest_context: false,
932 is_from_bot: false,
933 owner_key: None,
934 };
935 assert_eq!(msg.text, "hello");
936 assert!(msg.attachments.is_empty());
937 }
938
939 struct StubChannel;
940
941 impl Channel for StubChannel {
942 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
943 Ok(None)
944 }
945
946 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
947 Ok(())
948 }
949
950 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
951 Ok(())
952 }
953
954 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
955 Ok(())
956 }
957 }
958
959 #[tokio::test]
960 async fn send_chunk_default_is_noop() {
961 let mut ch = StubChannel;
962 ch.send_chunk("partial").await.unwrap();
963 }
964
965 #[tokio::test]
966 async fn flush_chunks_default_is_noop() {
967 let mut ch = StubChannel;
968 ch.flush_chunks().await.unwrap();
969 }
970
971 #[tokio::test]
972 async fn stub_channel_confirm_auto_approves() {
973 let mut ch = StubChannel;
974 let result = ch.confirm("Delete everything?").await.unwrap();
975 assert!(result);
976 }
977
978 #[tokio::test]
979 async fn stub_channel_send_typing_default() {
980 let mut ch = StubChannel;
981 ch.send_typing().await.unwrap();
982 }
983
984 #[tokio::test]
985 async fn stub_channel_recv_returns_none() {
986 let mut ch = StubChannel;
987 let msg = ch.recv().await.unwrap();
988 assert!(msg.is_none());
989 }
990
991 #[tokio::test]
992 async fn stub_channel_send_ok() {
993 let mut ch = StubChannel;
994 ch.send("hello").await.unwrap();
995 }
996
997 #[tokio::test]
998 async fn send_status_best_effort_succeeds_silently() {
999 let mut ch = StubChannel;
1000 ch.send_status_best_effort("hello").await;
1002 }
1003
1004 struct ErroringStatusChannel;
1005
1006 impl Channel for ErroringStatusChannel {
1007 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
1008 Ok(None)
1009 }
1010
1011 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
1012 Ok(())
1013 }
1014
1015 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
1016 Ok(())
1017 }
1018
1019 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
1020 Ok(())
1021 }
1022
1023 async fn send_status(&mut self, _text: &str) -> Result<(), ChannelError> {
1024 Err(ChannelError::ChannelClosed)
1025 }
1026 }
1027
1028 #[tokio::test]
1029 async fn send_status_best_effort_swallows_errors() {
1030 let mut ch = ErroringStatusChannel;
1031 ch.send_status_best_effort("hello").await;
1033 }
1034
1035 #[tokio::test]
1040 #[tracing_test::traced_test]
1041 async fn send_status_best_effort_warns_on_error() {
1042 let mut ch = ErroringStatusChannel;
1043 ch.send_status_best_effort("hello").await;
1044 assert!(
1045 logs_contain("channel status send failed"),
1046 "expected a tracing::warn! logging the send_status error"
1047 );
1048 }
1049
1050 #[tokio::test]
1051 #[tracing_test::traced_test]
1052 async fn send_status_best_effort_debug_logs_on_success() {
1053 let mut ch = StubChannel;
1054 ch.send_status_best_effort("hello").await;
1055 assert!(
1056 logs_contain("channel status sent"),
1057 "expected a tracing::debug! logging the successful send_status"
1058 );
1059 }
1060
1061 struct HangingStatusChannel;
1062
1063 impl Channel for HangingStatusChannel {
1064 async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
1065 Ok(None)
1066 }
1067
1068 async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
1069 Ok(())
1070 }
1071
1072 async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
1073 Ok(())
1074 }
1075
1076 async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
1077 Ok(())
1078 }
1079
1080 async fn send_status(&mut self, _text: &str) -> Result<(), ChannelError> {
1081 std::future::pending().await
1082 }
1083 }
1084
1085 #[tokio::test(start_paused = true)]
1089 async fn send_status_best_effort_times_out_instead_of_hanging() {
1090 let mut ch = HangingStatusChannel;
1091 let call = ch.send_status_best_effort("hello");
1092 tokio::pin!(call);
1093
1094 assert!(
1096 futures::poll!(&mut call).is_pending(),
1097 "expected send_status_best_effort to still be pending immediately"
1098 );
1099
1100 tokio::time::advance(STATUS_SEND_TIMEOUT + std::time::Duration::from_secs(1)).await;
1101
1102 tokio::time::timeout(std::time::Duration::from_secs(1), call)
1104 .await
1105 .expect("send_status_best_effort must resolve once STATUS_SEND_TIMEOUT elapses");
1106 }
1107
1108 #[tokio::test(start_paused = true)]
1109 #[tracing_test::traced_test]
1110 async fn send_status_best_effort_warns_on_timeout() {
1111 let mut ch = HangingStatusChannel;
1112 let call = ch.send_status_best_effort("hello");
1113 tokio::pin!(call);
1114 let _ = futures::poll!(&mut call);
1115
1116 tokio::time::advance(STATUS_SEND_TIMEOUT + std::time::Duration::from_secs(1)).await;
1117 call.await;
1118
1119 assert!(
1120 logs_contain("channel status send timed out"),
1121 "expected a tracing::warn! logging the send_status timeout"
1122 );
1123 }
1124
1125 #[test]
1126 fn channel_message_clone() {
1127 let msg = ChannelMessage {
1128 text: "test".to_string(),
1129 attachments: vec![],
1130 is_guest_context: false,
1131 is_from_bot: false,
1132 owner_key: None,
1133 };
1134 let cloned = msg.clone();
1135 assert_eq!(cloned.text, "test");
1136 }
1137
1138 #[test]
1139 fn channel_message_debug() {
1140 let msg = ChannelMessage {
1141 text: "debug".to_string(),
1142 attachments: vec![],
1143 is_guest_context: false,
1144 is_from_bot: false,
1145 owner_key: None,
1146 };
1147 let debug = format!("{msg:?}");
1148 assert!(debug.contains("debug"));
1149 }
1150
1151 #[test]
1152 fn attachment_kind_equality() {
1153 assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
1154 assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
1155 }
1156
1157 #[test]
1158 fn attachment_construction() {
1159 let a = Attachment {
1160 kind: AttachmentKind::Audio,
1161 data: vec![0, 1, 2],
1162 filename: Some("test.wav".into()),
1163 };
1164 assert_eq!(a.kind, AttachmentKind::Audio);
1165 assert_eq!(a.data.len(), 3);
1166 assert_eq!(a.filename.as_deref(), Some("test.wav"));
1167 }
1168
1169 #[test]
1170 fn channel_message_with_attachments() {
1171 let msg = ChannelMessage {
1172 text: String::new(),
1173 attachments: vec![Attachment {
1174 kind: AttachmentKind::Audio,
1175 data: vec![42],
1176 filename: None,
1177 }],
1178 is_guest_context: false,
1179 is_from_bot: false,
1180 owner_key: None,
1181 };
1182 assert_eq!(msg.attachments.len(), 1);
1183 assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
1184 }
1185
1186 #[test]
1187 fn stub_channel_try_recv_returns_none() {
1188 let mut ch = StubChannel;
1189 assert!(ch.try_recv().is_none());
1190 }
1191
1192 #[tokio::test]
1193 async fn stub_channel_send_queue_count_noop() {
1194 let mut ch = StubChannel;
1195 ch.send_queue_count(5).await.unwrap();
1196 }
1197
1198 #[test]
1201 fn loopback_pair_returns_linked_handles() {
1202 let (channel, handle) = LoopbackChannel::pair(8);
1203 drop(channel);
1205 drop(handle);
1206 }
1207
1208 #[tokio::test]
1209 async fn loopback_cancel_signal_can_be_notified_and_awaited() {
1210 let (_channel, handle) = LoopbackChannel::pair(8);
1211 let signal = std::sync::Arc::clone(&handle.cancel_signal);
1212 let notified = signal.notified();
1214 handle.cancel_signal.notify_one();
1215 notified.await; }
1217
1218 #[tokio::test]
1219 async fn loopback_cancel_signal_shared_across_clones() {
1220 let (_channel, handle) = LoopbackChannel::pair(8);
1221 let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
1222 let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
1223 let notified = signal_b.notified();
1224 signal_a.notify_one();
1225 notified.await;
1226 }
1227
1228 #[tokio::test]
1229 async fn loopback_send_recv_round_trip() {
1230 let (mut channel, handle) = LoopbackChannel::pair(8);
1231 handle
1232 .input_tx
1233 .send(ChannelMessage {
1234 text: "hello".to_owned(),
1235 attachments: vec![],
1236 is_guest_context: false,
1237 is_from_bot: false,
1238 owner_key: None,
1239 })
1240 .await
1241 .unwrap();
1242 let msg = channel.recv().await.unwrap().unwrap();
1243 assert_eq!(msg.text, "hello");
1244 }
1245
1246 #[tokio::test]
1247 async fn loopback_recv_returns_none_when_handle_dropped() {
1248 let (mut channel, handle) = LoopbackChannel::pair(8);
1249 drop(handle);
1250 let result = channel.recv().await.unwrap();
1251 assert!(result.is_none());
1252 }
1253
1254 #[tokio::test]
1255 async fn loopback_send_produces_full_message_event() {
1256 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1257 channel.send("world").await.unwrap();
1258 let event = handle.output_rx.recv().await.unwrap();
1259 assert_matches!(event, LoopbackEvent::FullMessage(t) if t == "world");
1260 }
1261
1262 #[tokio::test]
1263 async fn loopback_send_chunk_then_flush() {
1264 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1265 channel.send_chunk("part1").await.unwrap();
1266 channel.flush_chunks().await.unwrap();
1267 let ev1 = handle.output_rx.recv().await.unwrap();
1268 let ev2 = handle.output_rx.recv().await.unwrap();
1269 assert_matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1");
1270 assert_matches!(ev2, LoopbackEvent::Flush);
1271 }
1272
1273 #[tokio::test]
1274 async fn loopback_send_tool_output() {
1275 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1276 channel
1277 .send_tool_output(ToolOutputEvent {
1278 tool_name: "bash".into(),
1279 display: "exit 0".into(),
1280 diff: None,
1281 filter_stats: None,
1282 kept_lines: None,
1283 locations: None,
1284 tool_call_id: String::new(),
1285 terminal_id: None,
1286 is_error: false,
1287 parent_tool_use_id: None,
1288 raw_response: None,
1289 started_at: None,
1290 })
1291 .await
1292 .unwrap();
1293 let event = handle.output_rx.recv().await.unwrap();
1294 match event {
1295 LoopbackEvent::ToolOutput(data) => {
1296 assert_eq!(data.tool_name, "bash");
1297 assert_eq!(data.display, "exit 0");
1298 assert!(data.diff.is_none());
1299 assert!(data.filter_stats.is_none());
1300 assert!(data.kept_lines.is_none());
1301 assert!(data.locations.is_none());
1302 assert_eq!(data.tool_call_id, "");
1303 assert!(!data.is_error);
1304 assert!(data.terminal_id.is_none());
1305 assert!(data.parent_tool_use_id.is_none());
1306 assert!(data.raw_response.is_none());
1307 }
1308 _ => panic!("expected ToolOutput event"),
1309 }
1310 }
1311
1312 #[tokio::test]
1313 async fn loopback_confirm_auto_approves() {
1314 let (mut channel, _handle) = LoopbackChannel::pair(8);
1315 let result = channel.confirm("are you sure?").await.unwrap();
1316 assert!(result);
1317 }
1318
1319 #[tokio::test]
1320 async fn loopback_send_error_when_output_closed() {
1321 let (mut channel, handle) = LoopbackChannel::pair(8);
1322 drop(handle);
1324 let result = channel.send("too late").await;
1325 assert_matches!(result, Err(ChannelError::ChannelClosed));
1326 }
1327
1328 #[tokio::test]
1329 async fn loopback_send_chunk_error_when_output_closed() {
1330 let (mut channel, handle) = LoopbackChannel::pair(8);
1331 drop(handle);
1332 let result = channel.send_chunk("chunk").await;
1333 assert_matches!(result, Err(ChannelError::ChannelClosed));
1334 }
1335
1336 #[tokio::test]
1337 async fn loopback_flush_error_when_output_closed() {
1338 let (mut channel, handle) = LoopbackChannel::pair(8);
1339 drop(handle);
1340 let result = channel.flush_chunks().await;
1341 assert_matches!(result, Err(ChannelError::ChannelClosed));
1342 }
1343
1344 #[tokio::test]
1345 async fn loopback_send_status_event() {
1346 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1347 channel.send_status("working...").await.unwrap();
1348 let event = handle.output_rx.recv().await.unwrap();
1349 assert_matches!(event, LoopbackEvent::Status(s) if s == "working...");
1350 }
1351
1352 #[tokio::test]
1353 async fn loopback_send_usage_produces_usage_event() {
1354 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1355 channel
1356 .send_usage(100, 50, 200_000, 10, 5, 1.5)
1357 .await
1358 .unwrap();
1359 let event = handle.output_rx.recv().await.unwrap();
1360 match event {
1361 LoopbackEvent::Usage {
1362 input_tokens,
1363 output_tokens,
1364 context_window,
1365 cache_read_tokens,
1366 cache_write_tokens,
1367 cost_cents,
1368 } => {
1369 assert_eq!(input_tokens, 100);
1370 assert_eq!(output_tokens, 50);
1371 assert_eq!(context_window, 200_000);
1372 assert_eq!(cache_read_tokens, 10);
1373 assert_eq!(cache_write_tokens, 5);
1374 assert!((cost_cents - 1.5).abs() < f64::EPSILON);
1375 }
1376 _ => panic!("expected Usage event"),
1377 }
1378 }
1379
1380 #[tokio::test]
1381 async fn loopback_send_usage_error_when_closed() {
1382 let (mut channel, handle) = LoopbackChannel::pair(8);
1383 drop(handle);
1384 let result = channel.send_usage(1, 2, 3, 0, 0, 0.0).await;
1385 assert_matches!(result, Err(ChannelError::ChannelClosed));
1386 }
1387
1388 #[test]
1389 fn plan_item_status_variants_are_distinct() {
1390 assert!(!matches!(
1391 PlanItemStatus::Pending,
1392 PlanItemStatus::InProgress
1393 ));
1394 assert!(!matches!(
1395 PlanItemStatus::InProgress,
1396 PlanItemStatus::Completed
1397 ));
1398 assert!(!matches!(
1399 PlanItemStatus::Completed,
1400 PlanItemStatus::Pending
1401 ));
1402 }
1403
1404 #[test]
1405 fn loopback_event_session_title_carries_string() {
1406 let event = LoopbackEvent::SessionTitle("hello".to_owned());
1407 assert_matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello");
1408 }
1409
1410 #[test]
1411 fn loopback_event_plan_carries_entries() {
1412 let entries = vec![
1413 ("step 1".to_owned(), PlanItemStatus::Pending),
1414 ("step 2".to_owned(), PlanItemStatus::InProgress),
1415 ];
1416 let event = LoopbackEvent::Plan(entries);
1417 match event {
1418 LoopbackEvent::Plan(e) => {
1419 assert_eq!(e.len(), 2);
1420 assert_matches!(e[0].1, PlanItemStatus::Pending);
1421 assert_matches!(e[1].1, PlanItemStatus::InProgress);
1422 }
1423 _ => panic!("expected Plan event"),
1424 }
1425 }
1426
1427 #[tokio::test]
1428 async fn loopback_send_tool_start_produces_tool_start_event() {
1429 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1430 channel
1431 .send_tool_start(ToolStartEvent {
1432 tool_name: "shell".into(),
1433 tool_call_id: "tc-001".into(),
1434 params: Some(serde_json::json!({"command": "ls"})),
1435 parent_tool_use_id: None,
1436 started_at: std::time::Instant::now(),
1437 speculative: false,
1438 sandbox_profile: None,
1439 is_mcp: false,
1440 })
1441 .await
1442 .unwrap();
1443 let event = handle.output_rx.recv().await.unwrap();
1444 match event {
1445 LoopbackEvent::ToolStart(data) => {
1446 assert_eq!(data.tool_name.as_str(), "shell");
1447 assert_eq!(data.tool_call_id.as_str(), "tc-001");
1448 assert!(data.params.is_some());
1449 assert!(data.parent_tool_use_id.is_none());
1450 }
1451 _ => panic!("expected ToolStart event"),
1452 }
1453 }
1454
1455 #[tokio::test]
1456 async fn loopback_send_tool_start_with_parent_id() {
1457 let (mut channel, mut handle) = LoopbackChannel::pair(8);
1458 channel
1459 .send_tool_start(ToolStartEvent {
1460 tool_name: "web".into(),
1461 tool_call_id: "tc-002".into(),
1462 params: None,
1463 parent_tool_use_id: Some("parent-123".into()),
1464 started_at: std::time::Instant::now(),
1465 speculative: false,
1466 sandbox_profile: None,
1467 is_mcp: false,
1468 })
1469 .await
1470 .unwrap();
1471 let event = handle.output_rx.recv().await.unwrap();
1472 assert_matches!(
1473 event,
1474 LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
1475 );
1476 }
1477
1478 #[tokio::test]
1479 async fn loopback_send_tool_start_error_when_output_closed() {
1480 let (mut channel, handle) = LoopbackChannel::pair(8);
1481 drop(handle);
1482 let result = channel
1483 .send_tool_start(ToolStartEvent {
1484 tool_name: "shell".into(),
1485 tool_call_id: "tc-003".into(),
1486 params: None,
1487 parent_tool_use_id: None,
1488 started_at: std::time::Instant::now(),
1489 speculative: false,
1490 sandbox_profile: None,
1491 is_mcp: false,
1492 })
1493 .await;
1494 assert_matches!(result, Err(ChannelError::ChannelClosed));
1495 }
1496
1497 #[tokio::test]
1498 async fn default_send_tool_output_formats_message() {
1499 let mut ch = StubChannel;
1500 ch.send_tool_output(ToolOutputEvent {
1502 tool_name: "bash".into(),
1503 display: "hello".into(),
1504 diff: None,
1505 filter_stats: None,
1506 kept_lines: None,
1507 locations: None,
1508 tool_call_id: "id".into(),
1509 terminal_id: None,
1510 is_error: false,
1511 parent_tool_use_id: None,
1512 raw_response: None,
1513 started_at: None,
1514 })
1515 .await
1516 .unwrap();
1517 }
1518}