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