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