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