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