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::{Command, Event, InstallEvent, ProcessHandle};
30use crate::node_cli::ResolveCli;
31
32// --- Streaming callbacks --------------------------------------------
33
34/// Callback a harness invokes for each run event. `Arc<dyn Fn>` is
35/// `Clone + Send + Sync`, so it can be handed to the multiple reader
36/// threads a process-backed harness uses without the trait method
37/// needing to be generic.
38pub type RunCallback = Arc<dyn Fn(RunEvent) + Send + Sync>;
39
40/// Callback a harness invokes for each install event.
41pub type InstallCallback = Arc<dyn Fn(InstallEvent) + Send + Sync>;
42
43// --- Errors ---------------------------------------------------------
44
45/// A boxed, type-erased error source. The [`Error`] variants carry one
46/// of these instead of `#[from]`-ing a single concrete type, because each
47/// *category* can be produced by more than one underlying error: a `Spawn`
48/// failure is a [`cli_stream::StreamError`] for the claude/codex adapters but a
49/// `bob_rs::BobError` for bob. The real error stays reachable through
50/// [`std::error::Error::source`] (and `downcast_ref`); the category is the
51/// variant.
52pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
53
54/// Why a [`Harness`] operation failed. Returned by `install` / `run` /
55/// `login` / [`RunControl::cancel`] so a consumer can branch on the *kind* of
56/// failure — offer install vs sign-in vs surface the message — instead of
57/// string-matching.
58///
59/// Each category carries the real underlying error as a [`source`] (via the
60/// [`BoxError`] field), so a consumer that wants more than the category can
61/// walk `.source()` or `downcast_ref::<cli_stream::StreamError>()` /
62/// `::<bob_rs::BobError>()`. The `Display` still flattens the source into the
63/// message (`"failed to start the agent: <source>"`), so a consumer that just
64/// stringifies at a boundary (e.g. a Tauri command's `.to_string()`) gets the
65/// same full message as before. `#[non_exhaustive]` so adding a variant later
66/// isn't a breaking change.
67///
68/// ```
69/// use harness::{Error, StreamError};
70/// use std::error::Error as _; // for `.source()`, without shadowing `harness::Error`
71///
72/// // Box any typed source under a category constructor:
73/// let err = Error::spawn(StreamError::PipeNotCaptured { stream: "stdout" });
74///
75/// // Stringifying at a boundary flattens the source into the message
76/// // (so a Tauri command's `.to_string()` keeps its full text)…
77/// assert!(err.to_string().starts_with("failed to start the agent: "));
78///
79/// // …while the real typed cause stays reachable for a consumer that wants
80/// // to branch on it rather than parse a string.
81/// let source = err.source().expect("Command carries a source");
82/// assert!(source.downcast_ref::<StreamError>().is_some());
83/// ```
84///
85/// [`source`]: std::error::Error::source
86#[derive(Debug, thiserror::Error)]
87#[non_exhaustive]
88pub enum Error {
89    /// The harness's CLI couldn't be started — not installed, not on `PATH`,
90    /// or an OS-level spawn failure.
91    #[error("failed to start the agent: {0}")]
92    Spawn(#[source] BoxError),
93    /// A one-time install step failed.
94    #[error("install failed: {0}")]
95    Install(#[source] BoxError),
96    /// Interactive sign-in failed.
97    #[error("sign-in failed: {0}")]
98    Login(#[source] BoxError),
99    /// Cancelling an in-flight run failed.
100    #[error("cancel failed: {0}")]
101    Cancel(#[source] BoxError),
102    /// Any other adapter/runtime failure (e.g. a backend SDK error that
103    /// doesn't map onto the cases above). Carries a message rather than a
104    /// source — it's the catch-all when there's nothing typed to preserve.
105    #[error("{0}")]
106    Other(String),
107}
108
109impl Error {
110    /// Categorize a source error as a [`Spawn`](Error::Spawn) failure.
111    /// Accepts anything boxable — a typed `StreamError`/`BobError`, or a
112    /// `String`/`&str` for adapters with nothing typed to carry.
113    pub fn spawn(source: impl Into<BoxError>) -> Self {
114        Self::Spawn(source.into())
115    }
116    /// Categorize a source error as an [`Install`](Error::Install) failure.
117    pub fn install(source: impl Into<BoxError>) -> Self {
118        Self::Install(source.into())
119    }
120    /// Categorize a source error as a [`Login`](Error::Login) failure.
121    pub fn login(source: impl Into<BoxError>) -> Self {
122        Self::Login(source.into())
123    }
124    /// Categorize a source error as a [`Cancel`](Error::Cancel) failure.
125    pub fn cancel(source: impl Into<BoxError>) -> Self {
126        Self::Cancel(source.into())
127    }
128}
129
130// --- Run control (cancellation) -------------------------------------
131
132/// Object-safe handle to an in-flight run. A process-backed harness
133/// cancels by signalling its child; a request-backed harness (a hosted
134/// LLM API) cancels by aborting its HTTP stream. The consumer only needs
135/// these two operations, so the concrete mechanism stays behind the trait.
136pub trait RunControl: Send + Sync {
137    /// Stop the run. Best-effort; idempotent.
138    fn cancel(&self) -> Result<(), Error>;
139    /// Whether [`cancel`](RunControl::cancel) was called.
140    fn was_cancelled(&self) -> bool;
141    /// The OS process id of the underlying child while it's alive, for a
142    /// process-backed run. `None` for adapters with no child process (a
143    /// direct-model run aborts an HTTP stream, not a process) — so an embedder
144    /// can record live pids and reap a child a hard crash orphaned.
145    fn pid(&self) -> Option<u32> {
146        None
147    }
148}
149
150/// Boxed [`RunControl`] returned by [`Harness::run`].
151pub type RunHandle = Box<dyn RunControl>;
152
153// The engine's run handle is the canonical process-backed `RunControl`.
154// Both the trait and the handle live in this crate, so this impl is here
155// (orphan rule) rather than in any adapter crate.
156impl RunControl for ProcessHandle {
157    fn cancel(&self) -> Result<(), Error> {
158        ProcessHandle::cancel(self).map_err(Error::cancel)
159    }
160    fn was_cancelled(&self) -> bool {
161        ProcessHandle::was_cancelled(self)
162    }
163    fn pid(&self) -> Option<u32> {
164        ProcessHandle::pid(self)
165    }
166}
167
168// --- Neutral request / metadata shapes ------------------------------
169
170/// What the user wants the harness to do with the prompt. Mirrors
171/// the Ask / Edit split the comment bubble already exposes; adapters
172/// map it onto their own mode vocabulary.
173///
174/// **How strongly `Ask` is enforced depends on the adapter**, because only one
175/// of them owns the tools:
176///
177/// * `openai-compatible` — this crate owns the tool surface, so `Ask` simply
178///   does not offer the mutating tools. The model cannot call what it was
179///   never given.
180/// * `acp` — the agent owns its tools. `Ask` denies ACP permission requests,
181///   which works only for calls the agent chooses to ask about. An agent that
182///   treats reading a file or searching the web as safe will just do it.
183/// * `claude` / `codex` — mapped onto each CLI's own permission flags, so the
184///   CLI enforces it.
185///
186/// Treat `Ask` as "do not change my files", not as a sandbox. None of these
187/// adapters isolate the process.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
189#[serde(rename_all = "snake_case")]
190pub enum RunMode {
191    /// Answer / discuss. No file edits expected.
192    ///
193    /// The default, deliberately: a caller who omits the mode gets the
194    /// read-only one. Defaulting to `Edit` would hand write access to anyone
195    /// who forgot the field.
196    #[default]
197    Ask,
198    /// Propose edits to the workspace.
199    Edit,
200}
201
202/// How hard the model should think, in harness-neutral terms. Codex
203/// maps this onto `model_reasoning_effort`; Claude Code has no
204/// equivalent `-p` flag today and ignores it. Kept neutral so a future
205/// harness that exposes effort can honor the same field.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum ReasoningEffort {
209    Minimal,
210    Low,
211    Medium,
212    High,
213}
214
215impl ReasoningEffort {
216    /// The CLI/config token for this level (e.g. codex's
217    /// `model_reasoning_effort="high"`).
218    pub fn as_cli_value(self) -> &'static str {
219        match self {
220            ReasoningEffort::Minimal => "minimal",
221            ReasoningEffort::Low => "low",
222            ReasoningEffort::Medium => "medium",
223            ReasoningEffort::High => "high",
224        }
225    }
226}
227
228/// User-chosen, harness-neutral run-shaping knobs. Every field is
229/// optional; each adapter maps the ones its CLI supports and ignores
230/// the rest (Claude has no reasoning-effort flag; Codex has no
231/// max-turns flag). Grouped into one struct so the neutral
232/// [`RunRequest`] stays open for extension — a new knob is a field
233/// here, not a new positional parameter threaded through every caller.
234#[derive(Debug, Clone, Default)]
235pub struct RunTuning {
236    /// Model id or alias passed verbatim to the CLI (`--model` /
237    /// `-m`). `None` → let the CLI use its configured default.
238    pub model: Option<String>,
239    /// Reasoning effort (Codex: `-c model_reasoning_effort`).
240    pub effort: Option<ReasoningEffort>,
241    /// Cap on agentic turns (Claude: `--max-turns`).
242    pub max_turns: Option<u32>,
243    /// Raw CLI args the host appends verbatim **after** the adapter's own,
244    /// so a host can add a flag (`--settings`, `--add-dir`) or override one
245    /// it already sets — for CLIs where a repeated flag is last-wins (e.g.
246    /// Claude Code / commander) — without editing the adapter. The host opts
247    /// into CLI-specific flag names when it uses this; keep cross-harness
248    /// knobs as their own typed fields above. Default empty.
249    pub extra_args: Vec<String>,
250    /// A JSON Schema the final assistant answer must conform to (structured
251    /// output). Adapters that support it constrain the model's final message to
252    /// this schema; the rest ignore it. `None` → free-form text.
253    pub output_schema: Option<serde_json::Value>,
254    /// Extra system-prompt instructions from the host — the user's per-harness
255    /// "custom instructions". The `openai-compatible` adapter appends it after
256    /// its base system prompt; other adapters currently ignore it (a CLI mapping
257    /// such as Claude's `--append-system-prompt` can opt in later). `None` → none.
258    pub extra_instructions: Option<String>,
259    /// Absolute path to the agent's executable, overriding PATH resolution of
260    /// the bare CLI name. `None` → resolve by name on PATH. CLI adapters
261    /// (claude/codex/bob) spawn this path instead of their default program; the
262    /// `openai-compatible` adapter spawns no process and ignores it.
263    pub binary_path: Option<std::path::PathBuf>,
264}
265
266/// A non-text input attached to a run — currently an image. Multimodal adapters
267/// (`openai-compatible`) send it to the model; text-only CLI adapters ignore it.
268#[derive(Debug, Clone)]
269pub struct Attachment {
270    /// MIME type, e.g. `image/png` or `image/jpeg`.
271    pub mime_type: String,
272    /// Raw bytes; the adapter base64-encodes them into a data URI for the wire.
273    pub data: Vec<u8>,
274}
275
276/// A harness-neutral run request. Adapter-specific knobs (bob's
277/// approval mode, coin budget, executable override) are filled in by
278/// the adapter from its own defaults; the user-facing tuning the
279/// picker exposes (model, effort, turn cap) rides on `tuning`.
280/// Derives `Default`, so a call site names only what it cares about and leaves
281/// the rest to `..Default::default()`. Spelling out `cwd: None`, `resume:
282/// None` and `attachments: Vec::new()` on every call was noise, and it made
283/// each new field a breaking change for every caller.
284#[derive(Debug, Clone, Default)]
285pub struct RunRequest {
286    /// Caller-chosen id used to correlate events with the handle.
287    pub run_id: String,
288    pub prompt: String,
289    /// Non-text inputs (images) for multimodal models; empty for a text run.
290    /// Multimodal adapters send them to the model; text-only CLI adapters
291    /// ignore them.
292    pub attachments: Vec<Attachment>,
293    /// Working directory for the run — the workspace path, so the
294    /// harness's tool calls land inside the user's vault.
295    pub cwd: Option<PathBuf>,
296    pub mode: RunMode,
297    /// Optional, harness-neutral run-shaping knobs (model, effort,
298    /// turn cap). Adapters honor the subset their CLI supports.
299    pub tuning: RunTuning,
300    /// Session id to **resume** — continue a prior run's conversation instead
301    /// of starting fresh, so the CLI supplies the history (no transcript replay
302    /// in the prompt). `None` → a new session. Each adapter maps it to its
303    /// CLI's resume form (Claude `--resume <id>`, codex `exec resume <id>`,
304    /// bob `-r <id>`); the id comes from the earlier run's init `SessionInfo`.
305    pub resume: Option<String>,
306}
307
308/// Where a harness's secret lives in the OS keychain, and how to
309/// label it in the UI. Lets the front-end ask for the right
310/// credential per harness without hard-coding any one harness's slot.
311#[derive(Debug, Clone, Serialize)]
312#[serde(rename_all = "camelCase")]
313pub struct CredentialSpec {
314    /// Human label, e.g. "Bob API key" / "Anthropic API key".
315    pub label: String,
316    pub keychain_service: String,
317    pub keychain_account: String,
318    /// Whether the harness can run at all without this credential.
319    pub required: bool,
320}
321
322/// Harness-neutral readiness snapshot for the UI. `details` carries
323/// adapter-specific probes (bob's Node/npm) as free-form JSON so the
324/// trait stays generic.
325#[derive(Debug, Clone, Serialize)]
326#[serde(rename_all = "camelCase")]
327pub struct Readiness {
328    pub harness_id: String,
329    /// Installed *and* authenticated *and* able to run.
330    pub ready: bool,
331    pub installed: bool,
332    pub version: Option<String>,
333    pub auth_configured: bool,
334    pub error: Option<String>,
335    /// Adapter-specific extra fields (serialized harness snapshot).
336    pub details: serde_json::Value,
337}
338
339/// A model the harness can be pointed at, for the picker's model
340/// selector. `value` is passed verbatim to the CLI (`--model` / `-m`)
341/// via [`RunTuning::model`]; `label` is the human-facing name.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
343#[serde(rename_all = "camelCase")]
344pub struct ModelChoice {
345    pub value: String,
346    pub label: String,
347}
348
349/// An installed model with the metadata a model-manager UI shows — the
350/// neutral shape returned by [`Harness::list_installed_models`]. Richer than
351/// [`ModelChoice`] (the picker's name-only entry): on-disk `size` in bytes plus
352/// the parameter count / quantization where the backend reports them.
353#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
354#[serde(rename_all = "camelCase")]
355pub struct InstalledModel {
356    pub name: String,
357    /// On-disk size in bytes.
358    pub size: u64,
359    /// e.g. `"3.2B"`; `None` when the backend doesn't report it.
360    pub parameter_size: Option<String>,
361    /// e.g. `"Q4_K_M"`; `None` when the backend doesn't report it.
362    pub quantization_level: Option<String>,
363}
364
365/// A progress update from [`Harness::pull_model`], one per chunk of a streaming
366/// download. `status` is always present (`"pulling manifest"`, `"success"`, …);
367/// the byte counters appear once a layer is downloading, so a host can show a
368/// percentage from `completed`/`total` aggregated across `digest`s.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct PullProgress {
371    pub status: String,
372    /// The layer this line reports on; `None` for phase lines (manifest,
373    /// success).
374    pub digest: Option<String>,
375    pub total: Option<u64>,
376    pub completed: Option<u64>,
377}
378
379/// A host push-callback for [`Harness::pull_model`] download progress, invoked
380/// per stream chunk on the calling thread.
381pub type PullProgressCallback<'a> = &'a mut (dyn FnMut(PullProgress) + Send);
382
383/// Folds a stream of [`PullProgress`] into a single overall percent, so a host
384/// shows one progress bar for a multi-layer download. A streaming pull reports
385/// `completed`/`total` *per `digest`* and resends a digest's line as it grows;
386/// this keeps the latest figures per digest, so the overall percent is
387/// `100 * Σcompleted / Σtotal` across the digests seen so far.
388#[derive(Debug, Default)]
389pub struct PullProgressAggregator {
390    layers: std::collections::HashMap<String, (u64, u64)>,
391}
392
393impl PullProgressAggregator {
394    /// Fold one progress update in (only `digest` lines carrying a `total`
395    /// count) and return the overall percent so far — `None` until any byte
396    /// total is known (e.g. during the manifest phase).
397    pub fn update(&mut self, progress: &PullProgress) -> Option<f64> {
398        if let (Some(digest), Some(total)) = (&progress.digest, progress.total) {
399            self.layers.insert(digest.clone(), (progress.completed.unwrap_or(0), total));
400        }
401        self.percent()
402    }
403
404    /// Overall percent across every digest seen, clamped to 0–100; `None` until a
405    /// total is known. A just-finished layer can momentarily report
406    /// `completed > total`, so the ratio is capped.
407    pub fn percent(&self) -> Option<f64> {
408        let total: u64 = self.layers.values().map(|(_, t)| *t).sum();
409        if total == 0 {
410            return None;
411        }
412        let completed: u64 = self.layers.values().map(|(c, _)| *c).sum();
413        Some((completed as f64 / total as f64 * 100.0).clamp(0.0, 100.0))
414    }
415}
416
417/// What a harness's local-model management exposes — returned by
418/// [`Harness::model_management`] (`None` when unsupported). Today only the
419/// `openai-compatible` Ollama adapter manages models; carrying its endpoint lets
420/// a host link out (e.g. "browse all models") without re-deriving it, while the
421/// pull/list/delete operations themselves stay behind the trait so HTTP never
422/// leaves the adapter.
423#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
424#[serde(rename_all = "camelCase")]
425pub struct ModelManagement {
426    /// The model server's base URL (e.g. `http://localhost:11434`), for a host
427    /// to show or link to; not used to issue requests host-side.
428    pub base_url: String,
429}
430
431/// What a harness supports, so every consumer (the picker, the options
432/// panel, the credential preflight, the chat availability gate) adapts
433/// to it *declaratively* instead of branching on the harness id. A new
434/// adapter that, say, needs a stored key just sets `credential_required:
435/// true` here — no `id == "bob"` checks to hunt down.
436///
437/// [`Default`] is "supports nothing", which is the honest starting point: an
438/// adapter names what it does support and leaves the rest, rather than
439/// restating eight fields and risking one being wrong by omission.
440///
441/// ```
442/// # use harness::Features;
443/// let claude_like = Features { max_turns: true, ..Default::default() };
444/// assert!(!claude_like.effort);
445/// ```
446#[derive(Debug, Clone, Default, Serialize)]
447#[serde(rename_all = "camelCase")]
448pub struct Features {
449    /// Compose stores this harness's credential (bob). When `false`,
450    /// the CLI owns its own login (claude/codex) and Compose runs no
451    /// credential/install preflight — a missing login surfaces as the
452    /// harness's own run error rather than a Compose prompt.
453    pub credential_required: bool,
454    /// Emits previewable suggested edits the user approves before they
455    /// apply (bob). When `false`, edits land on disk directly and the
456    /// file watcher reflects them (claude/codex).
457    pub previews_edits: bool,
458    /// Curated model choices for the picker's selector. Empty → no
459    /// curated list (rely on `custom_model`).
460    pub models: Vec<ModelChoice>,
461    /// Whether a free-text model id is accepted beyond `models` (codex,
462    /// whose model names change frequently). Drives a text field vs a
463    /// fixed dropdown in the picker.
464    pub custom_model: bool,
465    /// Honors [`RunTuning::effort`] (codex reasoning effort).
466    pub effort: bool,
467    /// Honors [`RunTuning::max_turns`] (claude turn cap).
468    pub max_turns: bool,
469    /// Supports an interactive [`Harness::login`] flow (the CLI's own
470    /// OAuth, e.g. `claude auth login` / `codex login`). Drives the
471    /// picker's "Sign in" affordance when installed-but-not-signed-in.
472    /// `false` for harnesses Compose authenticates itself (bob).
473    pub login: bool,
474    /// Honors [`RunTuning::extra_instructions`] — the user's per-harness custom
475    /// instructions, appended to the system prompt. `true` only for the
476    /// `openai-compatible` adapter so far; the picker hides the field for the
477    /// rest rather than offering a control that does nothing.
478    pub custom_instructions: bool,
479}
480
481/// Where a user gets a harness that isn't on the machine yet.
482///
483/// This crate discovers and runs agents; it never installs them. A harness
484/// that depends on an external CLI says so here and the host renders it, so
485/// "not installed" is a next step rather than a dead end.
486#[derive(Debug, Clone, Serialize)]
487#[serde(rename_all = "camelCase")]
488pub struct InstallHint {
489    /// Where to get it. Always present — every agent has a home page, while
490    /// only some have a one-liner that works on every platform.
491    pub url: String,
492    /// A copy-pasteable command, when one exists for every supported platform.
493    pub command: Option<String>,
494}
495
496impl InstallHint {
497    pub fn url(url: impl Into<String>) -> Self {
498        Self { url: url.into(), command: None }
499    }
500
501    pub fn with_command(mut self, command: impl Into<String>) -> Self {
502        self.command = Some(command.into());
503        self
504    }
505}
506
507/// Who a harness is: the identity and presentation a picker renders.
508///
509/// What it can *do* is [`Features`], asked for separately — that question
510/// is put far more often than this one, and answering it should not mean
511/// building three strings.
512#[derive(Debug, Clone, Serialize)]
513#[serde(rename_all = "camelCase")]
514pub struct Info {
515    pub id: String,
516    pub display_name: String,
517    pub description: String,
518    /// How the user installs this harness themselves. `None` when there is
519    /// nothing to install — a hosted endpoint, or an agent they already supply.
520    pub install_hint: Option<InstallHint>,
521}
522
523// --- The trait ------------------------------------------------------
524
525/// A pluggable agent backend. Implementors are cheap to construct
526/// (they hold config, not connections) so a registry can hand out
527/// fresh boxes on demand.
528pub trait Harness: Send + Sync {
529    /// Who this harness is — identity and presentation, for the picker.
530    fn info(&self) -> Info;
531
532    /// What this harness supports, so a consumer adapts to it declaratively
533    /// instead of branching on [`Info::id`].
534    ///
535    /// Defaults to supporting nothing, which is the safe direction: an adapter
536    /// names what it does, and one that has not heard of a capability added
537    /// later does not claim it.
538    fn features(&self) -> Features {
539        Features::default()
540    }
541
542    /// Probe availability / version / auth. May shell out; callers
543    /// should treat it as blocking and run it off the UI thread.
544    fn readiness(&self) -> Readiness;
545
546    /// Start a run, streaming events through `on_event`. Returns a
547    /// handle immediately; work continues on background threads.
548    fn start(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, Error>;
549
550    /// The credential this harness needs.
551    fn credential(&self) -> CredentialSpec;
552
553    /// Enumerate the models this harness can run, *live*. The default returns
554    /// the static list declared in [`Info`]
555    /// (`capabilities().models`), so existing adapters need no change.
556    ///
557    /// Override it when the model set is discovered at runtime rather than
558    /// known at compile time — a hosted-API adapter querying the provider's
559    /// `/v1/models`, an Ollama adapter hitting `/api/tags`. A harness with no
560    /// model-selection concept (bob runs whatever it's configured with)
561    /// returns an empty list, and the host hides the picker — capability by
562    /// the *absence* of models, not a separate flag. May shell out / hit the
563    /// network; treat it as blocking and run it off the UI thread.
564    fn list_models(&self) -> Result<Vec<ModelChoice>, Error> {
565        Ok(self.features().models)
566    }
567
568    /// Whether this harness can install/list/delete its own models locally, and
569    /// if so the endpoint metadata a host UI can surface (see
570    /// [`ModelManagement`]). `None` (the default) means model management isn't
571    /// supported — a host hides the "Manage models" surface. Only the
572    /// `openai-compatible` Ollama adapter returns `Some` today.
573    fn model_management(&self) -> Option<ModelManagement> {
574        None
575    }
576
577    /// Installed local models with their on-disk size + details, for a manager
578    /// UI (distinct from [`list_models`](Harness::list_models), the picker's
579    /// name-only set). Default: unsupported — override alongside
580    /// [`model_management`](Harness::model_management). Blocking (hits the local
581    /// server); run it off the UI thread.
582    fn list_installed_models(&self) -> Result<Vec<InstalledModel>, Error> {
583        Err(Error::Other(
584            "This harness does not support managing models.".to_owned(),
585        ))
586    }
587
588    /// Download (install) a model, streaming progress to `on_progress`. `cancel`
589    /// is polled during the download; flipping it aborts the pull. Blocking
590    /// until the download finishes (or fails / is cancelled); run it off the UI
591    /// thread. Default: unsupported.
592    fn pull_model(
593        &self,
594        _model: &str,
595        _cancel: &std::sync::atomic::AtomicBool,
596        _on_progress: PullProgressCallback<'_>,
597    ) -> Result<(), Error> {
598        Err(Error::Other(
599            "This harness does not support managing models.".to_owned(),
600        ))
601    }
602
603    /// Remove an installed local model. Removing one that's already absent
604    /// succeeds (the requested end state). Default: unsupported.
605    fn delete_model(&self, _model: &str) -> Result<(), Error> {
606        Err(Error::Other(
607            "This harness does not support managing models.".to_owned(),
608        ))
609    }
610
611    /// Trigger the harness's own interactive sign-in (its CLI's OAuth),
612    /// streaming progress as [`InstallEvent`]s. The flow opens the user's
613    /// browser; this blocks until the login process exits, then
614    /// `Done { ok }` reports success. This is the agent authenticating
615    /// itself — distinct from installing it, which the host's user does.
616    /// Default: unsupported, for harnesses the host authenticates by key.
617    fn login(&self, _on_event: InstallCallback) -> Result<(), Error> {
618        Err(Error::login(
619            "This harness does not support interactive sign-in.",
620        ))
621    }
622
623    /// Convenience over [`run`](Harness::run) for callers that want to
624    /// *pull* events off a channel instead of supplying a push callback.
625    /// Forwards each [`RunEvent`] into an `mpsc` channel and hands the
626    /// receiver back alongside the run handle, so the caller can simply
627    /// `for event in rx { … }` rather than re-write the
628    /// `Arc::new(move |ev| tx.send(ev))` plumbing at every call site.
629    ///
630    /// The receiver hangs up when the run ends — and on its own, without
631    /// the caller dropping the [`RunHandle`] first. The forwarding callback
632    /// (and the `Sender` it owns) lives only on the engine's reader
633    /// threads; once the process exits and those threads finish, every
634    /// clone of the callback drops, the `Sender` drops, and the `for` loop
635    /// over `rx` terminates. (Dropping the handle never cancels a run — see
636    /// [`RunControl`] — so it is safe to drain `rx` to completion while
637    /// still holding the handle for a possible [`cancel`](RunControl::cancel).)
638    ///
639    /// Prefer [`start`](Harness::start) when you need push semantics —
640    /// e.g. forwarding straight onto a Tauri `Channel` or an SSE sink from
641    /// inside the callback — where an intermediate channel is just an extra
642    /// hop. This is a provided method (not overridable surface): an adapter
643    /// implements only [`start`](Harness::start), and every harness — built-in
644    /// or third-party — gets `run` for free.
645    ///
646    /// ```no_run
647    /// use harness::{Claude, Harness, RunEvent, RunMode, RunRequest, RunTuning};
648    ///
649    /// # fn main() -> Result<(), harness::Error> {
650    /// let (_handle, rx) = Claude::new().run(RunRequest {
651    ///     run_id: "demo".into(),
652    ///     prompt: "Explain Markdown headings in one sentence.".into(),
653    ///     ..Default::default()
654    /// })?;
655    /// for event in rx {
656    ///     match event {
657    ///         RunEvent::Text { delta, .. } => print!("{delta}"),
658    ///         RunEvent::Exited { .. } => break,
659    ///         _ => {}
660    ///     }
661    /// }
662    /// # Ok(())
663    /// # }
664    /// ```
665    fn run(
666        &self,
667        request: RunRequest,
668    ) -> Result<(RunHandle, mpsc::Receiver<RunEvent>), Error> {
669        let (tx, rx) = mpsc::channel();
670        let handle = self.start(
671            request,
672            Arc::new(move |event| {
673                // A hung-up receiver (consumer stopped early) is not an
674                // error: the run keeps streaming; we just drop the event
675                // nobody is waiting for.
676                let _ = tx.send(event);
677            }),
678        )?;
679        Ok((handle, rx))
680    }
681}
682
683/// Run a harness's interactive sign-in command, streaming its output as
684/// [`InstallEvent`]s and blocking until it exits. Reuses
685/// [`ResolveCli`] (CLI resolution + reader threads, so a packaged
686/// `.app` finds the CLI), mapping its process events onto the
687/// install-stream shape (Step / Stdout / Stderr / Done). The login CLI
688/// opens the user's browser for OAuth; we surface its output (incl. any
689/// device-code URL) so the UI can show progress. Blocks on a condvar
690/// until the process exits — the caller is a Tauri `(async)` command on
691/// a worker thread, so the UI never blocks.
692pub fn run_login_command(
693    program: &str,
694    args: &[&str],
695    on_event: InstallCallback,
696) -> Result<(), Error> {
697    (*on_event)(InstallEvent::Step {
698        text: "Opening your browser to sign in…".to_owned(),
699    });
700    let done = Arc::new((Mutex::new(false), Condvar::new()));
701    let done_cb = Arc::clone(&done);
702    let events_cb = Arc::clone(&on_event);
703    // Bound, not `_`, so the handle outlives the wait (dropping it could
704    // signal the child); by the time we return, the process has exited.
705    let spawn = Command::new(program).cwd(std::env::current_dir().unwrap_or_default()).run_id(format!("login-{program}"))
706        .args(args.iter().copied());
707    let _handle = spawn.resolve_cli().stream(move |event| {
708            let finished = matches!(event, Event::Exited { .. });
709            if let Some(install) = login_event(&event) {
710                (*events_cb)(install);
711            }
712            if finished {
713                let (lock, cvar) = &*done_cb;
714                // Recover from a poisoned lock instead of panicking on a
715                // reader thread: the guarded value is a plain bool, never in a
716                // half-updated state worth bailing on.
717                *lock.lock().unwrap_or_else(|p| p.into_inner()) = true;
718                cvar.notify_all();
719            }
720        },
721    )
722    .map_err(Error::login)?;
723    let (lock, cvar) = &*done;
724    let mut finished = lock.lock().unwrap_or_else(|p| p.into_inner());
725    while !*finished {
726        finished = cvar.wait(finished).unwrap_or_else(|p| p.into_inner());
727    }
728    Ok(())
729}
730
731/// One process event as an install-stream event, or `None` for the ones a user
732/// has no use for.
733///
734/// A separate function because the interesting cases are decisions, not
735/// plumbing: stderr carries text worth showing (an OAuth device code often
736/// arrives there, and so does the reason a login failed), and a read or wait
737/// failure is surfaced as stderr rather than dropped — a run that dies mid-way
738/// would otherwise end with no explanation at all. Those paths are provoked by
739/// OS-level failures no test can arrange, so the mapping is checked here with
740/// values instead.
741fn login_event(event: &Event) -> Option<InstallEvent> {
742    match event {
743        Event::Stdout { line, .. } => Some(InstallEvent::Stdout { text: line.clone() }),
744        Event::Stderr { line, .. } => Some(InstallEvent::Stderr { text: line.clone() }),
745        Event::Error { message, .. } => Some(InstallEvent::Stderr { text: message.clone() }),
746        Event::Exited { exit_code, .. } => {
747            Some(InstallEvent::Done { exit_code: *exit_code, ok: *exit_code == Some(0) })
748        }
749        // `Started` is the spawn itself, which the caller already knows about;
750        // `Event` is #[non_exhaustive], so anything new is ignored too.
751        _ => None,
752    }
753}
754
755/// Whether an API-key value an adapter pulled from the environment counts as
756/// authenticated — i.e. present and non-blank. Adapters OR this into their
757/// [`Harness::readiness`] so a key in the env (headless / CI / container)
758/// reports authenticated, not only the CLI's own interactive OAuth login —
759/// which can't complete where there's no browser. Pure (the env read stays at
760/// the call site) so it's unit-tested directly.
761///
762/// Only the claude/codex adapters OR this into readiness — bob reports auth via
763/// `bob-rs`'s own keychain source — so it's gated to those features. Without
764/// them (`--no-default-features`) it would be dead code, hence the `cfg`.
765#[cfg(any(feature = "claude", feature = "codex"))]
766pub(crate) fn api_key_value_usable(value: Option<String>) -> bool {
767    matches!(value, Some(v) if !v.trim().is_empty())
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    fn layer(digest: &str, completed: u64, total: u64) -> PullProgress {
775        PullProgress {
776            status: format!("pulling {digest}"),
777            digest: Some(digest.to_owned()),
778            total: Some(total),
779            completed: Some(completed),
780        }
781    }
782
783    #[test]
784    fn pull_aggregator_sums_across_digests_keeping_latest_per_digest() {
785        let mut agg = PullProgressAggregator::default();
786        // Info phase: no byte totals yet → no percent.
787        assert_eq!(
788            agg.update(&PullProgress { status: "pulling manifest".into(), digest: None, total: None, completed: None }),
789            None
790        );
791        // One layer half done.
792        assert_eq!(agg.update(&layer("sha256:a", 50, 100)), Some(50.0));
793        // A second layer appears; percent now spans both totals.
794        assert_eq!(agg.update(&layer("sha256:b", 0, 100)), Some(25.0));
795        // The first layer's line is resent larger — the latest figure replaces
796        // it (not summed onto the prior one).
797        assert_eq!(agg.update(&layer("sha256:a", 100, 100)), Some(50.0));
798        assert_eq!(agg.update(&layer("sha256:b", 100, 100)), Some(100.0));
799    }
800
801    #[test]
802    fn pull_aggregator_clamps_overshoot_to_100() {
803        let mut agg = PullProgressAggregator::default();
804        // A finished layer can report completed > total momentarily.
805        assert_eq!(agg.update(&layer("sha256:a", 120, 100)), Some(100.0));
806    }
807
808    // Gated like the fn it tests — `api_key_value_usable` only exists when a
809    // claude/codex adapter is compiled in.
810    #[cfg(any(feature = "claude", feature = "codex"))]
811    #[test]
812    fn api_key_value_usable_requires_a_nonblank_value() {
813        assert!(api_key_value_usable(Some("sk-abc".to_owned())));
814        assert!(!api_key_value_usable(Some(String::new())));
815        assert!(!api_key_value_usable(Some("   ".to_owned())));
816        assert!(!api_key_value_usable(None));
817    }
818
819    /// An adapter that implements only the four required methods — the whole
820    /// point of the trait's provided ones. What it gets for free is a contract
821    /// third-party adapters depend on, so it is asserted rather than assumed.
822    struct MinimalHarness;
823
824    impl Harness for MinimalHarness {
825        fn info(&self) -> Info {
826            Info {
827                id: "minimal".to_owned(),
828                display_name: "Minimal".to_owned(),
829                description: "implements the required surface and nothing else".to_owned(),
830                install_hint: None,
831            }
832        }
833
834        fn features(&self) -> Features {
835            Features {
836                models: vec![ModelChoice { value: "m1".to_owned(), label: "Model one".to_owned() }],
837                ..Default::default()
838            }
839        }
840        fn readiness(&self) -> Readiness {
841            Readiness {
842                harness_id: "minimal".to_owned(),
843                ready: true,
844                installed: true,
845                version: None,
846                auth_configured: true,
847                error: None,
848                details: serde_json::Value::Null,
849            }
850        }
851        fn start(&self, _request: RunRequest, _on_event: RunCallback) -> Result<RunHandle, Error> {
852            Ok(Box::new(NoopControl))
853        }
854        fn credential(&self) -> CredentialSpec {
855            CredentialSpec {
856                label: "none".to_owned(),
857                keychain_service: "s".to_owned(),
858                keychain_account: "a".to_owned(),
859                required: false,
860            }
861        }
862    }
863
864    #[test]
865    fn an_adapter_that_implements_only_the_required_surface_still_answers_the_rest() {
866        let harness = MinimalHarness;
867        // The picker asks every harness for models; the default answers from
868        // the capabilities it already declared rather than making each adapter
869        // write the same one-liner.
870        assert_eq!(harness.list_models().unwrap(), harness.features().models);
871        assert!(harness.model_management().is_none(), "no model management is the default");
872        assert!(NoopControl.pid().is_none(), "a harness with no process reports no pid");
873    }
874
875    #[test]
876    fn unsupported_optional_features_refuse_rather_than_pretend_to_succeed() {
877        // Returning Ok(vec![]) here would read as "you have no models
878        // installed" from a harness that cannot install any — the same
879        // absent-versus-unsupported confusion the session store had.
880        let harness = MinimalHarness;
881        let cancel = std::sync::atomic::AtomicBool::new(false);
882
883        for message in [
884            harness.list_installed_models().map(|_| ()).unwrap_err().to_string(),
885            harness.pull_model("m", &cancel, &mut |_| {}).unwrap_err().to_string(),
886            harness.delete_model("m").unwrap_err().to_string(),
887        ] {
888            assert!(message.contains("does not support managing models"), "got {message}");
889        }
890        assert!(
891            harness.login(Arc::new(|_| {})).unwrap_err().to_string().contains("interactive sign-in"),
892            "and sign-in says which thing is unsupported"
893        );
894    }
895
896    #[test]
897    fn capabilities_default_to_supporting_nothing() {
898        // The safe direction: a new field defaults to off, so an adapter that
899        // has not heard of it does not silently claim it.
900        let none = Features::default();
901        assert!(!none.credential_required && !none.previews_edits && !none.custom_model);
902        assert!(!none.effort && !none.max_turns && !none.login);
903        assert!(!none.custom_instructions);
904        assert!(none.models.is_empty());
905    }
906
907    #[test]
908    fn reasoning_effort_keeps_the_tokens_a_cli_actually_accepts() {
909        // These are sent verbatim as `model_reasoning_effort=<value>`; a
910        // prettified variant name would be rejected by the CLI, not by us.
911        assert_eq!(ReasoningEffort::Minimal.as_cli_value(), "minimal");
912        assert_eq!(ReasoningEffort::Low.as_cli_value(), "low");
913        assert_eq!(ReasoningEffort::Medium.as_cli_value(), "medium");
914        assert_eq!(ReasoningEffort::High.as_cli_value(), "high");
915    }
916
917    #[test]
918    fn an_install_hint_always_has_a_url_and_optionally_a_command() {
919        // Not every agent has a one-liner that works on every platform, so the
920        // command is optional while the home page never is.
921        let bare = InstallHint::url("https://example.test/install");
922        assert_eq!(bare.url, "https://example.test/install");
923        assert!(bare.command.is_none());
924        assert_eq!(bare.with_command("brew install thing").command.as_deref(), Some("brew install thing"));
925    }
926
927    fn login_events(program: &str, args: &[&str]) -> (Result<(), Error>, Vec<InstallEvent>) {
928        let seen: Arc<Mutex<Vec<InstallEvent>>> = Arc::default();
929        let sink = Arc::clone(&seen);
930        let result = run_login_command(program, args, Arc::new(move |event| sink.lock().unwrap().push(event)));
931        let events = seen.lock().unwrap().clone();
932        (result, events)
933    }
934
935    #[test]
936    fn a_sign_in_streams_the_cli_output_the_user_has_to_act_on() {
937        // The whole reason this streams rather than waiting: an OAuth flow
938        // prints a device code or a URL, and a user who never sees it cannot
939        // finish signing in.
940        let (result, events) = login_events("echo", &["visit https://example.test/device"]);
941        assert!(result.is_ok(), "{result:?}");
942
943        assert!(
944            matches!(events.first(), Some(InstallEvent::Step { .. })),
945            "something is said before the browser opens: {events:?}"
946        );
947        assert!(
948            events.iter().any(|e| matches!(e, InstallEvent::Stdout { text } if text.contains("example.test/device"))),
949            "the URL reaches the host: {events:?}"
950        );
951        assert!(
952            matches!(events.last(), Some(InstallEvent::Done { ok: true, exit_code: Some(0) })),
953            "and it ends exactly once, saying how: {events:?}"
954        );
955    }
956
957    #[test]
958    fn every_kind_of_process_output_reaches_the_user_during_sign_in() {
959        // The paths that matter here cannot be provoked from a test — a wait or
960        // read failure is an OS-level fault — so the mapping is checked with
961        // values. Losing any of these leaves a stalled sign-in with nothing on
962        // screen to explain it.
963        let ev = |e: Event| login_event(&e);
964        let run_id = || "r".to_owned();
965
966        assert!(ev(Event::Started { run_id: run_id() }).is_none(), "the spawn is not news");
967
968        let out = ev(Event::Stdout { run_id: run_id(), line: "visit https://x.test".into() });
969        assert!(matches!(out, Some(InstallEvent::Stdout { text }) if text.contains("x.test")));
970
971        // A device code arrives on stderr as often as stdout, and so does the
972        // reason a login failed.
973        let err = ev(Event::Stderr { run_id: run_id(), line: "code ABCD".into() });
974        assert!(matches!(err, Some(InstallEvent::Stderr { text }) if text == "code ABCD"));
975
976        // A stream that dies is reported, not swallowed.
977        let broken = ev(Event::Error { run_id: run_id(), message: "stream read failed".into() });
978        assert!(matches!(broken, Some(InstallEvent::Stderr { text }) if text.contains("read failed")));
979
980        let ok = ev(Event::Exited { run_id: run_id(), exit_code: Some(0), cancelled: false });
981        assert!(matches!(ok, Some(InstallEvent::Done { ok: true, exit_code: Some(0) })));
982        let failed = ev(Event::Exited { run_id: run_id(), exit_code: Some(1), cancelled: false });
983        assert!(matches!(failed, Some(InstallEvent::Done { ok: false, .. })), "only zero is success");
984    }
985
986    #[test]
987    fn a_failed_sign_in_says_so_rather_than_completing_quietly() {
988        // `false` exits non-zero without printing. Reporting ok here would
989        // leave a host believing the user is signed in.
990        let (result, events) = login_events("false", &[]);
991        assert!(result.is_ok(), "the command ran; it is its exit code that failed");
992        assert!(
993            matches!(events.last(), Some(InstallEvent::Done { ok: false, .. })),
994            "got {events:?}"
995        );
996    }
997
998    #[cfg(unix)]
999    #[test]
1000    fn a_process_backed_run_reports_its_pid_and_whether_it_was_stopped() {
1001        // Both answers are load-bearing for an embedder. The pid is recorded so
1002        // a child orphaned by a hard crash can be reaped on the next launch;
1003        // `was_cancelled` is how a run the user stopped is told apart from one
1004        // that finished on its own. Forwarding either wrongly is invisible
1005        // until a stale agent is left running.
1006        let child = Command::new("sleep")
1007            .cwd(std::env::temp_dir())
1008            .run_id("pid-test")
1009            .args(["30"])
1010            .resolve_cli()
1011            .stream(|_| {})
1012        .expect("sleep should spawn");
1013        let run: RunHandle = Box::new(child);
1014
1015        let pid = run.pid().expect("a live child has a pid");
1016        assert!(pid > 1, "a real OS pid, not a placeholder: {pid}");
1017        assert!(!run.was_cancelled(), "nothing has stopped it yet");
1018
1019        run.cancel().expect("cancel");
1020        assert!(run.was_cancelled(), "a stopped run says so");
1021    }
1022
1023    /// A no-op [`RunControl`] so the mock harness below can hand back a
1024    /// [`RunHandle`] without a real process behind it.
1025    struct NoopControl;
1026    impl RunControl for NoopControl {
1027        fn cancel(&self) -> Result<(), Error> {
1028            Ok(())
1029        }
1030        fn was_cancelled(&self) -> bool {
1031            false
1032        }
1033    }
1034
1035    /// A minimal in-memory harness whose `run()` pushes a fixed event
1036    /// sequence straight to the callback, synchronously, then returns —
1037    /// dropping its only `RunCallback` clone. That's exactly the ownership
1038    /// shape `run` relies on, with no subprocess to spawn, so it
1039    /// pins down the contract: events are forwarded, and the receiver hangs
1040    /// up on its own once the run's callback ownership ends.
1041    struct MockHarness {
1042        events: Vec<RunEvent>,
1043    }
1044    impl Harness for MockHarness {
1045        fn info(&self) -> Info {
1046            unreachable!("not exercised by run")
1047        }
1048        fn readiness(&self) -> Readiness {
1049            unreachable!("not exercised by run")
1050        }
1051        fn start(
1052            &self,
1053            _request: RunRequest,
1054            on_event: RunCallback,
1055        ) -> Result<RunHandle, Error> {
1056            for event in &self.events {
1057                on_event(event.clone());
1058            }
1059            // `on_event` (the lone RunCallback clone, owning the channel's
1060            // Sender) drops as this returns → the receiver closes.
1061            Ok(Box::new(NoopControl))
1062        }
1063        fn credential(&self) -> CredentialSpec {
1064            unreachable!("not exercised by run")
1065        }
1066    }
1067
1068    fn demo_request() -> RunRequest {
1069        RunRequest {
1070            run_id: "t".to_owned(),
1071            prompt: "hi".to_owned(),
1072            cwd: None,
1073            mode: RunMode::Ask,
1074            tuning: RunTuning::default(),
1075            resume: None,
1076            attachments: Vec::new(),
1077        }
1078    }
1079
1080    #[test]
1081    fn run_forwards_every_event_then_closes() {
1082        let harness = MockHarness {
1083            events: vec![
1084                RunEvent::Text {
1085                    run_id: "t".to_owned(),
1086                    delta: "hello".to_owned(),
1087                },
1088                RunEvent::Exited {
1089                    run_id: "t".to_owned(),
1090                    exit_code: Some(0),
1091                    cancelled: false,
1092                },
1093            ],
1094        };
1095        let (_handle, rx) = harness.run(demo_request()).expect("run ok");
1096        // Draining to completion *terminates* — proof the channel closed
1097        // without us dropping the handle.
1098        let collected: Vec<RunEvent> = rx.into_iter().collect();
1099        assert_eq!(
1100            collected,
1101            vec![
1102                RunEvent::Text {
1103                    run_id: "t".to_owned(),
1104                    delta: "hello".to_owned(),
1105                },
1106                RunEvent::Exited {
1107                    run_id: "t".to_owned(),
1108                    exit_code: Some(0),
1109                    cancelled: false,
1110                },
1111            ]
1112        );
1113    }
1114
1115    #[test]
1116    fn run_receiver_closes_even_with_no_events() {
1117        let harness = MockHarness { events: Vec::new() };
1118        let (_handle, rx) = harness.run(demo_request()).expect("run ok");
1119        assert_eq!(rx.into_iter().count(), 0); // closes immediately, doesn't hang
1120    }
1121
1122    #[test]
1123    fn harness_error_preserves_typed_source_and_flattened_message() {
1124        use std::error::Error as _;
1125
1126        // Categorize a real typed engine error as a Command failure.
1127        let err = Error::spawn(cli_stream::StreamError::PipeNotCaptured { stream: "stdout" });
1128
1129        // Display still flattens the source into the message, so a consumer
1130        // that just `.to_string()`s at a boundary (a Tauri command) gets the
1131        // category prefix *and* the full underlying detail — unchanged from
1132        // when the variant held a String.
1133        let message = err.to_string();
1134        assert!(message.starts_with("failed to start the agent: "), "got {message:?}");
1135        assert!(message.contains("stdout pipe was not captured"), "got {message:?}");
1136
1137        // And the real typed error is reachable via the source chain — the
1138        // whole point of carrying a source instead of a flattened string.
1139        let source = err.source().expect("Error::Spawn has a source");
1140        assert!(
1141            source.downcast_ref::<cli_stream::StreamError>().is_some(),
1142            "source should downcast back to the typed StreamError"
1143        );
1144    }
1145}