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
582pub use zeph_common::StopHint;
583
584/// Event carrying data for a tool call start, emitted before execution begins.
585///
586/// Passed by value to [`Channel::send_tool_start`] and carried by
587/// [`LoopbackEvent::ToolStart`]. All fields are owned — no lifetime parameters.
588#[derive(Debug, Clone)]
589pub struct ToolStartEvent {
590    /// Name of the tool being invoked.
591    pub tool_name: zeph_common::ToolName,
592    /// Opaque tool call ID assigned by the LLM.
593    pub tool_call_id: String,
594    /// Raw input parameters passed to the tool (e.g. `{"command": "..."}` for bash).
595    pub params: Option<serde_json::Value>,
596    /// Set when this tool call is made by a subagent; identifies the parent's `tool_call_id`.
597    pub parent_tool_use_id: Option<String>,
598    /// Wall-clock instant when the tool call was initiated; used to compute elapsed time.
599    pub started_at: std::time::Instant,
600    /// True when this tool call was speculatively dispatched before LLM finished decoding.
601    ///
602    /// TUI renders a `[spec]` prefix; Telegram suppresses unless `chat_visibility = verbose`.
603    pub speculative: bool,
604    /// OS sandbox profile applied to this tool call, if any.
605    ///
606    /// `None` means no sandbox was applied (not configured or not a subprocess executor).
607    pub sandbox_profile: Option<zeph_tools::SandboxProfile>,
608    /// True when this tool call originates from an MCP server rather than a native tool.
609    pub is_mcp: bool,
610}
611
612/// Event carrying data for a completed tool output, emitted after execution.
613///
614/// Passed by value to [`Channel::send_tool_output`] and carried by
615/// [`LoopbackEvent::ToolOutput`]. All fields are owned — no lifetime parameters.
616#[derive(Debug, Clone)]
617pub struct ToolOutputEvent {
618    /// Name of the tool that produced this output.
619    pub tool_name: zeph_common::ToolName,
620    /// Human-readable output text.
621    pub display: String,
622    /// Optional diff for file-editing tools.
623    pub diff: Option<crate::DiffData>,
624    /// Optional filter statistics from output filtering.
625    pub filter_stats: Option<String>,
626    /// Kept line indices after filtering (for display).
627    pub kept_lines: Option<Vec<usize>>,
628    /// Source locations for code search results.
629    pub locations: Option<Vec<String>>,
630    /// Opaque tool call ID matching the corresponding `ToolStartEvent`.
631    pub tool_call_id: String,
632    /// Whether this output represents an error.
633    pub is_error: bool,
634    /// Terminal ID for shell tool calls routed through the IDE terminal.
635    pub terminal_id: Option<String>,
636    /// Set when this tool output belongs to a subagent; identifies the parent's `tool_call_id`.
637    pub parent_tool_use_id: Option<String>,
638    /// Structured tool response payload for ACP intermediate `tool_call_update` notifications.
639    pub raw_response: Option<serde_json::Value>,
640    /// Wall-clock instant when the corresponding `ToolStartEvent` was emitted.
641    pub started_at: Option<std::time::Instant>,
642}
643
644/// Backward-compatible alias for [`ToolStartEvent`].
645///
646/// Kept for use in the ACP layer. Prefer [`ToolStartEvent`] in new code.
647pub type ToolStartData = ToolStartEvent;
648
649/// Backward-compatible alias for [`ToolOutputEvent`].
650///
651/// Kept for use in the ACP layer. Prefer [`ToolOutputEvent`] in new code.
652pub type ToolOutputData = ToolOutputEvent;
653
654#[non_exhaustive]
655/// Events emitted by the agent side toward the A2A caller.
656#[derive(Debug, Clone)]
657pub enum LoopbackEvent {
658    Chunk(String),
659    Flush,
660    FullMessage(String),
661    Status(String),
662    /// Emitted immediately before tool execution begins.
663    ToolStart(Box<ToolStartEvent>),
664    ToolOutput(Box<ToolOutputEvent>),
665    /// Token usage from the last LLM call.
666    ///
667    /// `cost_cents` is the **cumulative** session cost in USD cents at the time of emission;
668    /// receivers should overwrite (not sum) their stored cost field.
669    ///
670    /// This variant is only produced by `Agent::emit_usage_event` in `metrics_compact.rs`
671    /// after a verified LLM response — it is never constructed from external input.
672    Usage {
673        input_tokens: u64,
674        output_tokens: u64,
675        context_window: u64,
676        /// Cache read tokens for this LLM call.
677        cache_read_tokens: u64,
678        /// Cache write tokens for this LLM call.
679        cache_write_tokens: u64,
680        /// Cumulative session cost in USD cents (overwrite, do not sum).
681        cost_cents: f64,
682    },
683    /// Generated session title (emitted after the first agent response).
684    SessionTitle(String),
685    /// Execution plan update.
686    Plan(Vec<(String, PlanItemStatus)>),
687    /// Thinking/reasoning token chunk from the LLM.
688    ThinkingChunk(String),
689    /// Non-default stop condition detected by the agent loop.
690    ///
691    /// Emitted immediately before `Flush`. When absent, the stop reason is `EndTurn`.
692    Stop(StopHint),
693}
694
695#[non_exhaustive]
696/// Status of a plan item, mirroring `acp::PlanEntryStatus`.
697#[derive(Debug, Clone)]
698pub enum PlanItemStatus {
699    Pending,
700    InProgress,
701    Completed,
702}
703
704/// Caller-side handle for sending input and receiving agent output.
705pub struct LoopbackHandle {
706    pub input_tx: tokio::sync::mpsc::Sender<ChannelMessage>,
707    pub output_rx: tokio::sync::mpsc::Receiver<LoopbackEvent>,
708    /// Shared cancel signal: notify to interrupt the agent's current operation.
709    pub cancel_signal: std::sync::Arc<tokio::sync::Notify>,
710}
711
712/// Headless channel bridging an A2A `TaskProcessor` with the agent loop.
713pub struct LoopbackChannel {
714    input_rx: tokio::sync::mpsc::Receiver<ChannelMessage>,
715    output_tx: tokio::sync::mpsc::Sender<LoopbackEvent>,
716}
717
718impl LoopbackChannel {
719    /// Create a linked `(LoopbackChannel, LoopbackHandle)` pair.
720    #[must_use]
721    pub fn pair(buffer: usize) -> (Self, LoopbackHandle) {
722        let (input_tx, input_rx) = tokio::sync::mpsc::channel(buffer);
723        let (output_tx, output_rx) = tokio::sync::mpsc::channel(buffer);
724        let cancel_signal = std::sync::Arc::new(tokio::sync::Notify::new());
725        (
726            Self {
727                input_rx,
728                output_tx,
729            },
730            LoopbackHandle {
731                input_tx,
732                output_rx,
733                cancel_signal,
734            },
735        )
736    }
737}
738
739impl Channel for LoopbackChannel {
740    fn supports_exit(&self) -> bool {
741        false
742    }
743
744    async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
745        Ok(self.input_rx.recv().await)
746    }
747
748    async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
749        self.output_tx
750            .send(LoopbackEvent::FullMessage(text.to_owned()))
751            .await
752            .map_err(|_| ChannelError::ChannelClosed)
753    }
754
755    async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
756        self.output_tx
757            .send(LoopbackEvent::Chunk(chunk.to_owned()))
758            .await
759            .map_err(|_| ChannelError::ChannelClosed)
760    }
761
762    async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
763        self.output_tx
764            .send(LoopbackEvent::Flush)
765            .await
766            .map_err(|_| ChannelError::ChannelClosed)
767    }
768
769    async fn send_status(&mut self, text: &str) -> Result<(), ChannelError> {
770        self.output_tx
771            .send(LoopbackEvent::Status(text.to_owned()))
772            .await
773            .map_err(|_| ChannelError::ChannelClosed)
774    }
775
776    async fn send_thinking_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
777        self.output_tx
778            .send(LoopbackEvent::ThinkingChunk(chunk.to_owned()))
779            .await
780            .map_err(|_| ChannelError::ChannelClosed)
781    }
782
783    async fn send_tool_start(&mut self, event: ToolStartEvent) -> Result<(), ChannelError> {
784        self.output_tx
785            .send(LoopbackEvent::ToolStart(Box::new(event)))
786            .await
787            .map_err(|_| ChannelError::ChannelClosed)
788    }
789
790    async fn send_tool_output(&mut self, event: ToolOutputEvent) -> Result<(), ChannelError> {
791        self.output_tx
792            .send(LoopbackEvent::ToolOutput(Box::new(event)))
793            .await
794            .map_err(|_| ChannelError::ChannelClosed)
795    }
796
797    async fn confirm(&mut self, _prompt: &str) -> Result<bool, ChannelError> {
798        Ok(true)
799    }
800
801    async fn send_stop_hint(&mut self, hint: StopHint) -> Result<(), ChannelError> {
802        self.output_tx
803            .send(LoopbackEvent::Stop(hint))
804            .await
805            .map_err(|_| ChannelError::ChannelClosed)
806    }
807
808    async fn send_usage(
809        &mut self,
810        input_tokens: u64,
811        output_tokens: u64,
812        context_window: u64,
813        cache_read_tokens: u64,
814        cache_write_tokens: u64,
815        cost_cents: f64,
816    ) -> Result<(), ChannelError> {
817        self.output_tx
818            .send(LoopbackEvent::Usage {
819                input_tokens,
820                output_tokens,
821                context_window,
822                cache_read_tokens,
823                cache_write_tokens,
824                cost_cents,
825            })
826            .await
827            .map_err(|_| ChannelError::ChannelClosed)
828    }
829}
830
831/// Adapter that wraps a [`Channel`] reference and implements [`zeph_commands::ChannelSink`].
832///
833/// Used at command dispatch time to coerce `&mut C` into `&mut dyn ChannelSink` without
834/// a blanket impl (which would violate Rust's orphan rules).
835pub(crate) struct ChannelSinkAdapter<'a, C: Channel>(pub &'a mut C);
836
837impl<C: Channel> zeph_commands::ChannelSink for ChannelSinkAdapter<'_, C> {
838    fn send<'a>(
839        &'a mut self,
840        msg: &'a str,
841    ) -> std::pin::Pin<
842        Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
843    > {
844        Box::pin(async move {
845            self.0
846                .send(msg)
847                .await
848                .map_err(zeph_commands::CommandError::new)
849        })
850    }
851
852    fn flush_chunks<'a>(
853        &'a mut self,
854    ) -> std::pin::Pin<
855        Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
856    > {
857        Box::pin(async move {
858            self.0
859                .flush_chunks()
860                .await
861                .map_err(zeph_commands::CommandError::new)
862        })
863    }
864
865    fn send_queue_count<'a>(
866        &'a mut self,
867        count: usize,
868    ) -> std::pin::Pin<
869        Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
870    > {
871        Box::pin(async move {
872            self.0
873                .send_queue_count(count)
874                .await
875                .map_err(zeph_commands::CommandError::new)
876        })
877    }
878
879    fn supports_exit(&self) -> bool {
880        self.0.supports_exit()
881    }
882
883    fn send_transcript<'a>(
884        &'a mut self,
885        entries: &'a [zeph_commands::TranscriptEntry],
886    ) -> std::pin::Pin<
887        Box<dyn std::future::Future<Output = Result<(), zeph_commands::CommandError>> + Send + 'a>,
888    > {
889        Box::pin(async move {
890            self.0
891                .send_transcript_backfill(entries)
892                .await
893                .map_err(zeph_commands::CommandError::new)
894        })
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use std::assert_matches;
902
903    #[test]
904    fn channel_message_creation() {
905        let msg = ChannelMessage {
906            text: "hello".to_string(),
907            attachments: vec![],
908            is_guest_context: false,
909            is_from_bot: false,
910            owner_key: None,
911        };
912        assert_eq!(msg.text, "hello");
913        assert!(msg.attachments.is_empty());
914    }
915
916    struct StubChannel;
917
918    impl Channel for StubChannel {
919        async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
920            Ok(None)
921        }
922
923        async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
924            Ok(())
925        }
926
927        async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
928            Ok(())
929        }
930
931        async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
932            Ok(())
933        }
934    }
935
936    #[tokio::test]
937    async fn send_chunk_default_is_noop() {
938        let mut ch = StubChannel;
939        ch.send_chunk("partial").await.unwrap();
940    }
941
942    #[tokio::test]
943    async fn flush_chunks_default_is_noop() {
944        let mut ch = StubChannel;
945        ch.flush_chunks().await.unwrap();
946    }
947
948    #[tokio::test]
949    async fn stub_channel_confirm_auto_approves() {
950        let mut ch = StubChannel;
951        let result = ch.confirm("Delete everything?").await.unwrap();
952        assert!(result);
953    }
954
955    #[tokio::test]
956    async fn stub_channel_send_typing_default() {
957        let mut ch = StubChannel;
958        ch.send_typing().await.unwrap();
959    }
960
961    #[tokio::test]
962    async fn stub_channel_recv_returns_none() {
963        let mut ch = StubChannel;
964        let msg = ch.recv().await.unwrap();
965        assert!(msg.is_none());
966    }
967
968    #[tokio::test]
969    async fn stub_channel_send_ok() {
970        let mut ch = StubChannel;
971        ch.send("hello").await.unwrap();
972    }
973
974    #[tokio::test]
975    async fn send_status_best_effort_succeeds_silently() {
976        let mut ch = StubChannel;
977        // Must not panic even though the return type carries no `Result`.
978        ch.send_status_best_effort("hello").await;
979    }
980
981    struct ErroringStatusChannel;
982
983    impl Channel for ErroringStatusChannel {
984        async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
985            Ok(None)
986        }
987
988        async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
989            Ok(())
990        }
991
992        async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
993            Ok(())
994        }
995
996        async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
997            Ok(())
998        }
999
1000        async fn send_status(&mut self, _text: &str) -> Result<(), ChannelError> {
1001            Err(ChannelError::ChannelClosed)
1002        }
1003    }
1004
1005    #[tokio::test]
1006    async fn send_status_best_effort_swallows_errors() {
1007        let mut ch = ErroringStatusChannel;
1008        // Must not propagate or panic — errors are logged, not surfaced.
1009        ch.send_status_best_effort("hello").await;
1010    }
1011
1012    // The two tests below use `tracing_test::traced_test` to assert on the actual log
1013    // output of `send_status_best_effort`, not just its return type — closing the gap
1014    // flagged in the #6106 handoff where only "doesn't panic" was verified.
1015
1016    #[tokio::test]
1017    #[tracing_test::traced_test]
1018    async fn send_status_best_effort_warns_on_error() {
1019        let mut ch = ErroringStatusChannel;
1020        ch.send_status_best_effort("hello").await;
1021        assert!(
1022            logs_contain("channel status send failed"),
1023            "expected a tracing::warn! logging the send_status error"
1024        );
1025    }
1026
1027    #[tokio::test]
1028    #[tracing_test::traced_test]
1029    async fn send_status_best_effort_debug_logs_on_success() {
1030        let mut ch = StubChannel;
1031        ch.send_status_best_effort("hello").await;
1032        assert!(
1033            logs_contain("channel status sent"),
1034            "expected a tracing::debug! logging the successful send_status"
1035        );
1036    }
1037
1038    struct HangingStatusChannel;
1039
1040    impl Channel for HangingStatusChannel {
1041        async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
1042            Ok(None)
1043        }
1044
1045        async fn send(&mut self, _text: &str) -> Result<(), ChannelError> {
1046            Ok(())
1047        }
1048
1049        async fn send_chunk(&mut self, _chunk: &str) -> Result<(), ChannelError> {
1050            Ok(())
1051        }
1052
1053        async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
1054            Ok(())
1055        }
1056
1057        async fn send_status(&mut self, _text: &str) -> Result<(), ChannelError> {
1058            std::future::pending().await
1059        }
1060    }
1061
1062    // Regression test for #6094: a channel whose `send_status` never resolves (e.g. stuck in
1063    // a retry-with-backoff loop under sustained 429s) must not stall the caller past
1064    // `STATUS_SEND_TIMEOUT`. Uses paused tokio time so the test itself completes instantly.
1065    #[tokio::test(start_paused = true)]
1066    async fn send_status_best_effort_times_out_instead_of_hanging() {
1067        let mut ch = HangingStatusChannel;
1068        let call = ch.send_status_best_effort("hello");
1069        tokio::pin!(call);
1070
1071        // Not ready before the timeout elapses.
1072        assert!(
1073            futures::poll!(&mut call).is_pending(),
1074            "expected send_status_best_effort to still be pending immediately"
1075        );
1076
1077        tokio::time::advance(STATUS_SEND_TIMEOUT + std::time::Duration::from_secs(1)).await;
1078
1079        // Now the timeout has elapsed and the future must resolve (returns `()`, not stuck).
1080        tokio::time::timeout(std::time::Duration::from_secs(1), call)
1081            .await
1082            .expect("send_status_best_effort must resolve once STATUS_SEND_TIMEOUT elapses");
1083    }
1084
1085    #[tokio::test(start_paused = true)]
1086    #[tracing_test::traced_test]
1087    async fn send_status_best_effort_warns_on_timeout() {
1088        let mut ch = HangingStatusChannel;
1089        let call = ch.send_status_best_effort("hello");
1090        tokio::pin!(call);
1091        let _ = futures::poll!(&mut call);
1092
1093        tokio::time::advance(STATUS_SEND_TIMEOUT + std::time::Duration::from_secs(1)).await;
1094        call.await;
1095
1096        assert!(
1097            logs_contain("channel status send timed out"),
1098            "expected a tracing::warn! logging the send_status timeout"
1099        );
1100    }
1101
1102    #[test]
1103    fn channel_message_clone() {
1104        let msg = ChannelMessage {
1105            text: "test".to_string(),
1106            attachments: vec![],
1107            is_guest_context: false,
1108            is_from_bot: false,
1109            owner_key: None,
1110        };
1111        let cloned = msg.clone();
1112        assert_eq!(cloned.text, "test");
1113    }
1114
1115    #[test]
1116    fn channel_message_debug() {
1117        let msg = ChannelMessage {
1118            text: "debug".to_string(),
1119            attachments: vec![],
1120            is_guest_context: false,
1121            is_from_bot: false,
1122            owner_key: None,
1123        };
1124        let debug = format!("{msg:?}");
1125        assert!(debug.contains("debug"));
1126    }
1127
1128    #[test]
1129    fn attachment_kind_equality() {
1130        assert_eq!(AttachmentKind::Audio, AttachmentKind::Audio);
1131        assert_ne!(AttachmentKind::Audio, AttachmentKind::Image);
1132    }
1133
1134    #[test]
1135    fn attachment_construction() {
1136        let a = Attachment {
1137            kind: AttachmentKind::Audio,
1138            data: vec![0, 1, 2],
1139            filename: Some("test.wav".into()),
1140        };
1141        assert_eq!(a.kind, AttachmentKind::Audio);
1142        assert_eq!(a.data.len(), 3);
1143        assert_eq!(a.filename.as_deref(), Some("test.wav"));
1144    }
1145
1146    #[test]
1147    fn channel_message_with_attachments() {
1148        let msg = ChannelMessage {
1149            text: String::new(),
1150            attachments: vec![Attachment {
1151                kind: AttachmentKind::Audio,
1152                data: vec![42],
1153                filename: None,
1154            }],
1155            is_guest_context: false,
1156            is_from_bot: false,
1157            owner_key: None,
1158        };
1159        assert_eq!(msg.attachments.len(), 1);
1160        assert_eq!(msg.attachments[0].kind, AttachmentKind::Audio);
1161    }
1162
1163    #[test]
1164    fn stub_channel_try_recv_returns_none() {
1165        let mut ch = StubChannel;
1166        assert!(ch.try_recv().is_none());
1167    }
1168
1169    #[tokio::test]
1170    async fn stub_channel_send_queue_count_noop() {
1171        let mut ch = StubChannel;
1172        ch.send_queue_count(5).await.unwrap();
1173    }
1174
1175    // LoopbackChannel tests
1176
1177    #[test]
1178    fn loopback_pair_returns_linked_handles() {
1179        let (channel, handle) = LoopbackChannel::pair(8);
1180        // Both sides exist and channels are connected via their sender capacity
1181        drop(channel);
1182        drop(handle);
1183    }
1184
1185    #[tokio::test]
1186    async fn loopback_cancel_signal_can_be_notified_and_awaited() {
1187        let (_channel, handle) = LoopbackChannel::pair(8);
1188        let signal = std::sync::Arc::clone(&handle.cancel_signal);
1189        // Notify from one side, await on the other.
1190        let notified = signal.notified();
1191        handle.cancel_signal.notify_one();
1192        notified.await; // resolves immediately after notify_one()
1193    }
1194
1195    #[tokio::test]
1196    async fn loopback_cancel_signal_shared_across_clones() {
1197        let (_channel, handle) = LoopbackChannel::pair(8);
1198        let signal_a = std::sync::Arc::clone(&handle.cancel_signal);
1199        let signal_b = std::sync::Arc::clone(&handle.cancel_signal);
1200        let notified = signal_b.notified();
1201        signal_a.notify_one();
1202        notified.await;
1203    }
1204
1205    #[tokio::test]
1206    async fn loopback_send_recv_round_trip() {
1207        let (mut channel, handle) = LoopbackChannel::pair(8);
1208        handle
1209            .input_tx
1210            .send(ChannelMessage {
1211                text: "hello".to_owned(),
1212                attachments: vec![],
1213                is_guest_context: false,
1214                is_from_bot: false,
1215                owner_key: None,
1216            })
1217            .await
1218            .unwrap();
1219        let msg = channel.recv().await.unwrap().unwrap();
1220        assert_eq!(msg.text, "hello");
1221    }
1222
1223    #[tokio::test]
1224    async fn loopback_recv_returns_none_when_handle_dropped() {
1225        let (mut channel, handle) = LoopbackChannel::pair(8);
1226        drop(handle);
1227        let result = channel.recv().await.unwrap();
1228        assert!(result.is_none());
1229    }
1230
1231    #[tokio::test]
1232    async fn loopback_send_produces_full_message_event() {
1233        let (mut channel, mut handle) = LoopbackChannel::pair(8);
1234        channel.send("world").await.unwrap();
1235        let event = handle.output_rx.recv().await.unwrap();
1236        assert_matches!(event, LoopbackEvent::FullMessage(t) if t == "world");
1237    }
1238
1239    #[tokio::test]
1240    async fn loopback_send_chunk_then_flush() {
1241        let (mut channel, mut handle) = LoopbackChannel::pair(8);
1242        channel.send_chunk("part1").await.unwrap();
1243        channel.flush_chunks().await.unwrap();
1244        let ev1 = handle.output_rx.recv().await.unwrap();
1245        let ev2 = handle.output_rx.recv().await.unwrap();
1246        assert_matches!(ev1, LoopbackEvent::Chunk(t) if t == "part1");
1247        assert_matches!(ev2, LoopbackEvent::Flush);
1248    }
1249
1250    #[tokio::test]
1251    async fn loopback_send_tool_output() {
1252        let (mut channel, mut handle) = LoopbackChannel::pair(8);
1253        channel
1254            .send_tool_output(ToolOutputEvent {
1255                tool_name: "bash".into(),
1256                display: "exit 0".into(),
1257                diff: None,
1258                filter_stats: None,
1259                kept_lines: None,
1260                locations: None,
1261                tool_call_id: String::new(),
1262                terminal_id: None,
1263                is_error: false,
1264                parent_tool_use_id: None,
1265                raw_response: None,
1266                started_at: None,
1267            })
1268            .await
1269            .unwrap();
1270        let event = handle.output_rx.recv().await.unwrap();
1271        match event {
1272            LoopbackEvent::ToolOutput(data) => {
1273                assert_eq!(data.tool_name, "bash");
1274                assert_eq!(data.display, "exit 0");
1275                assert!(data.diff.is_none());
1276                assert!(data.filter_stats.is_none());
1277                assert!(data.kept_lines.is_none());
1278                assert!(data.locations.is_none());
1279                assert_eq!(data.tool_call_id, "");
1280                assert!(!data.is_error);
1281                assert!(data.terminal_id.is_none());
1282                assert!(data.parent_tool_use_id.is_none());
1283                assert!(data.raw_response.is_none());
1284            }
1285            _ => panic!("expected ToolOutput event"),
1286        }
1287    }
1288
1289    #[tokio::test]
1290    async fn loopback_confirm_auto_approves() {
1291        let (mut channel, _handle) = LoopbackChannel::pair(8);
1292        let result = channel.confirm("are you sure?").await.unwrap();
1293        assert!(result);
1294    }
1295
1296    #[tokio::test]
1297    async fn loopback_send_error_when_output_closed() {
1298        let (mut channel, handle) = LoopbackChannel::pair(8);
1299        // Drop only the output_rx side by dropping the handle
1300        drop(handle);
1301        let result = channel.send("too late").await;
1302        assert_matches!(result, Err(ChannelError::ChannelClosed));
1303    }
1304
1305    #[tokio::test]
1306    async fn loopback_send_chunk_error_when_output_closed() {
1307        let (mut channel, handle) = LoopbackChannel::pair(8);
1308        drop(handle);
1309        let result = channel.send_chunk("chunk").await;
1310        assert_matches!(result, Err(ChannelError::ChannelClosed));
1311    }
1312
1313    #[tokio::test]
1314    async fn loopback_flush_error_when_output_closed() {
1315        let (mut channel, handle) = LoopbackChannel::pair(8);
1316        drop(handle);
1317        let result = channel.flush_chunks().await;
1318        assert_matches!(result, Err(ChannelError::ChannelClosed));
1319    }
1320
1321    #[tokio::test]
1322    async fn loopback_send_status_event() {
1323        let (mut channel, mut handle) = LoopbackChannel::pair(8);
1324        channel.send_status("working...").await.unwrap();
1325        let event = handle.output_rx.recv().await.unwrap();
1326        assert_matches!(event, LoopbackEvent::Status(s) if s == "working...");
1327    }
1328
1329    #[tokio::test]
1330    async fn loopback_send_usage_produces_usage_event() {
1331        let (mut channel, mut handle) = LoopbackChannel::pair(8);
1332        channel
1333            .send_usage(100, 50, 200_000, 10, 5, 1.5)
1334            .await
1335            .unwrap();
1336        let event = handle.output_rx.recv().await.unwrap();
1337        match event {
1338            LoopbackEvent::Usage {
1339                input_tokens,
1340                output_tokens,
1341                context_window,
1342                cache_read_tokens,
1343                cache_write_tokens,
1344                cost_cents,
1345            } => {
1346                assert_eq!(input_tokens, 100);
1347                assert_eq!(output_tokens, 50);
1348                assert_eq!(context_window, 200_000);
1349                assert_eq!(cache_read_tokens, 10);
1350                assert_eq!(cache_write_tokens, 5);
1351                assert!((cost_cents - 1.5).abs() < f64::EPSILON);
1352            }
1353            _ => panic!("expected Usage event"),
1354        }
1355    }
1356
1357    #[tokio::test]
1358    async fn loopback_send_usage_error_when_closed() {
1359        let (mut channel, handle) = LoopbackChannel::pair(8);
1360        drop(handle);
1361        let result = channel.send_usage(1, 2, 3, 0, 0, 0.0).await;
1362        assert_matches!(result, Err(ChannelError::ChannelClosed));
1363    }
1364
1365    #[test]
1366    fn plan_item_status_variants_are_distinct() {
1367        assert!(!matches!(
1368            PlanItemStatus::Pending,
1369            PlanItemStatus::InProgress
1370        ));
1371        assert!(!matches!(
1372            PlanItemStatus::InProgress,
1373            PlanItemStatus::Completed
1374        ));
1375        assert!(!matches!(
1376            PlanItemStatus::Completed,
1377            PlanItemStatus::Pending
1378        ));
1379    }
1380
1381    #[test]
1382    fn loopback_event_session_title_carries_string() {
1383        let event = LoopbackEvent::SessionTitle("hello".to_owned());
1384        assert_matches!(event, LoopbackEvent::SessionTitle(s) if s == "hello");
1385    }
1386
1387    #[test]
1388    fn loopback_event_plan_carries_entries() {
1389        let entries = vec![
1390            ("step 1".to_owned(), PlanItemStatus::Pending),
1391            ("step 2".to_owned(), PlanItemStatus::InProgress),
1392        ];
1393        let event = LoopbackEvent::Plan(entries);
1394        match event {
1395            LoopbackEvent::Plan(e) => {
1396                assert_eq!(e.len(), 2);
1397                assert_matches!(e[0].1, PlanItemStatus::Pending);
1398                assert_matches!(e[1].1, PlanItemStatus::InProgress);
1399            }
1400            _ => panic!("expected Plan event"),
1401        }
1402    }
1403
1404    #[tokio::test]
1405    async fn loopback_send_tool_start_produces_tool_start_event() {
1406        let (mut channel, mut handle) = LoopbackChannel::pair(8);
1407        channel
1408            .send_tool_start(ToolStartEvent {
1409                tool_name: "shell".into(),
1410                tool_call_id: "tc-001".into(),
1411                params: Some(serde_json::json!({"command": "ls"})),
1412                parent_tool_use_id: None,
1413                started_at: std::time::Instant::now(),
1414                speculative: false,
1415                sandbox_profile: None,
1416                is_mcp: false,
1417            })
1418            .await
1419            .unwrap();
1420        let event = handle.output_rx.recv().await.unwrap();
1421        match event {
1422            LoopbackEvent::ToolStart(data) => {
1423                assert_eq!(data.tool_name.as_str(), "shell");
1424                assert_eq!(data.tool_call_id.as_str(), "tc-001");
1425                assert!(data.params.is_some());
1426                assert!(data.parent_tool_use_id.is_none());
1427            }
1428            _ => panic!("expected ToolStart event"),
1429        }
1430    }
1431
1432    #[tokio::test]
1433    async fn loopback_send_tool_start_with_parent_id() {
1434        let (mut channel, mut handle) = LoopbackChannel::pair(8);
1435        channel
1436            .send_tool_start(ToolStartEvent {
1437                tool_name: "web".into(),
1438                tool_call_id: "tc-002".into(),
1439                params: None,
1440                parent_tool_use_id: Some("parent-123".into()),
1441                started_at: std::time::Instant::now(),
1442                speculative: false,
1443                sandbox_profile: None,
1444                is_mcp: false,
1445            })
1446            .await
1447            .unwrap();
1448        let event = handle.output_rx.recv().await.unwrap();
1449        assert_matches!(
1450            event,
1451            LoopbackEvent::ToolStart(ref data) if data.parent_tool_use_id.as_deref() == Some("parent-123")
1452        );
1453    }
1454
1455    #[tokio::test]
1456    async fn loopback_send_tool_start_error_when_output_closed() {
1457        let (mut channel, handle) = LoopbackChannel::pair(8);
1458        drop(handle);
1459        let result = channel
1460            .send_tool_start(ToolStartEvent {
1461                tool_name: "shell".into(),
1462                tool_call_id: "tc-003".into(),
1463                params: None,
1464                parent_tool_use_id: None,
1465                started_at: std::time::Instant::now(),
1466                speculative: false,
1467                sandbox_profile: None,
1468                is_mcp: false,
1469            })
1470            .await;
1471        assert_matches!(result, Err(ChannelError::ChannelClosed));
1472    }
1473
1474    #[tokio::test]
1475    async fn default_send_tool_output_formats_message() {
1476        let mut ch = StubChannel;
1477        // Default impl calls self.send() which is a no-op in StubChannel — just verify it doesn't panic.
1478        ch.send_tool_output(ToolOutputEvent {
1479            tool_name: "bash".into(),
1480            display: "hello".into(),
1481            diff: None,
1482            filter_stats: None,
1483            kept_lines: None,
1484            locations: None,
1485            tool_call_id: "id".into(),
1486            terminal_id: None,
1487            is_error: false,
1488            parent_tool_use_id: None,
1489            raw_response: None,
1490            started_at: None,
1491        })
1492        .await
1493        .unwrap();
1494    }
1495}