Skip to main content

zeph_core/
channel.rs

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