Skip to main content

harness/
harness.rs

1//! The neutral harness contract: the [`Harness`] trait, the run-control
2//! handle, the neutral request/metadata types, and the shared
3//! interactive-login helper.
4//!
5//! A *harness* is whatever actually answers the user's prompt — a CLI
6//! agent (bob / Claude Code / Codex today), a direct LLM API tomorrow,
7//! some other runner after that. A consumer only needs to: probe whether
8//! a harness is ready, run a one-time install if required, stream a run,
9//! and know which credential to ask for. This module is that seam.
10//!
11//! ## Design rules
12//!
13//! - **Object-safe trait.** Consumers hold `Box<dyn Harness>`; no
14//!   generics leak across the seam.
15//! - **Arc callbacks, not generic closures.** Streaming methods take
16//!   `Arc<dyn Fn(..) + Send + Sync>` so they stay object-safe and can be
17//!   cloned onto the reader threads the subprocess engine uses.
18//! - **Normalize at the adapter, not the UI.** The event enums in
19//!   [`crate::events`] are harness-neutral by intent; each adapter
20//!   translates its CLI's wire format into them so the front-end consumes
21//!   one shape regardless of which harness produced it.
22
23use std::path::PathBuf;
24use std::sync::{mpsc, Arc, Condvar, Mutex};
25
26use serde::{Deserialize, Serialize};
27
28use crate::events::RunEvent;
29use cli_stream::{spawn_streaming, InstallEvent, ProcessEvent, ProcessHandle};
30
31// --- Streaming callbacks --------------------------------------------
32
33/// Callback a harness invokes for each run event. `Arc<dyn Fn>` is
34/// `Clone + Send + Sync`, so it can be handed to the multiple reader
35/// threads a process-backed harness uses without the trait method
36/// needing to be generic.
37pub type RunCallback = Arc<dyn Fn(RunEvent) + Send + Sync>;
38
39/// Callback a harness invokes for each install event.
40pub type InstallCallback = Arc<dyn Fn(InstallEvent) + Send + Sync>;
41
42// --- Errors ---------------------------------------------------------
43
44/// A boxed, type-erased error source. The [`HarnessError`] variants carry one
45/// of these instead of `#[from]`-ing a single concrete type, because each
46/// *category* can be produced by more than one underlying error: a `Spawn`
47/// failure is a [`cli_stream::StreamError`] for the claude/codex adapters but a
48/// `bob_rs::BobError` for bob. The real error stays reachable through
49/// [`std::error::Error::source`] (and `downcast_ref`); the category is the
50/// variant.
51pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
52
53/// Why a [`Harness`] operation failed. Returned by `install` / `run` /
54/// `login` / [`RunControl::cancel`] so a consumer can branch on the *kind* of
55/// failure — offer install vs sign-in vs surface the message — instead of
56/// string-matching.
57///
58/// Each category carries the real underlying error as a [`source`] (via the
59/// [`BoxError`] field), so a consumer that wants more than the category can
60/// walk `.source()` or `downcast_ref::<cli_stream::StreamError>()` /
61/// `::<bob_rs::BobError>()`. The `Display` still flattens the source into the
62/// message (`"failed to start the agent: <source>"`), so a consumer that just
63/// stringifies at a boundary (e.g. a Tauri command's `.to_string()`) gets the
64/// same full message as before. `#[non_exhaustive]` so adding a variant later
65/// isn't a breaking change.
66///
67/// ```
68/// use harness::{HarnessError, StreamError};
69/// use std::error::Error;
70///
71/// // Box any typed source under a category constructor:
72/// let err = HarnessError::spawn(StreamError::PipeNotCaptured { stream: "stdout" });
73///
74/// // Stringifying at a boundary flattens the source into the message
75/// // (so a Tauri command's `.to_string()` keeps its full text)…
76/// assert!(err.to_string().starts_with("failed to start the agent: "));
77///
78/// // …while the real typed cause stays reachable for a consumer that wants
79/// // to branch on it rather than parse a string.
80/// let source = err.source().expect("Spawn carries a source");
81/// assert!(source.downcast_ref::<StreamError>().is_some());
82/// ```
83///
84/// [`source`]: std::error::Error::source
85#[derive(Debug, thiserror::Error)]
86#[non_exhaustive]
87pub enum HarnessError {
88    /// The harness's CLI couldn't be started — not installed, not on `PATH`,
89    /// or an OS-level spawn failure.
90    #[error("failed to start the agent: {0}")]
91    Spawn(#[source] BoxError),
92    /// A one-time install step failed.
93    #[error("install failed: {0}")]
94    Install(#[source] BoxError),
95    /// Interactive sign-in failed.
96    #[error("sign-in failed: {0}")]
97    Login(#[source] BoxError),
98    /// Cancelling an in-flight run failed.
99    #[error("cancel failed: {0}")]
100    Cancel(#[source] BoxError),
101    /// Any other adapter/runtime failure (e.g. a backend SDK error that
102    /// doesn't map onto the cases above). Carries a message rather than a
103    /// source — it's the catch-all when there's nothing typed to preserve.
104    #[error("{0}")]
105    Other(String),
106}
107
108impl HarnessError {
109    /// Categorize a source error as a [`Spawn`](HarnessError::Spawn) failure.
110    /// Accepts anything boxable — a typed `StreamError`/`BobError`, or a
111    /// `String`/`&str` for adapters with nothing typed to carry.
112    pub fn spawn(source: impl Into<BoxError>) -> Self {
113        Self::Spawn(source.into())
114    }
115    /// Categorize a source error as an [`Install`](HarnessError::Install) failure.
116    pub fn install(source: impl Into<BoxError>) -> Self {
117        Self::Install(source.into())
118    }
119    /// Categorize a source error as a [`Login`](HarnessError::Login) failure.
120    pub fn login(source: impl Into<BoxError>) -> Self {
121        Self::Login(source.into())
122    }
123    /// Categorize a source error as a [`Cancel`](HarnessError::Cancel) failure.
124    pub fn cancel(source: impl Into<BoxError>) -> Self {
125        Self::Cancel(source.into())
126    }
127}
128
129// --- Run control (cancellation) -------------------------------------
130
131/// Object-safe handle to an in-flight run. A process-backed harness
132/// cancels by signalling its child; a request-backed harness (a hosted
133/// LLM API) cancels by aborting its HTTP stream. The consumer only needs
134/// these two operations, so the concrete mechanism stays behind the trait.
135pub trait RunControl: Send + Sync {
136    /// Stop the run. Best-effort; idempotent.
137    fn cancel(&self) -> Result<(), HarnessError>;
138    /// Whether [`cancel`](RunControl::cancel) was called.
139    fn was_cancelled(&self) -> bool;
140    /// The OS process id of the underlying child while it's alive, for a
141    /// process-backed run. `None` for adapters with no child process (a
142    /// direct-model run aborts an HTTP stream, not a process) — so an embedder
143    /// can record live pids and reap a child a hard crash orphaned.
144    fn pid(&self) -> Option<u32> {
145        None
146    }
147}
148
149/// Boxed [`RunControl`] returned by [`Harness::run`].
150pub type RunHandle = Box<dyn RunControl>;
151
152// The engine's run handle is the canonical process-backed `RunControl`.
153// Both the trait and the handle live in this crate, so this impl is here
154// (orphan rule) rather than in any adapter crate.
155impl RunControl for ProcessHandle {
156    fn cancel(&self) -> Result<(), HarnessError> {
157        ProcessHandle::cancel(self).map_err(HarnessError::cancel)
158    }
159    fn was_cancelled(&self) -> bool {
160        ProcessHandle::was_cancelled(self)
161    }
162    fn pid(&self) -> Option<u32> {
163        ProcessHandle::pid(self)
164    }
165}
166
167// --- Neutral request / metadata shapes ------------------------------
168
169/// What the user wants the harness to do with the prompt. Mirrors
170/// the Ask / Edit split the comment bubble already exposes; adapters
171/// map it onto their own mode vocabulary.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum RunMode {
175    /// Answer / discuss. No file edits expected.
176    Ask,
177    /// Propose edits to the workspace.
178    Edit,
179}
180
181/// How hard the model should think, in harness-neutral terms. Codex
182/// maps this onto `model_reasoning_effort`; Claude Code has no
183/// equivalent `-p` flag today and ignores it. Kept neutral so a future
184/// harness that exposes effort can honor the same field.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum ReasoningEffort {
188    Minimal,
189    Low,
190    Medium,
191    High,
192}
193
194impl ReasoningEffort {
195    /// The CLI/config token for this level (e.g. codex's
196    /// `model_reasoning_effort="high"`).
197    pub fn as_cli_value(self) -> &'static str {
198        match self {
199            ReasoningEffort::Minimal => "minimal",
200            ReasoningEffort::Low => "low",
201            ReasoningEffort::Medium => "medium",
202            ReasoningEffort::High => "high",
203        }
204    }
205}
206
207/// User-chosen, harness-neutral run-shaping knobs. Every field is
208/// optional; each adapter maps the ones its CLI supports and ignores
209/// the rest (Claude has no reasoning-effort flag; Codex has no
210/// max-turns flag). Grouped into one struct so the neutral
211/// [`RunRequest`] stays open for extension — a new knob is a field
212/// here, not a new positional parameter threaded through every caller.
213#[derive(Debug, Clone, Default)]
214pub struct RunTuning {
215    /// Model id or alias passed verbatim to the CLI (`--model` /
216    /// `-m`). `None` → let the CLI use its configured default.
217    pub model: Option<String>,
218    /// Reasoning effort (Codex: `-c model_reasoning_effort`).
219    pub effort: Option<ReasoningEffort>,
220    /// Cap on agentic turns (Claude: `--max-turns`).
221    pub max_turns: Option<u32>,
222    /// Raw CLI args the host appends verbatim **after** the adapter's own,
223    /// so a host can add a flag (`--settings`, `--add-dir`) or override one
224    /// it already sets — for CLIs where a repeated flag is last-wins (e.g.
225    /// Claude Code / commander) — without editing the adapter. The host opts
226    /// into CLI-specific flag names when it uses this; keep cross-harness
227    /// knobs as their own typed fields above. Default empty.
228    pub extra_args: Vec<String>,
229    /// A JSON Schema the final assistant answer must conform to (structured
230    /// output). Adapters that support it constrain the model's final message to
231    /// this schema; the rest ignore it. `None` → free-form text.
232    pub output_schema: Option<serde_json::Value>,
233    /// Extra system-prompt instructions from the host — the user's per-harness
234    /// "custom instructions". The `openai-compatible` adapter appends it after
235    /// its base system prompt; other adapters currently ignore it (a CLI mapping
236    /// such as Claude's `--append-system-prompt` can opt in later). `None` → none.
237    pub extra_instructions: Option<String>,
238    /// Absolute path to the agent's executable, overriding PATH resolution of
239    /// the bare CLI name. `None` → resolve by name on PATH. CLI adapters
240    /// (claude/codex/bob) spawn this path instead of their default program; the
241    /// `openai-compatible` adapter spawns no process and ignores it.
242    pub binary_path: Option<std::path::PathBuf>,
243}
244
245/// A non-text input attached to a run — currently an image. Multimodal adapters
246/// (`openai-compatible`) send it to the model; text-only CLI adapters ignore it.
247#[derive(Debug, Clone)]
248pub struct Attachment {
249    /// MIME type, e.g. `image/png` or `image/jpeg`.
250    pub mime_type: String,
251    /// Raw bytes; the adapter base64-encodes them into a data URI for the wire.
252    pub data: Vec<u8>,
253}
254
255/// A harness-neutral run request. Adapter-specific knobs (bob's
256/// approval mode, coin budget, executable override) are filled in by
257/// the adapter from its own defaults; the user-facing tuning the
258/// picker exposes (model, effort, turn cap) rides on `tuning`.
259#[derive(Debug, Clone)]
260pub struct RunRequest {
261    /// Caller-chosen id used to correlate events with the handle.
262    pub run_id: String,
263    pub prompt: String,
264    /// Non-text inputs (images) for multimodal models; empty for a text run.
265    /// Multimodal adapters send them to the model; text-only CLI adapters
266    /// ignore them.
267    pub attachments: Vec<Attachment>,
268    /// Working directory for the run — the workspace path, so the
269    /// harness's tool calls land inside the user's vault.
270    pub cwd: Option<PathBuf>,
271    pub mode: RunMode,
272    /// Optional, harness-neutral run-shaping knobs (model, effort,
273    /// turn cap). Adapters honor the subset their CLI supports.
274    pub tuning: RunTuning,
275    /// Session id to **resume** — continue a prior run's conversation instead
276    /// of starting fresh, so the CLI supplies the history (no transcript replay
277    /// in the prompt). `None` → a new session. Each adapter maps it to its
278    /// CLI's resume form (Claude `--resume <id>`, codex `exec resume <id>`,
279    /// bob `-r <id>`); the id comes from the earlier run's init `SessionInfo`.
280    pub resume: Option<String>,
281}
282
283/// Where a harness's secret lives in the OS keychain, and how to
284/// label it in the UI. Lets the front-end ask for the right
285/// credential per harness without hard-coding any one harness's slot.
286#[derive(Debug, Clone, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct CredentialSpec {
289    /// Human label, e.g. "Bob API key" / "Anthropic API key".
290    pub label: String,
291    pub keychain_service: String,
292    pub keychain_account: String,
293    /// Whether the harness can run at all without this credential.
294    pub required: bool,
295}
296
297/// Harness-neutral readiness snapshot for the UI. `details` carries
298/// adapter-specific probes (bob's Node/npm) as free-form JSON so the
299/// trait stays generic.
300#[derive(Debug, Clone, Serialize)]
301#[serde(rename_all = "camelCase")]
302pub struct HarnessReadiness {
303    pub harness_id: String,
304    /// Installed *and* authenticated *and* able to run.
305    pub ready: bool,
306    pub installed: bool,
307    pub version: Option<String>,
308    pub auth_configured: bool,
309    pub error: Option<String>,
310    /// Adapter-specific extra fields (serialized harness snapshot).
311    pub details: serde_json::Value,
312}
313
314/// A model the harness can be pointed at, for the picker's model
315/// selector. `value` is passed verbatim to the CLI (`--model` / `-m`)
316/// via [`RunTuning::model`]; `label` is the human-facing name.
317#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
318#[serde(rename_all = "camelCase")]
319pub struct HarnessModel {
320    pub value: String,
321    pub label: String,
322}
323
324/// An installed model with the metadata a model-manager UI shows — the
325/// neutral shape returned by [`Harness::list_installed_models`]. Richer than
326/// [`HarnessModel`] (the picker's name-only entry): on-disk `size` in bytes plus
327/// the parameter count / quantization where the backend reports them.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329#[serde(rename_all = "camelCase")]
330pub struct InstalledModel {
331    pub name: String,
332    /// On-disk size in bytes.
333    pub size: u64,
334    /// e.g. `"3.2B"`; `None` when the backend doesn't report it.
335    pub parameter_size: Option<String>,
336    /// e.g. `"Q4_K_M"`; `None` when the backend doesn't report it.
337    pub quantization_level: Option<String>,
338}
339
340/// A progress update from [`Harness::pull_model`], one per chunk of a streaming
341/// download. `status` is always present (`"pulling manifest"`, `"success"`, …);
342/// the byte counters appear once a layer is downloading, so a host can show a
343/// percentage from `completed`/`total` aggregated across `digest`s.
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct PullProgress {
346    pub status: String,
347    /// The layer this line reports on; `None` for phase lines (manifest,
348    /// success).
349    pub digest: Option<String>,
350    pub total: Option<u64>,
351    pub completed: Option<u64>,
352}
353
354/// A host push-callback for [`Harness::pull_model`] download progress, invoked
355/// per stream chunk on the calling thread.
356pub type PullProgressCallback<'a> = &'a mut (dyn FnMut(PullProgress) + Send);
357
358/// Folds a stream of [`PullProgress`] into a single overall percent, so a host
359/// shows one progress bar for a multi-layer download. A streaming pull reports
360/// `completed`/`total` *per `digest`* and resends a digest's line as it grows;
361/// this keeps the latest figures per digest, so the overall percent is
362/// `100 * Σcompleted / Σtotal` across the digests seen so far.
363#[derive(Debug, Default)]
364pub struct PullProgressAggregator {
365    layers: std::collections::HashMap<String, (u64, u64)>,
366}
367
368impl PullProgressAggregator {
369    /// Fold one progress update in (only `digest` lines carrying a `total`
370    /// count) and return the overall percent so far — `None` until any byte
371    /// total is known (e.g. during the manifest phase).
372    pub fn update(&mut self, progress: &PullProgress) -> Option<f64> {
373        if let (Some(digest), Some(total)) = (&progress.digest, progress.total) {
374            self.layers.insert(digest.clone(), (progress.completed.unwrap_or(0), total));
375        }
376        self.percent()
377    }
378
379    /// Overall percent across every digest seen, clamped to 0–100; `None` until a
380    /// total is known. A just-finished layer can momentarily report
381    /// `completed > total`, so the ratio is capped.
382    pub fn percent(&self) -> Option<f64> {
383        let total: u64 = self.layers.values().map(|(_, t)| *t).sum();
384        if total == 0 {
385            return None;
386        }
387        let completed: u64 = self.layers.values().map(|(c, _)| *c).sum();
388        Some((completed as f64 / total as f64 * 100.0).clamp(0.0, 100.0))
389    }
390}
391
392/// What a harness's local-model management exposes — returned by
393/// [`Harness::model_management`] (`None` when unsupported). Today only the
394/// `openai-compatible` Ollama adapter manages models; carrying its endpoint lets
395/// a host link out (e.g. "browse all models") without re-deriving it, while the
396/// pull/list/delete operations themselves stay behind the trait so HTTP never
397/// leaves the adapter.
398#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
399#[serde(rename_all = "camelCase")]
400pub struct ModelManagement {
401    /// The model server's base URL (e.g. `http://localhost:11434`), for a host
402    /// to show or link to; not used to issue requests host-side.
403    pub base_url: String,
404}
405
406/// What a harness supports, so every consumer (the picker, the options
407/// panel, the credential preflight, the chat availability gate) adapts
408/// to it *declaratively* instead of branching on the harness id. A new
409/// adapter that, say, needs a stored key just sets `credential_required:
410/// true` here — no `id == "bob"` checks to hunt down.
411#[derive(Debug, Clone, Serialize)]
412#[serde(rename_all = "camelCase")]
413pub struct HarnessCapabilities {
414    /// Compose stores this harness's credential (bob). When `false`,
415    /// the CLI owns its own login (claude/codex) and Compose runs no
416    /// credential/install preflight — a missing login surfaces as the
417    /// harness's own run error rather than a Compose prompt.
418    pub credential_required: bool,
419    /// Emits previewable suggested edits the user approves before they
420    /// apply (bob). When `false`, edits land on disk directly and the
421    /// file watcher reflects them (claude/codex).
422    pub previews_edits: bool,
423    /// Curated model choices for the picker's selector. Empty → no
424    /// curated list (rely on `allows_custom_model`).
425    pub models: Vec<HarnessModel>,
426    /// Whether a free-text model id is accepted beyond `models` (codex,
427    /// whose model names change frequently). Drives a text field vs a
428    /// fixed dropdown in the picker.
429    pub allows_custom_model: bool,
430    /// Honors [`RunTuning::effort`] (codex reasoning effort).
431    pub supports_effort: bool,
432    /// Honors [`RunTuning::max_turns`] (claude turn cap).
433    pub supports_max_turns: bool,
434    /// Supports an interactive [`Harness::login`] flow (the CLI's own
435    /// OAuth, e.g. `claude auth login` / `codex login`). Drives the
436    /// picker's "Sign in" affordance when installed-but-not-signed-in.
437    /// `false` for harnesses Compose authenticates itself (bob).
438    pub supports_login: bool,
439    /// Honors [`RunTuning::extra_instructions`] — the user's per-harness custom
440    /// instructions, appended to the system prompt. `true` only for the
441    /// `openai-compatible` adapter so far; the picker hides the field for the
442    /// rest rather than offering a control that does nothing.
443    pub supports_custom_instructions: bool,
444}
445
446/// Where a user gets a harness that isn't on the machine yet.
447///
448/// This crate discovers and runs agents; it never installs them. A harness
449/// that depends on an external CLI says so here and the host renders it, so
450/// "not installed" is a next step rather than a dead end.
451#[derive(Debug, Clone, Serialize)]
452#[serde(rename_all = "camelCase")]
453pub struct InstallHint {
454    /// Where to get it. Always present — every agent has a home page, while
455    /// only some have a one-liner that works on every platform.
456    pub url: String,
457    /// A copy-pasteable command, when one exists for every supported platform.
458    pub command: Option<String>,
459}
460
461impl InstallHint {
462    pub fn url(url: impl Into<String>) -> Self {
463        Self { url: url.into(), command: None }
464    }
465
466    pub fn with_command(mut self, command: impl Into<String>) -> Self {
467        self.command = Some(command.into());
468        self
469    }
470}
471
472/// Static metadata for the harness picker.
473#[derive(Debug, Clone, Serialize)]
474#[serde(rename_all = "camelCase")]
475pub struct HarnessInfo {
476    pub id: String,
477    pub display_name: String,
478    pub description: String,
479    /// How the user installs this harness themselves. `None` when there is
480    /// nothing to install — a hosted endpoint, or an agent they already supply.
481    pub install_hint: Option<InstallHint>,
482    /// Declarative capabilities — what the harness supports, so the UI
483    /// and run-gating never special-case its id.
484    pub capabilities: HarnessCapabilities,
485}
486
487// --- The trait ------------------------------------------------------
488
489/// A pluggable agent backend. Implementors are cheap to construct
490/// (they hold config, not connections) so a registry can hand out
491/// fresh boxes on demand.
492pub trait Harness: Send + Sync {
493    /// Static metadata for the UI.
494    fn info(&self) -> HarnessInfo;
495
496    /// Probe availability / version / auth. May shell out; callers
497    /// should treat it as blocking and run it off the UI thread.
498    fn readiness(&self) -> HarnessReadiness;
499
500    /// Start a run, streaming events through `on_event`. Returns a
501    /// handle immediately; work continues on background threads.
502    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError>;
503
504    /// The credential this harness needs.
505    fn credential(&self) -> CredentialSpec;
506
507    /// Enumerate the models this harness can run, *live*. The default returns
508    /// the static list declared in [`HarnessInfo`]
509    /// (`info().capabilities.models`), so existing adapters need no change.
510    ///
511    /// Override it when the model set is discovered at runtime rather than
512    /// known at compile time — a hosted-API adapter querying the provider's
513    /// `/v1/models`, an Ollama adapter hitting `/api/tags`. A harness with no
514    /// model-selection concept (bob runs whatever it's configured with)
515    /// returns an empty list, and the host hides the picker — capability by
516    /// the *absence* of models, not a separate flag. May shell out / hit the
517    /// network; treat it as blocking and run it off the UI thread.
518    fn list_models(&self) -> Result<Vec<HarnessModel>, HarnessError> {
519        Ok(self.info().capabilities.models)
520    }
521
522    /// Whether this harness can install/list/delete its own models locally, and
523    /// if so the endpoint metadata a host UI can surface (see
524    /// [`ModelManagement`]). `None` (the default) means model management isn't
525    /// supported — a host hides the "Manage models" surface. Only the
526    /// `openai-compatible` Ollama adapter returns `Some` today.
527    fn model_management(&self) -> Option<ModelManagement> {
528        None
529    }
530
531    /// Installed local models with their on-disk size + details, for a manager
532    /// UI (distinct from [`list_models`](Harness::list_models), the picker's
533    /// name-only set). Default: unsupported — override alongside
534    /// [`model_management`](Harness::model_management). Blocking (hits the local
535    /// server); run it off the UI thread.
536    fn list_installed_models(&self) -> Result<Vec<InstalledModel>, HarnessError> {
537        Err(HarnessError::Other(
538            "This harness does not support managing models.".to_owned(),
539        ))
540    }
541
542    /// Download (install) a model, streaming progress to `on_progress`. `cancel`
543    /// is polled during the download; flipping it aborts the pull. Blocking
544    /// until the download finishes (or fails / is cancelled); run it off the UI
545    /// thread. Default: unsupported.
546    fn pull_model(
547        &self,
548        _model: &str,
549        _cancel: &std::sync::atomic::AtomicBool,
550        _on_progress: PullProgressCallback<'_>,
551    ) -> Result<(), HarnessError> {
552        Err(HarnessError::Other(
553            "This harness does not support managing models.".to_owned(),
554        ))
555    }
556
557    /// Remove an installed local model. Removing one that's already absent
558    /// succeeds (the requested end state). Default: unsupported.
559    fn delete_model(&self, _model: &str) -> Result<(), HarnessError> {
560        Err(HarnessError::Other(
561            "This harness does not support managing models.".to_owned(),
562        ))
563    }
564
565    /// Trigger the harness's own interactive sign-in (its CLI's OAuth),
566    /// streaming progress as [`InstallEvent`]s. The flow opens the user's
567    /// browser; this blocks until the login process exits, then
568    /// `Done { ok }` reports success. This is the agent authenticating
569    /// itself — distinct from installing it, which the host's user does.
570    /// Default: unsupported, for harnesses the host authenticates by key.
571    fn login(&self, _on_event: InstallCallback) -> Result<(), HarnessError> {
572        Err(HarnessError::login(
573            "This harness does not support interactive sign-in.",
574        ))
575    }
576
577    /// Convenience over [`run`](Harness::run) for callers that want to
578    /// *pull* events off a channel instead of supplying a push callback.
579    /// Forwards each [`RunEvent`] into an `mpsc` channel and hands the
580    /// receiver back alongside the run handle, so the caller can simply
581    /// `for event in rx { … }` rather than re-write the
582    /// `Arc::new(move |ev| tx.send(ev))` plumbing at every call site.
583    ///
584    /// The receiver hangs up when the run ends — and on its own, without
585    /// the caller dropping the [`RunHandle`] first. The forwarding callback
586    /// (and the `Sender` it owns) lives only on the engine's reader
587    /// threads; once the process exits and those threads finish, every
588    /// clone of the callback drops, the `Sender` drops, and the `for` loop
589    /// over `rx` terminates. (Dropping the handle never cancels a run — see
590    /// [`RunControl`] — so it is safe to drain `rx` to completion while
591    /// still holding the handle for a possible [`cancel`](RunControl::cancel).)
592    ///
593    /// Prefer [`run`](Harness::run) directly when you need push semantics —
594    /// e.g. forwarding straight onto a Tauri `Channel` or an SSE sink from
595    /// inside the callback — where an intermediate channel is just an extra
596    /// hop. This is a provided method (not overridable surface): adapters
597    /// implement only `run`, and every harness — built-in or third-party —
598    /// gets `run_channel` for free.
599    ///
600    /// ```no_run
601    /// use harness::{Claude, Harness, RunEvent, RunMode, RunRequest, RunTuning};
602    ///
603    /// # fn main() -> Result<(), harness::HarnessError> {
604    /// let (_handle, rx) = Claude::new().run_channel(RunRequest {
605    ///     run_id: "demo".into(),
606    ///     prompt: "Explain Markdown headings in one sentence.".into(),
607    ///     cwd: None,
608    ///     mode: RunMode::Ask,
609    ///     tuning: RunTuning::default(),
610    ///     resume: None,
611    ///     attachments: Vec::new(),
612    /// })?;
613    /// for event in rx {
614    ///     match event {
615    ///         RunEvent::Text { delta, .. } => print!("{delta}"),
616    ///         RunEvent::Exited { .. } => break,
617    ///         _ => {}
618    ///     }
619    /// }
620    /// # Ok(())
621    /// # }
622    /// ```
623    fn run_channel(
624        &self,
625        request: RunRequest,
626    ) -> Result<(RunHandle, mpsc::Receiver<RunEvent>), HarnessError> {
627        let (tx, rx) = mpsc::channel();
628        let handle = self.run(
629            request,
630            Arc::new(move |event| {
631                // A hung-up receiver (consumer stopped early) is not an
632                // error: the run keeps streaming; we just drop the event
633                // nobody is waiting for.
634                let _ = tx.send(event);
635            }),
636        )?;
637        Ok((handle, rx))
638    }
639}
640
641/// Run a harness's interactive sign-in command, streaming its output as
642/// [`InstallEvent`]s and blocking until it exits. Reuses
643/// [`spawn_streaming`] (PATH augmentation + reader threads, so a packaged
644/// `.app` finds the CLI), mapping its process events onto the
645/// install-stream shape (Step / Stdout / Stderr / Done). The login CLI
646/// opens the user's browser for OAuth; we surface its output (incl. any
647/// device-code URL) so the UI can show progress. Blocks on a condvar
648/// until the process exits — the caller is a Tauri `(async)` command on
649/// a worker thread, so the UI never blocks.
650pub fn run_login_command(
651    program: &str,
652    args: &[&str],
653    on_event: InstallCallback,
654) -> Result<(), HarnessError> {
655    (*on_event)(InstallEvent::Step {
656        text: "Opening your browser to sign in…".to_owned(),
657    });
658    let done = Arc::new((Mutex::new(false), Condvar::new()));
659    let done_cb = Arc::clone(&done);
660    let events_cb = Arc::clone(&on_event);
661    // Bound, not `_`, so the handle outlives the wait (dropping it could
662    // signal the child); by the time we return, the process has exited.
663    let _handle = spawn_streaming(
664        PathBuf::from(program),
665        args.iter().map(|s| (*s).to_owned()).collect(),
666        Vec::new(),
667        std::env::current_dir().unwrap_or_default(),
668        format!("login-{program}"),
669        move |event| match event {
670            ProcessEvent::Started { .. } => {}
671            ProcessEvent::Stdout { line, .. } => {
672                (*events_cb)(InstallEvent::Stdout { text: line });
673            }
674            ProcessEvent::Stderr { line, .. } => {
675                (*events_cb)(InstallEvent::Stderr { text: line });
676            }
677            ProcessEvent::Error { message, .. } => {
678                (*events_cb)(InstallEvent::Stderr { text: message });
679            }
680            ProcessEvent::Exited { exit_code, .. } => {
681                (*events_cb)(InstallEvent::Done {
682                    exit_code,
683                    ok: exit_code == Some(0),
684                });
685                let (lock, cvar) = &*done_cb;
686                // Recover from a poisoned lock instead of panicking on a
687                // reader thread: the guarded value is a plain bool, never in a
688                // half-updated state worth bailing on.
689                *lock.lock().unwrap_or_else(|p| p.into_inner()) = true;
690                cvar.notify_all();
691            }
692            // `ProcessEvent` is #[non_exhaustive]; ignore any future variant.
693            _ => {}
694        },
695    )
696    .map_err(HarnessError::login)?;
697    let (lock, cvar) = &*done;
698    let mut finished = lock.lock().unwrap_or_else(|p| p.into_inner());
699    while !*finished {
700        finished = cvar.wait(finished).unwrap_or_else(|p| p.into_inner());
701    }
702    Ok(())
703}
704
705/// Whether an API-key value an adapter pulled from the environment counts as
706/// authenticated — i.e. present and non-blank. Adapters OR this into their
707/// [`Harness::readiness`] so a key in the env (headless / CI / container)
708/// reports authenticated, not only the CLI's own interactive OAuth login —
709/// which can't complete where there's no browser. Pure (the env read stays at
710/// the call site) so it's unit-tested directly.
711///
712/// Only the claude/codex adapters OR this into readiness — bob reports auth via
713/// `bob-rs`'s own keychain source — so it's gated to those features. Without
714/// them (`--no-default-features`) it would be dead code, hence the `cfg`.
715#[cfg(any(feature = "claude", feature = "codex"))]
716pub(crate) fn api_key_value_usable(value: Option<String>) -> bool {
717    matches!(value, Some(v) if !v.trim().is_empty())
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn layer(digest: &str, completed: u64, total: u64) -> PullProgress {
725        PullProgress {
726            status: format!("pulling {digest}"),
727            digest: Some(digest.to_owned()),
728            total: Some(total),
729            completed: Some(completed),
730        }
731    }
732
733    #[test]
734    fn pull_aggregator_sums_across_digests_keeping_latest_per_digest() {
735        let mut agg = PullProgressAggregator::default();
736        // Manifest phase: no byte totals yet → no percent.
737        assert_eq!(
738            agg.update(&PullProgress { status: "pulling manifest".into(), digest: None, total: None, completed: None }),
739            None
740        );
741        // One layer half done.
742        assert_eq!(agg.update(&layer("sha256:a", 50, 100)), Some(50.0));
743        // A second layer appears; percent now spans both totals.
744        assert_eq!(agg.update(&layer("sha256:b", 0, 100)), Some(25.0));
745        // The first layer's line is resent larger — the latest figure replaces
746        // it (not summed onto the prior one).
747        assert_eq!(agg.update(&layer("sha256:a", 100, 100)), Some(50.0));
748        assert_eq!(agg.update(&layer("sha256:b", 100, 100)), Some(100.0));
749    }
750
751    #[test]
752    fn pull_aggregator_clamps_overshoot_to_100() {
753        let mut agg = PullProgressAggregator::default();
754        // A finished layer can report completed > total momentarily.
755        assert_eq!(agg.update(&layer("sha256:a", 120, 100)), Some(100.0));
756    }
757
758    // Gated like the fn it tests — `api_key_value_usable` only exists when a
759    // claude/codex adapter is compiled in.
760    #[cfg(any(feature = "claude", feature = "codex"))]
761    #[test]
762    fn api_key_value_usable_requires_a_nonblank_value() {
763        assert!(api_key_value_usable(Some("sk-abc".to_owned())));
764        assert!(!api_key_value_usable(Some(String::new())));
765        assert!(!api_key_value_usable(Some("   ".to_owned())));
766        assert!(!api_key_value_usable(None));
767    }
768
769    /// A no-op [`RunControl`] so the mock harness below can hand back a
770    /// [`RunHandle`] without a real process behind it.
771    struct NoopControl;
772    impl RunControl for NoopControl {
773        fn cancel(&self) -> Result<(), HarnessError> {
774            Ok(())
775        }
776        fn was_cancelled(&self) -> bool {
777            false
778        }
779    }
780
781    /// A minimal in-memory harness whose `run()` pushes a fixed event
782    /// sequence straight to the callback, synchronously, then returns —
783    /// dropping its only `RunCallback` clone. That's exactly the ownership
784    /// shape `run_channel` relies on, with no subprocess to spawn, so it
785    /// pins down the contract: events are forwarded, and the receiver hangs
786    /// up on its own once the run's callback ownership ends.
787    struct MockHarness {
788        events: Vec<RunEvent>,
789    }
790    impl Harness for MockHarness {
791        fn info(&self) -> HarnessInfo {
792            unreachable!("not exercised by run_channel")
793        }
794        fn readiness(&self) -> HarnessReadiness {
795            unreachable!("not exercised by run_channel")
796        }
797        fn run(
798            &self,
799            _request: RunRequest,
800            on_event: RunCallback,
801        ) -> Result<RunHandle, HarnessError> {
802            for event in &self.events {
803                on_event(event.clone());
804            }
805            // `on_event` (the lone RunCallback clone, owning the channel's
806            // Sender) drops as this returns → the receiver closes.
807            Ok(Box::new(NoopControl))
808        }
809        fn credential(&self) -> CredentialSpec {
810            unreachable!("not exercised by run_channel")
811        }
812    }
813
814    fn demo_request() -> RunRequest {
815        RunRequest {
816            run_id: "t".to_owned(),
817            prompt: "hi".to_owned(),
818            cwd: None,
819            mode: RunMode::Ask,
820            tuning: RunTuning::default(),
821            resume: None,
822            attachments: Vec::new(),
823        }
824    }
825
826    #[test]
827    fn run_channel_forwards_every_event_then_closes() {
828        let harness = MockHarness {
829            events: vec![
830                RunEvent::Text {
831                    run_id: "t".to_owned(),
832                    delta: "hello".to_owned(),
833                },
834                RunEvent::Exited {
835                    run_id: "t".to_owned(),
836                    exit_code: Some(0),
837                    cancelled: false,
838                },
839            ],
840        };
841        let (_handle, rx) = harness.run_channel(demo_request()).expect("run_channel ok");
842        // Draining to completion *terminates* — proof the channel closed
843        // without us dropping the handle.
844        let collected: Vec<RunEvent> = rx.into_iter().collect();
845        assert_eq!(
846            collected,
847            vec![
848                RunEvent::Text {
849                    run_id: "t".to_owned(),
850                    delta: "hello".to_owned(),
851                },
852                RunEvent::Exited {
853                    run_id: "t".to_owned(),
854                    exit_code: Some(0),
855                    cancelled: false,
856                },
857            ]
858        );
859    }
860
861    #[test]
862    fn run_channel_receiver_closes_even_with_no_events() {
863        let harness = MockHarness { events: Vec::new() };
864        let (_handle, rx) = harness.run_channel(demo_request()).expect("run_channel ok");
865        assert_eq!(rx.into_iter().count(), 0); // closes immediately, doesn't hang
866    }
867
868    #[test]
869    fn harness_error_preserves_typed_source_and_flattened_message() {
870        use std::error::Error;
871
872        // Categorize a real typed engine error as a Spawn failure.
873        let err = HarnessError::spawn(cli_stream::StreamError::PipeNotCaptured { stream: "stdout" });
874
875        // Display still flattens the source into the message, so a consumer
876        // that just `.to_string()`s at a boundary (a Tauri command) gets the
877        // category prefix *and* the full underlying detail — unchanged from
878        // when the variant held a String.
879        let message = err.to_string();
880        assert!(message.starts_with("failed to start the agent: "), "got {message:?}");
881        assert!(message.contains("stdout pipe was not captured"), "got {message:?}");
882
883        // And the real typed error is reachable via the source chain — the
884        // whole point of carrying a source instead of a flattened string.
885        let source = err.source().expect("HarnessError::Spawn has a source");
886        assert!(
887            source.downcast_ref::<cli_stream::StreamError>().is_some(),
888            "source should downcast back to the typed StreamError"
889        );
890    }
891}