Skip to main content

magi/
agent.rs

1//! Driving agent CLIs.
2//!
3//! Every agent in magi is a subscription CLI (`claude`, `opencode`, `agy`) or
4//! an arbitrary command, invoked headless in a working directory. There is no
5//! API-key path on purpose: the CLIs carry the operator's own plan, and they
6//! are the only interface that exposes an agent's whole tool loop rather than a
7//! single completion.
8//!
9//! # Seats, not agents
10//!
11//! Conversations are keyed by *seat* ([`SeatState::key`]), never by agent id. A
12//! model that implements candidate B and also sits as judge 3 gets two
13//! unrelated conversations, so the judge cannot recognise its own work from
14//! having written it. Sessions are what make deliberation affordable — a judge
15//! remembers its own argument instead of being re-fed the entire candidate set
16//! — and seat scoping is what keeps that from destroying blindness.
17//!
18//! # Session mechanics per CLI
19//!
20//! | CLI | open | resume |
21//! |-----|------|--------|
22//! | `claude` | `--session-id <uuid>` (magi mints it) | `--resume <uuid>` |
23//! | `opencode` | `--format json` reports `sessionID` | `-s <id>` |
24//! | `agy` | `--output-format json` reports `conversation_id` | `--conversation <id>` |
25//!
26//! Claude is the only one magi can address before the first turn; the other two
27//! report an id back, so [`SeatState::captured_session`] stays `None` until a
28//! turn has completed and [`has_session`] answers honestly instead of
29//! optimistically.
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context as _, Result, bail};
36use serde::{Deserialize, Serialize};
37use std::sync::{Arc, Mutex};
38
39use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
40use tokio::process::Command;
41
42use crate::config::{AgentKind, AgentSpec, Delivery};
43use crate::proc::Quiet as _;
44use crate::rng::SplitMix64;
45
46/// Conversation state for one seat, persisted with the run so `magi run
47/// --resume` continues the same CLI conversations.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SeatState {
50    /// Stable seat name, e.g. `impl-A`, `judge-2`, `review-1`, `fix`.
51    pub key: String,
52    /// Agent id occupying the seat.
53    pub agent: String,
54    /// Turns already taken in this seat.
55    pub turns: usize,
56    /// Claude session uuid, minted up front so the first turn and every resume
57    /// agree on it without parsing anything back.
58    pub claude_session: Option<String>,
59    /// Session id reported by a CLI that mints its own (`opencode`, `agy`).
60    pub captured_session: Option<String>,
61}
62
63impl SeatState {
64    /// New seat. `run_seed` scopes the minted Claude uuid to this run.
65    pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
66        let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
67        Self {
68            key: key.to_owned(),
69            agent: agent.to_owned(),
70            turns: 0,
71            claude_session: Some(rng.uuid_v4()),
72            captured_session: None,
73        }
74    }
75}
76
77/// Can a follow-up prompt rely on this seat remembering the conversation?
78pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
79    if !sessions_enabled || seat.turns == 0 {
80        return false;
81    }
82    match kind {
83        AgentKind::Claude => seat.claude_session.is_some(),
84        AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex => {
85            seat.captured_session.is_some()
86        }
87        AgentKind::Command => true,
88    }
89}
90
91/// One agent invocation.
92#[derive(Debug)]
93pub struct Invocation<'a> {
94    /// Working directory. Always a real checkout, so every CLI can read the
95    /// repository without per-vendor "extra directory" flags.
96    pub cwd: &'a Path,
97    /// The full prompt.
98    pub prompt: &'a str,
99    /// Wall-clock limit; the process tree is killed when it elapses.
100    pub timeout: Duration,
101    /// May the agent modify files? Judges and reviewers may not.
102    pub allow_write: bool,
103    /// Continue this seat's conversation when the CLI supports it.
104    pub sessions: bool,
105    /// Directory for prompt / stdout / stderr artifacts.
106    pub artifacts: &'a Path,
107    /// Artifact filename stem.
108    pub stem: &'a str,
109    /// Run this invocation belongs to. Exported as `MAGI_RUN` so an agent that
110    /// files a task with `magi task add` is attributed to the run that was
111    /// paying for it, rather than looking like a human wandered by.
112    pub run: &'a str,
113    /// Graph node being executed, e.g. `implement` or `review`. Exported as
114    /// `MAGI_NODE` for the same reason: "who asked for this" is the first
115    /// question about an autonomously created task.
116    pub node: &'a str,
117    /// Shared build cache the seat should build into, from the rendered
118    /// `CARGO_TARGET_DIR=` in the verify commands. Exported as
119    /// `CARGO_TARGET_DIR` so the implementer's compile lands inside the same
120    /// directory `verify` reads back from - one cache, one prune, and the
121    /// build the agent just paid for is the build the gate reuses.
122    pub cache_dir: Option<&'a Path>,
123    /// Absolute paths of images the operator attached to this conversation,
124    /// outside `cwd` - see `chat`/`talk`'s `attachments_dir`. Empty for every
125    /// invocation that is not a chat or talk turn. [`build_command`] uses
126    /// this only to decide whether a CLI's sandbox needs widening to read
127    /// them; the prompt text naming each path and its mime is built by the
128    /// caller, not here.
129    pub attachments: &'a [PathBuf],
130}
131
132/// Evidence that a CLI ran out of its rate limit / quota, distinct from an
133/// ordinary failure.
134///
135/// `reset` is free text: CLIs render the reset time in their own locale, and
136/// parsing it exactly would be a bug factory. When it is not readable we say
137/// nothing rather than invent a format.
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct Quota {
140    /// Human-readable reset time, when the CLI printed one.
141    #[serde(default)]
142    pub reset: Option<String>,
143}
144
145/// A CLI hung up on its own stream while the agent was working.
146///
147/// Separate from a failure because the work was done and billed, and separate
148/// from a [`Quota`] because it is worth asking again: the answer is in the
149/// conversation, not lost to a limit that has to reset first. See
150/// [`dropped_stream`].
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct Dropped {
153    /// What the CLI said as it hung up, verbatim.
154    pub why: String,
155    /// Output tokens the CLI reported before it did - the evidence that this
156    /// was a delivery failure and not an agent that produced nothing.
157    pub output_tokens: u64,
158}
159
160/// Result of an agent invocation.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct AgentOutput {
163    /// The agent's final message, extracted from whatever the CLI printed.
164    pub text: String,
165    /// Exit status code.
166    pub exit_code: Option<i32>,
167    /// Did the invocation hit its timeout?
168    pub timed_out: bool,
169    /// Wall-clock duration.
170    pub duration_ms: u64,
171    /// Artifact file names, relative to the run's `artifacts/` directory.
172    pub artifacts: Vec<String>,
173    /// Rate-limit / quota exhaustion, when it can be told apart from a normal
174    /// failure. `None` for a normal failure, a timeout, or a CLI we cannot
175    /// read — the conservative default.
176    #[serde(default)]
177    pub quota: Option<Quota>,
178    /// The CLI hung up on its own stream after the agent had done billed
179    /// work. `None` unless that exact shape was recognised — see
180    /// [`dropped_stream`].
181    #[serde(default)]
182    pub dropped: Option<Dropped>,
183}
184
185impl AgentOutput {
186    /// Did the CLI exit cleanly with something to say?
187    pub fn usable(&self) -> bool {
188        !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
189    }
190
191    /// Did this invocation run out of the CLI's rate limit / quota?
192    pub fn quota_exhausted(&self) -> bool {
193        self.quota.is_some()
194    }
195
196    /// Did the agent work and the CLI fail to deliver it?
197    ///
198    /// Worth re-asking, unlike [`AgentOutput::quota_exhausted`]: the answer is
199    /// in a conversation this process can resume.
200    pub fn work_undelivered(&self) -> bool {
201        self.dropped.is_some()
202    }
203}
204
205/// How long to keep reading a pipe after the child is gone.
206///
207/// Bounded on purpose: a surviving grandchild can hold the write end open
208/// forever, and the graph must not hang on a process it has already killed.
209const PIPE_GRACE: Duration = Duration::from_secs(3);
210
211/// Bytes a pipe reader has accumulated so far, shared with whoever spawned it.
212type Captured = Arc<Mutex<Vec<u8>>>;
213
214/// Read `pipe` to end in its own task, appending into a buffer the caller can
215/// inspect at any time.
216///
217/// The buffer is shared rather than returned because the interesting moment is
218/// exactly the one where the reader has *not* finished: a killed agent's pipe
219/// may still be held open by a surviving grandchild, and the bytes that did
220/// arrive are the only evidence of what it was doing. An earlier version
221/// returned the buffer from the task and dropped it on timeout, which is how
222/// `<stem>.out` came to be empty on every timeout.
223fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
224where
225    R: tokio::io::AsyncRead + Unpin + Send + 'static,
226{
227    let buf: Captured = Arc::new(Mutex::new(Vec::new()));
228    let Some(mut pipe) = pipe else {
229        return (buf, None);
230    };
231    let sink = Arc::clone(&buf);
232    let handle = tokio::spawn(async move {
233        let mut chunk = [0u8; 8192];
234        loop {
235            match pipe.read(&mut chunk).await {
236                Ok(0) | Err(_) => break,
237                Ok(n) => {
238                    if let Ok(mut guard) = sink.lock() {
239                        guard.extend_from_slice(&chunk[..n]);
240                    }
241                }
242            }
243        }
244    });
245    (buf, Some(handle))
246}
247
248/// Take whatever a reader has captured, giving it at most `grace` to finish.
249///
250/// A reader still blocked after that is abandoned, not awaited — but its bytes
251/// come back either way, which is the whole point.
252async fn collect(
253    buf: &Captured,
254    handle: Option<tokio::task::JoinHandle<()>>,
255    grace: Duration,
256) -> String {
257    if let Some(handle) = handle {
258        if tokio::time::timeout(grace, handle).await.is_err() {
259            tracing::debug!("a pipe is still held open after the child exited");
260        }
261    }
262    let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
263    String::from_utf8_lossy(&bytes).into_owned()
264}
265
266/// Invoke `spec` for `seat`, updating the seat's conversation state.
267pub async fn invoke(
268    spec: &AgentSpec,
269    seat: &mut SeatState,
270    inv: &Invocation<'_>,
271) -> Result<AgentOutput> {
272    tokio::fs::create_dir_all(inv.artifacts)
273        .await
274        .with_context(|| format!("create {}", inv.artifacts.display()))?;
275    let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
276    tokio::fs::write(&prompt_path, inv.prompt)
277        .await
278        .with_context(|| format!("write {}", prompt_path.display()))?;
279
280    let plan = build_command(spec, seat, inv, &prompt_path)?;
281    tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
282
283    let started = Instant::now();
284    let mut cmd = Command::new(&plan.argv[0]);
285    cmd.args(&plan.argv[1..])
286        .current_dir(inv.cwd)
287        .envs(&spec.env)
288        .env("MAGI_SEAT", &seat.key)
289        .env("MAGI_TURN", seat.turns.to_string())
290        .env("MAGI_RUN", inv.run)
291        .env("MAGI_NODE", inv.node)
292        .env("MAGI_PROMPT_FILE", &prompt_path)
293        .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
294        .env("GIT_TERMINAL_PROMPT", "0")
295        .stdin(if plan.stdin.is_some() {
296            Stdio::piped()
297        } else {
298            Stdio::null()
299        })
300        .stdout(Stdio::piped())
301        .stderr(Stdio::piped())
302        .kill_on_drop(true)
303        // No console window. `magi web` has no console of its own, so Windows
304        // would give each agent a fresh one - and draw it. See `crate::proc`.
305        .quiet();
306    if let Some(cache) = inv.cache_dir {
307        // Same directory the verify commands build into: one cache to prune,
308        // and the compile the seat pays for is the compile the gate reuses.
309        cmd.env("CARGO_TARGET_DIR", cache);
310    }
311
312    let mut child = cmd
313        .spawn()
314        .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
315    // Feed stdin from a task rather than inline: a `command` agent that never
316    // reads its stdin, or a prompt larger than the pipe buffer, would
317    // otherwise deadlock here before the process is ever waited on.
318    if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
319        tokio::spawn(async move {
320            sink.write_all(body.as_bytes()).await.ok();
321            sink.shutdown().await.ok();
322        });
323    }
324
325    // Drain the pipes in their own tasks, and wait on the *process*, not on
326    // end-of-file. Two failures come out of conflating those:
327    //
328    // 1. `wait_with_output` returns when both pipes reach EOF, which is not
329    //    when the child exits. A CLI that leaves a helper process holding the
330    //    inherited stdout handle - normal on Windows, where a `.cmd` shim and
331    //    its grandchildren share handles - never closes the pipe, so a seat
332    //    that answered in five minutes was billed the full hour and then
333    //    recorded as a timeout. The answer was thrown away with it.
334    // 2. Cancelling `wait_with_output` at the timeout drops the buffers it
335    //    owned, so `<stem>.out` and `<stem>.err` were written empty exactly
336    //    when an operator needs them most. "It printed nothing" and "we
337    //    discarded what it printed" looked identical on disk.
338    //
339    // Now the readers own the bytes, so a timeout keeps whatever arrived, and
340    // the wait ends at exit even if a stray handle stays open.
341    let (out_buf, out_reader) = drain(child.stdout.take());
342    let (err_buf, err_reader) = drain(child.stderr.take());
343
344    let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
345        Ok(res) => {
346            let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
347            (status.code(), false)
348        }
349        Err(_) => {
350            tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
351            // Kill the tree so the readers see EOF instead of hanging with it.
352            child.start_kill().ok();
353            (None, true)
354        }
355    };
356
357    // The child is gone either way, so the readers are bounded now. A grace
358    // window rather than an unbounded await: a surviving grandchild can still
359    // hold the write end open, and losing a few trailing bytes beats hanging
360    // the graph on a process we no longer control.
361    let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
362    let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
363
364    let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
365    let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
366    tokio::fs::write(&out_path, &stdout).await.ok();
367    tokio::fs::write(&err_path, &stderr).await.ok();
368
369    let extracted = extract(spec.kind, &stdout);
370    if let Some(session) = extracted.session {
371        match spec.kind {
372            AgentKind::Claude => seat.claude_session = Some(session),
373            AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex => {
374                seat.captured_session = Some(session);
375            }
376            AgentKind::Command => {}
377        }
378    }
379    if let Some(status) = &extracted.status
380        && !status.eq_ignore_ascii_case("success")
381    {
382        tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
383    }
384    let text = if extracted.text.trim().is_empty() {
385        // A CLI that printed only to stderr still told us something.
386        if stdout.trim().is_empty() {
387            stderr.trim().to_owned()
388        } else {
389            stdout.trim().to_owned()
390        }
391    } else {
392        extracted.text
393    };
394    seat.turns += 1;
395
396    Ok(AgentOutput {
397        text,
398        exit_code: code,
399        timed_out,
400        duration_ms: started.elapsed().as_millis() as u64,
401        artifacts: vec![
402            file_name(&prompt_path),
403            file_name(&out_path),
404            file_name(&err_path),
405        ],
406        quota: extracted.quota,
407        dropped: extracted.dropped,
408    })
409}
410
411fn file_name(p: &Path) -> String {
412    p.file_name()
413        .unwrap_or_default()
414        .to_string_lossy()
415        .into_owned()
416}
417
418/// The argv plus optional stdin body for one invocation.
419#[derive(Debug)]
420struct Plan {
421    argv: Vec<String>,
422    stdin: Option<String>,
423}
424
425/// How a file-delivered prompt is pointed at, per CLI.
426///
427/// `agy` has a native file-context syntax, `@<path>`, and it is measurably the
428/// better contract: on the same trivial task it finished in 17s against 73s for
429/// the prose form, because prose makes the model spend a tool round-trip
430/// deciding to read the file. It is also the form yukimemi/rvpm proved out.
431///
432/// opencode has no equivalent, so it gets the prose. That is not a fallback
433/// worth apologising for — it works, and it is what the winning opencode
434/// candidates on this repository have been driven by all along.
435fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
436    if matches!(kind, AgentKind::Antigravity) {
437        return format!("@{}", prompt_path.display());
438    }
439    format!(
440        "Read the file at {} and follow every instruction in it exactly. That \
441         file is your complete task description; this message contains nothing \
442         else.",
443        prompt_path.display()
444    )
445}
446
447fn build_command(
448    spec: &AgentSpec,
449    seat: &SeatState,
450    inv: &Invocation<'_>,
451    prompt_path: &Path,
452) -> Result<Plan> {
453    let mut argv: Vec<String> = Vec::new();
454    let mut stdin: Option<String> = None;
455    let delivery = spec.delivery();
456    let resuming = has_session(spec.kind, seat, inv.sessions);
457
458    match spec.kind {
459        AgentKind::Claude => {
460            // Claude's own tools have no cwd-confined sandbox - the CLI can
461            // already `Read` any absolute path magi hands it, an attachment
462            // outside the repository included - so no extra flag is needed
463            // here.
464            argv.push("claude".to_owned());
465            argv.push("-p".to_owned());
466            argv.push("--output-format".to_owned());
467            argv.push("json".to_owned());
468            if let Some(m) = &spec.model {
469                argv.push("--model".to_owned());
470                argv.push(m.clone());
471            }
472            if inv.sessions {
473                let uuid = seat
474                    .claude_session
475                    .as_deref()
476                    .context("claude seat is missing its session uuid")?;
477                argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
478                argv.push(uuid.to_owned());
479            }
480            argv.push("--permission-mode".to_owned());
481            argv.push("bypassPermissions".to_owned());
482            if !inv.allow_write {
483                argv.push("--disallowed-tools".to_owned());
484                argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
485            }
486        }
487        AgentKind::Opencode => {
488            // `--auto` below already bypasses every permission, reads of a
489            // path outside `--dir` included, so an attachment elsewhere
490            // needs no extra flag.
491            argv.push("opencode".to_owned());
492            argv.push("run".to_owned());
493            argv.push("--format".to_owned());
494            argv.push("json".to_owned());
495            argv.push("--dir".to_owned());
496            argv.push(inv.cwd.to_string_lossy().into_owned());
497            // `--auto` gates *every* permission, reads included: without it a
498            // non-interactive opencode cannot even open the prompt file, and
499            // the seat drops out of the panel with "the user rejected
500            // permission to use this specific tool call". opencode has no
501            // read-only mode, so read-only seats rely on the prompt plus the
502            // fact that judge and reviewer worktrees are disposable — judges'
503            // are deleted after the tally, reviewers' are reset to the commit
504            // under review every round.
505            argv.push("--auto".to_owned());
506            if let Some(m) = &spec.model {
507                argv.push("-m".to_owned());
508                argv.push(m.clone());
509            }
510            if resuming {
511                argv.push("-s".to_owned());
512                argv.push(
513                    seat.captured_session
514                        .clone()
515                        .expect("has_session checked the id is present"),
516                );
517            }
518        }
519        AgentKind::Antigravity => {
520            argv.push("agy".to_owned());
521            argv.push("--output-format".to_owned());
522            argv.push("json".to_owned());
523            // agy's print mode gives up after 5 minutes by default, which is
524            // far below an implementation node's budget.
525            argv.push("--print-timeout".to_owned());
526            argv.push(format!("{}s", inv.timeout.as_secs()));
527            argv.push("--mode".to_owned());
528            argv.push(
529                if inv.allow_write {
530                    "accept-edits"
531                } else {
532                    "plan"
533                }
534                .to_owned(),
535            );
536            if inv.allow_write {
537                argv.push("--dangerously-skip-permissions".to_owned());
538            }
539            if let Some(m) = &spec.model {
540                argv.push("--model".to_owned());
541                argv.push(m.clone());
542            }
543            if resuming {
544                argv.push("--conversation".to_owned());
545                argv.push(
546                    seat.captured_session
547                        .clone()
548                        .expect("has_session checked the id is present"),
549                );
550            }
551            // The prompt file lives outside the worktree, so the workspace has
552            // to be widened to reach it - and so does an attachment's own
553            // directory, which usually lives right beside it under the
554            // conversation's `artifacts_dir` (see `chat`/`talk`). "Usually":
555            // a chat derived from another one (`chat::derived_background`)
556            // can carry attachment paths that live under the *source*
557            // conversation's own artifacts dir instead, so each attachment
558            // outside `inv.artifacts` gets its own `--add-dir` rather than
559            // assuming one directory covers all of `inv.attachments`.
560            let mut add_dirs: Vec<String> = Vec::new();
561            if delivery == Delivery::File || !inv.attachments.is_empty() {
562                add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
563            }
564            for path in inv.attachments {
565                let Some(parent) = path.parent() else {
566                    continue;
567                };
568                if parent.starts_with(inv.artifacts) {
569                    continue;
570                }
571                let dir = parent.to_string_lossy().into_owned();
572                if !add_dirs.contains(&dir) {
573                    add_dirs.push(dir);
574                }
575            }
576            for dir in add_dirs {
577                argv.push("--add-dir".to_owned());
578                argv.push(dir);
579            }
580        }
581        AgentKind::Codex => {
582            // `--sandbox` below governs writes, not reads (see the module
583            // doc: it is what makes codex the one kind whose *read-only*
584            // mode is enforced, by refusing edits) - both presets can read
585            // anywhere the OS lets the process, so an attachment outside
586            // `cwd` is already reachable without an extra flag.
587            argv.push("codex".to_owned());
588            argv.push("exec".to_owned());
589            argv.push("--json".to_owned());
590            // The worktrees magi hands out are real checkouts, but a judge's
591            // is detached and a fixture's may be no repository at all.
592            argv.push("--skip-git-repo-check".to_owned());
593            argv.push("-C".to_owned());
594            argv.push(inv.cwd.to_string_lossy().into_owned());
595            // Codex is the only kind whose read-only-ness is enforced by the
596            // CLI rather than by the prompt: a judge or reviewer seat cannot
597            // write even if it decides to try. Implementers get the workspace,
598            // and nothing ever gets `--dangerously-bypass-approvals-and-sandbox`.
599            argv.push("--sandbox".to_owned());
600            argv.push(
601                if inv.allow_write {
602                    "workspace-write"
603                } else {
604                    "read-only"
605                }
606                .to_owned(),
607            );
608            // Nothing is watching to approve anything: an unattended seat that
609            // asks blocks until its timeout kills it.
610            argv.push("-c".to_owned());
611            argv.push("approval_policy=\"never\"".to_owned());
612            if let Some(m) = &spec.model {
613                argv.push("-m".to_owned());
614                argv.push(m.clone());
615            }
616            // `resume` is a subcommand of `exec`, and it rejects the flags
617            // above when they follow it - so every option is emitted first and
618            // the subcommand last. Established by hand against codex-cli
619            // 0.153.4: with the order reversed the CLI exits on
620            // `unexpected argument '--sandbox'`.
621            if resuming {
622                argv.push("resume".to_owned());
623                argv.push(
624                    seat.captured_session
625                        .clone()
626                        .expect("has_session checked the id is present"),
627                );
628            }
629        }
630        AgentKind::Command => {
631            // The operator's own command line, not one of the roster CLIs -
632            // there is no flag this function could add on its behalf, so an
633            // attachment's path has to reach it the same way the prompt
634            // does, through the substitutions below.
635            if spec.command.is_empty() {
636                bail!("agent `{}` has kind = \"command\" but no command", spec.id);
637            }
638            let vars: BTreeMap<&str, String> = BTreeMap::from([
639                ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
640                ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
641                ("{label}", seat.key.clone()),
642                ("{session}", seat.claude_session.clone().unwrap_or_default()),
643            ]);
644            for raw in &spec.command {
645                let mut arg = raw.clone();
646                for (k, v) in &vars {
647                    if arg.contains(k) {
648                        arg = arg.replace(k, v);
649                    }
650                }
651                argv.push(arg);
652            }
653        }
654    }
655
656    argv.extend(spec.extra_args.iter().cloned());
657
658    // `agy` takes the prompt as the value of `-p`, so the flag has to be
659    // emitted right before whatever the delivery mode produces.
660    if spec.kind == AgentKind::Antigravity {
661        argv.push("-p".to_owned());
662    }
663    // `codex exec` reads stdin only when its prompt argument is `-`; without
664    // it the CLI waits on a prompt it will never be given.
665    if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
666        argv.push("-".to_owned());
667    }
668    match delivery {
669        Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
670            // agy has no text stdin path; fall back to the pointer file.
671            argv.push(pointer(spec.kind, prompt_path));
672        }
673        Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
674        Delivery::Argv => argv.push(inv.prompt.to_owned()),
675        Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
676    }
677
678    Ok(Plan { argv, stdin })
679}
680
681/// What a CLI's stdout yielded.
682#[derive(Debug, Default)]
683struct Extracted {
684    text: String,
685    session: Option<String>,
686    status: Option<String>,
687    quota: Option<Quota>,
688    dropped: Option<Dropped>,
689}
690
691/// Pull the agent's message (and any session id) out of a CLI's stdout.
692fn extract(kind: AgentKind, stdout: &str) -> Extracted {
693    match kind {
694        AgentKind::Claude => {
695            let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
696                return Extracted {
697                    text: stdout.trim().to_owned(),
698                    ..Extracted::default()
699                };
700            };
701            Extracted {
702                text: v
703                    .get("result")
704                    .and_then(|r| r.as_str())
705                    .unwrap_or_default()
706                    .to_owned(),
707                session: v
708                    .get("session_id")
709                    .and_then(|s| s.as_str())
710                    .map(str::to_owned),
711                status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
712                    if e {
713                        "error".to_owned()
714                    } else {
715                        "success".to_owned()
716                    }
717                }),
718                quota: claude_quota(&v),
719                // Claude reports a truncated stream as an ordinary error; the
720                // shape `dropped_stream` keys on is agy's.
721                dropped: None,
722            }
723        }
724        AgentKind::Opencode => {
725            // A JSONL event stream: text parts concatenated in arrival order.
726            let mut text = String::new();
727            let mut session = None;
728            for line in stdout.lines() {
729                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
730                    continue;
731                };
732                if session.is_none() {
733                    session = v
734                        .get("sessionID")
735                        .and_then(|s| s.as_str())
736                        .map(str::to_owned);
737                }
738                let part = v.get("part").unwrap_or(&serde_json::Value::Null);
739                if part.get("type").and_then(|t| t.as_str()) == Some("text")
740                    && let Some(t) = part.get("text").and_then(|t| t.as_str())
741                {
742                    if !text.is_empty() {
743                        text.push('\n');
744                    }
745                    text.push_str(t);
746                }
747            }
748            Extracted {
749                text,
750                session,
751                status: None,
752                quota: None,
753                dropped: None,
754            }
755        }
756        AgentKind::Antigravity => {
757            // agy prints warnings before the JSON object, so parse the last
758            // line that is one rather than the whole stream.
759            let obj = stdout
760                .lines()
761                .rev()
762                .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
763            let Some(v) = obj else {
764                return Extracted {
765                    text: stdout.trim().to_owned(),
766                    ..Extracted::default()
767                };
768            };
769            Extracted {
770                text: v
771                    .get("response")
772                    .and_then(|r| r.as_str())
773                    .unwrap_or_default()
774                    .trim()
775                    .to_owned(),
776                session: v
777                    .get("conversation_id")
778                    .and_then(|s| s.as_str())
779                    .map(str::to_owned),
780                status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
781                quota: None,
782                dropped: dropped_stream(&v),
783            }
784        }
785        AgentKind::Codex => {
786            // A JSONL event stream, prefixed on a real machine by tracing
787            // lines the CLI writes about its own config and skills - so
788            // non-JSON lines are skipped rather than treated as the answer.
789            //
790            // The thread id arrives once, in `thread.started`, and a resumed
791            // turn reports the same one. The answer is the last
792            // `item.completed` carrying an `agent_message`: earlier ones are
793            // the model narrating its way through the tool loop, and taking
794            // the first would hand the caller a progress note instead of a
795            // verdict.
796            let mut text = String::new();
797            let mut session = None;
798            let mut status = None;
799            for line in stdout.lines() {
800                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
801                    continue;
802                };
803                match v.get("type").and_then(|t| t.as_str()) {
804                    Some("thread.started") => {
805                        session = v
806                            .get("thread_id")
807                            .and_then(|s| s.as_str())
808                            .map(str::to_owned);
809                    }
810                    Some("item.completed") => {
811                        let item = v.get("item").unwrap_or(&serde_json::Value::Null);
812                        if item.get("type").and_then(|t| t.as_str()) == Some("agent_message")
813                            && let Some(t) = item.get("text").and_then(|t| t.as_str())
814                        {
815                            text = t.trim().to_owned();
816                        }
817                    }
818                    Some("turn.completed") => status = Some("success".to_owned()),
819                    Some("turn.failed") => status = Some("error".to_owned()),
820                    _ => {}
821                }
822            }
823            Extracted {
824                text,
825                session,
826                status,
827                quota: None,
828                dropped: None,
829            }
830        }
831        AgentKind::Command => {
832            // A `command` agent may wrap a subscription CLI (a fixture, or a
833            // thin shim around `claude`). If its output is the claude error
834            // shape we recognise the quota the same way, so tests and wrappers
835            // do not need their own detection; anything else is just text.
836            let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
837            let quota = parsed.as_ref().and_then(claude_quota);
838            // A `command` fixture may also stand in for a CLI that hangs up on
839            // its own stream, which is how that path is tested.
840            let dropped = parsed.as_ref().and_then(dropped_stream);
841            Extracted {
842                text: stdout.trim().to_owned(),
843                session: None,
844                status: None,
845                quota,
846                dropped,
847            }
848        }
849    }
850}
851
852/// Recognise claude's rate-limit error shape, when it is present.
853///
854/// The only output we have observed is the JSON object carrying `is_error:
855/// true` and a `result` mentioning the session limit. We key on exactly that;
856/// every other CLI (and any future shape) returns `None` and is treated as an
857/// ordinary failure — the conservative side.
858fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
859    let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
860    if !is_err {
861        return None;
862    }
863    let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
864    if !result.to_lowercase().contains("session limit") {
865        return None;
866    }
867    // "…session limit · resets 4:50am (Asia/Tokyo)". The timezone read is not
868    // worth parsing exactly; keep the whole phrase after "resets" as free text.
869    let reset = result
870        .split("resets ")
871        .nth(1)
872        .map(str::trim)
873        .filter(|s| !s.is_empty())
874        .map(str::to_owned);
875    Some(Quota { reset })
876}
877
878/// Recognise a CLI that gave up on its own stream while the agent was working.
879///
880/// Observed once, verbatim, from `agy` on a candidate that produced nothing:
881///
882/// ```text
883/// {"conversation_id":"36743d06-…","status":"ERROR","response":"",
884///  "error":"the connection to the agent was interrupted before the response
885///           finished: subscriber fell behind updates, stalled for 5s",
886///  "duration_seconds":431.19,"num_turns":1,
887///  "usage":{"input_tokens":260113,"output_tokens":14267,
888///           "thinking_tokens":9695,"cache_read_tokens":2200925}}
889/// ```
890///
891/// Seven minutes of work and fourteen thousand output tokens, billed, with an
892/// empty `response`: the agent did the job and the CLI's own subscriber fell
893/// behind and hung up. That is **not** an agent that failed to implement, and
894/// counting it as one is how `agy` came to read as 0 wins in 4 entries with
895/// five empty candidates - a number that has twice been used to argue the seat
896/// out of the roster, and twice been wrong (see `cb6b830`, which reverted the
897/// first removal: *"agy does not fail to implement, it fails to report"*).
898///
899/// The distinction that matters is **billed work with nothing delivered**, so
900/// that is what this keys on: an error status, an empty response, and a usage
901/// report showing output tokens. Everything else - including an error with no
902/// usage at all - returns `None` and stays an ordinary failure, the
903/// conservative side, exactly as [`claude_quota`] treats shapes it does not
904/// recognise.
905///
906/// Unlike a quota, this **is** worth re-asking: the work exists in the
907/// conversation the CLI just abandoned, and `conversation_id` is right there.
908fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
909    let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
910    if !status.eq_ignore_ascii_case("error") {
911        return None;
912    }
913    let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
914    if !response.trim().is_empty() {
915        // It answered. Whatever the status says, there is something to read.
916        return None;
917    }
918    let produced = v
919        .get("usage")
920        .and_then(|u| u.get("output_tokens"))
921        .and_then(serde_json::Value::as_u64)
922        .unwrap_or(0);
923    if produced == 0 {
924        // An error with nothing produced is just an error.
925        return None;
926    }
927    Some(Dropped {
928        why: v
929            .get("error")
930            .and_then(|e| e.as_str())
931            .unwrap_or("the CLI ended the stream without delivering its answer")
932            .trim()
933            .to_owned(),
934        output_tokens: produced,
935    })
936}
937
938/// Preflight: which configured agents are not runnable here?
939pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
940    let mut missing = Vec::new();
941    for s in specs {
942        let program = match s.kind {
943            AgentKind::Command => s.command.first().map(String::as_str),
944            other => other.program(),
945        };
946        if let Some(p) = program
947            && !crate::config::which(p)
948            && !Path::new(p).is_file()
949            && !missing.iter().any(|m: &String| m == p)
950        {
951            missing.push(p.to_owned());
952        }
953    }
954    missing
955}
956
957/// Absolute path of a run's artifact directory.
958pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
959    run_dir.join("artifacts")
960}
961
962/// Can this agent's CLI actually be run on this machine?
963pub fn installed(spec: &AgentSpec) -> bool {
964    // A `command` agent has no program of its own to look for - its argv is the
965    // operator's, and they are the authority on whether it runs.
966    spec.kind.program().is_none_or(crate::config::which)
967}
968
969/// Choose the agent for a seat that stands alone rather than rotating through
970/// the roster: [`crate::talk`]'s standing conversation, [`crate::bump`]'s
971/// release-bump decision, or anything else that needs one agent picked once
972/// rather than a panel filled in.
973///
974/// `available` is a parameter rather than a call to [`installed`] so the order
975/// below is assertable on a machine with none of these CLIs installed, which is
976/// every CI runner.
977///
978/// The order, and why:
979///
980/// 1. An explicit id always wins, and is an error rather than a fallback when
981///    it is unusable. Naming a seat has a reason, and silently substituting a
982///    different model would waste whatever that reason was.
983/// 2. Otherwise a [`AgentKind::Claude`] seat, ahead of the roster order: it is
984///    the only one of the three CLIs magi can address before the first turn
985///    (see this module's own doc on session mechanics), which matters most for
986///    a conversation that opens with nothing typed yet.
987/// 3. Otherwise the first runnable agent in roster order, because the roster
988///    order is the operator's own stated preference and magi has nothing
989///    better to go on.
990pub fn pick(
991    agents: &[AgentSpec],
992    want: Option<&str>,
993    available: &dyn Fn(&AgentSpec) -> bool,
994) -> Result<AgentSpec> {
995    if let Some(id) = want {
996        let spec = agents
997            .iter()
998            .find(|a| a.id == id)
999            .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1000        if !available(spec) {
1001            bail!(
1002                "agent `{}` needs `{}` on PATH; install it or pass a different \
1003                 --agent",
1004                spec.id,
1005                spec.kind.program().unwrap_or("its command")
1006            );
1007        }
1008        return Ok(spec.clone());
1009    }
1010
1011    if agents.is_empty() {
1012        bail!(
1013            "the agent roster is empty, so there is nobody to ask: install one \
1014             of claude, opencode or agy - magi derives a roster from what is on \
1015             PATH - or add an [[agents]] entry to magi.toml."
1016        );
1017    }
1018
1019    if let Some(spec) = agents
1020        .iter()
1021        .find(|a| a.kind == AgentKind::Claude && available(a))
1022    {
1023        return Ok(spec.clone());
1024    }
1025
1026    agents
1027        .iter()
1028        .find(|a| available(a))
1029        .cloned()
1030        .with_context(|| {
1031            let missing = agents
1032                .iter()
1033                .filter_map(|a| a.kind.program())
1034                .collect::<Vec<_>>()
1035                .join(", ");
1036            format!(
1037                "no agent in the roster can be run here: install one of \
1038                 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1039                 you do have"
1040            )
1041        })
1042}
1043
1044fn ids(agents: &[AgentSpec]) -> String {
1045    if agents.is_empty() {
1046        return "no agents at all".to_owned();
1047    }
1048    agents
1049        .iter()
1050        .map(|a| a.id.clone())
1051        .collect::<Vec<_>>()
1052        .join(", ")
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057    use super::*;
1058
1059    const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1060
1061    /// Test-only command agent implemented by this test binary itself. Unlike
1062    /// `echo` and `sleep`, it is available wherever the Rust tests run.
1063    fn command_helper(mode: &str) -> AgentSpec {
1064        AgentSpec {
1065            id: "helper".to_owned(),
1066            kind: AgentKind::Command,
1067            model: None,
1068            command: vec![
1069                std::env::current_exe()
1070                    .expect("locate test helper")
1071                    .to_string_lossy()
1072                    .into_owned(),
1073                "--exact".to_owned(),
1074                "agent::tests::command_agent_test_helper".to_owned(),
1075                "--nocapture".to_owned(),
1076            ],
1077            extra_args: Vec::new(),
1078            env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1079            prompt_delivery: None,
1080        }
1081    }
1082
1083    #[test]
1084    fn command_agent_test_helper() {
1085        match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1086            Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1087            Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1088            Ok("ignore-stdin") => println!("done"),
1089            Ok("chatty-sleep") => {
1090                println!("i-said-something");
1091                std::thread::sleep(Duration::from_secs(30));
1092            }
1093            Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1094            Ok(other) => panic!("unknown command helper mode {other}"),
1095            Err(_) => {}
1096        }
1097    }
1098
1099    fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1100        AgentSpec {
1101            id: "a".to_owned(),
1102            kind,
1103            model: model.map(str::to_owned),
1104            command: vec!["echo".to_owned(), "{label}".to_owned()],
1105            extra_args: Vec::new(),
1106            env: BTreeMap::new(),
1107            prompt_delivery: None,
1108        }
1109    }
1110
1111    fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1112        Invocation {
1113            cwd,
1114            prompt: "do the thing",
1115            timeout: Duration::from_secs(900),
1116            allow_write,
1117            sessions: true,
1118            artifacts: art,
1119            stem: "t",
1120            run: "test-run",
1121            node: "test",
1122            cache_dir: None,
1123            attachments: &[],
1124        }
1125    }
1126
1127    fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1128        build_command(
1129            &spec(kind, None),
1130            seat,
1131            &inv(Path::new("."), Path::new("/art"), allow_write),
1132            Path::new("/art/p.md"),
1133        )
1134        .unwrap()
1135    }
1136
1137    #[test]
1138    fn claude_mints_then_resumes_the_same_uuid() {
1139        let mut seat = SeatState::new("judge-1", "a", 7);
1140        let uuid = seat.claude_session.clone().unwrap();
1141        let first = plan_for(AgentKind::Claude, &seat, true);
1142        assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1143        assert!(!first.argv.iter().any(|a| a == "--resume"));
1144
1145        seat.turns = 1;
1146        let second = plan_for(AgentKind::Claude, &seat, true);
1147        assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1148        assert!(!second.argv.iter().any(|a| a == "--session-id"));
1149    }
1150
1151    #[test]
1152    fn read_only_seats_cannot_edit() {
1153        let seat = SeatState::new("judge-1", "a", 7);
1154        let claude = plan_for(AgentKind::Claude, &seat, false);
1155        assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1156        assert!(
1157            !plan_for(AgentKind::Claude, &seat, true)
1158                .argv
1159                .iter()
1160                .any(|a| a == "--disallowed-tools")
1161        );
1162
1163        let agy = plan_for(AgentKind::Antigravity, &seat, false);
1164        assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1165        assert!(
1166            !agy.argv
1167                .iter()
1168                .any(|a| a == "--dangerously-skip-permissions")
1169        );
1170        let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1171        assert!(
1172            agy_rw
1173                .argv
1174                .windows(2)
1175                .any(|w| w == ["--mode", "accept-edits"])
1176        );
1177        assert!(
1178            agy_rw
1179                .argv
1180                .iter()
1181                .any(|a| a == "--dangerously-skip-permissions")
1182        );
1183        // agy is pointed at its prompt with its own `@<path>` syntax, not with
1184        // prose asking it to read a file. Measured on one trivial task: 17s
1185        // against 73s, because prose costs a tool round-trip before the model
1186        // has even seen its instructions. It is also the form rvpm proved.
1187        let agy_prompt = agy_rw
1188            .argv
1189            .iter()
1190            .position(|a| a == "-p")
1191            .map(|i| agy_rw.argv[i + 1].clone())
1192            .expect("agy takes its prompt with -p");
1193        assert!(
1194            agy_prompt.starts_with('@'),
1195            "agy must get a file reference, got {agy_prompt:?}"
1196        );
1197        assert!(
1198            !agy_prompt.contains("Read the file at"),
1199            "the prose pointer is for CLIs with no file syntax"
1200        );
1201
1202        // opencode is the exception: `--auto` also gates reads, so withholding
1203        // it silently drops the seat out of the panel. Verified against the CLI
1204        // — a read-only judge failed with "the user rejected permission to use
1205        // this specific tool call" while trying to open its own prompt.
1206        for allow_write in [false, true] {
1207            assert!(
1208                plan_for(AgentKind::Opencode, &seat, allow_write)
1209                    .argv
1210                    .iter()
1211                    .any(|a| a == "--auto"),
1212                "opencode needs --auto even to read (allow_write = {allow_write})"
1213            );
1214        }
1215    }
1216
1217    /// The three things about `codex exec` that were established by hand and
1218    /// that a rewrite would silently get wrong.
1219    #[test]
1220    fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1221        let mut seat = SeatState::new("judge-1", "a", 7);
1222
1223        // 1. Read-only is enforced by the CLI, not by the prompt - the only
1224        //    roster member for which that is true - and nothing ever asks for
1225        //    the bypass.
1226        let ro = plan_for(AgentKind::Codex, &seat, false);
1227        assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1228        let rw = plan_for(AgentKind::Codex, &seat, true);
1229        assert!(
1230            rw.argv
1231                .windows(2)
1232                .any(|w| w == ["--sandbox", "workspace-write"])
1233        );
1234        for p in [&ro, &rw] {
1235            assert!(
1236                !p.argv
1237                    .iter()
1238                    .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1239                "the bypass defeats the only enforced read-only mode we have"
1240            );
1241            // Nobody is watching to approve anything.
1242            assert!(
1243                p.argv
1244                    .windows(2)
1245                    .any(|w| w == ["-c", "approval_policy=\"never\""]),
1246                "an unattended seat that asks for approval blocks until timeout"
1247            );
1248        }
1249
1250        // 2. The prompt arrives on stdin, and `-` is what makes codex read it.
1251        assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1252        assert_eq!(
1253            ro.argv.last().map(String::as_str),
1254            Some("-"),
1255            "without the `-` argument codex waits for a prompt it never gets"
1256        );
1257
1258        // 3. `resume` is a subcommand and rejects the options above when they
1259        //    follow it, so it has to be emitted after all of them - and only
1260        //    once the CLI has reported a thread id.
1261        seat.turns = 1;
1262        assert!(!has_session(AgentKind::Codex, &seat, true));
1263        assert!(
1264            !plan_for(AgentKind::Codex, &seat, true)
1265                .argv
1266                .iter()
1267                .any(|a| a == "resume")
1268        );
1269        seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1270        let resumed = plan_for(AgentKind::Codex, &seat, true);
1271        let at = resumed
1272            .argv
1273            .iter()
1274            .position(|a| a == "resume")
1275            .expect("resumes by subcommand");
1276        assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1277        assert!(
1278            resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1279            "every option precedes the subcommand"
1280        );
1281        assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1282    }
1283
1284    /// A real `codex exec --json` stream, tracing prefix included.
1285    #[test]
1286    fn codex_takes_the_last_agent_message_and_the_thread_id() {
1287        let stream = concat!(
1288            "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1289            r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1290            "\n",
1291            r#"{"type":"turn.started"}"#,
1292            "\n",
1293            r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1294            "\n",
1295            r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1296            "\n",
1297            r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1298            "\n",
1299            r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1300            "\n",
1301        );
1302        let out = extract(AgentKind::Codex, stream);
1303        assert_eq!(
1304            out.text, "{\"verdict\": \"ok\"}",
1305            "the last agent message is the answer; earlier ones narrate"
1306        );
1307        assert_eq!(
1308            out.session.as_deref(),
1309            Some("01a07440-4545-7492-85c1-024e3259a90a")
1310        );
1311        assert_eq!(out.status.as_deref(), Some("success"));
1312
1313        let failed = concat!(
1314            r#"{"type":"thread.started","thread_id":"t1"}"#,
1315            "\n",
1316            r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1317            "\n",
1318        );
1319        assert_eq!(
1320            extract(AgentKind::Codex, failed).status.as_deref(),
1321            Some("error")
1322        );
1323    }
1324
1325    #[test]
1326    fn captured_sessions_resume_only_once_reported() {
1327        let mut seat = SeatState::new("impl-A", "a", 7);
1328        seat.turns = 1;
1329        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1330            assert!(!has_session(kind, &seat, true));
1331            let p = plan_for(kind, &seat, true);
1332            assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1333        }
1334
1335        seat.captured_session = Some("sid".to_owned());
1336        assert!(has_session(AgentKind::Opencode, &seat, true));
1337        assert!(
1338            plan_for(AgentKind::Opencode, &seat, true)
1339                .argv
1340                .windows(2)
1341                .any(|w| w == ["-s", "sid"])
1342        );
1343        assert!(
1344            plan_for(AgentKind::Antigravity, &seat, true)
1345                .argv
1346                .windows(2)
1347                .any(|w| w == ["--conversation", "sid"])
1348        );
1349    }
1350
1351    #[test]
1352    fn sessions_disabled_never_resumes() {
1353        let mut seat = SeatState::new("impl-A", "a", 7);
1354        seat.turns = 3;
1355        seat.captured_session = Some("sid".to_owned());
1356        for kind in [
1357            AgentKind::Claude,
1358            AgentKind::Opencode,
1359            AgentKind::Antigravity,
1360        ] {
1361            assert!(!has_session(kind, &seat, false));
1362        }
1363    }
1364
1365    #[test]
1366    fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1367        let seat = SeatState::new("judge-1", "a", 7);
1368        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1369            let p = plan_for(kind, &seat, false);
1370            assert!(
1371                p.argv.iter().all(|a| a != "do the thing"),
1372                "{kind:?} put the prompt on the command line"
1373            );
1374            assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1375        }
1376        // agy has no text stdin, so its `-p` must always carry something.
1377        let p = plan_for(AgentKind::Antigravity, &seat, false);
1378        let at = p.argv.iter().position(|a| a == "-p").unwrap();
1379        assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1380        assert!(p.stdin.is_none());
1381    }
1382
1383    #[test]
1384    fn agy_print_timeout_tracks_the_node_budget() {
1385        let seat = SeatState::new("impl-A", "a", 7);
1386        let p = build_command(
1387            &spec(AgentKind::Antigravity, None),
1388            &seat,
1389            &Invocation {
1390                cwd: Path::new("."),
1391                prompt: "p",
1392                timeout: Duration::from_secs(3600),
1393                allow_write: true,
1394                sessions: true,
1395                artifacts: Path::new("/art"),
1396                stem: "t",
1397                run: "test-run",
1398                node: "test",
1399                cache_dir: None,
1400                attachments: &[],
1401            },
1402            Path::new("/art/p.md"),
1403        )
1404        .unwrap();
1405        assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1406    }
1407
1408    /// `--add-dir` is what lets antigravity open a file outside the
1409    /// worktree at all. Today that only happens when the delivery mode is
1410    /// already `File`, but an attachment can arrive on a seat whose delivery
1411    /// is `Stdin` or `Argv` (an explicit `prompt_delivery` override), and the
1412    /// image still lives outside `cwd` - so the flag has to widen for that
1413    /// reason too, independent of how the prompt itself is delivered.
1414    #[test]
1415    fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
1416        let mut s = spec(AgentKind::Antigravity, None);
1417        s.prompt_delivery = Some(Delivery::Argv);
1418        let seat = SeatState::new("talk", "a", 7);
1419        let atts = [PathBuf::from("/art/attachments/abc.png")];
1420
1421        let without = build_command(
1422            &s,
1423            &seat,
1424            &Invocation {
1425                attachments: &[],
1426                ..inv(Path::new("."), Path::new("/art"), true)
1427            },
1428            Path::new("/art/p.md"),
1429        )
1430        .unwrap();
1431        assert!(
1432            !without.argv.iter().any(|a| a == "--add-dir"),
1433            "no attachment, no reason to widen the sandbox: {without:?}"
1434        );
1435
1436        let with = build_command(
1437            &s,
1438            &seat,
1439            &Invocation {
1440                attachments: &atts,
1441                ..inv(Path::new("."), Path::new("/art"), true)
1442            },
1443            Path::new("/art/p.md"),
1444        )
1445        .unwrap();
1446        assert!(
1447            with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1448            "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
1449        );
1450    }
1451
1452    /// A chat derived from another one (`chat::derived_background`) can pass
1453    /// `turn` attachment paths that live under the *source* conversation's
1454    /// own artifacts dir, not this invocation's `artifacts`. A single
1455    /// `--add-dir` for `inv.artifacts` alone would leave those unreadable, so
1456    /// each attachment directory outside it must get its own grant.
1457    #[test]
1458    fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
1459        let seat = SeatState::new("plan", "a", 7);
1460        let atts = [
1461            PathBuf::from("/art/attachments/own.png"),
1462            PathBuf::from("/other-chat/attachments/inherited.png"),
1463        ];
1464
1465        let p = build_command(
1466            &spec(AgentKind::Antigravity, None),
1467            &seat,
1468            &Invocation {
1469                attachments: &atts,
1470                ..inv(Path::new("."), Path::new("/art"), true)
1471            },
1472            Path::new("/art/p.md"),
1473        )
1474        .unwrap();
1475
1476        assert!(
1477            p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1478            "this conversation's own artifacts dir must still be granted: {p:?}"
1479        );
1480        assert!(
1481            p.argv
1482                .windows(2)
1483                .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
1484            "the inherited attachment's own directory must be granted too: {p:?}"
1485        );
1486    }
1487
1488    #[test]
1489    fn command_agents_get_placeholders_substituted() {
1490        let seat = SeatState::new("impl-A", "a", 7);
1491        let p = plan_for(AgentKind::Command, &seat, true);
1492        assert_eq!(p.argv[0], "echo");
1493        assert_eq!(p.argv[1], "impl-A");
1494        assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1495    }
1496
1497    #[test]
1498    fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1499        // The exact shape observed in the wild (run 20260831-031005-ae94).
1500        let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1501                        "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1502                        "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1503        let out = extract(AgentKind::Claude, stdout);
1504        let quota = out.quota.as_ref().expect("rate limit must be detected");
1505        assert_eq!(
1506            quota.reset.as_deref(),
1507            Some("4:50am (Asia/Tokyo)"),
1508            "reset time read from the body"
1509        );
1510    }
1511
1512    #[test]
1513    fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1514        let out = extract(
1515            AgentKind::Claude,
1516            r#"{"is_error":true,"result":"session limit reached"}"#,
1517        );
1518        let quota = out.quota.expect("rate limit detected without a reset");
1519        assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1520    }
1521
1522    #[test]
1523    fn ordinary_failures_are_never_quota() {
1524        // A normal failed claude call (is_error with a different message).
1525        let claude_fail = extract(
1526            AgentKind::Claude,
1527            r#"{"is_error":true,"result":"account does not exist"}"#,
1528        );
1529        assert!(claude_fail.quota.is_none());
1530
1531        // A command agent that exits 1 with plain text.
1532        let cmd_fail = extract(AgentKind::Command, "boom");
1533        assert!(cmd_fail.quota.is_none());
1534
1535        // A successful call is not quota even if it mentions the phrase.
1536        let success = extract(
1537            AgentKind::Command,
1538            r#"{"is_error":false,"result":"session limit is fine"}"#,
1539        );
1540        assert!(success.quota.is_none());
1541    }
1542
1543    #[test]
1544    fn command_agent_can_carry_the_claude_quota_shape() {
1545        let out = extract(
1546            AgentKind::Command,
1547            r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1548        );
1549        assert!(
1550            out.quota.is_some(),
1551            "a wrapper emitting the claude shape counts as quota"
1552        );
1553    }
1554
1555    #[test]
1556    fn claude_json_result_is_extracted() {
1557        let out = extract(
1558            AgentKind::Claude,
1559            r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1560        );
1561        assert_eq!(out.text, "all done");
1562        assert_eq!(out.session.as_deref(), Some("abc"));
1563        assert_eq!(out.status.as_deref(), Some("success"));
1564    }
1565
1566    #[test]
1567    fn opencode_event_stream_is_concatenated() {
1568        let stream = concat!(
1569            r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
1570            "\n",
1571            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
1572            "\n",
1573            "garbage line\n",
1574            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
1575            "\n"
1576        );
1577        let out = extract(AgentKind::Opencode, stream);
1578        assert_eq!(out.text, "first\nsecond");
1579        assert_eq!(out.session.as_deref(), Some("ses_1"));
1580    }
1581
1582    #[test]
1583    fn agy_json_survives_a_leading_warning_line() {
1584        let stdout = concat!(
1585            "warning: --mode plan has no effect while slash commands are disabled.\n",
1586            r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1587            "\n"
1588        );
1589        let out = extract(AgentKind::Antigravity, stdout);
1590        assert_eq!(out.text, "persimmon");
1591        assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1592        assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1593    }
1594
1595    /// Run 26c7's candidate B, verbatim from `artifacts/impl-B.out`.
1596    ///
1597    /// The seat read as an empty candidate. It was seven minutes of work and
1598    /// 14,267 output tokens, billed, that the CLI then declined to hand over.
1599    /// Five such candidates are why `agy` reads as 0 wins in 4 entries, and
1600    /// that number has twice been used to argue the seat out of the roster.
1601    const AGY_DROPPED: &str = concat!(
1602        r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
1603        r#""response":"","error":"the connection to the agent was interrupted before "#,
1604        r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
1605        r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
1606        r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
1607        r#""total_tokens":274380}}"#
1608    );
1609
1610    #[test]
1611    fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
1612        let out = extract(AgentKind::Antigravity, AGY_DROPPED);
1613        let dropped = out.dropped.expect("recognised as undelivered work");
1614        assert_eq!(dropped.output_tokens, 14267);
1615        assert!(
1616            dropped.why.contains("subscriber fell behind"),
1617            "the CLI's own words are kept for the record: {}",
1618            dropped.why
1619        );
1620        // And the conversation is still there to resume, which is the whole
1621        // reason this is worth re-asking where a quota is not.
1622        assert_eq!(
1623            out.session.as_deref(),
1624            Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
1625        );
1626        assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
1627    }
1628
1629    #[test]
1630    fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
1631        // No usage at all: the agent never got going, so there is nothing in
1632        // the conversation to resume and nothing was billed. Treating this as
1633        // undelivered work would buy a second call for no reason.
1634        let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
1635        assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
1636
1637        // Produced tokens, but it did answer - so there is something to read
1638        // and the status is not our business.
1639        let answered = concat!(
1640            r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
1641            r#""usage":{"output_tokens":10}}"#
1642        );
1643        assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
1644
1645        // A success is a success.
1646        let ok = concat!(
1647            r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
1648            r#""usage":{"output_tokens":10}}"#
1649        );
1650        assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
1651    }
1652
1653    #[test]
1654    fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
1655        let out = AgentOutput {
1656            text: String::new(),
1657            exit_code: Some(1),
1658            timed_out: false,
1659            duration_ms: 431_194,
1660            artifacts: Vec::new(),
1661            quota: None,
1662            dropped: Some(Dropped {
1663                why: "subscriber fell behind updates".to_owned(),
1664                output_tokens: 14267,
1665            }),
1666        };
1667        assert!(!out.usable());
1668        assert!(out.work_undelivered());
1669        // The distinction the retry policy rests on: a quota fails the same way
1670        // until it resets, an abandoned conversation can be picked up.
1671        assert!(!out.quota_exhausted());
1672    }
1673
1674    #[test]
1675    fn non_json_stdout_falls_back_to_raw_text() {
1676        let out = extract(AgentKind::Antigravity, "plain answer\n");
1677        assert_eq!(out.text, "plain answer");
1678        assert!(out.session.is_none());
1679    }
1680
1681    #[tokio::test]
1682    async fn command_agent_round_trip_writes_artifacts() {
1683        let dir = tempfile::tempdir().unwrap();
1684        let art = dir.path().join("artifacts");
1685        let mut seat = SeatState::new("impl-A", "a", 7);
1686        let s = command_helper("reply");
1687        let out = invoke(
1688            &s,
1689            &mut seat,
1690            &Invocation {
1691                cwd: dir.path(),
1692                prompt: "unused",
1693                timeout: Duration::from_secs(30),
1694                allow_write: true,
1695                sessions: true,
1696                artifacts: &art,
1697                stem: "impl-A",
1698                run: "test-run",
1699                node: "test",
1700                cache_dir: None,
1701                attachments: &[],
1702            },
1703        )
1704        .await
1705        .unwrap();
1706        assert!(out.usable(), "{out:?}");
1707        assert!(out.text.contains("hello impl-A"), "{}", out.text);
1708        assert_eq!(seat.turns, 1);
1709        assert!(art.join("impl-A.prompt.md").is_file());
1710        assert!(art.join("impl-A.out").is_file());
1711    }
1712
1713    #[tokio::test]
1714    async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
1715        // The whole point of threading the cache path through `Invocation`:
1716        // the compile the agent pays for lands in the directory `verify` reads
1717        // back out of its rendered commands, so one cache has one prune.
1718        let dir = tempfile::tempdir().unwrap();
1719        let cache = dir.path().join("magi-cache");
1720        let mut seat = SeatState::new("impl-A", "a", 7);
1721        let s = command_helper("cache");
1722        let out = invoke(
1723            &s,
1724            &mut seat,
1725            &Invocation {
1726                cwd: dir.path(),
1727                prompt: "unused",
1728                timeout: Duration::from_secs(30),
1729                allow_write: true,
1730                sessions: true,
1731                artifacts: &dir.path().join("artifacts"),
1732                stem: "cache",
1733                run: "test-run",
1734                node: "test",
1735                cache_dir: Some(&cache),
1736                attachments: &[],
1737            },
1738        )
1739        .await
1740        .unwrap();
1741        assert!(out.usable(), "{out:?}");
1742        assert!(
1743            out.text.contains(cache.to_string_lossy().as_ref()),
1744            "the seat must see CARGO_TARGET_DIR = the shared cache"
1745        );
1746    }
1747
1748    #[tokio::test]
1749    async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1750        let dir = tempfile::tempdir().unwrap();
1751        let mut seat = SeatState::new("impl-A", "a", 7);
1752        // The helper never reads stdin, so an inline write_all would block once
1753        // the OS pipe buffer filled — long before the process could be waited on.
1754        let s = command_helper("ignore-stdin");
1755        let big = "x".repeat(1_000_000);
1756        let out = invoke(
1757            &s,
1758            &mut seat,
1759            &Invocation {
1760                cwd: dir.path(),
1761                prompt: &big,
1762                timeout: Duration::from_secs(60),
1763                allow_write: true,
1764                sessions: true,
1765                artifacts: &dir.path().join("artifacts"),
1766                stem: "big",
1767                run: "test-run",
1768                node: "test",
1769                cache_dir: None,
1770                attachments: &[],
1771            },
1772        )
1773        .await
1774        .unwrap();
1775        assert!(out.usable(), "{out:?}");
1776        assert!(out.text.contains("done"), "{}", out.text);
1777    }
1778
1779    #[tokio::test]
1780    async fn timeout_is_reported_not_hung() {
1781        let dir = tempfile::tempdir().unwrap();
1782        let mut seat = SeatState::new("impl-A", "a", 7);
1783        let s = command_helper("sleep");
1784        let out = invoke(
1785            &s,
1786            &mut seat,
1787            &Invocation {
1788                cwd: dir.path(),
1789                prompt: "unused",
1790                timeout: Duration::from_millis(300),
1791                allow_write: true,
1792                sessions: true,
1793                artifacts: &dir.path().join("artifacts"),
1794                stem: "slow",
1795                run: "test-run",
1796                node: "test",
1797                cache_dir: None,
1798                attachments: &[],
1799            },
1800        )
1801        .await
1802        .unwrap();
1803        assert!(out.timed_out);
1804        assert!(!out.usable());
1805    }
1806
1807    #[tokio::test]
1808    async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1809        // The old implementation cancelled `wait_with_output`, which dropped
1810        // the buffers it owned, so `<stem>.out` was written empty on every
1811        // timeout. "It printed nothing" and "we discarded what it printed"
1812        // looked identical on disk — and one real hour-long stall was
1813        // diagnosed wrongly twice because of it.
1814        let dir = tempfile::tempdir().unwrap();
1815        let artifacts = dir.path().join("artifacts");
1816        let mut seat = SeatState::new("impl-A", "a", 7);
1817        let s = command_helper("chatty-sleep");
1818        let out = invoke(
1819            &s,
1820            &mut seat,
1821            &Invocation {
1822                cwd: dir.path(),
1823                prompt: "unused",
1824                // Wide enough to cover process-spawn latency inside a loaded
1825                // parallel test run, not merely the helper's first write. At
1826                // two seconds this passed alone and failed in the full suite,
1827                // which is a dice roll rather than a test.
1828                timeout: Duration::from_secs(10),
1829                allow_write: true,
1830                sessions: true,
1831                artifacts: &artifacts,
1832                stem: "chatty",
1833                run: "test-run",
1834                node: "test",
1835                cache_dir: None,
1836                attachments: &[],
1837            },
1838        )
1839        .await
1840        .unwrap();
1841
1842        assert!(out.timed_out, "{out:?}");
1843        assert!(!out.usable(), "a cut-off answer is still not an answer");
1844        let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1845        assert!(
1846            recorded.contains("i-said-something"),
1847            "the artifact must keep what arrived before the kill, got {recorded:?}"
1848        );
1849        assert!(
1850            out.text.contains("i-said-something"),
1851            "and the graph must be able to see it too, got {:?}",
1852            out.text
1853        );
1854    }
1855
1856    #[test]
1857    fn missing_programs_reports_command_binaries() {
1858        let mut s = spec(AgentKind::Command, None);
1859        s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1860        assert_eq!(
1861            missing_programs(&[s]),
1862            ["definitely-not-a-real-binary-xyz".to_owned()]
1863        );
1864    }
1865
1866    fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
1867        AgentSpec {
1868            id: id.to_owned(),
1869            kind,
1870            model: None,
1871            command: Vec::new(),
1872            extra_args: Vec::new(),
1873            env: BTreeMap::new(),
1874            prompt_delivery: None,
1875        }
1876    }
1877
1878    /// Availability stub: an agent is runnable unless its id was listed as
1879    /// missing. Keeps the selection tests off `PATH` entirely.
1880    fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
1881        move |a: &AgentSpec| !missing.contains(&a.id.as_str())
1882    }
1883
1884    #[test]
1885    fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
1886        let agents = [
1887            pick_spec("oc", AgentKind::Opencode),
1888            pick_spec("opus", AgentKind::Claude),
1889            pick_spec("agy", AgentKind::Antigravity),
1890        ];
1891        let got = pick(&agents, None, &without(&[])).expect("a pick");
1892        assert_eq!(got.id, "opus");
1893    }
1894
1895    #[test]
1896    fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
1897        let agents = [
1898            pick_spec("opus", AgentKind::Claude),
1899            pick_spec("oc", AgentKind::Opencode),
1900            pick_spec("agy", AgentKind::Antigravity),
1901        ];
1902        let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
1903        assert_eq!(got.id, "agy");
1904    }
1905
1906    #[test]
1907    fn pick_on_an_empty_roster_says_what_to_install() {
1908        let msg = pick(&[], None, &without(&[]))
1909            .expect_err("nobody to ask")
1910            .to_string();
1911        assert!(msg.contains("roster is empty"), "{msg}");
1912        assert!(msg.contains("claude"), "{msg}");
1913        assert!(msg.contains("magi.toml"), "{msg}");
1914    }
1915
1916    #[test]
1917    fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
1918        let agents = [
1919            pick_spec("opus", AgentKind::Claude),
1920            pick_spec("oc", AgentKind::Opencode),
1921        ];
1922        let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
1923        let msg = format!("{err:#}");
1924        assert!(msg.contains("claude"), "{msg}");
1925        assert!(msg.contains("opencode"), "{msg}");
1926    }
1927
1928    #[test]
1929    fn an_explicitly_named_agent_wins_over_the_claude_preference() {
1930        let agents = [
1931            pick_spec("opus", AgentKind::Claude),
1932            pick_spec("oc", AgentKind::Opencode),
1933        ];
1934        let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
1935        assert_eq!(got.id, "oc");
1936    }
1937
1938    #[test]
1939    fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
1940        let agents = [
1941            pick_spec("opus", AgentKind::Claude),
1942            pick_spec("oc", AgentKind::Opencode),
1943        ];
1944        let msg = pick(&agents, Some("gemini"), &without(&[]))
1945            .expect_err("no such agent")
1946            .to_string();
1947        assert!(msg.contains("gemini"), "{msg}");
1948        assert!(msg.contains("opus, oc"), "{msg}");
1949    }
1950
1951    #[test]
1952    fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
1953        let agents = [
1954            pick_spec("opus", AgentKind::Claude),
1955            pick_spec("oc", AgentKind::Opencode),
1956        ];
1957        let msg = pick(&agents, Some("oc"), &without(&["oc"]))
1958            .expect_err("must not silently substitute another model")
1959            .to_string();
1960        assert!(msg.contains("opencode"), "{msg}");
1961        assert!(msg.contains("--agent"), "{msg}");
1962    }
1963}