Skip to main content

harness/acp/
mod.rs

1//! ACP-client adapter: drive an external **Agent Client Protocol** agent
2//! (OpenCode, Gemini CLI, Goose, …) over JSON-RPC/stdio and normalize its
3//! `session/update` stream into [`crate::RunEvent`]s — the third adapter
4//! archetype (the CLI-wrapping adapters parse a stdout stream; the
5//! OpenAI-compatible adapter owns the loop; this one relays an ACP agent).
6//!
7//! Built on Zed's `agent-client-protocol` crate (we are the *client*; the
8//! external process is the *agent*). It uses the 0.14 role/builder model: a
9//! `Client.builder()` registers handlers for the agent's incoming
10//! requests/notifications, then `connect_with` spawns the agent (an [`AcpAgent`]
11//! stdio transport) and runs the session (`initialize` → `new_session` →
12//! `prompt`). The connection is async + runtime-agnostic, so `run()` drives it
13//! on a `smol` executor inside the worker thread it spawns — keeping the same
14//! thread+callback shape as the other adapters.
15//!
16//! Opt-in behind the `acp` feature (it pulls the ACP crate + a small async
17//! runtime and spawns external agents).
18//!
19//! [`AcpAgent`]: agent_client_protocol::AcpAgent
20
21use 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
38/// An ACP agent driven as a [`Harness`]. The vendor is configuration (the
39/// command to spawn), not a type — `::opencode()` and `::custom(...)` are
40/// constructors over one adapter.
41pub struct AcpHarness {
42    id: String,
43    display_name: String,
44    description: String,
45    /// Program to spawn (e.g. `opencode`) …
46    command: String,
47    /// … and the args that put it in ACP mode (e.g. `["acp"]`).
48    args: Vec<String>,
49    /// Where the user gets this agent when `command` isn't on PATH.
50    install_hint: Option<InstallHint>,
51    /// How this vendor exposes launch-time model selection — ACP itself carries
52    /// no model, so listing + selecting a model happen out-of-band. `None` → a
53    /// generic ACP agent: no model list, and the per-run model
54    /// ([`RunTuning`](crate::RunTuning)) is ignored.
55    model_control: Option<ModelControl>,
56}
57
58/// How a config-file ACP agent (opencode) exposes a launch-time model. ACP has
59/// no model field, so we **list** models via a vendor CLI subcommand and
60/// **select** one by writing a JSON config file and pointing an env var at it
61/// just before spawn. Set by [`AcpHarness::opencode`]; absent on a generic
62/// [`AcpHarness::custom`], which then has no model picker.
63struct ModelControl {
64    /// CLI subcommand that prints one `provider/model` per line, used by
65    /// [`AcpHarness::list_models`]. opencode: `["models"]` → `opencode models`.
66    list_subcommand: Vec<String>,
67    /// Env var the agent reads its JSON config path from. opencode: `OPENCODE_CONFIG`.
68    config_env: String,
69    /// JSON field in that config that selects the model. opencode: `model`.
70    config_field: String,
71}
72
73/// Configuration for [`AcpHarness::custom`] — named fields rather than a
74/// positional list, so a call site reads unambiguously. Derives `Default`.
75#[derive(Clone, Debug, Default)]
76pub struct AcpHarnessConfig {
77    /// Stable id used in the registry / picker (e.g. `"gemini"`).
78    pub id: String,
79    /// Human-readable name shown in the UI (e.g. `"Gemini"`).
80    pub display_name: String,
81    /// Program to spawn (e.g. `"gemini"`).
82    pub command: String,
83    /// Args that launch it in ACP mode (e.g. `["--experimental-acp"]`).
84    pub args: Vec<String>,
85    /// Where the user gets this agent. `None` leaves the picker saying only
86    /// that the command is missing.
87    pub install_hint: Option<InstallHint>,
88}
89
90impl AcpHarness {
91    /// OpenCode over ACP — spawns `opencode acp`. opencode reads a JSON config
92    /// file named by `$OPENCODE_CONFIG`; its `model` field (a `provider/model`
93    /// id) selects the model, and `opencode models` lists the available ids — so
94    /// this constructor wires launch-time model selection (`list_models` + the
95    /// per-run [`RunTuning`](crate::RunTuning) model).
96    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    /// Any ACP agent, configured by an [`AcpHarnessConfig`]: `command` + `args`
113    /// that launch it as an ACP server over stdio, with named fields so the call
114    /// site reads clearly.
115    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            // Models are discovered live via `list_models()` (opencode lists
142            // its own; a generic ACP agent has none), so the static list is
143            // left empty by `Default`; a free-text model id is accepted.
144            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        // resume (session/load) is a follow-up; this first cut runs a fresh
171        // session. `attachments` ignored: a text prompt only.
172        let RunRequest { run_id, prompt, cwd, mode, tuning, resume: _, attachments: _ } = request;
173        // ACP carries no model. For a config-file vendor (opencode), select the
174        // chosen model out-of-band: write a temp JSON config `{ <field>: <model> }`
175        // and point the agent's config env var at it for this spawn. Other tuning
176        // knobs (effort, max_turns, …) have no ACP equivalent and are ignored.
177        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        // Same thread+callback shape as the other adapters: the async ACP
198        // connection is driven on a smol executor on a worker thread, so run()
199        // returns immediately.
200        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        // ACP exposes no model list; a config-file vendor (opencode) lists via
215        // its own CLI subcommand. A generic ACP agent has none → empty (the host
216        // hides the picker). PATH is augmented so a packaged `.app` finds the CLI.
217        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
235/// The models a listing subcommand reported, one id per line.
236///
237/// A non-zero exit is deliberately not an error: an agent that is offline or
238/// signed out should leave the picker empty, not fail the harness that owns it.
239/// Separate from the spawn because both of those are decisions, and neither is
240/// reachable through a process.
241fn 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        // No prettier name is on offer, so the id is also the label.
250        .map(|line| ModelChoice { value: line.to_owned(), label: line.to_owned() })
251        .collect()
252}
253
254/// Whether `command` is runnable on the (augmented) PATH — `<command> --version`
255/// exits without a spawn error. Augmented PATH so a packaged `.app` finds a
256/// CLI installed via nvm / Homebrew / etc.
257fn 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
266/// Write a one-off JSON config selecting `model` (under `field`) to a temp file
267/// keyed by `run_id`, returning its path. An ACP agent that reads a config file
268/// (opencode, via `$OPENCODE_CONFIG`) picks the model from it — the out-of-band
269/// way to choose a model the protocol itself can't carry. Removed when the run
270/// ends (see [`run_acp`]).
271fn 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
279/// Everything `run_acp` needs, assembled by `run()` from the `RunRequest`.
280struct AcpRunCfg {
281    command: String,
282    args: Vec<String>,
283    run_id: String,
284    prompt: String,
285    cwd: PathBuf,
286    mode: RunMode,
287    /// Extra env for the spawned agent — e.g. opencode's `OPENCODE_CONFIG`
288    /// pointing at the temp model-config file. Empty when no model was chosen.
289    env: Vec<(String, String)>,
290    /// Temp model-config file to delete when the run ends, if one was written.
291    model_config_file: Option<PathBuf>,
292}
293
294/// Cancel handle for an in-flight ACP run. Cooperative: a watcher future races
295/// the connection and, on cancel, drops it (tearing down the agent process).
296struct 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
310/// Drive the ACP connection to completion on a `smol` executor, emitting the
311/// normalized event stream. Always ends with exactly one `RunEvent::Exited`.
312fn 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    // Transport: spawn `command args…` as a stdio ACP agent. The session's
322    // working directory is carried in `new_session`, not the spawn dir.
323    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    // The connection: register handlers for the agent's incoming
336    // requests/notifications, then run the session inside `connect_with`.
337    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                        // Edit mode → allow; Ask mode (read-only) → reject. Pick
348                        // an option of the matching kind, else the first offered.
349                        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    // Cooperative cancel: race the connection against the cancel flag. If cancel
393    // wins, the connection future is dropped, tearing down the agent process.
394    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    // Remove the temp model-config file (if one was written for this run).
411    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            // The error is just the agent being torn down by the cancel race.
422            (*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
431/// Whether a permission option allows the action (vs. rejects it).
432fn 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    /// A throwaway CLI standing in for an ACP agent's own binary.
445    #[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        // This is what the picker shows as installed-or-not, and the only
460        // evidence is whether the binary answers at all.
461        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        // Offline or signed out is not a broken harness. Failing here would take
472        // the whole picker down over a model list nobody asked for yet.
473        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        // The agent offers a list and we pick one. Choosing a reject as though
489        // it were an allow would silently deny every tool call; the reverse
490        // would approve them without asking.
491        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    /// An ACP agent that speaks just enough of the protocol to complete one
507    /// run: the three requests a prompt needs, plus a streamed reply.
508    ///
509    /// It echoes back each request's id rather than assuming a sequence — the
510    /// client sends UUIDs, and a reply carrying the wrong id is simply never
511    /// matched, so the run hangs with no error to explain it.
512    #[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        // Everything under `run_acp` — the handshake, the session, the prompt,
587        // and the notification translation — only happens together. Its pieces
588        // being tested says nothing about whether the sequence works.
589        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        // A `custom` agent has no model_control, so list_models() short-circuits
614        // to empty — it never spawns the command (here a bogus one) — and the
615        // host hides the picker on the *absence* of models.
616        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}