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