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::program_path::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 a CLI adapter and an I/O or
49/// protocol error for an ACP one. 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>()`. The
62/// `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, so it is gated to
763/// those features: without them (`--no-default-features`) it would be dead
764/// 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}