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}