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