Skip to main content

harness/
harness.rs

1//! The neutral harness contract: the [`Harness`] trait, the run-control
2//! handle, the neutral request/metadata types, and the shared
3//! interactive-login helper.
4//!
5//! A *harness* is whatever actually answers the user's prompt — a CLI
6//! agent (bob / Claude Code / Codex today), a direct LLM API tomorrow,
7//! some other runner after that. A consumer only needs to: probe whether
8//! a harness is ready, run a one-time install if required, stream a run,
9//! and know which credential to ask for. This module is that seam.
10//!
11//! ## Design rules
12//!
13//! - **Object-safe trait.** Consumers hold `Box<dyn Harness>`; no
14//!   generics leak across the seam.
15//! - **Arc callbacks, not generic closures.** Streaming methods take
16//!   `Arc<dyn Fn(..) + Send + Sync>` so they stay object-safe and can be
17//!   cloned onto the reader threads the subprocess engine uses.
18//! - **Normalize at the adapter, not the UI.** The event enums in
19//!   [`crate::events`] are harness-neutral by intent; each adapter
20//!   translates its CLI's wire format into them so the front-end consumes
21//!   one shape regardless of which harness produced it.
22
23use std::path::PathBuf;
24use std::sync::{mpsc, Arc, Condvar, Mutex};
25
26use serde::{Deserialize, Serialize};
27
28use crate::events::RunEvent;
29use cli_stream::{spawn_streaming, InstallEvent, ProcessEvent, ProcessHandle};
30
31// --- Streaming callbacks --------------------------------------------
32
33/// Callback a harness invokes for each run event. `Arc<dyn Fn>` is
34/// `Clone + Send + Sync`, so it can be handed to the multiple reader
35/// threads a process-backed harness uses without the trait method
36/// needing to be generic.
37pub type RunCallback = Arc<dyn Fn(RunEvent) + Send + Sync>;
38
39/// Callback a harness invokes for each install event.
40pub type InstallCallback = Arc<dyn Fn(InstallEvent) + Send + Sync>;
41
42// --- Errors ---------------------------------------------------------
43
44/// A boxed, type-erased error source. The [`HarnessError`] variants carry one
45/// of these instead of `#[from]`-ing a single concrete type, because each
46/// *category* can be produced by more than one underlying error: a `Spawn`
47/// failure is a [`cli_stream::StreamError`] for the claude/codex adapters but a
48/// `bob_rs::BobError` for bob. The real error stays reachable through
49/// [`std::error::Error::source`] (and `downcast_ref`); the category is the
50/// variant.
51pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
52
53/// Why a [`Harness`] operation failed. Returned by `install` / `run` /
54/// `login` / [`RunControl::cancel`] so a consumer can branch on the *kind* of
55/// failure — offer install vs sign-in vs surface the message — instead of
56/// string-matching.
57///
58/// Each category carries the real underlying error as a [`source`] (via the
59/// [`BoxError`] field), so a consumer that wants more than the category can
60/// walk `.source()` or `downcast_ref::<cli_stream::StreamError>()` /
61/// `::<bob_rs::BobError>()`. The `Display` still flattens the source into the
62/// message (`"failed to start the agent: <source>"`), so a consumer that just
63/// stringifies at a boundary (e.g. a Tauri command's `.to_string()`) gets the
64/// same full message as before. `#[non_exhaustive]` so adding a variant later
65/// isn't a breaking change.
66///
67/// ```
68/// use harness::{HarnessError, StreamError};
69/// use std::error::Error;
70///
71/// // Box any typed source under a category constructor:
72/// let err = HarnessError::spawn(StreamError::PipeNotCaptured { stream: "stdout" });
73///
74/// // Stringifying at a boundary flattens the source into the message
75/// // (so a Tauri command's `.to_string()` keeps its full text)…
76/// assert!(err.to_string().starts_with("failed to start the agent: "));
77///
78/// // …while the real typed cause stays reachable for a consumer that wants
79/// // to branch on it rather than parse a string.
80/// let source = err.source().expect("Spawn carries a source");
81/// assert!(source.downcast_ref::<StreamError>().is_some());
82/// ```
83///
84/// [`source`]: std::error::Error::source
85#[derive(Debug, thiserror::Error)]
86#[non_exhaustive]
87pub enum HarnessError {
88    /// The harness's CLI couldn't be started — not installed, not on `PATH`,
89    /// or an OS-level spawn failure.
90    #[error("failed to start the agent: {0}")]
91    Spawn(#[source] BoxError),
92    /// A one-time install step failed.
93    #[error("install failed: {0}")]
94    Install(#[source] BoxError),
95    /// Interactive sign-in failed.
96    #[error("sign-in failed: {0}")]
97    Login(#[source] BoxError),
98    /// Cancelling an in-flight run failed.
99    #[error("cancel failed: {0}")]
100    Cancel(#[source] BoxError),
101    /// Any other adapter/runtime failure (e.g. a backend SDK error that
102    /// doesn't map onto the cases above). Carries a message rather than a
103    /// source — it's the catch-all when there's nothing typed to preserve.
104    #[error("{0}")]
105    Other(String),
106}
107
108impl HarnessError {
109    /// Categorize a source error as a [`Spawn`](HarnessError::Spawn) failure.
110    /// Accepts anything boxable — a typed `StreamError`/`BobError`, or a
111    /// `String`/`&str` for adapters with nothing typed to carry.
112    pub fn spawn(source: impl Into<BoxError>) -> Self {
113        Self::Spawn(source.into())
114    }
115    /// Categorize a source error as an [`Install`](HarnessError::Install) failure.
116    pub fn install(source: impl Into<BoxError>) -> Self {
117        Self::Install(source.into())
118    }
119    /// Categorize a source error as a [`Login`](HarnessError::Login) failure.
120    pub fn login(source: impl Into<BoxError>) -> Self {
121        Self::Login(source.into())
122    }
123    /// Categorize a source error as a [`Cancel`](HarnessError::Cancel) failure.
124    pub fn cancel(source: impl Into<BoxError>) -> Self {
125        Self::Cancel(source.into())
126    }
127}
128
129// --- Run control (cancellation) -------------------------------------
130
131/// Object-safe handle to an in-flight run. A process-backed harness
132/// cancels by signalling its child; a request-backed harness (a hosted
133/// LLM API) cancels by aborting its HTTP stream. The consumer only needs
134/// these two operations, so the concrete mechanism stays behind the trait.
135pub trait RunControl: Send + Sync {
136    /// Stop the run. Best-effort; idempotent.
137    fn cancel(&self) -> Result<(), HarnessError>;
138    /// Whether [`cancel`](RunControl::cancel) was called.
139    fn was_cancelled(&self) -> bool;
140}
141
142/// Boxed [`RunControl`] returned by [`Harness::run`].
143pub type RunHandle = Box<dyn RunControl>;
144
145// The engine's run handle is the canonical process-backed `RunControl`.
146// Both the trait and the handle live in this crate, so this impl is here
147// (orphan rule) rather than in any adapter crate.
148impl RunControl for ProcessHandle {
149    fn cancel(&self) -> Result<(), HarnessError> {
150        ProcessHandle::cancel(self).map_err(HarnessError::cancel)
151    }
152    fn was_cancelled(&self) -> bool {
153        ProcessHandle::was_cancelled(self)
154    }
155}
156
157// --- Neutral request / metadata shapes ------------------------------
158
159/// What the user wants the harness to do with the prompt. Mirrors
160/// the Ask / Edit split the comment bubble already exposes; adapters
161/// map it onto their own mode vocabulary.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163#[serde(rename_all = "snake_case")]
164pub enum RunMode {
165    /// Answer / discuss. No file edits expected.
166    Ask,
167    /// Propose edits to the workspace.
168    Edit,
169}
170
171/// How hard the model should think, in harness-neutral terms. Codex
172/// maps this onto `model_reasoning_effort`; Claude Code has no
173/// equivalent `-p` flag today and ignores it. Kept neutral so a future
174/// harness that exposes effort can honor the same field.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum ReasoningEffort {
178    Minimal,
179    Low,
180    Medium,
181    High,
182}
183
184impl ReasoningEffort {
185    /// The CLI/config token for this level (e.g. codex's
186    /// `model_reasoning_effort="high"`).
187    pub fn as_cli_value(self) -> &'static str {
188        match self {
189            ReasoningEffort::Minimal => "minimal",
190            ReasoningEffort::Low => "low",
191            ReasoningEffort::Medium => "medium",
192            ReasoningEffort::High => "high",
193        }
194    }
195}
196
197/// User-chosen, harness-neutral run-shaping knobs. Every field is
198/// optional; each adapter maps the ones its CLI supports and ignores
199/// the rest (Claude has no reasoning-effort flag; Codex has no
200/// max-turns flag). Grouped into one struct so the neutral
201/// [`RunRequest`] stays open for extension — a new knob is a field
202/// here, not a new positional parameter threaded through every caller.
203#[derive(Debug, Clone, Default)]
204pub struct RunTuning {
205    /// Model id or alias passed verbatim to the CLI (`--model` /
206    /// `-m`). `None` → let the CLI use its configured default.
207    pub model: Option<String>,
208    /// Reasoning effort (Codex: `-c model_reasoning_effort`).
209    pub effort: Option<ReasoningEffort>,
210    /// Cap on agentic turns (Claude: `--max-turns`).
211    pub max_turns: Option<u32>,
212    /// Raw CLI args the host appends verbatim **after** the adapter's own,
213    /// so a host can add a flag (`--settings`, `--add-dir`) or override one
214    /// it already sets — for CLIs where a repeated flag is last-wins (e.g.
215    /// Claude Code / commander) — without editing the adapter. The host opts
216    /// into CLI-specific flag names when it uses this; keep cross-harness
217    /// knobs as their own typed fields above. Default empty.
218    pub extra_args: Vec<String>,
219}
220
221/// A harness-neutral run request. Adapter-specific knobs (bob's
222/// approval mode, coin budget, executable override) are filled in by
223/// the adapter from its own defaults; the user-facing tuning the
224/// picker exposes (model, effort, turn cap) rides on `tuning`.
225#[derive(Debug, Clone)]
226pub struct RunRequest {
227    /// Caller-chosen id used to correlate events with the handle.
228    pub run_id: String,
229    pub prompt: String,
230    /// Working directory for the run — the workspace path, so the
231    /// harness's tool calls land inside the user's vault.
232    pub cwd: Option<PathBuf>,
233    pub mode: RunMode,
234    /// Optional, harness-neutral run-shaping knobs (model, effort,
235    /// turn cap). Adapters honor the subset their CLI supports.
236    pub tuning: RunTuning,
237    /// Session id to **resume** — continue a prior run's conversation instead
238    /// of starting fresh, so the CLI supplies the history (no transcript replay
239    /// in the prompt). `None` → a new session. Each adapter maps it to its
240    /// CLI's resume form (Claude `--resume <id>`, codex `exec resume <id>`,
241    /// bob `-r <id>`); the id comes from the earlier run's init `SessionInfo`.
242    pub resume: Option<String>,
243}
244
245/// Where a harness's secret lives in the OS keychain, and how to
246/// label it in the UI. Lets the front-end ask for the right
247/// credential per harness without hard-coding any one harness's slot.
248#[derive(Debug, Clone, Serialize)]
249#[serde(rename_all = "camelCase")]
250pub struct CredentialSpec {
251    /// Human label, e.g. "Bob API key" / "Anthropic API key".
252    pub label: String,
253    pub keychain_service: String,
254    pub keychain_account: String,
255    /// Whether the harness can run at all without this credential.
256    pub required: bool,
257}
258
259/// Harness-neutral readiness snapshot for the UI. `details` carries
260/// adapter-specific probes (bob's Node/npm) as free-form JSON so the
261/// trait stays generic.
262#[derive(Debug, Clone, Serialize)]
263#[serde(rename_all = "camelCase")]
264pub struct HarnessReadiness {
265    pub harness_id: String,
266    /// Installed *and* authenticated *and* able to run.
267    pub ready: bool,
268    pub installed: bool,
269    pub version: Option<String>,
270    pub auth_configured: bool,
271    pub error: Option<String>,
272    /// Adapter-specific extra fields (serialized harness snapshot).
273    pub details: serde_json::Value,
274}
275
276/// A model the harness can be pointed at, for the picker's model
277/// selector. `value` is passed verbatim to the CLI (`--model` / `-m`)
278/// via [`RunTuning::model`]; `label` is the human-facing name.
279#[derive(Debug, Clone, Serialize)]
280#[serde(rename_all = "camelCase")]
281pub struct HarnessModel {
282    pub value: String,
283    pub label: String,
284}
285
286/// What a harness supports, so every consumer (the picker, the options
287/// panel, the credential preflight, the chat availability gate) adapts
288/// to it *declaratively* instead of branching on the harness id. A new
289/// adapter that, say, needs a stored key just sets `credential_required:
290/// true` here — no `id == "bob"` checks to hunt down.
291#[derive(Debug, Clone, Serialize)]
292#[serde(rename_all = "camelCase")]
293pub struct HarnessCapabilities {
294    /// Compose stores this harness's credential (bob). When `false`,
295    /// the CLI owns its own login (claude/codex) and Compose runs no
296    /// credential/install preflight — a missing login surfaces as the
297    /// harness's own run error rather than a Compose prompt.
298    pub credential_required: bool,
299    /// Emits previewable suggested edits the user approves before they
300    /// apply (bob). When `false`, edits land on disk directly and the
301    /// file watcher reflects them (claude/codex).
302    pub previews_edits: bool,
303    /// Curated model choices for the picker's selector. Empty → no
304    /// curated list (rely on `allows_custom_model`).
305    pub models: Vec<HarnessModel>,
306    /// Whether a free-text model id is accepted beyond `models` (codex,
307    /// whose model names change frequently). Drives a text field vs a
308    /// fixed dropdown in the picker.
309    pub allows_custom_model: bool,
310    /// Honors [`RunTuning::effort`] (codex reasoning effort).
311    pub supports_effort: bool,
312    /// Honors [`RunTuning::max_turns`] (claude turn cap).
313    pub supports_max_turns: bool,
314    /// Supports an interactive [`Harness::login`] flow (the CLI's own
315    /// OAuth, e.g. `claude auth login` / `codex login`). Drives the
316    /// picker's "Sign in" affordance when installed-but-not-signed-in.
317    /// `false` for harnesses Compose authenticates itself (bob).
318    pub supports_login: bool,
319}
320
321/// Static metadata for the harness picker.
322#[derive(Debug, Clone, Serialize)]
323#[serde(rename_all = "camelCase")]
324pub struct HarnessInfo {
325    pub id: String,
326    pub display_name: String,
327    pub description: String,
328    /// True if the harness needs a one-time [`Harness::install`].
329    pub requires_install: bool,
330    /// Declarative capabilities — what the harness supports, so the UI
331    /// and run-gating never special-case its id.
332    pub capabilities: HarnessCapabilities,
333}
334
335// --- The trait ------------------------------------------------------
336
337/// A pluggable agent backend. Implementors are cheap to construct
338/// (they hold config, not connections) so a registry can hand out
339/// fresh boxes on demand.
340pub trait Harness: Send + Sync {
341    /// Static metadata for the UI.
342    fn info(&self) -> HarnessInfo;
343
344    /// Probe availability / version / auth. May shell out; callers
345    /// should treat it as blocking and run it off the UI thread.
346    fn readiness(&self) -> HarnessReadiness;
347
348    /// Stream a one-time install. Harnesses that need no install
349    /// (e.g. a hosted-API adapter) return `Ok(())` immediately.
350    fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError>;
351
352    /// Start a run, streaming events through `on_event`. Returns a
353    /// handle immediately; work continues on background threads.
354    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError>;
355
356    /// The credential this harness needs.
357    fn credential(&self) -> CredentialSpec;
358
359    /// Trigger the harness's own interactive sign-in (its CLI's OAuth),
360    /// streaming progress as [`InstallEvent`]s — the same subprocess
361    /// stream shape as [`install`](Harness::install). The flow opens the
362    /// user's browser; this blocks until the login process exits, then
363    /// `Done { ok }` reports success. Default: unsupported — harnesses
364    /// that Compose authenticates itself (bob, via its API key) keep it.
365    fn login(&self, _on_event: InstallCallback) -> Result<(), HarnessError> {
366        Err(HarnessError::login(
367            "This harness does not support interactive sign-in.",
368        ))
369    }
370
371    /// Convenience over [`run`](Harness::run) for callers that want to
372    /// *pull* events off a channel instead of supplying a push callback.
373    /// Forwards each [`RunEvent`] into an `mpsc` channel and hands the
374    /// receiver back alongside the run handle, so the caller can simply
375    /// `for event in rx { … }` rather than re-write the
376    /// `Arc::new(move |ev| tx.send(ev))` plumbing at every call site.
377    ///
378    /// The receiver hangs up when the run ends — and on its own, without
379    /// the caller dropping the [`RunHandle`] first. The forwarding callback
380    /// (and the `Sender` it owns) lives only on the engine's reader
381    /// threads; once the process exits and those threads finish, every
382    /// clone of the callback drops, the `Sender` drops, and the `for` loop
383    /// over `rx` terminates. (Dropping the handle never cancels a run — see
384    /// [`RunControl`] — so it is safe to drain `rx` to completion while
385    /// still holding the handle for a possible [`cancel`](RunControl::cancel).)
386    ///
387    /// Prefer [`run`](Harness::run) directly when you need push semantics —
388    /// e.g. forwarding straight onto a Tauri `Channel` or an SSE sink from
389    /// inside the callback — where an intermediate channel is just an extra
390    /// hop. This is a provided method (not overridable surface): adapters
391    /// implement only `run`, and every harness — built-in or third-party —
392    /// gets `run_channel` for free.
393    ///
394    /// ```no_run
395    /// use harness::{Claude, Harness, RunEvent, RunMode, RunRequest, RunTuning};
396    ///
397    /// # fn main() -> Result<(), harness::HarnessError> {
398    /// let (_handle, rx) = Claude::new().run_channel(RunRequest {
399    ///     run_id: "demo".into(),
400    ///     prompt: "Explain Markdown headings in one sentence.".into(),
401    ///     cwd: None,
402    ///     mode: RunMode::Ask,
403    ///     tuning: RunTuning::default(),
404    ///     resume: None,
405    /// })?;
406    /// for event in rx {
407    ///     match event {
408    ///         RunEvent::Text { delta, .. } => print!("{delta}"),
409    ///         RunEvent::Exited { .. } => break,
410    ///         _ => {}
411    ///     }
412    /// }
413    /// # Ok(())
414    /// # }
415    /// ```
416    fn run_channel(
417        &self,
418        request: RunRequest,
419    ) -> Result<(RunHandle, mpsc::Receiver<RunEvent>), HarnessError> {
420        let (tx, rx) = mpsc::channel();
421        let handle = self.run(
422            request,
423            Arc::new(move |event| {
424                // A hung-up receiver (consumer stopped early) is not an
425                // error: the run keeps streaming; we just drop the event
426                // nobody is waiting for.
427                let _ = tx.send(event);
428            }),
429        )?;
430        Ok((handle, rx))
431    }
432}
433
434/// Run a harness's interactive sign-in command, streaming its output as
435/// [`InstallEvent`]s and blocking until it exits. Reuses
436/// [`spawn_streaming`] (PATH augmentation + reader threads, so a packaged
437/// `.app` finds the CLI), mapping its process events onto the
438/// install-stream shape (Step / Stdout / Stderr / Done). The login CLI
439/// opens the user's browser for OAuth; we surface its output (incl. any
440/// device-code URL) so the UI can show progress. Blocks on a condvar
441/// until the process exits — the caller is a Tauri `(async)` command on
442/// a worker thread, so the UI never blocks.
443pub fn run_login_command(
444    program: &str,
445    args: &[&str],
446    on_event: InstallCallback,
447) -> Result<(), HarnessError> {
448    (*on_event)(InstallEvent::Step {
449        text: "Opening your browser to sign in…".to_owned(),
450    });
451    let done = Arc::new((Mutex::new(false), Condvar::new()));
452    let done_cb = Arc::clone(&done);
453    let events_cb = Arc::clone(&on_event);
454    // Bound, not `_`, so the handle outlives the wait (dropping it could
455    // signal the child); by the time we return, the process has exited.
456    let _handle = spawn_streaming(
457        PathBuf::from(program),
458        args.iter().map(|s| (*s).to_owned()).collect(),
459        Vec::new(),
460        std::env::current_dir().unwrap_or_default(),
461        format!("login-{program}"),
462        move |event| match event {
463            ProcessEvent::Started { .. } => {}
464            ProcessEvent::Stdout { line, .. } => {
465                (*events_cb)(InstallEvent::Stdout { text: line });
466            }
467            ProcessEvent::Stderr { line, .. } => {
468                (*events_cb)(InstallEvent::Stderr { text: line });
469            }
470            ProcessEvent::Error { message, .. } => {
471                (*events_cb)(InstallEvent::Stderr { text: message });
472            }
473            ProcessEvent::Exited { exit_code, .. } => {
474                (*events_cb)(InstallEvent::Done {
475                    exit_code,
476                    ok: exit_code == Some(0),
477                });
478                let (lock, cvar) = &*done_cb;
479                // Recover from a poisoned lock instead of panicking on a
480                // reader thread: the guarded value is a plain bool, never in a
481                // half-updated state worth bailing on.
482                *lock.lock().unwrap_or_else(|p| p.into_inner()) = true;
483                cvar.notify_all();
484            }
485            // `ProcessEvent` is #[non_exhaustive]; ignore any future variant.
486            _ => {}
487        },
488    )
489    .map_err(HarnessError::login)?;
490    let (lock, cvar) = &*done;
491    let mut finished = lock.lock().unwrap_or_else(|p| p.into_inner());
492    while !*finished {
493        finished = cvar.wait(finished).unwrap_or_else(|p| p.into_inner());
494    }
495    Ok(())
496}
497
498/// Whether an API-key value an adapter pulled from the environment counts as
499/// authenticated — i.e. present and non-blank. Adapters OR this into their
500/// [`Harness::readiness`] so a key in the env (headless / CI / container)
501/// reports authenticated, not only the CLI's own interactive OAuth login —
502/// which can't complete where there's no browser. Pure (the env read stays at
503/// the call site) so it's unit-tested directly.
504///
505/// Only the claude/codex adapters OR this into readiness — bob reports auth via
506/// `bob-rs`'s own keychain source — so it's gated to those features. Without
507/// them (`--no-default-features`) it would be dead code, hence the `cfg`.
508#[cfg(any(feature = "claude", feature = "codex"))]
509pub(crate) fn api_key_value_usable(value: Option<String>) -> bool {
510    matches!(value, Some(v) if !v.trim().is_empty())
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    // Gated like the fn it tests — `api_key_value_usable` only exists when a
518    // claude/codex adapter is compiled in.
519    #[cfg(any(feature = "claude", feature = "codex"))]
520    #[test]
521    fn api_key_value_usable_requires_a_nonblank_value() {
522        assert!(api_key_value_usable(Some("sk-abc".to_owned())));
523        assert!(!api_key_value_usable(Some(String::new())));
524        assert!(!api_key_value_usable(Some("   ".to_owned())));
525        assert!(!api_key_value_usable(None));
526    }
527
528    /// A no-op [`RunControl`] so the mock harness below can hand back a
529    /// [`RunHandle`] without a real process behind it.
530    struct NoopControl;
531    impl RunControl for NoopControl {
532        fn cancel(&self) -> Result<(), HarnessError> {
533            Ok(())
534        }
535        fn was_cancelled(&self) -> bool {
536            false
537        }
538    }
539
540    /// A minimal in-memory harness whose `run()` pushes a fixed event
541    /// sequence straight to the callback, synchronously, then returns —
542    /// dropping its only `RunCallback` clone. That's exactly the ownership
543    /// shape `run_channel` relies on, with no subprocess to spawn, so it
544    /// pins down the contract: events are forwarded, and the receiver hangs
545    /// up on its own once the run's callback ownership ends.
546    struct MockHarness {
547        events: Vec<RunEvent>,
548    }
549    impl Harness for MockHarness {
550        fn info(&self) -> HarnessInfo {
551            unreachable!("not exercised by run_channel")
552        }
553        fn readiness(&self) -> HarnessReadiness {
554            unreachable!("not exercised by run_channel")
555        }
556        fn install(&self, _on_event: InstallCallback) -> Result<(), HarnessError> {
557            Ok(())
558        }
559        fn run(
560            &self,
561            _request: RunRequest,
562            on_event: RunCallback,
563        ) -> Result<RunHandle, HarnessError> {
564            for event in &self.events {
565                on_event(event.clone());
566            }
567            // `on_event` (the lone RunCallback clone, owning the channel's
568            // Sender) drops as this returns → the receiver closes.
569            Ok(Box::new(NoopControl))
570        }
571        fn credential(&self) -> CredentialSpec {
572            unreachable!("not exercised by run_channel")
573        }
574    }
575
576    fn demo_request() -> RunRequest {
577        RunRequest {
578            run_id: "t".to_owned(),
579            prompt: "hi".to_owned(),
580            cwd: None,
581            mode: RunMode::Ask,
582            tuning: RunTuning::default(),
583            resume: None,
584        }
585    }
586
587    #[test]
588    fn run_channel_forwards_every_event_then_closes() {
589        let harness = MockHarness {
590            events: vec![
591                RunEvent::Text {
592                    run_id: "t".to_owned(),
593                    delta: "hello".to_owned(),
594                },
595                RunEvent::Exited {
596                    run_id: "t".to_owned(),
597                    exit_code: Some(0),
598                    cancelled: false,
599                },
600            ],
601        };
602        let (_handle, rx) = harness.run_channel(demo_request()).expect("run_channel ok");
603        // Draining to completion *terminates* — proof the channel closed
604        // without us dropping the handle.
605        let collected: Vec<RunEvent> = rx.into_iter().collect();
606        assert_eq!(
607            collected,
608            vec![
609                RunEvent::Text {
610                    run_id: "t".to_owned(),
611                    delta: "hello".to_owned(),
612                },
613                RunEvent::Exited {
614                    run_id: "t".to_owned(),
615                    exit_code: Some(0),
616                    cancelled: false,
617                },
618            ]
619        );
620    }
621
622    #[test]
623    fn run_channel_receiver_closes_even_with_no_events() {
624        let harness = MockHarness { events: Vec::new() };
625        let (_handle, rx) = harness.run_channel(demo_request()).expect("run_channel ok");
626        assert_eq!(rx.into_iter().count(), 0); // closes immediately, doesn't hang
627    }
628
629    #[test]
630    fn harness_error_preserves_typed_source_and_flattened_message() {
631        use std::error::Error;
632
633        // Categorize a real typed engine error as a Spawn failure.
634        let err = HarnessError::spawn(cli_stream::StreamError::PipeNotCaptured { stream: "stdout" });
635
636        // Display still flattens the source into the message, so a consumer
637        // that just `.to_string()`s at a boundary (a Tauri command) gets the
638        // category prefix *and* the full underlying detail — unchanged from
639        // when the variant held a String.
640        let message = err.to_string();
641        assert!(message.starts_with("failed to start the agent: "), "got {message:?}");
642        assert!(message.contains("stdout pipe was not captured"), "got {message:?}");
643
644        // And the real typed error is reachable via the source chain — the
645        // whole point of carrying a source instead of a flattened string.
646        let source = err.source().expect("HarnessError::Spawn has a source");
647        assert!(
648            source.downcast_ref::<cli_stream::StreamError>().is_some(),
649            "source should downcast back to the typed StreamError"
650        );
651    }
652}