use std::path::PathBuf;
use std::sync::{mpsc, Arc, Condvar, Mutex};
use serde::{Deserialize, Serialize};
use crate::events::RunEvent;
use cli_stream::{spawn_streaming, InstallEvent, ProcessEvent, ProcessHandle};
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 HarnessError {
#[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 HarnessError {
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<(), HarnessError>;
fn was_cancelled(&self) -> bool;
}
pub type RunHandle = Box<dyn RunControl>;
impl RunControl for ProcessHandle {
fn cancel(&self) -> Result<(), HarnessError> {
ProcessHandle::cancel(self).map_err(HarnessError::cancel)
}
fn was_cancelled(&self) -> bool {
ProcessHandle::was_cancelled(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RunMode {
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>,
}
#[derive(Debug, Clone)]
pub struct RunRequest {
pub run_id: String,
pub prompt: String,
pub cwd: Option<PathBuf>,
pub mode: RunMode,
pub tuning: RunTuning,
}
#[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 HarnessReadiness {
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, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HarnessModel {
pub value: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HarnessCapabilities {
pub credential_required: bool,
pub previews_edits: bool,
pub models: Vec<HarnessModel>,
pub allows_custom_model: bool,
pub supports_effort: bool,
pub supports_max_turns: bool,
pub supports_login: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HarnessInfo {
pub id: String,
pub display_name: String,
pub description: String,
pub requires_install: bool,
pub capabilities: HarnessCapabilities,
}
pub trait Harness: Send + Sync {
fn info(&self) -> HarnessInfo;
fn readiness(&self) -> HarnessReadiness;
fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError>;
fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError>;
fn credential(&self) -> CredentialSpec;
fn login(&self, _on_event: InstallCallback) -> Result<(), HarnessError> {
Err(HarnessError::login(
"This harness does not support interactive sign-in.",
))
}
fn run_channel(
&self,
request: RunRequest,
) -> Result<(RunHandle, mpsc::Receiver<RunEvent>), HarnessError> {
let (tx, rx) = mpsc::channel();
let handle = self.run(
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<(), HarnessError> {
(*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 _handle = spawn_streaming(
PathBuf::from(program),
args.iter().map(|s| (*s).to_owned()).collect(),
Vec::new(),
std::env::current_dir().unwrap_or_default(),
format!("login-{program}"),
move |event| match event {
ProcessEvent::Started { .. } => {}
ProcessEvent::Stdout { line, .. } => {
(*events_cb)(InstallEvent::Stdout { text: line });
}
ProcessEvent::Stderr { line, .. } => {
(*events_cb)(InstallEvent::Stderr { text: line });
}
ProcessEvent::Error { message, .. } => {
(*events_cb)(InstallEvent::Stderr { text: message });
}
ProcessEvent::Exited { exit_code, .. } => {
(*events_cb)(InstallEvent::Done {
exit_code,
ok: exit_code == Some(0),
});
let (lock, cvar) = &*done_cb;
*lock.lock().unwrap_or_else(|p| p.into_inner()) = true;
cvar.notify_all();
}
_ => {}
},
)
.map_err(HarnessError::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(())
}
#[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::*;
#[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 NoopControl;
impl RunControl for NoopControl {
fn cancel(&self) -> Result<(), HarnessError> {
Ok(())
}
fn was_cancelled(&self) -> bool {
false
}
}
struct MockHarness {
events: Vec<RunEvent>,
}
impl Harness for MockHarness {
fn info(&self) -> HarnessInfo {
unreachable!("not exercised by run_channel")
}
fn readiness(&self) -> HarnessReadiness {
unreachable!("not exercised by run_channel")
}
fn install(&self, _on_event: InstallCallback) -> Result<(), HarnessError> {
Ok(())
}
fn run(
&self,
_request: RunRequest,
on_event: RunCallback,
) -> Result<RunHandle, HarnessError> {
for event in &self.events {
on_event(event.clone());
}
Ok(Box::new(NoopControl))
}
fn credential(&self) -> CredentialSpec {
unreachable!("not exercised by run_channel")
}
}
fn demo_request() -> RunRequest {
RunRequest {
run_id: "t".to_owned(),
prompt: "hi".to_owned(),
cwd: None,
mode: RunMode::Ask,
tuning: RunTuning::default(),
}
}
#[test]
fn run_channel_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_channel(demo_request()).expect("run_channel 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_channel_receiver_closes_even_with_no_events() {
let harness = MockHarness { events: Vec::new() };
let (_handle, rx) = harness.run_channel(demo_request()).expect("run_channel ok");
assert_eq!(rx.into_iter().count(), 0); }
#[test]
fn harness_error_preserves_typed_source_and_flattened_message() {
use std::error::Error;
let err = HarnessError::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("HarnessError::Spawn has a source");
assert!(
source.downcast_ref::<cli_stream::StreamError>().is_some(),
"source should downcast back to the typed StreamError"
);
}
}