use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol as acp;
use serde_json::Value;
use smol::Timer;
use crate::{
CredentialSpec, Harness, HarnessCapabilities, HarnessError, HarnessInfo, HarnessModel,
HarnessReadiness, InstallCallback, RunCallback, RunControl, RunEvent, RunHandle, RunMode,
RunRequest,
};
mod translate;
pub struct AcpHarness {
id: String,
display_name: String,
description: String,
command: String,
args: Vec<String>,
model_control: Option<ModelControl>,
}
struct ModelControl {
list_subcommand: Vec<String>,
config_env: String,
config_field: String,
}
#[derive(Clone, Debug, Default)]
pub struct AcpHarnessConfig {
pub id: String,
pub display_name: String,
pub command: String,
pub args: Vec<String>,
}
impl AcpHarness {
pub fn opencode() -> Self {
let mut harness = Self::custom(AcpHarnessConfig {
id: "opencode".to_owned(),
display_name: "OpenCode".to_owned(),
command: "opencode".to_owned(),
args: vec!["acp".to_owned()],
});
harness.model_control = Some(ModelControl {
list_subcommand: vec!["models".to_owned()],
config_env: "OPENCODE_CONFIG".to_owned(),
config_field: "model".to_owned(),
});
harness
}
pub fn custom(config: AcpHarnessConfig) -> Self {
let AcpHarnessConfig { id, display_name, command, args } = config;
Self {
id,
description: format!("{display_name} via the Agent Client Protocol."),
display_name,
command,
args,
model_control: None,
}
}
}
impl Harness for AcpHarness {
fn info(&self) -> HarnessInfo {
HarnessInfo {
id: self.id.clone(),
display_name: self.display_name.clone(),
description: self.description.clone(),
requires_install: false,
capabilities: HarnessCapabilities {
credential_required: false,
previews_edits: false,
models: Vec::new(),
allows_custom_model: true,
supports_effort: false,
supports_max_turns: false,
supports_login: false,
supports_custom_instructions: false,
},
}
}
fn readiness(&self) -> HarnessReadiness {
let installed = probe_command(&self.command);
HarnessReadiness {
harness_id: self.id.clone(),
ready: installed,
installed,
version: None,
auth_configured: installed,
error: if installed {
None
} else {
Some(format!(
"`{}` is not installed or not on PATH (needed to run {} over ACP).",
self.command, self.display_name
))
},
details: Value::Null,
}
}
fn install(&self, _on_event: InstallCallback) -> Result<(), HarnessError> {
Ok(())
}
fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
let RunRequest { run_id, prompt, cwd, mode, tuning, resume: _, attachments: _ } = request;
let (env, model_config_file) = match (&self.model_control, tuning.model) {
(Some(mc), Some(model)) => {
let path = write_model_config(&run_id, &mc.config_field, &model)
.map_err(HarnessError::spawn)?;
(vec![(mc.config_env.clone(), path.to_string_lossy().into_owned())], Some(path))
}
_ => (Vec::new(), None),
};
let cfg = AcpRunCfg {
command: self.command.clone(),
args: self.args.clone(),
run_id,
prompt,
cwd: cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()),
mode,
env,
model_config_file,
};
let cancel = Arc::new(AtomicBool::new(false));
let thread_cancel = Arc::clone(&cancel);
std::thread::spawn(move || run_acp(cfg, thread_cancel, on_event));
Ok(Box::new(AcpRun { cancel }))
}
fn credential(&self) -> CredentialSpec {
CredentialSpec {
label: format!("{} (manages its own auth)", self.display_name),
keychain_service: self.id.clone(),
keychain_account: String::new(),
required: false,
}
}
fn list_models(&self) -> Result<Vec<HarnessModel>, HarnessError> {
let Some(mc) = &self.model_control else {
return Ok(Vec::new());
};
let output = Command::new(&self.command)
.args(&mc.list_subcommand)
.env("PATH", crate::augmented_node_path())
.output()
.map_err(|e| {
HarnessError::spawn(format!(
"`{} {}` failed: {e}",
self.command,
mc.list_subcommand.join(" ")
))
})?;
if !output.status.success() {
return Ok(Vec::new());
}
let models = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| HarnessModel { value: line.to_owned(), label: line.to_owned() })
.collect();
Ok(models)
}
}
fn probe_command(command: &str) -> bool {
Command::new(command)
.arg("--version")
.env("PATH", crate::augmented_node_path())
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn write_model_config(run_id: &str, field: &str, model: &str) -> Result<PathBuf, String> {
let path = std::env::temp_dir().join(format!("harness-acp-model-{run_id}.json"));
let body = serde_json::json!({ field: model }).to_string();
std::fs::write(&path, body)
.map_err(|e| format!("writing ACP model config to {}: {e}", path.display()))?;
Ok(path)
}
struct AcpRunCfg {
command: String,
args: Vec<String>,
run_id: String,
prompt: String,
cwd: PathBuf,
mode: RunMode,
env: Vec<(String, String)>,
model_config_file: Option<PathBuf>,
}
struct AcpRun {
cancel: Arc<AtomicBool>,
}
impl RunControl for AcpRun {
fn cancel(&self) -> Result<(), HarnessError> {
self.cancel.store(true, Ordering::SeqCst);
Ok(())
}
fn was_cancelled(&self) -> bool {
self.cancel.load(Ordering::SeqCst)
}
}
fn run_acp(cfg: AcpRunCfg, cancel: Arc<AtomicBool>, on_event: RunCallback) {
(*on_event)(RunEvent::Started { run_id: cfg.run_id.clone() });
let perm_mode = cfg.mode;
let notif_on_event = on_event.clone();
let notif_rid = cfg.run_id.clone();
let prompt = cfg.prompt.clone();
let cwd = cfg.cwd.clone();
let env_vars: Vec<acp::schema::EnvVariable> = cfg
.env
.iter()
.map(|(name, value)| acp::schema::EnvVariable::new(name.clone(), value.clone()))
.collect();
let server = acp::schema::McpServer::Stdio(
acp::schema::McpServerStdio::new(cfg.command.clone(), cfg.command.clone())
.args(cfg.args.clone())
.env(env_vars),
);
let agent = acp::AcpAgent::new(server);
let connect = async move {
acp::Client
.builder()
.name("openai-compatible")
.on_receive_request(
move |req: acp::schema::RequestPermissionRequest,
responder: acp::Responder<acp::schema::RequestPermissionResponse>,
_cx: acp::ConnectionTo<acp::Agent>| {
let mode = perm_mode;
async move {
let allow = matches!(mode, RunMode::Edit);
let pick =
req.options.iter().find(|o| is_allow(&o.kind) == allow).or_else(|| req.options.first());
let outcome = match pick {
Some(o) => acp::schema::RequestPermissionOutcome::Selected(
acp::schema::SelectedPermissionOutcome::new(o.option_id.clone()),
),
None => acp::schema::RequestPermissionOutcome::Cancelled,
};
responder.respond(acp::schema::RequestPermissionResponse::new(outcome))
}
},
acp::on_receive_request!(),
)
.on_receive_notification(
move |notif: acp::schema::SessionNotification, _cx: acp::ConnectionTo<acp::Agent>| {
let on_event = notif_on_event.clone();
let rid = notif_rid.clone();
async move {
for event in translate::session_update_to_events(&rid, notif.update) {
(*on_event)(event);
}
Ok(())
}
},
acp::on_receive_notification!(),
)
.connect_with(agent, move |cx: acp::ConnectionTo<acp::Agent>| async move {
cx.send_request(acp::schema::InitializeRequest::new(acp::schema::ProtocolVersion::LATEST))
.block_task()
.await?;
let session =
cx.send_request(acp::schema::NewSessionRequest::new(cwd.clone())).block_task().await?;
let resp = cx
.send_request(acp::schema::PromptRequest::new(session.session_id, vec![prompt.clone().into()]))
.block_task()
.await?;
Ok(resp.stop_reason)
})
.await
.map_err(|e| format!("ACP run failed: {e}"))
};
let cancel_fut = {
let cancel = Arc::clone(&cancel);
async move {
loop {
if cancel.load(Ordering::SeqCst) {
return Err("cancelled".to_owned());
}
Timer::after(Duration::from_millis(50)).await;
}
}
};
let outcome: Result<acp::schema::StopReason, String> =
smol::block_on(futures_lite::future::or(connect, cancel_fut));
let run_id = cfg.run_id;
if let Some(path) = cfg.model_config_file {
let _ = std::fs::remove_file(path);
}
match outcome {
Ok(stop) => {
let cancelled =
cancel.load(Ordering::SeqCst) || matches!(stop, acp::schema::StopReason::Cancelled);
(*on_event)(RunEvent::Exited { run_id, exit_code: Some(0), cancelled });
}
Err(_) if cancel.load(Ordering::SeqCst) => {
(*on_event)(RunEvent::Exited { run_id, exit_code: None, cancelled: true });
}
Err(message) => {
(*on_event)(RunEvent::Error { run_id: run_id.clone(), message });
(*on_event)(RunEvent::Exited { run_id, exit_code: Some(1), cancelled: false });
}
}
}
fn is_allow(kind: &acp::schema::PermissionOptionKind) -> bool {
matches!(
kind,
acp::schema::PermissionOptionKind::AllowOnce | acp::schema::PermissionOptionKind::AllowAlways
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Harness;
#[test]
fn generic_acp_agent_lists_no_models_without_shelling_out() {
let harness = AcpHarness::custom(AcpHarnessConfig {
id: "x".to_owned(),
display_name: "X".to_owned(),
command: "definitely-not-a-real-command".to_owned(),
args: vec!["acp".to_owned()],
});
assert!(harness.list_models().expect("ok").is_empty());
let caps = harness.info().capabilities;
assert!(caps.models.is_empty());
assert!(caps.allows_custom_model, "ACP agents accept a free-text model");
}
#[test]
fn write_model_config_emits_field_and_model_keyed_by_run_id() {
let path = write_model_config("run-abc", "model", "opencode/big-pickle")
.expect("writes the config file");
assert!(
path.to_string_lossy().contains("run-abc"),
"temp file is keyed by run_id: {path:?}"
);
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).expect("read back"))
.expect("valid JSON");
assert_eq!(json["model"], "opencode/big-pickle");
let _ = std::fs::remove_file(&path);
}
}