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