use std::path::PathBuf;
use std::sync::{mpsc, Arc, Condvar, Mutex};
use serde::{Deserialize, Serialize};
use crate::events::RunEvent;
use cli_stream::{Command, Event, InstallEvent, ProcessHandle};
use crate::program_path::ResolveCli;
pub type RunCallback = Arc<dyn Fn(RunEvent) + Send + Sync>;
pub type InstallCallback = Arc<dyn Fn(InstallEvent) + Send + Sync>;
pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("failed to start the agent: {0}")]
Spawn(#[source] BoxError),
#[error("install failed: {0}")]
Install(#[source] BoxError),
#[error("sign-in failed: {0}")]
Login(#[source] BoxError),
#[error("cancel failed: {0}")]
Cancel(#[source] BoxError),
#[error("{0}")]
Other(String),
}
impl Error {
pub fn spawn(source: impl Into<BoxError>) -> Self {
Self::Spawn(source.into())
}
pub fn install(source: impl Into<BoxError>) -> Self {
Self::Install(source.into())
}
pub fn login(source: impl Into<BoxError>) -> Self {
Self::Login(source.into())
}
pub fn cancel(source: impl Into<BoxError>) -> Self {
Self::Cancel(source.into())
}
}
pub trait RunControl: Send + Sync {
fn cancel(&self) -> Result<(), Error>;
fn was_cancelled(&self) -> bool;
fn pid(&self) -> Option<u32> {
None
}
}
pub type RunHandle = Box<dyn RunControl>;
impl RunControl for ProcessHandle {
fn cancel(&self) -> Result<(), Error> {
ProcessHandle::cancel(self).map_err(Error::cancel)
}
fn was_cancelled(&self) -> bool {
ProcessHandle::was_cancelled(self)
}
fn pid(&self) -> Option<u32> {
ProcessHandle::pid(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum RunMode {
#[default]
Ask,
Edit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningEffort {
Minimal,
Low,
Medium,
High,
}
impl ReasoningEffort {
pub fn as_cli_value(self) -> &'static str {
match self {
ReasoningEffort::Minimal => "minimal",
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High => "high",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RunTuning {
pub model: Option<String>,
pub effort: Option<ReasoningEffort>,
pub max_turns: Option<u32>,
pub extra_args: Vec<String>,
pub output_schema: Option<serde_json::Value>,
pub extra_instructions: Option<String>,
pub binary_path: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Attachment {
pub mime_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct RunRequest {
pub run_id: String,
pub prompt: String,
pub attachments: Vec<Attachment>,
pub cwd: Option<PathBuf>,
pub mode: RunMode,
pub tuning: RunTuning,
pub resume: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialSpec {
pub label: String,
pub keychain_service: String,
pub keychain_account: String,
pub required: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Readiness {
pub harness_id: String,
pub ready: bool,
pub installed: bool,
pub version: Option<String>,
pub auth_configured: bool,
pub error: Option<String>,
pub details: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelChoice {
pub value: String,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledModel {
pub name: String,
pub size: u64,
pub parameter_size: Option<String>,
pub quantization_level: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullProgress {
pub status: String,
pub digest: Option<String>,
pub total: Option<u64>,
pub completed: Option<u64>,
}
pub type PullProgressCallback<'a> = &'a mut (dyn FnMut(PullProgress) + Send);
#[derive(Debug, Default)]
pub struct PullProgressAggregator {
layers: std::collections::HashMap<String, (u64, u64)>,
}
impl PullProgressAggregator {
pub fn update(&mut self, progress: &PullProgress) -> Option<f64> {
if let (Some(digest), Some(total)) = (&progress.digest, progress.total) {
self.layers.insert(digest.clone(), (progress.completed.unwrap_or(0), total));
}
self.percent()
}
pub fn percent(&self) -> Option<f64> {
let total: u64 = self.layers.values().map(|(_, t)| *t).sum();
if total == 0 {
return None;
}
let completed: u64 = self.layers.values().map(|(c, _)| *c).sum();
Some((completed as f64 / total as f64 * 100.0).clamp(0.0, 100.0))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelManagement {
pub base_url: String,
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Features {
pub credential_required: bool,
pub previews_edits: bool,
pub models: Vec<ModelChoice>,
pub custom_model: bool,
pub effort: bool,
pub max_turns: bool,
pub login: bool,
pub custom_instructions: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallHint {
pub url: String,
pub command: Option<String>,
}
impl InstallHint {
pub fn url(url: impl Into<String>) -> Self {
Self { url: url.into(), command: None }
}
pub fn with_command(mut self, command: impl Into<String>) -> Self {
self.command = Some(command.into());
self
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Info {
pub id: String,
pub display_name: String,
pub description: String,
pub install_hint: Option<InstallHint>,
}
pub trait Harness: Send + Sync {
fn info(&self) -> Info;
fn features(&self) -> Features {
Features::default()
}
fn readiness(&self) -> Readiness;
fn start(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, Error>;
fn credential(&self) -> CredentialSpec;
fn list_models(&self) -> Result<Vec<ModelChoice>, Error> {
Ok(self.features().models)
}
fn model_management(&self) -> Option<ModelManagement> {
None
}
fn list_installed_models(&self) -> Result<Vec<InstalledModel>, Error> {
Err(Error::Other(
"This harness does not support managing models.".to_owned(),
))
}
fn pull_model(
&self,
_model: &str,
_cancel: &std::sync::atomic::AtomicBool,
_on_progress: PullProgressCallback<'_>,
) -> Result<(), Error> {
Err(Error::Other(
"This harness does not support managing models.".to_owned(),
))
}
fn delete_model(&self, _model: &str) -> Result<(), Error> {
Err(Error::Other(
"This harness does not support managing models.".to_owned(),
))
}
fn login(&self, _on_event: InstallCallback) -> Result<(), Error> {
Err(Error::login(
"This harness does not support interactive sign-in.",
))
}
fn run(
&self,
request: RunRequest,
) -> Result<(RunHandle, mpsc::Receiver<RunEvent>), Error> {
let (tx, rx) = mpsc::channel();
let handle = self.start(
request,
Arc::new(move |event| {
let _ = tx.send(event);
}),
)?;
Ok((handle, rx))
}
}
pub fn run_login_command(
program: &str,
args: &[&str],
on_event: InstallCallback,
) -> Result<(), Error> {
(*on_event)(InstallEvent::Step {
text: "Opening your browser to sign in…".to_owned(),
});
let done = Arc::new((Mutex::new(false), Condvar::new()));
let done_cb = Arc::clone(&done);
let events_cb = Arc::clone(&on_event);
let spawn = Command::new(program).cwd(std::env::current_dir().unwrap_or_default()).run_id(format!("login-{program}"))
.args(args.iter().copied());
let _handle = spawn.resolve_cli().stream(move |event| {
let finished = matches!(event, Event::Exited { .. });
if let Some(install) = login_event(&event) {
(*events_cb)(install);
}
if finished {
let (lock, cvar) = &*done_cb;
*lock.lock().unwrap_or_else(|p| p.into_inner()) = true;
cvar.notify_all();
}
},
)
.map_err(Error::login)?;
let (lock, cvar) = &*done;
let mut finished = lock.lock().unwrap_or_else(|p| p.into_inner());
while !*finished {
finished = cvar.wait(finished).unwrap_or_else(|p| p.into_inner());
}
Ok(())
}
fn login_event(event: &Event) -> Option<InstallEvent> {
match event {
Event::Stdout { line, .. } => Some(InstallEvent::Stdout { text: line.clone() }),
Event::Stderr { line, .. } => Some(InstallEvent::Stderr { text: line.clone() }),
Event::Error { message, .. } => Some(InstallEvent::Stderr { text: message.clone() }),
Event::Exited { exit_code, .. } => {
Some(InstallEvent::Done { exit_code: *exit_code, ok: *exit_code == Some(0) })
}
_ => None,
}
}
#[cfg(any(feature = "claude", feature = "codex"))]
pub(crate) fn api_key_value_usable(value: Option<String>) -> bool {
matches!(value, Some(v) if !v.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn layer(digest: &str, completed: u64, total: u64) -> PullProgress {
PullProgress {
status: format!("pulling {digest}"),
digest: Some(digest.to_owned()),
total: Some(total),
completed: Some(completed),
}
}
#[test]
fn pull_aggregator_sums_across_digests_keeping_latest_per_digest() {
let mut agg = PullProgressAggregator::default();
assert_eq!(
agg.update(&PullProgress { status: "pulling manifest".into(), digest: None, total: None, completed: None }),
None
);
assert_eq!(agg.update(&layer("sha256:a", 50, 100)), Some(50.0));
assert_eq!(agg.update(&layer("sha256:b", 0, 100)), Some(25.0));
assert_eq!(agg.update(&layer("sha256:a", 100, 100)), Some(50.0));
assert_eq!(agg.update(&layer("sha256:b", 100, 100)), Some(100.0));
}
#[test]
fn pull_aggregator_clamps_overshoot_to_100() {
let mut agg = PullProgressAggregator::default();
assert_eq!(agg.update(&layer("sha256:a", 120, 100)), Some(100.0));
}
#[cfg(any(feature = "claude", feature = "codex"))]
#[test]
fn api_key_value_usable_requires_a_nonblank_value() {
assert!(api_key_value_usable(Some("sk-abc".to_owned())));
assert!(!api_key_value_usable(Some(String::new())));
assert!(!api_key_value_usable(Some(" ".to_owned())));
assert!(!api_key_value_usable(None));
}
struct MinimalHarness;
impl Harness for MinimalHarness {
fn info(&self) -> Info {
Info {
id: "minimal".to_owned(),
display_name: "Minimal".to_owned(),
description: "implements the required surface and nothing else".to_owned(),
install_hint: None,
}
}
fn features(&self) -> Features {
Features {
models: vec![ModelChoice { value: "m1".to_owned(), label: "Model one".to_owned() }],
..Default::default()
}
}
fn readiness(&self) -> Readiness {
Readiness {
harness_id: "minimal".to_owned(),
ready: true,
installed: true,
version: None,
auth_configured: true,
error: None,
details: serde_json::Value::Null,
}
}
fn start(&self, _request: RunRequest, _on_event: RunCallback) -> Result<RunHandle, Error> {
Ok(Box::new(NoopControl))
}
fn credential(&self) -> CredentialSpec {
CredentialSpec {
label: "none".to_owned(),
keychain_service: "s".to_owned(),
keychain_account: "a".to_owned(),
required: false,
}
}
}
#[test]
fn an_adapter_that_implements_only_the_required_surface_still_answers_the_rest() {
let harness = MinimalHarness;
assert_eq!(harness.list_models().unwrap(), harness.features().models);
assert!(harness.model_management().is_none(), "no model management is the default");
assert!(NoopControl.pid().is_none(), "a harness with no process reports no pid");
}
#[test]
fn unsupported_optional_features_refuse_rather_than_pretend_to_succeed() {
let harness = MinimalHarness;
let cancel = std::sync::atomic::AtomicBool::new(false);
for message in [
harness.list_installed_models().map(|_| ()).unwrap_err().to_string(),
harness.pull_model("m", &cancel, &mut |_| {}).unwrap_err().to_string(),
harness.delete_model("m").unwrap_err().to_string(),
] {
assert!(message.contains("does not support managing models"), "got {message}");
}
assert!(
harness.login(Arc::new(|_| {})).unwrap_err().to_string().contains("interactive sign-in"),
"and sign-in says which thing is unsupported"
);
}
#[test]
fn capabilities_default_to_supporting_nothing() {
let none = Features::default();
assert!(!none.credential_required && !none.previews_edits && !none.custom_model);
assert!(!none.effort && !none.max_turns && !none.login);
assert!(!none.custom_instructions);
assert!(none.models.is_empty());
}
#[test]
fn reasoning_effort_keeps_the_tokens_a_cli_actually_accepts() {
assert_eq!(ReasoningEffort::Minimal.as_cli_value(), "minimal");
assert_eq!(ReasoningEffort::Low.as_cli_value(), "low");
assert_eq!(ReasoningEffort::Medium.as_cli_value(), "medium");
assert_eq!(ReasoningEffort::High.as_cli_value(), "high");
}
#[test]
fn an_install_hint_always_has_a_url_and_optionally_a_command() {
let bare = InstallHint::url("https://example.test/install");
assert_eq!(bare.url, "https://example.test/install");
assert!(bare.command.is_none());
assert_eq!(bare.with_command("brew install thing").command.as_deref(), Some("brew install thing"));
}
fn login_events(program: &str, args: &[&str]) -> (Result<(), Error>, Vec<InstallEvent>) {
let seen: Arc<Mutex<Vec<InstallEvent>>> = Arc::default();
let sink = Arc::clone(&seen);
let result = run_login_command(program, args, Arc::new(move |event| sink.lock().unwrap().push(event)));
let events = seen.lock().unwrap().clone();
(result, events)
}
#[test]
fn a_sign_in_streams_the_cli_output_the_user_has_to_act_on() {
let (result, events) = login_events("echo", &["visit https://example.test/device"]);
assert!(result.is_ok(), "{result:?}");
assert!(
matches!(events.first(), Some(InstallEvent::Step { .. })),
"something is said before the browser opens: {events:?}"
);
assert!(
events.iter().any(|e| matches!(e, InstallEvent::Stdout { text } if text.contains("example.test/device"))),
"the URL reaches the host: {events:?}"
);
assert!(
matches!(events.last(), Some(InstallEvent::Done { ok: true, exit_code: Some(0) })),
"and it ends exactly once, saying how: {events:?}"
);
}
#[test]
fn every_kind_of_process_output_reaches_the_user_during_sign_in() {
let ev = |e: Event| login_event(&e);
let run_id = || "r".to_owned();
assert!(ev(Event::Started { run_id: run_id() }).is_none(), "the spawn is not news");
let out = ev(Event::Stdout { run_id: run_id(), line: "visit https://x.test".into() });
assert!(matches!(out, Some(InstallEvent::Stdout { text }) if text.contains("x.test")));
let err = ev(Event::Stderr { run_id: run_id(), line: "code ABCD".into() });
assert!(matches!(err, Some(InstallEvent::Stderr { text }) if text == "code ABCD"));
let broken = ev(Event::Error { run_id: run_id(), message: "stream read failed".into() });
assert!(matches!(broken, Some(InstallEvent::Stderr { text }) if text.contains("read failed")));
let ok = ev(Event::Exited { run_id: run_id(), exit_code: Some(0), cancelled: false });
assert!(matches!(ok, Some(InstallEvent::Done { ok: true, exit_code: Some(0) })));
let failed = ev(Event::Exited { run_id: run_id(), exit_code: Some(1), cancelled: false });
assert!(matches!(failed, Some(InstallEvent::Done { ok: false, .. })), "only zero is success");
}
#[test]
fn a_failed_sign_in_says_so_rather_than_completing_quietly() {
let (result, events) = login_events("false", &[]);
assert!(result.is_ok(), "the command ran; it is its exit code that failed");
assert!(
matches!(events.last(), Some(InstallEvent::Done { ok: false, .. })),
"got {events:?}"
);
}
#[cfg(unix)]
#[test]
fn a_process_backed_run_reports_its_pid_and_whether_it_was_stopped() {
let child = Command::new("sleep")
.cwd(std::env::temp_dir())
.run_id("pid-test")
.args(["30"])
.resolve_cli()
.stream(|_| {})
.expect("sleep should spawn");
let run: RunHandle = Box::new(child);
let pid = run.pid().expect("a live child has a pid");
assert!(pid > 1, "a real OS pid, not a placeholder: {pid}");
assert!(!run.was_cancelled(), "nothing has stopped it yet");
run.cancel().expect("cancel");
assert!(run.was_cancelled(), "a stopped run says so");
}
struct NoopControl;
impl RunControl for NoopControl {
fn cancel(&self) -> Result<(), Error> {
Ok(())
}
fn was_cancelled(&self) -> bool {
false
}
}
struct MockHarness {
events: Vec<RunEvent>,
}
impl Harness for MockHarness {
fn info(&self) -> Info {
unreachable!("not exercised by run")
}
fn readiness(&self) -> Readiness {
unreachable!("not exercised by run")
}
fn start(
&self,
_request: RunRequest,
on_event: RunCallback,
) -> Result<RunHandle, Error> {
for event in &self.events {
on_event(event.clone());
}
Ok(Box::new(NoopControl))
}
fn credential(&self) -> CredentialSpec {
unreachable!("not exercised by run")
}
}
fn demo_request() -> RunRequest {
RunRequest {
run_id: "t".to_owned(),
prompt: "hi".to_owned(),
cwd: None,
mode: RunMode::Ask,
tuning: RunTuning::default(),
resume: None,
attachments: Vec::new(),
}
}
#[test]
fn run_forwards_every_event_then_closes() {
let harness = MockHarness {
events: vec![
RunEvent::Text {
run_id: "t".to_owned(),
delta: "hello".to_owned(),
},
RunEvent::Exited {
run_id: "t".to_owned(),
exit_code: Some(0),
cancelled: false,
},
],
};
let (_handle, rx) = harness.run(demo_request()).expect("run ok");
let collected: Vec<RunEvent> = rx.into_iter().collect();
assert_eq!(
collected,
vec![
RunEvent::Text {
run_id: "t".to_owned(),
delta: "hello".to_owned(),
},
RunEvent::Exited {
run_id: "t".to_owned(),
exit_code: Some(0),
cancelled: false,
},
]
);
}
#[test]
fn run_receiver_closes_even_with_no_events() {
let harness = MockHarness { events: Vec::new() };
let (_handle, rx) = harness.run(demo_request()).expect("run ok");
assert_eq!(rx.into_iter().count(), 0); }
#[test]
fn harness_error_preserves_typed_source_and_flattened_message() {
use std::error::Error as _;
let err = Error::spawn(cli_stream::StreamError::PipeNotCaptured { stream: "stdout" });
let message = err.to_string();
assert!(message.starts_with("failed to start the agent: "), "got {message:?}");
assert!(message.contains("stdout pipe was not captured"), "got {message:?}");
let source = err.source().expect("Error::Spawn has a source");
assert!(
source.downcast_ref::<cli_stream::StreamError>().is_some(),
"source should downcast back to the typed StreamError"
);
}
}