1use std::path::PathBuf;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::Arc;
24use std::time::Duration;
25
26use agent_client_protocol as acp;
27use serde_json::Value;
28use smol::Timer;
29
30use crate::{
31 CredentialSpec, Harness, Features, Error, Info, ModelChoice,
32 Readiness, InstallHint, RunCallback, RunControl, RunEvent, RunHandle, RunMode,
33 RunRequest,
34};
35
36mod translate;
37
38pub struct AcpHarness {
42 id: String,
43 display_name: String,
44 description: String,
45 command: String,
47 args: Vec<String>,
49 install_hint: Option<InstallHint>,
51 model_control: Option<ModelControl>,
56}
57
58struct ModelControl {
64 list_subcommand: Vec<String>,
67 config_env: String,
69 config_field: String,
71}
72
73#[derive(Clone, Debug, Default)]
76pub struct AcpHarnessConfig {
77 pub id: String,
79 pub display_name: String,
81 pub command: String,
83 pub args: Vec<String>,
85 pub install_hint: Option<InstallHint>,
88}
89
90impl AcpHarness {
91 pub fn opencode() -> Self {
97 let mut harness = Self::custom(AcpHarnessConfig {
98 id: "opencode".to_owned(),
99 display_name: "OpenCode".to_owned(),
100 command: "opencode".to_owned(),
101 args: vec!["acp".to_owned()],
102 install_hint: Some(InstallHint::url("https://github.com/sst/opencode")),
103 });
104 harness.model_control = Some(ModelControl {
105 list_subcommand: vec!["models".to_owned()],
106 config_env: "OPENCODE_CONFIG".to_owned(),
107 config_field: "model".to_owned(),
108 });
109 harness
110 }
111
112 pub fn custom(config: AcpHarnessConfig) -> Self {
116 let AcpHarnessConfig { id, display_name, command, args, install_hint } = config;
117 Self {
118 id,
119 description: format!("{display_name} via the Agent Client Protocol."),
120 display_name,
121 command,
122 args,
123 install_hint,
124 model_control: None,
125 }
126 }
127}
128
129impl Harness for AcpHarness {
130 fn info(&self) -> Info {
131 Info {
132 id: self.id.clone(),
133 display_name: self.display_name.clone(),
134 description: self.description.clone(),
135 install_hint: self.install_hint.clone(),
136 }
137 }
138
139 fn features(&self) -> Features {
140 Features {
141 custom_model: true,
145 ..Default::default()
146 }
147 }
148
149 fn readiness(&self) -> Readiness {
150 let installed = probe_command(&self.command);
151 Readiness {
152 harness_id: self.id.clone(),
153 ready: installed,
154 installed,
155 version: None,
156 auth_configured: installed,
157 error: if installed {
158 None
159 } else {
160 Some(format!(
161 "`{}` is not installed or not on PATH (needed to run {} over ACP).",
162 self.command, self.display_name
163 ))
164 },
165 details: Value::Null,
166 }
167 }
168
169 fn start(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, Error> {
170 let RunRequest { run_id, prompt, cwd, mode, tuning, resume: _, attachments: _ } = request;
173 let (env, model_config_file) = match (&self.model_control, tuning.model) {
178 (Some(mc), Some(model)) => {
179 let path = write_model_config(&run_id, &mc.config_field, &model)
180 .map_err(Error::spawn)?;
181 (vec![(mc.config_env.clone(), path.to_string_lossy().into_owned())], Some(path))
182 }
183 _ => (Vec::new(), None),
184 };
185 let cfg = AcpRunCfg {
186 command: self.command.clone(),
187 args: self.args.clone(),
188 run_id,
189 prompt,
190 cwd: cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()),
191 mode,
192 env,
193 model_config_file,
194 };
195 let cancel = Arc::new(AtomicBool::new(false));
196 let thread_cancel = Arc::clone(&cancel);
197 std::thread::spawn(move || run_acp(cfg, thread_cancel, on_event));
201 Ok(Box::new(AcpRun { cancel }))
202 }
203
204 fn credential(&self) -> CredentialSpec {
205 CredentialSpec {
206 label: format!("{} (manages its own auth)", self.display_name),
207 keychain_service: self.id.clone(),
208 keychain_account: String::new(),
209 required: false,
210 }
211 }
212
213 fn list_models(&self) -> Result<Vec<ModelChoice>, Error> {
214 let Some(mc) = &self.model_control else {
218 return Ok(Vec::new());
219 };
220 let output = crate::hidden_command(&self.command)
221 .args(&mc.list_subcommand)
222 .env("PATH", crate::augmented_path())
223 .output()
224 .map_err(|e| {
225 Error::spawn(format!(
226 "`{} {}` failed: {e}",
227 self.command,
228 mc.list_subcommand.join(" ")
229 ))
230 })?;
231 Ok(models_from_listing(output.status.success(), &String::from_utf8_lossy(&output.stdout)))
232 }
233}
234
235fn models_from_listing(succeeded: bool, stdout: &str) -> Vec<ModelChoice> {
242 if !succeeded {
243 return Vec::new();
244 }
245 stdout
246 .lines()
247 .map(str::trim)
248 .filter(|line| !line.is_empty())
249 .map(|line| ModelChoice { value: line.to_owned(), label: line.to_owned() })
251 .collect()
252}
253
254fn probe_command(command: &str) -> bool {
258 crate::hidden_command(command)
259 .arg("--version")
260 .env("PATH", crate::augmented_path())
261 .output()
262 .map(|o| o.status.success())
263 .unwrap_or(false)
264}
265
266fn write_model_config(run_id: &str, field: &str, model: &str) -> Result<PathBuf, String> {
272 let path = std::env::temp_dir().join(format!("harness-acp-model-{run_id}.json"));
273 let body = serde_json::json!({ field: model }).to_string();
274 std::fs::write(&path, body)
275 .map_err(|e| format!("writing ACP model config to {}: {e}", path.display()))?;
276 Ok(path)
277}
278
279struct AcpRunCfg {
281 command: String,
282 args: Vec<String>,
283 run_id: String,
284 prompt: String,
285 cwd: PathBuf,
286 mode: RunMode,
287 env: Vec<(String, String)>,
290 model_config_file: Option<PathBuf>,
292}
293
294struct AcpRun {
297 cancel: Arc<AtomicBool>,
298}
299
300impl RunControl for AcpRun {
301 fn cancel(&self) -> Result<(), Error> {
302 self.cancel.store(true, Ordering::SeqCst);
303 Ok(())
304 }
305 fn was_cancelled(&self) -> bool {
306 self.cancel.load(Ordering::SeqCst)
307 }
308}
309
310fn run_acp(cfg: AcpRunCfg, cancel: Arc<AtomicBool>, on_event: RunCallback) {
313 (*on_event)(RunEvent::Started { run_id: cfg.run_id.clone() });
314
315 let perm_mode = cfg.mode;
316 let notif_on_event = on_event.clone();
317 let notif_rid = cfg.run_id.clone();
318 let prompt = cfg.prompt.clone();
319 let cwd = cfg.cwd.clone();
320
321 let env_vars: Vec<acp::schema::EnvVariable> = cfg
324 .env
325 .iter()
326 .map(|(name, value)| acp::schema::EnvVariable::new(name.clone(), value.clone()))
327 .collect();
328 let server = acp::schema::McpServer::Stdio(
329 acp::schema::McpServerStdio::new(cfg.command.clone(), cfg.command.clone())
330 .args(cfg.args.clone())
331 .env(env_vars),
332 );
333 let agent = acp::AcpAgent::new(server);
334
335 let connect = async move {
338 acp::Client
339 .builder()
340 .name("openai-compatible")
341 .on_receive_request(
342 move |req: acp::schema::RequestPermissionRequest,
343 responder: acp::Responder<acp::schema::RequestPermissionResponse>,
344 _cx: acp::ConnectionTo<acp::Agent>| {
345 let mode = perm_mode;
346 async move {
347 let allow = matches!(mode, RunMode::Edit);
350 let pick =
351 req.options.iter().find(|o| is_allow(&o.kind) == allow).or_else(|| req.options.first());
352 let outcome = match pick {
353 Some(o) => acp::schema::RequestPermissionOutcome::Selected(
354 acp::schema::SelectedPermissionOutcome::new(o.option_id.clone()),
355 ),
356 None => acp::schema::RequestPermissionOutcome::Cancelled,
357 };
358 responder.respond(acp::schema::RequestPermissionResponse::new(outcome))
359 }
360 },
361 acp::on_receive_request!(),
362 )
363 .on_receive_notification(
364 move |notif: acp::schema::SessionNotification, _cx: acp::ConnectionTo<acp::Agent>| {
365 let on_event = notif_on_event.clone();
366 let rid = notif_rid.clone();
367 async move {
368 for event in translate::session_update_to_events(&rid, notif.update) {
369 (*on_event)(event);
370 }
371 Ok(())
372 }
373 },
374 acp::on_receive_notification!(),
375 )
376 .connect_with(agent, move |cx: acp::ConnectionTo<acp::Agent>| async move {
377 cx.send_request(acp::schema::InitializeRequest::new(acp::schema::ProtocolVersion::LATEST))
378 .block_task()
379 .await?;
380 let session =
381 cx.send_request(acp::schema::NewSessionRequest::new(cwd.clone())).block_task().await?;
382 let resp = cx
383 .send_request(acp::schema::PromptRequest::new(session.session_id, vec![prompt.clone().into()]))
384 .block_task()
385 .await?;
386 Ok(resp.stop_reason)
387 })
388 .await
389 .map_err(|e| format!("ACP run failed: {e}"))
390 };
391
392 let cancel_fut = {
395 let cancel = Arc::clone(&cancel);
396 async move {
397 loop {
398 if cancel.load(Ordering::SeqCst) {
399 return Err("cancelled".to_owned());
400 }
401 Timer::after(Duration::from_millis(50)).await;
402 }
403 }
404 };
405
406 let outcome: Result<acp::schema::StopReason, String> =
407 smol::block_on(futures_lite::future::or(connect, cancel_fut));
408
409 let run_id = cfg.run_id;
410 if let Some(path) = cfg.model_config_file {
412 let _ = std::fs::remove_file(path);
413 }
414 match outcome {
415 Ok(stop) => {
416 let cancelled =
417 cancel.load(Ordering::SeqCst) || matches!(stop, acp::schema::StopReason::Cancelled);
418 (*on_event)(RunEvent::Exited { run_id, exit_code: Some(0), cancelled });
419 }
420 Err(_) if cancel.load(Ordering::SeqCst) => {
421 (*on_event)(RunEvent::Exited { run_id, exit_code: None, cancelled: true });
423 }
424 Err(message) => {
425 (*on_event)(RunEvent::Error { run_id: run_id.clone(), message });
426 (*on_event)(RunEvent::Exited { run_id, exit_code: Some(1), cancelled: false });
427 }
428 }
429}
430
431fn is_allow(kind: &acp::schema::PermissionOptionKind) -> bool {
433 matches!(
434 kind,
435 acp::schema::PermissionOptionKind::AllowOnce | acp::schema::PermissionOptionKind::AllowAlways
436 )
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::Harness;
443
444 #[cfg(unix)]
446 fn fake_cli(tag: &str, script: &str) -> std::path::PathBuf {
447 use std::os::unix::fs::PermissionsExt;
448 let dir = std::env::temp_dir().join(format!("hl-acp-{tag}-{}", std::process::id()));
449 std::fs::create_dir_all(&dir).unwrap();
450 let path = dir.join("cli");
451 std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
452 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
453 path
454 }
455
456 #[cfg(unix)]
457 #[test]
458 fn an_agent_is_present_only_if_its_command_runs() {
459 let present = fake_cli("present", "exit 0");
462 assert!(probe_command(present.to_str().unwrap()));
463
464 let broken = fake_cli("broken", "exit 1");
465 assert!(!probe_command(broken.to_str().unwrap()), "a command that fails is not usable");
466 assert!(!probe_command("definitely-not-a-real-command"), "and one that is absent is not there");
467 }
468
469 #[test]
470 fn an_agent_that_cannot_list_leaves_the_picker_empty_rather_than_failing() {
471 assert!(models_from_listing(false, "anthropic/claude\nopenai/gpt").is_empty());
474 }
475
476 #[test]
477 fn a_model_listing_is_one_id_per_line_with_the_blanks_dropped() {
478 let models = models_from_listing(true, " anthropic/claude \n\n openai/gpt \n \n");
479 assert_eq!(models.len(), 2, "blank lines are not models: {models:?}");
480 assert_eq!(models[0].value, "anthropic/claude", "trimmed");
481 assert_eq!(models[0].label, models[0].value, "no prettier name is on offer, so the id is the label");
482 assert_eq!(models[1].value, "openai/gpt");
483 assert!(models_from_listing(true, "").is_empty());
484 }
485
486 #[test]
487 fn only_an_allow_option_counts_as_permission() {
488 use acp::schema::PermissionOptionKind as Kind;
492 assert!(is_allow(&Kind::AllowOnce));
493 assert!(is_allow(&Kind::AllowAlways));
494 assert!(!is_allow(&Kind::RejectOnce));
495 assert!(!is_allow(&Kind::RejectAlways));
496 }
497
498 #[test]
499 fn cancelling_a_run_is_visible_to_whoever_asks_afterwards() {
500 let run = AcpRun { cancel: Arc::new(AtomicBool::new(false)) };
501 assert!(!run.was_cancelled());
502 run.cancel().expect("cancel");
503 assert!(run.was_cancelled(), "a stopped run says so");
504 }
505
506 #[cfg(unix)]
513 fn fake_acp_agent(reply: &str) -> std::path::PathBuf {
514 use std::os::unix::fs::PermissionsExt;
515 let dir = std::env::temp_dir().join(format!("hl-acpagent-{}", std::process::id()));
516 std::fs::create_dir_all(&dir).unwrap();
517 let path = dir.join("agent");
518 let script = format!(
519 r#"#!/bin/sh
520while IFS= read -r line; do
521 id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
522 case "$line" in
523 *'"initialize"'*)
524 printf '{{"jsonrpc":"2.0","id":"%s","result":{{"protocolVersion":1,"agentCapabilities":{{}},"authMethods":[]}}}}\n' "$id" ;;
525 *'"session/new"'*)
526 printf '{{"jsonrpc":"2.0","id":"%s","result":{{"sessionId":"ses-1"}}}}\n' "$id" ;;
527 *'"session/prompt"'*)
528 printf '%s\n' '{{"jsonrpc":"2.0","method":"session/update","params":{{"sessionId":"ses-1","update":{{"sessionUpdate":"agent_message_chunk","content":{{"type":"text","text":"{reply}"}}}}}}}}'
529 printf '{{"jsonrpc":"2.0","id":"%s","result":{{"stopReason":"end_turn"}}}}\n' "$id" ;;
530 esac
531done
532"#
533 );
534 std::fs::write(&path, script).unwrap();
535 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
536 path
537 }
538
539 #[cfg(unix)]
540 fn collect_run(agent: &std::path::Path) -> Vec<RunEvent> {
541 use std::sync::atomic::AtomicBool as Flag;
542 use std::sync::Mutex;
543
544 let harness = AcpHarness::custom(AcpHarnessConfig {
545 id: "fake".to_owned(),
546 display_name: "Fake".to_owned(),
547 command: agent.to_string_lossy().into_owned(),
548 args: Vec::new(),
549 install_hint: None,
550 });
551 let events: Arc<Mutex<Vec<RunEvent>>> = Arc::default();
552 let sink = Arc::clone(&events);
553 let done = Arc::new(Flag::new(false));
554 let flag = Arc::clone(&done);
555 let handle = harness
556 .start(
557 RunRequest {
558 run_id: "acp-run".to_owned(),
559 prompt: "hello".to_owned(),
560 cwd: Some(std::env::temp_dir()),
561 mode: RunMode::Ask,
562 ..Default::default()
563 },
564 Arc::new(move |event| {
565 if matches!(event, RunEvent::Exited { .. }) {
566 flag.store(true, Ordering::SeqCst);
567 }
568 sink.lock().unwrap().push(event);
569 }),
570 )
571 .expect("the run should start");
572 for _ in 0..400 {
573 if done.load(Ordering::SeqCst) {
574 break;
575 }
576 std::thread::sleep(std::time::Duration::from_millis(25));
577 }
578 let _ = handle;
579 let out = events.lock().unwrap().clone();
580 out
581 }
582
583 #[cfg(unix)]
584 #[test]
585 fn a_run_against_a_real_acp_agent_streams_its_reply_and_finishes() {
586 let agent = fake_acp_agent("the answer");
590 let events = collect_run(&agent);
591
592 assert!(
593 events.iter().any(|e| matches!(e, RunEvent::Started { .. })),
594 "the run announces itself: {events:?}"
595 );
596 let text: String = events
597 .iter()
598 .filter_map(|e| match e {
599 RunEvent::Text { delta, .. } => Some(delta.as_str()),
600 _ => None,
601 })
602 .collect();
603 assert_eq!(text, "the answer", "the agent's reply reaches the caller: {events:?}");
604 assert!(
605 matches!(events.last(), Some(RunEvent::Exited { .. })),
606 "and exactly one Exited ends it: {events:?}"
607 );
608 let _ = std::fs::remove_dir_all(agent.parent().unwrap());
609 }
610
611 #[test]
612 fn generic_acp_agent_lists_no_models_without_shelling_out() {
613 let harness = AcpHarness::custom(AcpHarnessConfig {
617 id: "x".to_owned(),
618 display_name: "X".to_owned(),
619 command: "definitely-not-a-real-command".to_owned(),
620 args: vec!["acp".to_owned()],
621 install_hint: None,
622 });
623 assert!(harness.list_models().expect("ok").is_empty());
624 let caps = harness.features();
625 assert!(caps.models.is_empty());
626 assert!(caps.custom_model, "ACP agents accept a free-text model");
627 }
628
629 #[test]
630 fn write_model_config_emits_field_and_model_keyed_by_run_id() {
631 let path = write_model_config("run-abc", "model", "opencode/big-pickle")
632 .expect("writes the config file");
633 assert!(
634 path.to_string_lossy().contains("run-abc"),
635 "temp file is keyed by run_id: {path:?}"
636 );
637 let json: serde_json::Value =
638 serde_json::from_str(&std::fs::read_to_string(&path).expect("read back"))
639 .expect("valid JSON");
640 assert_eq!(json["model"], "opencode/big-pickle");
641 let _ = std::fs::remove_file(&path);
642 }
643}