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