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