Skip to main content

zeph_core/
channel.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// Typed error for channel operations.
5#[derive(Debug, thiserror::Error)]
6pub enum ChannelError {
7    /// Underlying I/O failure.
8    #[error("I/O error: {0}")]
9    Io(#[from] std::io::Error),
10
11    /// Channel closed (mpsc send/recv failure).
12    #[error("channel closed")]
13    ChannelClosed,
14
15    /// Confirmation dialog cancelled.
16    #[error("confirmation cancelled")]
17    ConfirmCancelled,
18
19    /// Catch-all for provider-specific errors.
20    #[error("{0}")]
21    Other(String),
22}
23
24impl ChannelError {
25    pub fn other(e: impl std::fmt::Display) -> Self {
26        Self::Other(e.to_string())
27    }
28}
29
30/// All fields that describe a tool-start event sent to a channel.
31#[derive(Debug)]
32pub struct ToolStartEvent<'a> {
33    pub tool_name: &'a str,
34    pub tool_call_id: &'a str,
35    pub params: Option<serde_json::Value>,
36    pub parent_tool_use_id: Option<String>,
37}
38
39/// All fields that describe a completed tool-output event sent to a channel.
40#[derive(Debug)]
41pub struct ToolOutputEvent<'a> {
42    pub tool_name: &'a str,
43    pub body: &'a str,
44    pub diff: Option<crate::DiffData>,
45    pub filter_stats: Option<String>,
46    pub kept_lines: Option<Vec<usize>>,
47    pub locations: Option<Vec<String>>,
48    pub tool_call_id: &'a str,
49    pub is_error: bool,
50    pub parent_tool_use_id: Option<String>,
51    pub raw_response: Option<serde_json::Value>,
52    pub started_at: Option<std::time::Instant>,
53}
54
55/// Kind of binary attachment on an incoming message.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum AttachmentKind {
58    Audio,
59    Image,
60    Video,
61    File,
62}
63
64/// Binary attachment carried by a [`ChannelMessage`].
65#[derive(Debug, Clone)]
66pub struct Attachment {
67    pub kind: AttachmentKind,
68    pub data: Vec<u8>,
69    pub filename: Option<String>,
70}
71
72/// Incoming message from a channel.
73#[derive(Debug, Clone)]
74pub struct ChannelMessage {
75    pub text: String,
76    pub attachments: Vec<Attachment>,
77}
78
79/// Bidirectional communication channel for the agent.
80pub trait Channel: Send {
81    /// Receive the next message. Returns `None` on EOF or shutdown.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if the underlying I/O fails.
86    fn recv(&mut self)
87    -> impl Future<Output = Result<Option<ChannelMessage>, ChannelError>> + Send;
88
89    /// Non-blocking receive. Returns `None` if no message is immediately available.
90    fn try_recv(&mut self) -> Option<ChannelMessage> {
91        None
92    }
93
94    /// Whether `/exit` and `/quit` commands should terminate the agent loop.
95    ///
96    /// Returns `false` for persistent server-side channels (e.g. Telegram) where
97    /// breaking the loop would not meaningfully exit from the user's perspective.
98    fn supports_exit(&self) -> bool {
99        true
100    }
101
102    /// Send a text response.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if the underlying I/O fails.
107    fn send(&mut self, text: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
108
109    /// Send a partial chunk of streaming response.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if the underlying I/O fails.
114    fn send_chunk(&mut self, chunk: &str) -> impl Future<Output = Result<(), ChannelError>> + Send;
115
116    /// Flush any buffered chunks.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the underlying I/O fails.
121    fn flush_chunks(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send;
122
123    /// Send a typing indicator. No-op by default.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the underlying I/O fails.
128    fn send_typing(&mut self) -> impl Future<Output = Result<(), ChannelError>> + Send {
129        async { Ok(()) }
130    }
131
132    /// Send a status label (shown as spinner text in TUI). No-op by default.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the underlying I/O fails.
137    fn send_status(
138        &mut self,
139        _text: &str,
140    ) -> impl Future<Output = Result<(), ChannelError>> + Send {
141        async { Ok(()) }
142    }
143
144    /// Send a thinking/reasoning token chunk. No-op by default.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the underlying I/O fails.
149    fn send_thinking_chunk(
150        &mut self,
151        _chunk: &str,
152    ) -> impl Future<Output = Result<(), ChannelError>> + Send {
153        async { Ok(()) }
154    }
155
156    /// Notify channel of queued message count. No-op by default.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the underlying I/O fails.
161    fn send_queue_count(
162        &mut self,
163        _count: usize,
164    ) -> impl Future<Output = Result<(), ChannelError>> + Send {
165        async { Ok(()) }
166    }
167
168    /// Send token usage after an LLM call. No-op by default.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the underlying I/O fails.
173    fn send_usage(
174        &mut self,
175        _input_tokens: u64,
176        _output_tokens: u64,
177        _context_window: u64,
178    ) -> impl Future<Output = Result<(), ChannelError>> + Send {
179        async { Ok(()) }
180    }
181
182    /// Send diff data for a tool result. No-op by default (TUI overrides).
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the underlying I/O fails.
187    fn send_diff(
188        &mut self,
189        _diff: crate::DiffData,
190    ) -> impl Future<Output = Result<(), ChannelError>> + Send {
191        async { Ok(()) }
192    }
193
194    /// Announce that a tool call is starting.
195    ///
196    /// Emitted before execution begins so the transport layer can send an
197    /// `InProgress` status to the peer before the result arrives.
198    /// No-op by default.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if the underlying I/O fails.
203    fn send_tool_start(
204        &mut self,
205        _event: ToolStartEvent<'_>,
206    ) -> impl Future<Output = Result<(), ChannelError>> + Send {
207        async { Ok(()) }
208    }
209
210    /// Send a complete tool output with optional diff and filter stats atomically.
211    ///
212    /// `body` is the raw tool output content (no header). The default implementation
213    /// formats it with `[tool output: <name>]` prefix for human-readable channels.
214    /// Structured channels (e.g. `LoopbackChannel`) override this to emit a typed event
215    /// so consumers can access `tool_name` and `body` as separate fields.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the underlying I/O fails.
220    fn send_tool_output(
221        &mut self,
222        event: ToolOutputEvent<'_>,
223    ) -> impl Future<Output = Result<(), ChannelError>> + Send {
224        let formatted = crate::agent::format_tool_output(event.tool_name, event.body);
225        async move { self.send(&formatted).await }
226    }
227
228    /// Request user confirmation for a destructive action. Returns `true` if confirmed.
229    /// Default: auto-confirm (for headless/test scenarios).
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the underlying I/O fails.
234    fn confirm(
235        &mut self,
236        _prompt: &str,
237    ) -> impl Future<Output = Result<bool, ChannelError>> + Send {
238        async { Ok(true) }
239    }
240
241    /// Signal the non-default stop reason to the consumer before flushing.
242    ///
243    /// Called by the agent loop immediately before `flush_chunks()` when a
244    /// truncation or turn-limit condition is detected. No-op by default.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if the underlying I/O fails.
249    fn send_stop_hint(
250        &mut self,
251        _hint: StopHint,
252    ) -> impl Future<Output = Result<(), ChannelError>> + Send {
253        async { Ok(()) }
254    }
255}
256
257/// Reason why the agent turn ended — carried by [`LoopbackEvent::Stop`].
258///
259/// Emitted by the agent loop immediately before `Flush` when a non-default
260/// terminal condition is detected. Consumers (e.g. the ACP layer) map this to
261/// the protocol-level `StopReason`.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum StopHint {
264    /// The LLM response was cut off by the token limit.
265    MaxTokens,
266    /// The turn loop exhausted `max_turns` without a final text response.
267    MaxTurnRequests,
268}
269
270/// Data carried by a [`LoopbackEvent::ToolStart`] variant.
271#[derive(Debug, Clone)]
272pub struct ToolStartData {
273    pub tool_name: String,
274    pub tool_call_id: String,
275    /// Raw input parameters passed to the tool (e.g. `{"command": "..."}` for bash).
276    pub params: Option<serde_json::Value>,
277    /// Set when this tool call is made by a subagent; identifies the parent's `tool_call_id`.
278    pub parent_tool_use_id: Option<String>,
279    /// Wall-clock instant when the tool call was initiated; used to compute elapsed time.
280    pub started_at: std::time::Instant,
281}
282
283/// Data carried by a [`LoopbackEvent::ToolOutput`] variant.
284#[derive(Debug, Clone)]
285pub struct ToolOutputData {
286    pub tool_name: String,
287    pub display: String,
288    pub diff: Option<crate::DiffData>,
289    pub filter_stats: Option<String>,
290    pub kept_lines: Option<Vec<usize>>,
291    pub locations: Option<Vec<String>>,
292    pub tool_call_id: String,
293    pub is_error: bool,
294    /// Terminal ID for shell tool calls routed through the IDE terminal.
295    pub terminal_id: Option<String>,
296    /// Set when this tool output belongs to a subagent; identifies the parent's `tool_call_id`.
297    pub parent_tool_use_id: Option<String>,
298    /// Structured tool response payload for ACP intermediate `tool_call_update` notifications.
299    pub raw_response: Option<serde_json::Value>,
300    /// Wall-clock instant when the corresponding `ToolStart` was emitted; used for elapsed time.
301    pub started_at: Option<std::time::Instant>,
302}
303
304/// Events emitted by the agent side toward the A2A caller.
305#[derive(Debug, Clone)]
306pub enum LoopbackEvent {
307    Chunk(String),
308    Flush,
309    FullMessage(String),
310    Status(String),
311    /// Emitted immediately before tool execution begins.
312    ToolStart(Box<ToolStartData>),
313    ToolOutput(Box<ToolOutputData>),
314    /// Token usage from the last LLM turn.
315    Usage {
316        input_tokens: u64,
317        output_tokens: u64,
318        context_window: u64,
319    },
320    /// Generated session title (emitted after the first agent response).
321    SessionTitle(String),
322    /// Execution plan update.
323    Plan(Vec<(String, PlanItemStatus)>),
324    /// Thinking/reasoning token chunk from the LLM.
325    ThinkingChunk(String),
326    /// Non-default stop condition detected by the agent loop.
327    ///
328    /// Emitted immediately before `Flush`. When absent, the stop reason is `EndTurn`.
329    Stop(StopHint),
330}
331
332/// Status of a plan item, mirroring `acp::PlanEntryStatus`.
333#[derive(Debug, Clone)]
334pub enum PlanItemStatus {
335    Pending,
336    InProgress,
337    Completed,
338}
339
340/// Caller-side handle for sending input and receiving agent output.
341pub struct LoopbackHandle {
342    pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
343    pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
344    /// Shared cancel signal: notify to interrupt the agent's current operation.
345    pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
346}
347
348/// Headless channel bridging an A2A `TaskProcessor` with the agent loop.
349pub struct LoopbackChannel {
350    input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
351    output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
352}
353
354impl LoopbackChannel {
355    /// Create a linked `(LoopbackChannel, LoopbackHandle)` pair.
356    #[must_use]
357    pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
358        let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
359        let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
360        let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
361        (
362            Self {
363                input_rx,
364                output_tx,
365            },
366            LoopbackHandle {
367                input_tx,
368                output_rx,
369                cancel_signal,
370            },
371        )
372    }
373}
374
375impl Channel for LoopbackChannel {
376    fn supports_exit(&self) -> bool {
377        false
378    }
379
380    async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
381        Ok(self.input_rx.recv().await)
382    }
383
384    async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
385        self.output_tx
386            .send(LoopbackEvent::FullMessage(text.to_owned()))
387            .await
388            .map_err(|_| ChannelError::ChannelClosed)
389    }
390
391    async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
392        self.output_tx
393            .send(LoopbackEvent::Chunk(chunk.to_owned()))
394            .await
395            .map_err(|_| ChannelError::ChannelClosed)
396    }
397
398    async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
399        self.output_tx
400            .send(LoopbackEvent::Flush)
401            .await
402            .map_err(|_| ChannelError::ChannelClosed)
403    }
404
405    async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
406        self.output_tx
407            .send(LoopbackEvent::Status(text.to_owned()))
408            .await
409            .map_err(|_| ChannelError::ChannelClosed)
410    }
411
412    async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
413        self.output_tx
414            .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
415            .await
416            .map_err(|_| ChannelError::ChannelClosed)
417    }
418
419    async fn send_tool_start(&mut self, event: ToolStartEvent<'_>) -> Result<(), ChannelError> {
420        self.output_tx
421            .send(LoopbackEvent::ToolStart(Box::new(ToolStartData {
422                tool_name: event.tool_name.to_owned(),
423                tool_call_id: event.tool_call_id.to_owned(),
424                params: event.params,
425                parent_tool_use_id: event.parent_tool_use_id,
426                started_at: std::time::Instant::now(),
427            })))
428            .await
429            .map_err(|_| ChannelError::ChannelClosed)
430    }
431
432    async fn send_tool_output(&mut self, event: ToolOutputEvent<'_>) -> Result<(), ChannelError> {
433        self.output_tx
434            .send(LoopbackEvent::ToolOutput(Box::new(ToolOutputData {
435                tool_name: event.tool_name.to_owned(),
436                display: event.body.to_owned(),
437                diff: event.diff,
438                filter_stats: event.filter_stats,
439                kept_lines: event.kept_lines,
440                locations: event.locations,
441                tool_call_id: event.tool_call_id.to_owned(),
442                is_error: event.is_error,
443                terminal_id: None,
444                parent_tool_use_id: event.parent_tool_use_id,
445                raw_response: event.raw_response,
446                started_at: event.started_at,
447            })))
448            .await
449            .map_err(|_| ChannelError::ChannelClosed)
450    }
451
452    async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
453        Ok(true)
454    }
455
456    async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
457        self.output_tx
458            .send(LoopbackEvent::Stop(hint))
459            .await
460            .map_err(|_| ChannelError::ChannelClosed)
461    }
462
463    async fn send_usage(
464        &mut self,
465        input_tokens: u64,
466        output_tokens: u64,
467        context_window: u64,
468    ) -> Result<(), ChannelError> {
469        self.output_tx
470            .send(LoopbackEvent::Usage {
471                input_tokens,
472                output_tokens,
473                context_window,
474            })
475            .await
476            .map_err(|_| ChannelError::ChannelClosed)
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn channel_message_creation() {
486        let msg = ChannelMessage {
487            text: "hello".to_string(),
488            attachments: vec![],
489        };
490        assert_eq!(msg.text, "hello");
491        assert!(msg.attachments.is_empty());
492    }
493
494    struct StubChannel;
495
496    impl Channel for StubChannel {
497        async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
498            Ok(None)
499        }
500
501        async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
502            Ok(())
503        }
504
505        async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
506            Ok(())
507        }
508
509        async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
510            Ok(())
511        }
512    }
513
514    #[tokio::test]
515    async fn send_chunk_default_is_noop() {
516        let mut ch = StubChannel;
517        ch.send_chunk("partial").await.unwrap();
518    }
519
520    #[tokio::test]
521    async fn flush_chunks_default_is_noop() {
522        let mut ch = StubChannel;
523        ch.flush_chunks().await.unwrap();
524    }
525
526    #[tokio::test]
527    async fn stub_channel_confirm_auto_approves() {
528        let mut ch = StubChannel;
529        let result = ch.confirm("Delete everything?").await.unwrap();
530        assert!(result);
531    }
532
533    #[tokio::test]
534    async fn stub_channel_send_typing_default() {
535        let mut ch = StubChannel;
536        ch.send_typing().await.unwrap();
537    }
538
539    #[tokio::test]
540    async fn stub_channel_recv_returns_none() {
541        let mut ch = StubChannel;
542        let msg = ch.recv().await.unwrap();
543        assert!(msg.is_none());
544    }
545
546    #[tokio::test]
547    async fn stub_channel_send_ok() {
548        let mut ch = StubChannel;
549        ch.send("hello").await.unwrap();
550    }
551
552    #[test]
553    fn channel_message_clone() {
554        let msg = ChannelMessage {
555            text: "test".to_string(),
556            attachments: vec![],
557        };
558        let cloned = msg.clone();
559        assert_eq!(cloned.text, "test");
560    }
561
562    #[test]
563    fn channel_message_debug() {
564        let msg = ChannelMessage {
565            text: "debug".to_string(),
566            attachments: vec![],
567        };
568        let debug = format!("{msg:?}");
569        assert!(debug.contains("debug"));
570    }
571
572    #[test]
573    fn attachment_kind_equality() {
574        assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
575        assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
576    }
577
578    #[test]
579    fn attachment_construction() {
580        let a = Attachment {
581            kind: AttachmentKind::Audio,
582            data: vec![0, 1, 2],
583            filename: Some("test.wav".into()),
584        };
585        assert_eq!(a.kind, AttachmentKind::Audio);
586        assert_eq!(a.data.len(), 3);
587        assert_eq!(a.filename.as_deref(), Some("test.wav"));
588    }
589
590    #[test]
591    fn channel_message_with_attachments() {
592        let msg = ChannelMessage {
593            text: String::new(),
594            attachments: vec![Attachment {
595                kind: AttachmentKind::Audio,
596                data: vec![42],
597                filename: None,
598            }],
599        };
600        assert_eq!(msg.attachments.len(), 1);
601        assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
602    }
603
604    #[test]
605    fn stub_channel_try_recv_returns_none() {
606        let mut ch = StubChannel;
607        assert!(ch.try_recv().is_none());
608    }
609
610    #[tokio::test]
611    async fn stub_channel_send_queue_count_noop() {
612        let mut ch = StubChannel;
613        ch.send_queue_count(5).await.unwrap();
614    }
615
616    // LoopbackChannel tests
617
618    #[test]
619    fn loopback_pair_returns_linked_handles() {
620        let (channel, handle) = LoopbackChannel::pair(8);
621        // Both sides exist and channels are connected via their sender capacity
622        drop(channel);
623        drop(handle);
624    }
625
626    #[tokio::test]
627    async fn loopback_cancel_signal_can_be_notified_and_awaited() {
628        let (_channel, handle) = LoopbackChannel::pair(8);
629        let signal = std::sync::Arc::clone(&handle.cancel_signal);
630        // Notify from one side, await on the other.
631        let notified = signal.notified();
632        handle.cancel_signal.notify_one();
633        notified.await; // resolves immediately after notify_one()
634    }
635
636    #[tokio::test]
637    async fn loopback_cancel_signal_shared_across_clones() {
638        let (_channel, handle) = LoopbackChannel::pair(8);
639        let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
640        let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
641        let notified = signal_b.notified();
642        signal_a.notify_one();
643        notified.await;
644    }
645
646    #[tokio::test]
647    async fn loopback_send_recv_round_trip() {
648        let (mut channel, handle) = LoopbackChannel::pair(8);
649        handle
650            .input_tx
651            .send(ChannelMessage {
652                text: "hello".to_owned(),
653                attachments: vec![],
654            })
655            .await
656            .unwrap();
657        let msg = channel.recv().await.unwrap().unwrap();
658        assert_eq!(msg.text, "hello");
659    }
660
661    #[tokio::test]
662    async fn loopback_recv_returns_none_when_handle_dropped() {
663        let (mut channel, handle) = LoopbackChannel::pair(8);
664        drop(handle);
665        let result = channel.recv().await.unwrap();
666        assert!(result.is_none());
667    }
668
669    #[tokio::test]
670    async fn loopback_send_produces_full_message_event() {
671        let (mut channel, mut handle) = LoopbackChannel::pair(8);
672        channel.send("world").await.unwrap();
673        let event = handle.output_rx.recv().await.unwrap();
674        assert!(matches!(event, LoopbackEvent::FullMessage(t) if t == "world"));
675    }
676
677    #[tokio::test]
678    async fn loopback_send_chunk_then_flush() {
679        let (mut channel, mut handle) = LoopbackChannel::pair(8);
680        channel.send_chunk("part1").await.unwrap();
681        channel.flush_chunks().await.unwrap();
682        let ev1 = handle.output_rx.recv().await.unwrap();
683        let ev2 = handle.output_rx.recv().await.unwrap();
684        assert!(matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1"));
685        assert!(matches!(ev2, LoopbackEvent::Flush));
686    }
687
688    #[tokio::test]
689    async fn loopback_send_tool_output() {
690        let (mut channel, mut handle) = LoopbackChannel::pair(8);
691        channel
692            .send_tool_output(ToolOutputEvent {
693                tool_name: "bash",
694                body: "exit 0",
695                diff: None,
696                filter_stats: None,
697                kept_lines: None,
698                locations: None,
699                tool_call_id: "",
700                is_error: false,
701                parent_tool_use_id: None,
702                raw_response: None,
703                started_at: None,
704            })
705            .await
706            .unwrap();
707        let event = handle.output_rx.recv().await.unwrap();
708        match event {
709            LoopbackEvent::ToolOutput(data) => {
710                assert_eq!(data.tool_name, "bash");
711                assert_eq!(data.display, "exit 0");
712                assert!(data.diff.is_none());
713                assert!(data.filter_stats.is_none());
714                assert!(data.kept_lines.is_none());
715                assert!(data.locations.is_none());
716                assert_eq!(data.tool_call_id, "");
717                assert!(!data.is_error);
718                assert!(data.terminal_id.is_none());
719                assert!(data.parent_tool_use_id.is_none());
720                assert!(data.raw_response.is_none());
721            }
722            _ => panic!("expected ToolOutput event"),
723        }
724    }
725
726    #[tokio::test]
727    async fn loopback_confirm_auto_approves() {
728        let (mut channel, _handle) = LoopbackChannel::pair(8);
729        let result = channel.confirm("are you sure?").await.unwrap();
730        assert!(result);
731    }
732
733    #[tokio::test]
734    async fn loopback_send_error_when_output_closed() {
735        let (mut channel, handle) = LoopbackChannel::pair(8);
736        // Drop only the output_rx side by dropping the handle
737        drop(handle);
738        let result = channel.send("too late").await;
739        assert!(matches!(result, Err(ChannelError::ChannelClosed)));
740    }
741
742    #[tokio::test]
743    async fn loopback_send_chunk_error_when_output_closed() {
744        let (mut channel, handle) = LoopbackChannel::pair(8);
745        drop(handle);
746        let result = channel.send_chunk("chunk").await;
747        assert!(matches!(result, Err(ChannelError::ChannelClosed)));
748    }
749
750    #[tokio::test]
751    async fn loopback_flush_error_when_output_closed() {
752        let (mut channel, handle) = LoopbackChannel::pair(8);
753        drop(handle);
754        let result = channel.flush_chunks().await;
755        assert!(matches!(result, Err(ChannelError::ChannelClosed)));
756    }
757
758    #[tokio::test]
759    async fn loopback_send_status_event() {
760        let (mut channel, mut handle) = LoopbackChannel::pair(8);
761        channel.send_status("working...").await.unwrap();
762        let event = handle.output_rx.recv().await.unwrap();
763        assert!(matches!(event, LoopbackEvent::Status(s) if s == "working..."));
764    }
765
766    #[tokio::test]
767    async fn loopback_send_usage_produces_usage_event() {
768        let (mut channel, mut handle) = LoopbackChannel::pair(8);
769        channel.send_usage(100, 50, 200_000).await.unwrap();
770        let event = handle.output_rx.recv().await.unwrap();
771        match event {
772            LoopbackEvent::Usage {
773                input_tokens,
774                output_tokens,
775                context_window,
776            } => {
777                assert_eq!(input_tokens, 100);
778                assert_eq!(output_tokens, 50);
779                assert_eq!(context_window, 200_000);
780            }
781            _ => panic!("expected Usage event"),
782        }
783    }
784
785    #[tokio::test]
786    async fn loopback_send_usage_error_when_closed() {
787        let (mut channel, handle) = LoopbackChannel::pair(8);
788        drop(handle);
789        let result = channel.send_usage(1, 2, 3).await;
790        assert!(matches!(result, Err(ChannelError::ChannelClosed)));
791    }
792
793    #[test]
794    fn plan_item_status_variants_are_distinct() {
795        assert!(!matches!(
796            PlanItemStatus::Pending,
797            PlanItemStatus::InProgress
798        ));
799        assert!(!matches!(
800            PlanItemStatus::InProgress,
801            PlanItemStatus::Completed
802        ));
803        assert!(!matches!(
804            PlanItemStatus::Completed,
805            PlanItemStatus::Pending
806        ));
807    }
808
809    #[test]
810    fn loopback_event_session_title_carries_string() {
811        let event = LoopbackEvent::SessionTitle("hello".to_owned());
812        assert!(matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello"));
813    }
814
815    #[test]
816    fn loopback_event_plan_carries_entries() {
817        let entries = vec![
818            ("step 1".to_owned(), PlanItemStatus::Pending),
819            ("step 2".to_owned(), PlanItemStatus::InProgress),
820        ];
821        let event = LoopbackEvent::Plan(entries);
822        match event {
823            LoopbackEvent::Plan(e) => {
824                assert_eq!(e.len(), 2);
825                assert!(matches!(e[0].1, PlanItemStatus::Pending));
826                assert!(matches!(e[1].1, PlanItemStatus::InProgress));
827            }
828            _ => panic!("expected Plan event"),
829        }
830    }
831
832    #[tokio::test]
833    async fn loopback_send_tool_start_produces_tool_start_event() {
834        let (mut channel, mut handle) = LoopbackChannel::pair(8);
835        channel
836            .send_tool_start(ToolStartEvent {
837                tool_name: "shell",
838                tool_call_id: "tc-001",
839                params: Some(serde_json::json!({"command": "ls"})),
840                parent_tool_use_id: None,
841            })
842            .await
843            .unwrap();
844        let event = handle.output_rx.recv().await.unwrap();
845        match event {
846            LoopbackEvent::ToolStart(data) => {
847                assert_eq!(data.tool_name, "shell");
848                assert_eq!(data.tool_call_id, "tc-001");
849                assert!(data.params.is_some());
850                assert!(data.parent_tool_use_id.is_none());
851            }
852            _ => panic!("expected ToolStart event"),
853        }
854    }
855
856    #[tokio::test]
857    async fn loopback_send_tool_start_with_parent_id() {
858        let (mut channel, mut handle) = LoopbackChannel::pair(8);
859        channel
860            .send_tool_start(ToolStartEvent {
861                tool_name: "web",
862                tool_call_id: "tc-002",
863                params: None,
864                parent_tool_use_id: Some("parent-123".into()),
865            })
866            .await
867            .unwrap();
868        let event = handle.output_rx.recv().await.unwrap();
869        assert!(matches!(
870            event,
871            LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
872        ));
873    }
874
875    #[tokio::test]
876    async fn loopback_send_tool_start_error_when_output_closed() {
877        let (mut channel, handle) = LoopbackChannel::pair(8);
878        drop(handle);
879        let result = channel
880            .send_tool_start(ToolStartEvent {
881                tool_name: "shell",
882                tool_call_id: "tc-003",
883                params: None,
884                parent_tool_use_id: None,
885            })
886            .await;
887        assert!(matches!(result, Err(ChannelError::ChannelClosed)));
888    }
889
890    #[tokio::test]
891    async fn default_send_tool_output_formats_message() {
892        let mut ch = StubChannel;
893        // Default impl calls self.send() which is a no-op in StubChannel — just verify it doesn't panic.
894        ch.send_tool_output(ToolOutputEvent {
895            tool_name: "bash",
896            body: "hello",
897            diff: None,
898            filter_stats: None,
899            kept_lines: None,
900            locations: None,
901            tool_call_id: "id",
902            is_error: false,
903            parent_tool_use_id: None,
904            raw_response: None,
905            started_at: None,
906        })
907        .await
908        .unwrap();
909    }
910}