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//! | `codex` | `exec --json` reports `thread.started.thread_id` | `exec … resume <id>` |
26//! | `omp` | `-p --mode=json` reports `id` on its `"type":"session"` line | `--resume <id>` |
27//!
28//! Claude is the only one magi can address before the first turn; the others
29//! report an id back, so [`SeatState::captured_session`] stays `None` until a
30//! turn has completed and [`has_session`] answers honestly instead of
31//! optimistically.
32use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::time::{Duration, Instant};
36
37use anyhow::{Context as _, Result, bail};
38use serde::{Deserialize, Serialize};
39use std::sync::{Arc, Mutex};
40
41use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
42use tokio::process::Command;
43
44use crate::config::{AgentKind, AgentSpec, Delivery};
45use crate::proc::Quiet as _;
46use crate::rng::SplitMix64;
47
48/// Conversation state for one seat, persisted with the run so `magi run
49/// --resume` continues the same CLI conversations.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SeatState {
52    /// Stable seat name, e.g. `impl-A`, `judge-2`, `review-1`, `fix`.
53    pub key: String,
54    /// Agent id occupying the seat.
55    pub agent: String,
56    /// Turns already taken in this seat.
57    pub turns: usize,
58    /// Claude session uuid, minted up front so the first turn and every resume
59    /// agree on it without parsing anything back.
60    pub claude_session: Option<String>,
61    /// Session id reported by a CLI that mints its own (`opencode`, `agy`).
62    pub captured_session: Option<String>,
63}
64
65impl SeatState {
66    /// New seat. `run_seed` scopes the minted Claude uuid to this run.
67    pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
68        let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
69        Self {
70            key: key.to_owned(),
71            agent: agent.to_owned(),
72            turns: 0,
73            claude_session: Some(rng.uuid_v4()),
74            captured_session: None,
75        }
76    }
77}
78
79/// Can a follow-up prompt rely on this seat remembering the conversation?
80pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
81    if !sessions_enabled || seat.turns == 0 {
82        return false;
83    }
84    match kind {
85        AgentKind::Claude => seat.claude_session.is_some(),
86        AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
87            seat.captured_session.is_some()
88        }
89        AgentKind::Command => true,
90    }
91}
92
93/// One agent invocation.
94#[derive(Debug)]
95pub struct Invocation<'a> {
96    /// Working directory. Always a real checkout, so every CLI can read the
97    /// repository without per-vendor "extra directory" flags.
98    pub cwd: &'a Path,
99    /// The full prompt.
100    pub prompt: &'a str,
101    /// Wall-clock limit; the process tree is killed when it elapses.
102    pub timeout: Duration,
103    /// May the agent modify files? Judges and reviewers may not.
104    pub allow_write: bool,
105    /// Continue this seat's conversation when the CLI supports it.
106    pub sessions: bool,
107    /// Directory for prompt / stdout / stderr artifacts.
108    pub artifacts: &'a Path,
109    /// Artifact filename stem.
110    pub stem: &'a str,
111    /// Run this invocation belongs to. Exported as `MAGI_RUN` so an agent that
112    /// files a task with `magi task add` is attributed to the run that was
113    /// paying for it, rather than looking like a human wandered by.
114    pub run: &'a str,
115    /// Graph node being executed, e.g. `implement` or `review`. Exported as
116    /// `MAGI_NODE` for the same reason: "who asked for this" is the first
117    /// question about an autonomously created task.
118    pub node: &'a str,
119    /// Shared build cache the seat should build into, from the rendered
120    /// `CARGO_TARGET_DIR=` in the verify commands. Exported as
121    /// `CARGO_TARGET_DIR` so the implementer's compile lands inside the same
122    /// directory `verify` reads back from - one cache, one prune, and the
123    /// build the agent just paid for is the build the gate reuses.
124    pub cache_dir: Option<&'a Path>,
125    /// Absolute paths of images the operator attached to this conversation,
126    /// outside `cwd` - see `chat`/`talk`'s `attachments_dir`. Empty for every
127    /// invocation that is not a chat or talk turn. [`build_command`] uses
128    /// this only to decide whether a CLI's sandbox needs widening to read
129    /// them; the prompt text naming each path and its mime is built by the
130    /// caller, not here.
131    pub attachments: &'a [PathBuf],
132}
133
134/// Evidence that a CLI ran out of its rate limit / quota, distinct from an
135/// ordinary failure.
136///
137/// `reset` is free text: CLIs render the reset time in their own locale, and
138/// parsing it exactly would be a bug factory. When it is not readable we say
139/// nothing rather than invent a format.
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141pub struct Quota {
142    /// Human-readable reset time, when the CLI printed one.
143    #[serde(default)]
144    pub reset: Option<String>,
145}
146
147/// A CLI hung up on its own stream while the agent was working.
148///
149/// Separate from a failure because the work was done and billed, and separate
150/// from a [`Quota`] because it is worth asking again: the answer is in the
151/// conversation, not lost to a limit that has to reset first. See
152/// [`dropped_stream`].
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
154pub struct Dropped {
155    /// What the CLI said as it hung up, verbatim.
156    pub why: String,
157    /// Output tokens the CLI reported before it did - the evidence that this
158    /// was a delivery failure and not an agent that produced nothing.
159    pub output_tokens: u64,
160}
161
162/// One command a CLI's own structured event stream reported running, with
163/// the result it reported for it.
164///
165/// This is evidence the CLI chose to report about its own tool loop — never
166/// something magi polled, supervised, or inferred from a process list. Only
167/// the Codex arm of [`extract`] currently populates it (its `item.completed`
168/// / `command_execution` events name `id`, `command`, `exit_code` and
169/// `aggregated_output` directly); every other backend's CLI does not expose
170/// this in what magi currently captures, so its seats simply never produce
171/// any. A command a CLI never reported finishing (still running when the
172/// turn ended, or the event stream never named it) has no entry here either
173/// — there is no event to build one from, and this type must never be used
174/// to *guess* that a command is still in flight.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct CommandEvidence {
177    /// The CLI's own id for this command.
178    pub id: String,
179    /// The command itself, as the CLI reported it.
180    pub description: String,
181    /// Exit code the CLI reported for it.
182    pub exit_code: Option<i32>,
183    /// Tail of the command's own output, when the CLI reported one.
184    pub result_summary: String,
185    /// Which CLI/event stream this came from, e.g. `"codex"`.
186    pub source: String,
187}
188
189/// Result of an agent invocation.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AgentOutput {
192    /// The agent's final message, extracted from whatever the CLI printed.
193    pub text: String,
194    /// Exit status code.
195    pub exit_code: Option<i32>,
196    /// Did the invocation hit its timeout?
197    pub timed_out: bool,
198    /// Wall-clock duration.
199    pub duration_ms: u64,
200    /// Artifact file names, relative to the run's `artifacts/` directory.
201    pub artifacts: Vec<String>,
202    /// Rate-limit / quota exhaustion, when it can be told apart from a normal
203    /// failure. `None` for a normal failure, a timeout, or a CLI we cannot
204    /// read — the conservative default.
205    #[serde(default)]
206    pub quota: Option<Quota>,
207    /// The CLI hung up on its own stream after the agent had done billed
208    /// work. `None` unless that exact shape was recognised — see
209    /// [`dropped_stream`].
210    #[serde(default)]
211    pub dropped: Option<Dropped>,
212    /// Commands the CLI's own event stream reported running, see
213    /// [`CommandEvidence`]. Always empty for a backend this crate does not
214    /// currently read structured job events from.
215    #[serde(default)]
216    pub commands: Vec<CommandEvidence>,
217}
218
219impl AgentOutput {
220    /// Did the CLI exit cleanly with something to say?
221    pub fn usable(&self) -> bool {
222        !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
223    }
224
225    /// Did this invocation run out of the CLI's rate limit / quota?
226    pub fn quota_exhausted(&self) -> bool {
227        self.quota.is_some()
228    }
229
230    /// Did the agent work and the CLI fail to deliver it?
231    ///
232    /// Worth re-asking, unlike [`AgentOutput::quota_exhausted`]: the answer is
233    /// in a conversation this process can resume.
234    pub fn work_undelivered(&self) -> bool {
235        self.dropped.is_some()
236    }
237}
238
239/// How long to keep reading a pipe after the child is gone.
240///
241/// Bounded on purpose: a surviving grandchild can hold the write end open
242/// forever, and the graph must not hang on a process it has already killed.
243const PIPE_GRACE: Duration = Duration::from_secs(3);
244
245/// Bytes a pipe reader has accumulated so far, shared with whoever spawned it.
246type Captured = Arc<Mutex<Vec<u8>>>;
247
248/// Read `pipe` to end in its own task, appending into a buffer the caller can
249/// inspect at any time.
250///
251/// The buffer is shared rather than returned because the interesting moment is
252/// exactly the one where the reader has *not* finished: a killed agent's pipe
253/// may still be held open by a surviving grandchild, and the bytes that did
254/// arrive are the only evidence of what it was doing. An earlier version
255/// returned the buffer from the task and dropped it on timeout, which is how
256/// `<stem>.out` came to be empty on every timeout.
257fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
258where
259    R: tokio::io::AsyncRead + Unpin + Send + 'static,
260{
261    let buf: Captured = Arc::new(Mutex::new(Vec::new()));
262    let Some(mut pipe) = pipe else {
263        return (buf, None);
264    };
265    let sink = Arc::clone(&buf);
266    let handle = tokio::spawn(async move {
267        let mut chunk = [0u8; 8192];
268        loop {
269            match pipe.read(&mut chunk).await {
270                Ok(0) | Err(_) => break,
271                Ok(n) => {
272                    if let Ok(mut guard) = sink.lock() {
273                        guard.extend_from_slice(&chunk[..n]);
274                    }
275                }
276            }
277        }
278    });
279    (buf, Some(handle))
280}
281
282/// Take whatever a reader has captured, giving it at most `grace` to finish.
283///
284/// A reader still blocked after that is abandoned, not awaited — but its bytes
285/// come back either way, which is the whole point.
286async fn collect(
287    buf: &Captured,
288    handle: Option<tokio::task::JoinHandle<()>>,
289    grace: Duration,
290) -> String {
291    if let Some(handle) = handle {
292        if tokio::time::timeout(grace, handle).await.is_err() {
293            tracing::debug!("a pipe is still held open after the child exited");
294        }
295    }
296    let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
297    String::from_utf8_lossy(&bytes).into_owned()
298}
299
300/// Invoke `spec` for `seat`, updating the seat's conversation state.
301pub async fn invoke(
302    spec: &AgentSpec,
303    seat: &mut SeatState,
304    inv: &Invocation<'_>,
305) -> Result<AgentOutput> {
306    tokio::fs::create_dir_all(inv.artifacts)
307        .await
308        .with_context(|| format!("create {}", inv.artifacts.display()))?;
309    let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
310    tokio::fs::write(&prompt_path, inv.prompt)
311        .await
312        .with_context(|| format!("write {}", prompt_path.display()))?;
313
314    let plan = build_command(spec, seat, inv, &prompt_path)?;
315    tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
316
317    let started = Instant::now();
318    // Resolve against the PATH the child will actually see: `spec.env` may
319    // override it, and the resolved absolute path bypasses any later lookup.
320    let child_path = spec
321        .env
322        .iter()
323        .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
324        .map(|(_, v)| std::ffi::OsString::from(v))
325        .or_else(|| std::env::var_os("PATH"))
326        .unwrap_or_default();
327    let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
328        || plan.argv[0].clone().into(),
329        std::path::PathBuf::into_os_string,
330    );
331    let mut cmd = Command::new(program);
332    cmd.args(&plan.argv[1..])
333        .current_dir(inv.cwd)
334        .envs(&spec.env)
335        .env("MAGI_SEAT", &seat.key)
336        .env("MAGI_TURN", seat.turns.to_string())
337        .env("MAGI_RUN", inv.run)
338        .env("MAGI_NODE", inv.node)
339        .env("MAGI_PROMPT_FILE", &prompt_path)
340        .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
341        .env("GIT_TERMINAL_PROMPT", "0")
342        .stdin(if plan.stdin.is_some() {
343            Stdio::piped()
344        } else {
345            Stdio::null()
346        })
347        .stdout(Stdio::piped())
348        .stderr(Stdio::piped())
349        .kill_on_drop(true)
350        // No console window. `magi web` has no console of its own, so Windows
351        // would give each agent a fresh one - and draw it. See `crate::proc`.
352        .quiet();
353    if let Some(cache) = inv.cache_dir {
354        // Same directory the verify commands build into: one cache to prune,
355        // and the compile the seat pays for is the compile the gate reuses.
356        cmd.env("CARGO_TARGET_DIR", cache);
357    } else {
358        // `Command` inherits this process's environment by default, so
359        // simply not setting the variable here is not the same as the seat
360        // not seeing it: if the magi process itself is running under a
361        // shared `CARGO_TARGET_DIR` (the ordinary case), a read-only seat
362        // would otherwise inherit that exact path and try to build there
363        // anyway - the write refusal this is meant to prevent in the first
364        // place. Strip it explicitly.
365        cmd.env_remove("CARGO_TARGET_DIR");
366    }
367
368    let mut child = cmd
369        .spawn()
370        .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
371    // Feed stdin from a task rather than inline: a `command` agent that never
372    // reads its stdin, or a prompt larger than the pipe buffer, would
373    // otherwise deadlock here before the process is ever waited on.
374    if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
375        tokio::spawn(async move {
376            sink.write_all(body.as_bytes()).await.ok();
377            sink.shutdown().await.ok();
378        });
379    }
380
381    // Drain the pipes in their own tasks, and wait on the *process*, not on
382    // end-of-file. Two failures come out of conflating those:
383    //
384    // 1. `wait_with_output` returns when both pipes reach EOF, which is not
385    //    when the child exits. A CLI that leaves a helper process holding the
386    //    inherited stdout handle - normal on Windows, where a `.cmd` shim and
387    //    its grandchildren share handles - never closes the pipe, so a seat
388    //    that answered in five minutes was billed the full hour and then
389    //    recorded as a timeout. The answer was thrown away with it.
390    // 2. Cancelling `wait_with_output` at the timeout drops the buffers it
391    //    owned, so `<stem>.out` and `<stem>.err` were written empty exactly
392    //    when an operator needs them most. "It printed nothing" and "we
393    //    discarded what it printed" looked identical on disk.
394    //
395    // Now the readers own the bytes, so a timeout keeps whatever arrived, and
396    // the wait ends at exit even if a stray handle stays open.
397    let (out_buf, out_reader) = drain(child.stdout.take());
398    let (err_buf, err_reader) = drain(child.stderr.take());
399
400    let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
401        Ok(res) => {
402            let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
403            (status.code(), false)
404        }
405        Err(_) => {
406            tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
407            // Kill the tree so the readers see EOF instead of hanging with it.
408            child.start_kill().ok();
409            (None, true)
410        }
411    };
412
413    // The child is gone either way, so the readers are bounded now. A grace
414    // window rather than an unbounded await: a surviving grandchild can still
415    // hold the write end open, and losing a few trailing bytes beats hanging
416    // the graph on a process we no longer control.
417    let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
418    let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
419
420    let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
421    let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
422    tokio::fs::write(&out_path, &stdout).await.ok();
423    tokio::fs::write(&err_path, &stderr).await.ok();
424
425    let mut extracted = extract(spec.kind, &stdout);
426    if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
427        extracted.quota = agy_quota(&stdout, &stderr);
428    }
429    if let Some(quota) = &extracted.quota {
430        // A quota response carries usage too, so it can look like a dropped
431        // stream; a rate limit is never worth re-asking, so it wins.
432        extracted.dropped = None;
433        tracing::warn!(
434            seat = %seat.key,
435            agent = %spec.id,
436            reset = ?quota.reset,
437            "agent is out of quota"
438        );
439    }
440    if let Some(session) = extracted.session {
441        match spec.kind {
442            AgentKind::Claude => seat.claude_session = Some(session),
443            AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
444                seat.captured_session = Some(session);
445            }
446            AgentKind::Command => {}
447        }
448    }
449    if let Some(status) = &extracted.status
450        && !status.eq_ignore_ascii_case("success")
451    {
452        tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
453    }
454    let text = if extracted.text.trim().is_empty() {
455        // A CLI that printed only to stderr still told us something.
456        if stdout.trim().is_empty() {
457            stderr.trim().to_owned()
458        } else {
459            stdout.trim().to_owned()
460        }
461    } else {
462        extracted.text
463    };
464    seat.turns += 1;
465
466    Ok(AgentOutput {
467        text,
468        exit_code: code,
469        timed_out,
470        duration_ms: started.elapsed().as_millis() as u64,
471        artifacts: vec![
472            file_name(&prompt_path),
473            file_name(&out_path),
474            file_name(&err_path),
475        ],
476        quota: extracted.quota,
477        dropped: extracted.dropped,
478        commands: extracted.commands,
479    })
480}
481
482fn file_name(p: &Path) -> String {
483    p.file_name()
484        .unwrap_or_default()
485        .to_string_lossy()
486        .into_owned()
487}
488
489/// The argv plus optional stdin body for one invocation.
490#[derive(Debug)]
491struct Plan {
492    argv: Vec<String>,
493    stdin: Option<String>,
494}
495
496/// How a file-delivered prompt is pointed at, per CLI.
497///
498/// `agy` has a native file-context syntax, `@<path>`, and it is measurably the
499/// better contract: on the same trivial task it finished in 17s against 73s for
500/// the prose form, because prose makes the model spend a tool round-trip
501/// deciding to read the file. It is also the form yukimemi/rvpm proved out.
502///
503/// opencode has no equivalent, so it gets the prose. That is not a fallback
504/// worth apologising for — it works, and it is what the winning opencode
505/// candidates on this repository have been driven by all along.
506fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
507    if matches!(kind, AgentKind::Antigravity) {
508        return format!("@{}", prompt_path.display());
509    }
510    format!(
511        "Read the file at {} and follow every instruction in it exactly. That \
512         file is your complete task description; this message contains nothing \
513         else.",
514        prompt_path.display()
515    )
516}
517
518fn build_command(
519    spec: &AgentSpec,
520    seat: &SeatState,
521    inv: &Invocation<'_>,
522    prompt_path: &Path,
523) -> Result<Plan> {
524    let mut argv: Vec<String> = Vec::new();
525    let mut stdin: Option<String> = None;
526    let delivery = spec.delivery();
527    let resuming = has_session(spec.kind, seat, inv.sessions);
528
529    match spec.kind {
530        AgentKind::Claude => {
531            // Claude's own tools have no cwd-confined sandbox - the CLI can
532            // already `Read` any absolute path magi hands it, an attachment
533            // outside the repository included - so no extra flag is needed
534            // here.
535            argv.push("claude".to_owned());
536            argv.push("-p".to_owned());
537            argv.push("--output-format".to_owned());
538            argv.push("json".to_owned());
539            if let Some(m) = &spec.model {
540                argv.push("--model".to_owned());
541                argv.push(m.clone());
542            }
543            if inv.sessions {
544                let uuid = seat
545                    .claude_session
546                    .as_deref()
547                    .context("claude seat is missing its session uuid")?;
548                argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
549                argv.push(uuid.to_owned());
550            }
551            argv.push("--permission-mode".to_owned());
552            argv.push("bypassPermissions".to_owned());
553            if !inv.allow_write {
554                argv.push("--disallowed-tools".to_owned());
555                argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
556            }
557        }
558        AgentKind::Opencode => {
559            // `--auto` below already bypasses every permission, reads of a
560            // path outside `--dir` included, so an attachment elsewhere
561            // needs no extra flag.
562            argv.push("opencode".to_owned());
563            argv.push("run".to_owned());
564            argv.push("--format".to_owned());
565            argv.push("json".to_owned());
566            argv.push("--dir".to_owned());
567            argv.push(inv.cwd.to_string_lossy().into_owned());
568            // `--auto` gates *every* permission, reads included: without it a
569            // non-interactive opencode cannot even open the prompt file, and
570            // the seat drops out of the panel with "the user rejected
571            // permission to use this specific tool call". opencode has no
572            // read-only mode, so read-only seats rely on the prompt plus the
573            // fact that judge and reviewer worktrees are disposable — judges'
574            // are deleted after the tally, reviewers' are reset to the commit
575            // under review every round.
576            argv.push("--auto".to_owned());
577            if let Some(m) = &spec.model {
578                argv.push("-m".to_owned());
579                argv.push(m.clone());
580            }
581            if resuming {
582                argv.push("-s".to_owned());
583                argv.push(
584                    seat.captured_session
585                        .clone()
586                        .expect("has_session checked the id is present"),
587                );
588            }
589        }
590        AgentKind::Antigravity => {
591            argv.push("agy".to_owned());
592            argv.push("--output-format".to_owned());
593            argv.push("json".to_owned());
594            // agy's print mode gives up after 5 minutes by default, which is
595            // far below an implementation node's budget.
596            argv.push("--print-timeout".to_owned());
597            argv.push(format!("{}s", inv.timeout.as_secs()));
598            argv.push("--mode".to_owned());
599            argv.push(
600                if inv.allow_write {
601                    "accept-edits"
602                } else {
603                    "plan"
604                }
605                .to_owned(),
606            );
607            if inv.allow_write {
608                argv.push("--dangerously-skip-permissions".to_owned());
609            }
610            if let Some(m) = &spec.model {
611                argv.push("--model".to_owned());
612                argv.push(m.clone());
613            }
614            if resuming {
615                argv.push("--conversation".to_owned());
616                argv.push(
617                    seat.captured_session
618                        .clone()
619                        .expect("has_session checked the id is present"),
620                );
621            }
622            // The prompt file lives outside the worktree, so the workspace has
623            // to be widened to reach it - and so does an attachment's own
624            // directory, which usually lives right beside it under the
625            // conversation's `artifacts_dir` (see `chat`/`talk`). "Usually":
626            // a chat derived from another one (`chat::derived_background`)
627            // can carry attachment paths that live under the *source*
628            // conversation's own artifacts dir instead, so each attachment
629            // outside `inv.artifacts` gets its own `--add-dir` rather than
630            // assuming one directory covers all of `inv.attachments`.
631            let mut add_dirs: Vec<String> = Vec::new();
632            if delivery == Delivery::File || !inv.attachments.is_empty() {
633                add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
634            }
635            for path in inv.attachments {
636                let Some(parent) = path.parent() else {
637                    continue;
638                };
639                if parent.starts_with(inv.artifacts) {
640                    continue;
641                }
642                let dir = parent.to_string_lossy().into_owned();
643                if !add_dirs.contains(&dir) {
644                    add_dirs.push(dir);
645                }
646            }
647            for dir in add_dirs {
648                argv.push("--add-dir".to_owned());
649                argv.push(dir);
650            }
651        }
652        AgentKind::Codex => {
653            // `--sandbox` below governs writes, not reads (see the module
654            // doc: it is what makes codex the one kind whose *read-only*
655            // mode is enforced, by refusing edits) - both presets can read
656            // anywhere the OS lets the process, so an attachment outside
657            // `cwd` is already reachable without an extra flag.
658            argv.push("codex".to_owned());
659            argv.push("exec".to_owned());
660            argv.push("--json".to_owned());
661            // The worktrees magi hands out are real checkouts, but a judge's
662            // is detached and a fixture's may be no repository at all.
663            argv.push("--skip-git-repo-check".to_owned());
664            argv.push("-C".to_owned());
665            argv.push(inv.cwd.to_string_lossy().into_owned());
666            // Codex is the only kind whose read-only-ness is enforced by the
667            // CLI rather than by the prompt: a judge or reviewer seat cannot
668            // write even if it decides to try. Implementers get the workspace,
669            // and nothing ever gets `--dangerously-bypass-approvals-and-sandbox`.
670            argv.push("--sandbox".to_owned());
671            argv.push(
672                if inv.allow_write {
673                    "workspace-write"
674                } else {
675                    "read-only"
676                }
677                .to_owned(),
678            );
679            // Nothing is watching to approve anything: an unattended seat that
680            // asks blocks until its timeout kills it.
681            argv.push("-c".to_owned());
682            argv.push("approval_policy=\"never\"".to_owned());
683            if let Some(m) = &spec.model {
684                argv.push("-m".to_owned());
685                argv.push(m.clone());
686            }
687            // `resume` is a subcommand of `exec`, and it rejects the flags
688            // above when they follow it - so every option is emitted first and
689            // the subcommand last. Established by hand against codex-cli
690            // 0.153.4: with the order reversed the CLI exits on
691            // `unexpected argument '--sandbox'`.
692            if resuming {
693                argv.push("resume".to_owned());
694                argv.push(
695                    seat.captured_session
696                        .clone()
697                        .expect("has_session checked the id is present"),
698                );
699            }
700        }
701        AgentKind::Omp => {
702            // `omp` reads the prompt from stdin in print mode (see
703            // `AgentSpec::delivery`), so the whole instruction arrives without
704            // an argv length limit - the same reason codex gets stdin.
705            argv.push("omp".to_owned());
706            argv.push("-p".to_owned());
707            argv.push("--mode=json".to_owned());
708            // `--auto-approve` is required, and is the same trade opencode's
709            // `--auto` makes: it gates *every* permission, reads included, so
710            // without it a non-interactive seat cannot even open the prompt
711            // file magi wrote and drops out of the panel on a permission
712            // rejection. `omp` has no read-only mode of its own, so a judge or
713            // reviewer seat rests on the prompt plus the worktree discipline
714            // (judge worktrees are deleted after the tally, reviewer worktrees
715            // are reset to the commit under review every round) - never on this
716            // flag, and never on a bypass flag.
717            argv.push("--auto-approve".to_owned());
718            if let Some(m) = &spec.model {
719                argv.push("--model".to_owned());
720                argv.push(m.clone());
721            }
722            // Established by hand against omp 18.1.19: `-p --mode=json` reports
723            // the session id on its `"type":"session"` line, and
724            // `--resume <id>` continues that conversation. `--continue` is
725            // deliberately not used - it opens a *new* session rather than the
726            // stored one, which silently loses the seat's memory.
727            if resuming {
728                argv.push("--resume".to_owned());
729                argv.push(
730                    seat.captured_session
731                        .clone()
732                        .expect("has_session checked the id is present"),
733                );
734            }
735        }
736        AgentKind::Command => {
737            // The operator's own command line, not one of the roster CLIs -
738            // there is no flag this function could add on its behalf, so an
739            // attachment's path has to reach it the same way the prompt
740            // does, through the substitutions below.
741            if spec.command.is_empty() {
742                bail!("agent `{}` has kind = \"command\" but no command", spec.id);
743            }
744            let vars: BTreeMap<&str, String> = BTreeMap::from([
745                ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
746                ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
747                ("{label}", seat.key.clone()),
748                ("{session}", seat.claude_session.clone().unwrap_or_default()),
749            ]);
750            for raw in &spec.command {
751                let mut arg = raw.clone();
752                for (k, v) in &vars {
753                    if arg.contains(k) {
754                        arg = arg.replace(k, v);
755                    }
756                }
757                argv.push(arg);
758            }
759        }
760    }
761
762    argv.extend(spec.extra_args.iter().cloned());
763
764    // `agy` takes the prompt as the value of `-p`, so the flag has to be
765    // emitted right before whatever the delivery mode produces.
766    if spec.kind == AgentKind::Antigravity {
767        argv.push("-p".to_owned());
768    }
769    // `codex exec` reads stdin only when its prompt argument is `-`; without
770    // it the CLI waits on a prompt it will never be given.
771    if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
772        argv.push("-".to_owned());
773    }
774    match delivery {
775        Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
776            // agy has no text stdin path; fall back to the pointer file.
777            argv.push(pointer(spec.kind, prompt_path));
778        }
779        Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
780        Delivery::Argv => argv.push(inv.prompt.to_owned()),
781        Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
782    }
783
784    Ok(Plan { argv, stdin })
785}
786
787/// What a CLI's stdout yielded.
788#[derive(Debug, Default)]
789struct Extracted {
790    text: String,
791    session: Option<String>,
792    status: Option<String>,
793    quota: Option<Quota>,
794    dropped: Option<Dropped>,
795    commands: Vec<CommandEvidence>,
796}
797
798/// Pull the agent's message (and any session id) out of a CLI's stdout.
799fn extract(kind: AgentKind, stdout: &str) -> Extracted {
800    match kind {
801        AgentKind::Claude => {
802            let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
803                return Extracted {
804                    text: stdout.trim().to_owned(),
805                    ..Extracted::default()
806                };
807            };
808            Extracted {
809                text: v
810                    .get("result")
811                    .and_then(|r| r.as_str())
812                    .unwrap_or_default()
813                    .to_owned(),
814                session: v
815                    .get("session_id")
816                    .and_then(|s| s.as_str())
817                    .map(str::to_owned),
818                status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
819                    if e {
820                        "error".to_owned()
821                    } else {
822                        "success".to_owned()
823                    }
824                }),
825                quota: claude_quota(&v),
826                // Claude reports a truncated stream as an ordinary error; the
827                // shape `dropped_stream` keys on is agy's.
828                dropped: None,
829                commands: Vec::new(),
830            }
831        }
832        AgentKind::Opencode => {
833            // A JSONL event stream: text parts concatenated in arrival order.
834            let mut text = String::new();
835            let mut session = None;
836            for line in stdout.lines() {
837                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
838                    continue;
839                };
840                if session.is_none() {
841                    session = v
842                        .get("sessionID")
843                        .and_then(|s| s.as_str())
844                        .map(str::to_owned);
845                }
846                let part = v.get("part").unwrap_or(&serde_json::Value::Null);
847                if part.get("type").and_then(|t| t.as_str()) == Some("text")
848                    && let Some(t) = part.get("text").and_then(|t| t.as_str())
849                {
850                    if !text.is_empty() {
851                        text.push('\n');
852                    }
853                    text.push_str(t);
854                }
855            }
856            Extracted {
857                text,
858                session,
859                status: None,
860                quota: None,
861                dropped: None,
862                commands: Vec::new(),
863            }
864        }
865        AgentKind::Antigravity => {
866            // agy prints warnings before the JSON object, so parse the last
867            // line that is one rather than the whole stream.
868            let obj = stdout
869                .lines()
870                .rev()
871                .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
872            let Some(v) = obj else {
873                return Extracted {
874                    text: stdout.trim().to_owned(),
875                    ..Extracted::default()
876                };
877            };
878            Extracted {
879                text: v
880                    .get("response")
881                    .and_then(|r| r.as_str())
882                    .unwrap_or_default()
883                    .trim()
884                    .to_owned(),
885                session: v
886                    .get("conversation_id")
887                    .and_then(|s| s.as_str())
888                    .map(str::to_owned),
889                status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
890                quota: None,
891                dropped: dropped_stream(&v),
892                commands: Vec::new(),
893            }
894        }
895        AgentKind::Codex => {
896            // A JSONL event stream, prefixed on a real machine by tracing
897            // lines the CLI writes about its own config and skills - so
898            // non-JSON lines are skipped rather than treated as the answer.
899            //
900            // The thread id arrives once, in `thread.started`, and a resumed
901            // turn reports the same one. The answer is the last
902            // `item.completed` carrying an `agent_message`: earlier ones are
903            // the model narrating its way through the tool loop, and taking
904            // the first would hand the caller a progress note instead of a
905            // verdict.
906            //
907            // A `command_execution` item is different evidence entirely: the
908            // CLI's own record that it ran a command and what that command
909            // reported back, kept as `CommandEvidence` — see run
910            // 20260912-214939-b3bb's artifacts, where these very fields
911            // (`id`/`command`/`exit_code`/`aggregated_output`) were what
912            // caught a paired test result magi's own agent prose had missed.
913            // Only what `item.completed` actually reports: a command still
914            // running when the turn ended emits no such event at all, and is
915            // not something this can detect — see [`CommandEvidence`]'s own
916            // doc for why that must not be guessed at instead.
917            let mut text = String::new();
918            let mut session = None;
919            let mut status = None;
920            let mut commands = Vec::new();
921            for line in stdout.lines() {
922                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
923                    continue;
924                };
925                match v.get("type").and_then(|t| t.as_str()) {
926                    Some("thread.started") => {
927                        session = v
928                            .get("thread_id")
929                            .and_then(|s| s.as_str())
930                            .map(str::to_owned);
931                    }
932                    Some("item.completed") => {
933                        let item = v.get("item").unwrap_or(&serde_json::Value::Null);
934                        match item.get("type").and_then(|t| t.as_str()) {
935                            Some("agent_message") => {
936                                if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
937                                    text = t.trim().to_owned();
938                                }
939                            }
940                            Some("command_execution") => {
941                                commands.push(command_evidence(item));
942                            }
943                            _ => {}
944                        }
945                    }
946                    Some("turn.completed") => status = Some("success".to_owned()),
947                    Some("turn.failed") => status = Some("error".to_owned()),
948                    _ => {}
949                }
950            }
951            Extracted {
952                text,
953                session,
954                status,
955                quota: None,
956                dropped: None,
957                commands,
958            }
959        }
960        AgentKind::Omp => {
961            // A JSONL event stream. The session id arrives once, on the
962            // `"type":"session"` line that opens the run.
963            //
964            // The answer is the *last* non-empty assistant text block anywhere
965            // in the stream, and neither of the two obvious shortcuts works:
966            //
967            // 1. Do not key on `agent_end`. `omp` emits it only for a run that
968            //    quiesces on a message turn; a turn that ends on a tool call
969            //    (`stopReason: "toolUse"`) ends the run with **no `agent_end`
970            //    line at all**, and the answer is in `message_end` / `turn_end`
971            //    instead. Reading only `agent_end` silently discards a complete
972            //    review - which is exactly what the first hand-written wrapper
973            //    did, three times, before this arm existed.
974            // 2. Do not take the first assistant text. Earlier ones narrate the
975            //    tool loop (sometimes with a single `.`), so the last non-empty
976            //    block is the answer and the one before it is a progress note.
977            //
978            // Every line is parsed independently: a non-JSON line (a CLI
979            // warning, a truncated write) is skipped rather than treated as the
980            // answer, the same way the codex arm treats its tracing prefix.
981            let mut text = String::new();
982            let mut session = None;
983            for line in stdout.lines() {
984                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
985                    continue;
986                };
987                if v.get("type").and_then(|t| t.as_str()) == Some("session") {
988                    session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
989                    continue;
990                }
991                // `agent_end` carries the whole thread; `turn_end` and
992                // `message_end` each carry one message. Whichever appears, the
993                // messages are walked the same way.
994                let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
995                {
996                    Some("agent_end") => v
997                        .get("messages")
998                        .and_then(|m| m.as_array())
999                        .map(|m| m.iter().collect())
1000                        .unwrap_or_default(),
1001                    Some("turn_end") | Some("message_end") => {
1002                        v.get("message").into_iter().collect()
1003                    }
1004                    _ => continue,
1005                };
1006                for message in messages {
1007                    if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1008                        continue;
1009                    }
1010                    let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1011                        continue;
1012                    };
1013                    for part in parts {
1014                        if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1015                            continue;
1016                        }
1017                        if let Some(t) = part.get("text").and_then(|t| t.as_str())
1018                            && !t.trim().is_empty()
1019                        {
1020                            text = t.trim().to_owned();
1021                        }
1022                    }
1023                }
1024            }
1025            Extracted {
1026                text,
1027                session,
1028                status: None,
1029                quota: None,
1030                dropped: None,
1031                commands: Vec::new(),
1032            }
1033        }
1034        AgentKind::Command => {
1035            // A `command` agent may wrap a subscription CLI (a fixture, or a
1036            // thin shim around `claude`). If its output is the claude error
1037            // shape we recognise the quota the same way, so tests and wrappers
1038            // do not need their own detection; anything else is just text.
1039            let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1040            let quota = parsed.as_ref().and_then(claude_quota);
1041            // A `command` fixture may also stand in for a CLI that hangs up on
1042            // its own stream, which is how that path is tested.
1043            let dropped = parsed.as_ref().and_then(dropped_stream);
1044            Extracted {
1045                text: stdout.trim().to_owned(),
1046                session: None,
1047                status: None,
1048                quota,
1049                dropped,
1050                commands: Vec::new(),
1051            }
1052        }
1053    }
1054}
1055
1056/// Build one [`CommandEvidence`] from a Codex `command_execution` item.
1057///
1058/// `command` arrives either as a single string or as an argv array,
1059/// depending on how the CLI shaped the call; both are read rather than
1060/// assuming one. Missing fields are left at their honest defaults (an empty
1061/// id/description, `exit_code: None`) rather than guessed at.
1062fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1063    let description = match item.get("command") {
1064        Some(serde_json::Value::String(s)) => s.clone(),
1065        Some(serde_json::Value::Array(parts)) => parts
1066            .iter()
1067            .filter_map(|p| p.as_str())
1068            .collect::<Vec<_>>()
1069            .join(" "),
1070        _ => String::new(),
1071    };
1072    let result_summary = item
1073        .get("aggregated_output")
1074        .and_then(|o| o.as_str())
1075        .map(|s| tail_chars(s.trim(), 400))
1076        .unwrap_or_default();
1077    CommandEvidence {
1078        id: item
1079            .get("id")
1080            .and_then(|s| s.as_str())
1081            .unwrap_or_default()
1082            .to_owned(),
1083        description,
1084        exit_code: item
1085            .get("exit_code")
1086            .and_then(serde_json::Value::as_i64)
1087            .map(|e| e as i32),
1088        result_summary,
1089        source: "codex".to_owned(),
1090    }
1091}
1092
1093/// The last `max` characters of `s`, cut on a char boundary.
1094fn tail_chars(s: &str, max: usize) -> String {
1095    let count = s.chars().count();
1096    if count <= max {
1097        return s.to_owned();
1098    }
1099    s.chars().skip(count - max).collect()
1100}
1101
1102/// Recognise claude's rate-limit error shape, when it is present.
1103///
1104/// The only output we have observed is the JSON object carrying `is_error:
1105/// true` and a `result` mentioning the session limit. We key on exactly that;
1106/// every other CLI (and any future shape) returns `None` and is treated as an
1107/// ordinary failure — the conservative side.
1108fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1109    let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1110    if !is_err {
1111        return None;
1112    }
1113    let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1114    if !result.to_lowercase().contains("session limit") {
1115        return None;
1116    }
1117    // "…session limit · resets 4:50am (Asia/Tokyo)". The timezone read is not
1118    // worth parsing exactly; keep the whole phrase after "resets" as free text.
1119    let reset = result
1120        .split("resets ")
1121        .nth(1)
1122        .map(str::trim)
1123        .filter(|s| !s.is_empty())
1124        .map(str::to_owned);
1125    Some(Quota { reset })
1126}
1127
1128/// Recognise `agy` running out of quota, from either stream.
1129///
1130/// Observed shape while out of quota: stdout carries the JSON object
1131/// `{"status":"ERROR","response":"","error":"Individual quota reached. … Resets
1132/// in 1h2m49s."}` and stderr carries `AGY_ERROR: {"status":"RESOURCE_EXHAUSTED",
1133/// "error_code":429,…}`. Either alone is enough (a mangled stdout must not hide
1134/// a quota that stderr states plainly), but each is keyed on a *pair* of
1135/// structured fields: `status: ERROR` with the quota text, or
1136/// `RESOURCE_EXHAUSTED` together with 429. An error status alone, or a 429
1137/// alone, stays an ordinary failure - the conservative side, as for
1138/// [`claude_quota`].
1139///
1140/// The reset hint is what follows `Resets ` (e.g. `in 1h2m49s`), trailing full
1141/// stop dropped.
1142fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1143    let from_stdout = stdout
1144        .lines()
1145        .rev()
1146        .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1147        .and_then(|v| {
1148            let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1149            let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1150            (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1151                .then(|| error.to_owned())
1152        });
1153    let from_stderr = || {
1154        stderr.lines().find_map(|l| {
1155            let v: serde_json::Value =
1156                serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1157            let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1158            let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1159            (exhausted && code).then(|| {
1160                v.get("short_error")
1161                    .and_then(|e| e.as_str())
1162                    .unwrap_or_default()
1163                    .to_owned()
1164            })
1165        })
1166    };
1167    let text = from_stdout.or_else(from_stderr)?;
1168    let reset = text
1169        .split_once("Resets ")
1170        .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1171        .filter(|s| !s.is_empty())
1172        .map(str::to_owned);
1173    Some(Quota { reset })
1174}
1175
1176/// Recognise a CLI that gave up on its own stream while the agent was working.
1177///
1178/// Observed once, verbatim, from `agy` on a candidate that produced nothing:
1179///
1180/// ```text
1181/// {"conversation_id":"36743d06-…","status":"ERROR","response":"",
1182///  "error":"the connection to the agent was interrupted before the response
1183///           finished: subscriber fell behind updates, stalled for 5s",
1184///  "duration_seconds":431.19,"num_turns":1,
1185///  "usage":{"input_tokens":260113,"output_tokens":14267,
1186///           "thinking_tokens":9695,"cache_read_tokens":2200925}}
1187/// ```
1188///
1189/// Seven minutes of work and fourteen thousand output tokens, billed, with an
1190/// empty `response`: the agent did the job and the CLI's own subscriber fell
1191/// behind and hung up. That is **not** an agent that failed to implement, and
1192/// counting it as one is how `agy` came to read as 0 wins in 4 entries with
1193/// five empty candidates - a number that has twice been used to argue the seat
1194/// out of the roster, and twice been wrong (see `cb6b830`, which reverted the
1195/// first removal: *"agy does not fail to implement, it fails to report"*).
1196///
1197/// The distinction that matters is **billed work with nothing delivered**, so
1198/// that is what this keys on: an error status, an empty response, and a usage
1199/// report showing output tokens. Everything else - including an error with no
1200/// usage at all - returns `None` and stays an ordinary failure, the
1201/// conservative side, exactly as [`claude_quota`] treats shapes it does not
1202/// recognise.
1203///
1204/// Unlike a quota, this **is** worth re-asking: the work exists in the
1205/// conversation the CLI just abandoned, and `conversation_id` is right there.
1206fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1207    let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1208    if !status.eq_ignore_ascii_case("error") {
1209        return None;
1210    }
1211    let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1212    if !response.trim().is_empty() {
1213        // It answered. Whatever the status says, there is something to read.
1214        return None;
1215    }
1216    let produced = v
1217        .get("usage")
1218        .and_then(|u| u.get("output_tokens"))
1219        .and_then(serde_json::Value::as_u64)
1220        .unwrap_or(0);
1221    if produced == 0 {
1222        // An error with nothing produced is just an error.
1223        return None;
1224    }
1225    Some(Dropped {
1226        why: v
1227            .get("error")
1228            .and_then(|e| e.as_str())
1229            .unwrap_or("the CLI ended the stream without delivering its answer")
1230            .trim()
1231            .to_owned(),
1232        output_tokens: produced,
1233    })
1234}
1235
1236/// Preflight: which configured agents are not runnable here?
1237pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1238    let mut missing = Vec::new();
1239    for s in specs {
1240        let program = match s.kind {
1241            AgentKind::Command => s.command.first().map(String::as_str),
1242            other => other.program(),
1243        };
1244        if let Some(p) = program
1245            && !crate::config::which(p)
1246            && !Path::new(p).is_file()
1247            && !missing.iter().any(|m: &String| m == p)
1248        {
1249            missing.push(p.to_owned());
1250        }
1251    }
1252    missing
1253}
1254
1255/// Absolute path of a run's artifact directory.
1256pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1257    run_dir.join("artifacts")
1258}
1259
1260/// Can this agent's CLI actually be run on this machine?
1261pub fn installed(spec: &AgentSpec) -> bool {
1262    // A `command` agent has no program of its own to look for - its argv is the
1263    // operator's, and they are the authority on whether it runs.
1264    spec.kind.program().is_none_or(crate::config::which)
1265}
1266
1267/// Choose the agent for a seat that stands alone rather than rotating through
1268/// the roster: [`crate::talk`]'s standing conversation, [`crate::bump`]'s
1269/// release-bump decision, or anything else that needs one agent picked once
1270/// rather than a panel filled in.
1271///
1272/// `available` is a parameter rather than a call to [`installed`] so the order
1273/// below is assertable on a machine with none of these CLIs installed, which is
1274/// every CI runner.
1275///
1276/// The order, and why:
1277///
1278/// 1. An explicit id always wins, and is an error rather than a fallback when
1279///    it is unusable. Naming a seat has a reason, and silently substituting a
1280///    different model would waste whatever that reason was.
1281/// 2. Otherwise a [`AgentKind::Claude`] seat, ahead of the roster order: it is
1282///    the only one of the three CLIs magi can address before the first turn
1283///    (see this module's own doc on session mechanics), which matters most for
1284///    a conversation that opens with nothing typed yet.
1285/// 3. Otherwise the first runnable agent in roster order, because the roster
1286///    order is the operator's own stated preference and magi has nothing
1287///    better to go on.
1288pub fn pick(
1289    agents: &[AgentSpec],
1290    want: Option<&str>,
1291    available: &dyn Fn(&AgentSpec) -> bool,
1292) -> Result<AgentSpec> {
1293    if let Some(id) = want {
1294        let spec = agents
1295            .iter()
1296            .find(|a| a.id == id)
1297            .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1298        if !available(spec) {
1299            bail!(
1300                "agent `{}` needs `{}` on PATH; install it or pass a different \
1301                 --agent",
1302                spec.id,
1303                spec.kind.program().unwrap_or("its command")
1304            );
1305        }
1306        return Ok(spec.clone());
1307    }
1308
1309    if agents.is_empty() {
1310        bail!(
1311            "the agent roster is empty, so there is nobody to ask: install one \
1312             of claude, opencode or agy - magi derives a roster from what is on \
1313             PATH - or add an [[agents]] entry to magi.toml."
1314        );
1315    }
1316
1317    if let Some(spec) = agents
1318        .iter()
1319        .find(|a| a.kind == AgentKind::Claude && available(a))
1320    {
1321        return Ok(spec.clone());
1322    }
1323
1324    agents
1325        .iter()
1326        .find(|a| available(a))
1327        .cloned()
1328        .with_context(|| {
1329            let missing = agents
1330                .iter()
1331                .filter_map(|a| a.kind.program())
1332                .collect::<Vec<_>>()
1333                .join(", ");
1334            format!(
1335                "no agent in the roster can be run here: install one of \
1336                 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1337                 you do have"
1338            )
1339        })
1340}
1341
1342fn ids(agents: &[AgentSpec]) -> String {
1343    if agents.is_empty() {
1344        return "no agents at all".to_owned();
1345    }
1346    agents
1347        .iter()
1348        .map(|a| a.id.clone())
1349        .collect::<Vec<_>>()
1350        .join(", ")
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355    use super::*;
1356
1357    const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1358
1359    /// Test-only command agent implemented by this test binary itself. Unlike
1360    /// `echo` and `sleep`, it is available wherever the Rust tests run.
1361    fn command_helper(mode: &str) -> AgentSpec {
1362        AgentSpec {
1363            id: "helper".to_owned(),
1364            kind: AgentKind::Command,
1365            model: None,
1366            command: vec![
1367                std::env::current_exe()
1368                    .expect("locate test helper")
1369                    .to_string_lossy()
1370                    .into_owned(),
1371                "--exact".to_owned(),
1372                "agent::tests::command_agent_test_helper".to_owned(),
1373                "--nocapture".to_owned(),
1374            ],
1375            extra_args: Vec::new(),
1376            env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1377            prompt_delivery: None,
1378        }
1379    }
1380
1381    #[test]
1382    fn command_agent_test_helper() {
1383        match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1384            Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1385            Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1386            Ok("no-cache") => println!(
1387                "{}",
1388                std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1389            ),
1390            Ok("ignore-stdin") => println!("done"),
1391            Ok("chatty-sleep") => {
1392                println!("i-said-something");
1393                std::thread::sleep(Duration::from_secs(30));
1394            }
1395            Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1396            Ok(other) => panic!("unknown command helper mode {other}"),
1397            Err(_) => {}
1398        }
1399    }
1400
1401    fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1402        AgentSpec {
1403            id: "a".to_owned(),
1404            kind,
1405            model: model.map(str::to_owned),
1406            command: vec!["echo".to_owned(), "{label}".to_owned()],
1407            extra_args: Vec::new(),
1408            env: BTreeMap::new(),
1409            prompt_delivery: None,
1410        }
1411    }
1412
1413    fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1414        Invocation {
1415            cwd,
1416            prompt: "do the thing",
1417            timeout: Duration::from_secs(900),
1418            allow_write,
1419            sessions: true,
1420            artifacts: art,
1421            stem: "t",
1422            run: "test-run",
1423            node: "test",
1424            cache_dir: None,
1425            attachments: &[],
1426        }
1427    }
1428
1429    fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1430        build_command(
1431            &spec(kind, None),
1432            seat,
1433            &inv(Path::new("."), Path::new("/art"), allow_write),
1434            Path::new("/art/p.md"),
1435        )
1436        .unwrap()
1437    }
1438
1439    #[test]
1440    fn claude_mints_then_resumes_the_same_uuid() {
1441        let mut seat = SeatState::new("judge-1", "a", 7);
1442        let uuid = seat.claude_session.clone().unwrap();
1443        let first = plan_for(AgentKind::Claude, &seat, true);
1444        assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1445        assert!(!first.argv.iter().any(|a| a == "--resume"));
1446
1447        seat.turns = 1;
1448        let second = plan_for(AgentKind::Claude, &seat, true);
1449        assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1450        assert!(!second.argv.iter().any(|a| a == "--session-id"));
1451    }
1452
1453    #[test]
1454    fn read_only_seats_cannot_edit() {
1455        let seat = SeatState::new("judge-1", "a", 7);
1456        let claude = plan_for(AgentKind::Claude, &seat, false);
1457        assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1458        assert!(
1459            !plan_for(AgentKind::Claude, &seat, true)
1460                .argv
1461                .iter()
1462                .any(|a| a == "--disallowed-tools")
1463        );
1464
1465        let agy = plan_for(AgentKind::Antigravity, &seat, false);
1466        assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1467        assert!(
1468            !agy.argv
1469                .iter()
1470                .any(|a| a == "--dangerously-skip-permissions")
1471        );
1472        let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1473        assert!(
1474            agy_rw
1475                .argv
1476                .windows(2)
1477                .any(|w| w == ["--mode", "accept-edits"])
1478        );
1479        assert!(
1480            agy_rw
1481                .argv
1482                .iter()
1483                .any(|a| a == "--dangerously-skip-permissions")
1484        );
1485        // agy is pointed at its prompt with its own `@<path>` syntax, not with
1486        // prose asking it to read a file. Measured on one trivial task: 17s
1487        // against 73s, because prose costs a tool round-trip before the model
1488        // has even seen its instructions. It is also the form rvpm proved.
1489        let agy_prompt = agy_rw
1490            .argv
1491            .iter()
1492            .position(|a| a == "-p")
1493            .map(|i| agy_rw.argv[i + 1].clone())
1494            .expect("agy takes its prompt with -p");
1495        assert!(
1496            agy_prompt.starts_with('@'),
1497            "agy must get a file reference, got {agy_prompt:?}"
1498        );
1499        assert!(
1500            !agy_prompt.contains("Read the file at"),
1501            "the prose pointer is for CLIs with no file syntax"
1502        );
1503
1504        // opencode is the exception: `--auto` also gates reads, so withholding
1505        // it silently drops the seat out of the panel. Verified against the CLI
1506        // — a read-only judge failed with "the user rejected permission to use
1507        // this specific tool call" while trying to open its own prompt.
1508        for allow_write in [false, true] {
1509            assert!(
1510                plan_for(AgentKind::Opencode, &seat, allow_write)
1511                    .argv
1512                    .iter()
1513                    .any(|a| a == "--auto"),
1514                "opencode needs --auto even to read (allow_write = {allow_write})"
1515            );
1516        }
1517    }
1518
1519    /// The three things about `codex exec` that were established by hand and
1520    /// that a rewrite would silently get wrong.
1521    #[test]
1522    fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1523        let mut seat = SeatState::new("judge-1", "a", 7);
1524
1525        // 1. Read-only is enforced by the CLI, not by the prompt - the only
1526        //    roster member for which that is true - and nothing ever asks for
1527        //    the bypass.
1528        let ro = plan_for(AgentKind::Codex, &seat, false);
1529        assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1530        let rw = plan_for(AgentKind::Codex, &seat, true);
1531        assert!(
1532            rw.argv
1533                .windows(2)
1534                .any(|w| w == ["--sandbox", "workspace-write"])
1535        );
1536        for p in [&ro, &rw] {
1537            assert!(
1538                !p.argv
1539                    .iter()
1540                    .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1541                "the bypass defeats the only enforced read-only mode we have"
1542            );
1543            // Nobody is watching to approve anything.
1544            assert!(
1545                p.argv
1546                    .windows(2)
1547                    .any(|w| w == ["-c", "approval_policy=\"never\""]),
1548                "an unattended seat that asks for approval blocks until timeout"
1549            );
1550        }
1551
1552        // 2. The prompt arrives on stdin, and `-` is what makes codex read it.
1553        assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1554        assert_eq!(
1555            ro.argv.last().map(String::as_str),
1556            Some("-"),
1557            "without the `-` argument codex waits for a prompt it never gets"
1558        );
1559
1560        // 3. `resume` is a subcommand and rejects the options above when they
1561        //    follow it, so it has to be emitted after all of them - and only
1562        //    once the CLI has reported a thread id.
1563        seat.turns = 1;
1564        assert!(!has_session(AgentKind::Codex, &seat, true));
1565        assert!(
1566            !plan_for(AgentKind::Codex, &seat, true)
1567                .argv
1568                .iter()
1569                .any(|a| a == "resume")
1570        );
1571        seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1572        let resumed = plan_for(AgentKind::Codex, &seat, true);
1573        let at = resumed
1574            .argv
1575            .iter()
1576            .position(|a| a == "resume")
1577            .expect("resumes by subcommand");
1578        assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1579        assert!(
1580            resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1581            "every option precedes the subcommand"
1582        );
1583        assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1584    }
1585
1586    /// The three things about `omp -p --mode=json` that were established by
1587    /// hand against omp 18.1.19 and that a rewrite would silently get wrong.
1588    #[test]
1589    fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
1590        let mut seat = SeatState::new("review-1", "a", 7);
1591
1592        // 1. Print mode plus JSON, and the prompt on stdin: a judging prompt
1593        //    carrying three patches is past the Windows argv cap, so argv
1594        //    delivery is not an option for every node.
1595        let first = plan_for(AgentKind::Omp, &seat, false);
1596        assert!(first.argv.iter().any(|a| a == "-p"));
1597        assert!(first.argv.iter().any(|a| a == "--mode=json"));
1598        assert_eq!(first.stdin.as_deref(), Some("do the thing"));
1599        assert!(
1600            !first.argv.iter().any(|a| a == "do the thing"),
1601            "the prompt reached argv, where Windows caps it"
1602        );
1603
1604        // 2. `--auto-approve` is required (an unattended seat that stops to ask
1605        //    blocks until its node timeout kills it), and it is the *only*
1606        //    permission flag: omp has no read-only mode, so the bypass flag
1607        //    that would throw away codex's one enforced guarantee must never
1608        //    appear here either.
1609        for allow_write in [false, true] {
1610            let p = plan_for(AgentKind::Omp, &seat, allow_write);
1611            assert!(
1612                p.argv.iter().any(|a| a == "--auto-approve"),
1613                "omp needs --auto-approve even to read (allow_write = {allow_write})"
1614            );
1615            assert!(
1616                !p.argv
1617                    .iter()
1618                    .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1619                "nothing ever asks for the bypass"
1620            );
1621        }
1622
1623        // 3. The id omp reports is the only resume token - magi cannot mint it
1624        //    up front, so a seat resumes only once a turn has reported one.
1625        seat.turns = 1;
1626        assert!(!has_session(AgentKind::Omp, &seat, true));
1627        assert!(
1628            !plan_for(AgentKind::Omp, &seat, true)
1629                .argv
1630                .iter()
1631                .any(|a| a == "--resume")
1632        );
1633        seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
1634        let resumed = plan_for(AgentKind::Omp, &seat, true);
1635        assert!(
1636            resumed
1637                .argv
1638                .windows(2)
1639                .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
1640            "a captured id is what makes the next turn a resume"
1641        );
1642        // `--continue` opens a *new* session instead of the stored one, which
1643        // would silently drop the seat's memory.
1644        assert!(!resumed.argv.iter().any(|a| a == "--continue"));
1645        // stdin still carries the prompt on a resumed turn.
1646        assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
1647    }
1648
1649    /// The extraction trap that cost three complete reviews when it was done by
1650    /// hand: a turn that ends on a tool call emits **no** `agent_end` line, so
1651    /// keying on `agent_end` finds nothing and the seat reads as one that
1652    /// produced no answer at all.
1653    #[test]
1654    fn omp_takes_the_answer_without_an_agent_end_line() {
1655        let stream = concat!(
1656            r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
1657            "\n",
1658            r#"{"type":"agent_start"}"#,
1659            "\n",
1660            r#"{"type":"turn_start"}"#,
1661            "\n",
1662            r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
1663            "\n",
1664            r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
1665            "\n",
1666            r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
1667            "\n",
1668        );
1669        let out = extract(AgentKind::Omp, stream);
1670        assert_eq!(
1671            out.text, "{\"vote\":\"approve\"}",
1672            "the last assistant text block is the answer even with no agent_end"
1673        );
1674        assert_eq!(
1675            out.session.as_deref(),
1676            Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
1677        );
1678    }
1679
1680    /// A stream that *does* carry `agent_end` walks the whole thread, and the
1681    /// last non-empty assistant text still wins over the tool-loop narration
1682    /// that came before it.
1683    #[test]
1684    fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
1685        let stream = concat!(
1686            r#"{"type":"session","version":3,"id":"s1"}"#,
1687            "\n",
1688            "{\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"review this\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Looking at the diff…\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"…\"},{\"type\":\"text\",\"text\":\"## 判定\\n\\n問題ありません。\"}]}]}",
1689            "\n",
1690        );
1691        let out = extract(AgentKind::Omp, stream);
1692        assert_eq!(
1693            out.text, "## 判定\n\n問題ありません。",
1694            "the narration is not the answer, and non-ASCII survives intact"
1695        );
1696        assert_eq!(out.session.as_deref(), Some("s1"));
1697    }
1698
1699    /// A line that is not JSON - a CLI warning, a half-written line - is
1700    /// skipped rather than becoming the answer.
1701    #[test]
1702    fn omp_skips_non_json_lines() {
1703        let stream = concat!(
1704            "Warning: some omp notice\n",
1705            r#"{"type":"session","version":3,"id":"s2"}"#,
1706            "\n",
1707            r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
1708            "\n",
1709            "trailing junk",
1710            "\n",
1711        );
1712        let out = extract(AgentKind::Omp, stream);
1713        assert_eq!(out.text, "the answer");
1714        assert_eq!(out.session.as_deref(), Some("s2"));
1715    }
1716
1717    /// A real `codex exec --json` stream, tracing prefix included.
1718    #[test]
1719    fn codex_takes_the_last_agent_message_and_the_thread_id() {
1720        let stream = concat!(
1721            "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1722            r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1723            "\n",
1724            r#"{"type":"turn.started"}"#,
1725            "\n",
1726            r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1727            "\n",
1728            r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1729            "\n",
1730            r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1731            "\n",
1732            r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1733            "\n",
1734        );
1735        let out = extract(AgentKind::Codex, stream);
1736        assert_eq!(
1737            out.text, "{\"verdict\": \"ok\"}",
1738            "the last agent message is the answer; earlier ones narrate"
1739        );
1740        assert_eq!(
1741            out.session.as_deref(),
1742            Some("01a07440-4545-7492-85c1-024e3259a90a")
1743        );
1744        assert_eq!(out.status.as_deref(), Some("success"));
1745
1746        let failed = concat!(
1747            r#"{"type":"thread.started","thread_id":"t1"}"#,
1748            "\n",
1749            r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1750            "\n",
1751        );
1752        assert_eq!(
1753            extract(AgentKind::Codex, failed).status.as_deref(),
1754            Some("error")
1755        );
1756    }
1757
1758    #[test]
1759    fn captured_sessions_resume_only_once_reported() {
1760        let mut seat = SeatState::new("impl-A", "a", 7);
1761        seat.turns = 1;
1762        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1763            assert!(!has_session(kind, &seat, true));
1764            let p = plan_for(kind, &seat, true);
1765            assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1766        }
1767
1768        seat.captured_session = Some("sid".to_owned());
1769        assert!(has_session(AgentKind::Opencode, &seat, true));
1770        assert!(
1771            plan_for(AgentKind::Opencode, &seat, true)
1772                .argv
1773                .windows(2)
1774                .any(|w| w == ["-s", "sid"])
1775        );
1776        assert!(
1777            plan_for(AgentKind::Antigravity, &seat, true)
1778                .argv
1779                .windows(2)
1780                .any(|w| w == ["--conversation", "sid"])
1781        );
1782    }
1783
1784    #[test]
1785    fn sessions_disabled_never_resumes() {
1786        let mut seat = SeatState::new("impl-A", "a", 7);
1787        seat.turns = 3;
1788        seat.captured_session = Some("sid".to_owned());
1789        for kind in [
1790            AgentKind::Claude,
1791            AgentKind::Opencode,
1792            AgentKind::Antigravity,
1793        ] {
1794            assert!(!has_session(kind, &seat, false));
1795        }
1796    }
1797
1798    #[test]
1799    fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1800        let seat = SeatState::new("judge-1", "a", 7);
1801        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1802            let p = plan_for(kind, &seat, false);
1803            assert!(
1804                p.argv.iter().all(|a| a != "do the thing"),
1805                "{kind:?} put the prompt on the command line"
1806            );
1807            assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1808        }
1809        // agy has no text stdin, so its `-p` must always carry something.
1810        let p = plan_for(AgentKind::Antigravity, &seat, false);
1811        let at = p.argv.iter().position(|a| a == "-p").unwrap();
1812        assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1813        assert!(p.stdin.is_none());
1814    }
1815
1816    #[test]
1817    fn agy_print_timeout_tracks_the_node_budget() {
1818        let seat = SeatState::new("impl-A", "a", 7);
1819        let p = build_command(
1820            &spec(AgentKind::Antigravity, None),
1821            &seat,
1822            &Invocation {
1823                cwd: Path::new("."),
1824                prompt: "p",
1825                timeout: Duration::from_secs(3600),
1826                allow_write: true,
1827                sessions: true,
1828                artifacts: Path::new("/art"),
1829                stem: "t",
1830                run: "test-run",
1831                node: "test",
1832                cache_dir: None,
1833                attachments: &[],
1834            },
1835            Path::new("/art/p.md"),
1836        )
1837        .unwrap();
1838        assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1839    }
1840
1841    /// `--add-dir` is what lets antigravity open a file outside the
1842    /// worktree at all. Today that only happens when the delivery mode is
1843    /// already `File`, but an attachment can arrive on a seat whose delivery
1844    /// is `Stdin` or `Argv` (an explicit `prompt_delivery` override), and the
1845    /// image still lives outside `cwd` - so the flag has to widen for that
1846    /// reason too, independent of how the prompt itself is delivered.
1847    #[test]
1848    fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
1849        let mut s = spec(AgentKind::Antigravity, None);
1850        s.prompt_delivery = Some(Delivery::Argv);
1851        let seat = SeatState::new("talk", "a", 7);
1852        let atts = [PathBuf::from("/art/attachments/abc.png")];
1853
1854        let without = build_command(
1855            &s,
1856            &seat,
1857            &Invocation {
1858                attachments: &[],
1859                ..inv(Path::new("."), Path::new("/art"), true)
1860            },
1861            Path::new("/art/p.md"),
1862        )
1863        .unwrap();
1864        assert!(
1865            !without.argv.iter().any(|a| a == "--add-dir"),
1866            "no attachment, no reason to widen the sandbox: {without:?}"
1867        );
1868
1869        let with = build_command(
1870            &s,
1871            &seat,
1872            &Invocation {
1873                attachments: &atts,
1874                ..inv(Path::new("."), Path::new("/art"), true)
1875            },
1876            Path::new("/art/p.md"),
1877        )
1878        .unwrap();
1879        assert!(
1880            with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1881            "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
1882        );
1883    }
1884
1885    /// A chat derived from another one (`chat::derived_background`) can pass
1886    /// `turn` attachment paths that live under the *source* conversation's
1887    /// own artifacts dir, not this invocation's `artifacts`. A single
1888    /// `--add-dir` for `inv.artifacts` alone would leave those unreadable, so
1889    /// each attachment directory outside it must get its own grant.
1890    #[test]
1891    fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
1892        let seat = SeatState::new("plan", "a", 7);
1893        let atts = [
1894            PathBuf::from("/art/attachments/own.png"),
1895            PathBuf::from("/other-chat/attachments/inherited.png"),
1896        ];
1897
1898        let p = build_command(
1899            &spec(AgentKind::Antigravity, None),
1900            &seat,
1901            &Invocation {
1902                attachments: &atts,
1903                ..inv(Path::new("."), Path::new("/art"), true)
1904            },
1905            Path::new("/art/p.md"),
1906        )
1907        .unwrap();
1908
1909        assert!(
1910            p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1911            "this conversation's own artifacts dir must still be granted: {p:?}"
1912        );
1913        assert!(
1914            p.argv
1915                .windows(2)
1916                .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
1917            "the inherited attachment's own directory must be granted too: {p:?}"
1918        );
1919    }
1920
1921    #[test]
1922    fn command_agents_get_placeholders_substituted() {
1923        let seat = SeatState::new("impl-A", "a", 7);
1924        let p = plan_for(AgentKind::Command, &seat, true);
1925        assert_eq!(p.argv[0], "echo");
1926        assert_eq!(p.argv[1], "impl-A");
1927        assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1928    }
1929
1930    #[test]
1931    fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1932        // The exact shape observed in the wild (run 20260831-031005-ae94).
1933        let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1934                        "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1935                        "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1936        let out = extract(AgentKind::Claude, stdout);
1937        let quota = out.quota.as_ref().expect("rate limit must be detected");
1938        assert_eq!(
1939            quota.reset.as_deref(),
1940            Some("4:50am (Asia/Tokyo)"),
1941            "reset time read from the body"
1942        );
1943    }
1944
1945    #[test]
1946    fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1947        let out = extract(
1948            AgentKind::Claude,
1949            r#"{"is_error":true,"result":"session limit reached"}"#,
1950        );
1951        let quota = out.quota.expect("rate limit detected without a reset");
1952        assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1953    }
1954
1955    #[test]
1956    fn ordinary_failures_are_never_quota() {
1957        // A normal failed claude call (is_error with a different message).
1958        let claude_fail = extract(
1959            AgentKind::Claude,
1960            r#"{"is_error":true,"result":"account does not exist"}"#,
1961        );
1962        assert!(claude_fail.quota.is_none());
1963
1964        // A command agent that exits 1 with plain text.
1965        let cmd_fail = extract(AgentKind::Command, "boom");
1966        assert!(cmd_fail.quota.is_none());
1967
1968        // A successful call is not quota even if it mentions the phrase.
1969        let success = extract(
1970            AgentKind::Command,
1971            r#"{"is_error":false,"result":"session limit is fine"}"#,
1972        );
1973        assert!(success.quota.is_none());
1974    }
1975
1976    /// Minimal, anonymised shape of run 20260912-214939-b3bb's
1977    /// artifacts/impl-A.out: `command_execution` items reporting a paired
1978    /// test run as `1 passed, 1 failed` twice, while the final
1979    /// `agent_message` nevertheless claimed the target passed. The point of
1980    /// `CommandEvidence` is that this claim and the CLI's own structured
1981    /// record of what actually ran are now two separate things a caller can
1982    /// compare, rather than the prose being the only account available.
1983    #[test]
1984    fn codex_command_execution_events_are_captured_alongside_the_final_message() {
1985        let stream = concat!(
1986            r#"{"type":"thread.started","thread_id":"t1"}"#,
1987            "\n",
1988            r#"{"type":"item.completed","item":{"id":"item49","type":"command_execution","command":["bash","-lc","cargo test --test graph_cached_gate"],"exit_code":1,"aggregated_output":"test result: 1 passed; 1 failed"}}"#,
1989            "\n",
1990            r#"{"type":"item.completed","item":{"id":"item52","type":"command_execution","command":["bash","-lc","cargo test --test graph_cached_gate a_single_test"],"exit_code":0,"aggregated_output":"test result: 1 passed; 0 failed"}}"#,
1991            "\n",
1992            r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
1993            "\n",
1994            r#"{"type":"turn.completed"}"#,
1995            "\n",
1996        );
1997        let out = extract(AgentKind::Codex, stream);
1998        assert_eq!(out.text, "Both tests in the target pass.");
1999        assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2000
2001        let paired = &out.commands[0];
2002        assert_eq!(paired.id, "item49");
2003        assert_eq!(paired.exit_code, Some(1));
2004        assert!(paired.description.contains("graph_cached_gate"));
2005        assert!(paired.result_summary.contains("1 failed"));
2006
2007        let solo = &out.commands[1];
2008        assert_eq!(solo.exit_code, Some(0));
2009
2010        // The structured evidence disagrees with the final prose - exactly
2011        // what a caller must be able to see instead of trusting the message
2012        // alone: the full target never passed in one command.
2013        assert!(
2014            out.commands
2015                .iter()
2016                .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2017            "a failed run of the actual target must still be visible: {:?}",
2018            out.commands
2019        );
2020    }
2021
2022    #[test]
2023    fn command_agent_can_carry_the_claude_quota_shape() {
2024        let out = extract(
2025            AgentKind::Command,
2026            r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2027        );
2028        assert!(
2029            out.quota.is_some(),
2030            "a wrapper emitting the claude shape counts as quota"
2031        );
2032    }
2033
2034    #[test]
2035    fn claude_json_result_is_extracted() {
2036        let out = extract(
2037            AgentKind::Claude,
2038            r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2039        );
2040        assert_eq!(out.text, "all done");
2041        assert_eq!(out.session.as_deref(), Some("abc"));
2042        assert_eq!(out.status.as_deref(), Some("success"));
2043    }
2044
2045    /// The shape behind fix-2 in run 20260912-114326-d3b8, minimal and
2046    /// anonymised: a Claude CLI turn that ended `subtype: success`,
2047    /// `is_error: false`, `terminal_reason: completed`, `stop_reason:
2048    /// end_turn` — every signal this crate reads as a clean CLI turn — while
2049    /// `result` is a progress update, not the report the fixer node needed,
2050    /// and no `FixReport` JSON is anywhere in it.
2051    ///
2052    /// `AgentOutput::usable()` (a CLI fact: exit 0, not timed out, non-empty
2053    /// text) must stay true here — that is the honest reading of what the
2054    /// CLI reported — while `verdict::extract_json` on the same text must
2055    /// fail. Conflating the two is exactly the bug this fixture reproduces:
2056    /// `run.json` recorded `fix.failed = "unparsable fix report: the reply
2057    /// contained no JSON object"` and moved straight to the next review round
2058    /// with no report ever recovered from that seat.
2059    #[test]
2060    fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2061        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","stop_reason":"end_turn","result":"I'll pause here until the `cargo make check` background run reports back.","session_id":"11111111-1111-1111-1111-111111111111"}"#;
2062        let out = extract(AgentKind::Claude, stdout);
2063        assert_eq!(out.status.as_deref(), Some("success"));
2064        assert!(out.quota.is_none());
2065        assert!(!out.text.trim().is_empty());
2066
2067        let agent_out = AgentOutput {
2068            text: out.text.clone(),
2069            exit_code: Some(0),
2070            timed_out: false,
2071            duration_ms: 500,
2072            artifacts: Vec::new(),
2073            quota: out.quota,
2074            dropped: out.dropped,
2075            commands: out.commands,
2076        };
2077        assert!(
2078            agent_out.usable(),
2079            "the CLI turn itself ended cleanly and must read as usable"
2080        );
2081        assert!(
2082            crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2083            "a clean CLI turn is not proof the node's own report ever arrived"
2084        );
2085    }
2086
2087    #[test]
2088    fn opencode_event_stream_is_concatenated() {
2089        let stream = concat!(
2090            r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2091            "\n",
2092            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2093            "\n",
2094            "garbage line\n",
2095            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2096            "\n"
2097        );
2098        let out = extract(AgentKind::Opencode, stream);
2099        assert_eq!(out.text, "first\nsecond");
2100        assert_eq!(out.session.as_deref(), Some("ses_1"));
2101    }
2102
2103    #[test]
2104    fn agy_json_survives_a_leading_warning_line() {
2105        let stdout = concat!(
2106            "warning: --mode plan has no effect while slash commands are disabled.\n",
2107            r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2108            "\n"
2109        );
2110        let out = extract(AgentKind::Antigravity, stdout);
2111        assert_eq!(out.text, "persimmon");
2112        assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2113        assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2114    }
2115
2116    /// Run 26c7's candidate B, verbatim from `artifacts/impl-B.out`.
2117    ///
2118    /// The seat read as an empty candidate. It was seven minutes of work and
2119    /// 14,267 output tokens, billed, that the CLI then declined to hand over.
2120    /// Five such candidates are why `agy` reads as 0 wins in 4 entries, and
2121    /// that number has twice been used to argue the seat out of the roster.
2122    const AGY_DROPPED: &str = concat!(
2123        r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2124        r#""response":"","error":"the connection to the agent was interrupted before "#,
2125        r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2126        r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2127        r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2128        r#""total_tokens":274380}}"#
2129    );
2130
2131    /// Run 1798's review seat, from `review-1-1.out` / `.err`.
2132    const AGY_QUOTA_OUT: &str = concat!(
2133        r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2134        r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2135        r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2136        r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2137    );
2138    const AGY_QUOTA_ERR: &str = concat!(
2139        "error: Individual quota reached. Resets in 1h2m49s.\n",
2140        r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
2141        r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
2142        r#""retryable":true}"#,
2143        "\n"
2144    );
2145
2146    #[test]
2147    fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
2148        let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
2149        assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
2150        let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
2151        assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
2152        // stderr alone: the hint is not in `short_error`, so none is carried.
2153        let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
2154        assert!(stderr_only.reset.is_none());
2155    }
2156
2157    #[test]
2158    fn ordinary_agy_failures_are_not_a_quota() {
2159        assert!(agy_quota(AGY_DROPPED, "").is_none());
2160        assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
2161        assert!(
2162            agy_quota(
2163                "",
2164                r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
2165            )
2166            .is_none()
2167        );
2168        assert!(
2169            agy_quota(
2170                "",
2171                r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
2172            )
2173            .is_none()
2174        );
2175        assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
2176    }
2177
2178    #[test]
2179    fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2180        let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2181        let dropped = out.dropped.expect("recognised as undelivered work");
2182        assert_eq!(dropped.output_tokens, 14267);
2183        assert!(
2184            dropped.why.contains("subscriber fell behind"),
2185            "the CLI's own words are kept for the record: {}",
2186            dropped.why
2187        );
2188        // And the conversation is still there to resume, which is the whole
2189        // reason this is worth re-asking where a quota is not.
2190        assert_eq!(
2191            out.session.as_deref(),
2192            Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
2193        );
2194        assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
2195    }
2196
2197    #[test]
2198    fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
2199        // No usage at all: the agent never got going, so there is nothing in
2200        // the conversation to resume and nothing was billed. Treating this as
2201        // undelivered work would buy a second call for no reason.
2202        let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
2203        assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
2204
2205        // Produced tokens, but it did answer - so there is something to read
2206        // and the status is not our business.
2207        let answered = concat!(
2208            r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
2209            r#""usage":{"output_tokens":10}}"#
2210        );
2211        assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
2212
2213        // A success is a success.
2214        let ok = concat!(
2215            r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
2216            r#""usage":{"output_tokens":10}}"#
2217        );
2218        assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
2219    }
2220
2221    #[test]
2222    fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
2223        let out = AgentOutput {
2224            text: String::new(),
2225            exit_code: Some(1),
2226            timed_out: false,
2227            duration_ms: 431_194,
2228            artifacts: Vec::new(),
2229            quota: None,
2230            dropped: Some(Dropped {
2231                why: "subscriber fell behind updates".to_owned(),
2232                output_tokens: 14267,
2233            }),
2234            commands: Vec::new(),
2235        };
2236        assert!(!out.usable());
2237        assert!(out.work_undelivered());
2238        // The distinction the retry policy rests on: a quota fails the same way
2239        // until it resets, an abandoned conversation can be picked up.
2240        assert!(!out.quota_exhausted());
2241    }
2242
2243    #[test]
2244    fn non_json_stdout_falls_back_to_raw_text() {
2245        let out = extract(AgentKind::Antigravity, "plain answer\n");
2246        assert_eq!(out.text, "plain answer");
2247        assert!(out.session.is_none());
2248    }
2249
2250    #[tokio::test]
2251    async fn command_agent_round_trip_writes_artifacts() {
2252        let dir = tempfile::tempdir().unwrap();
2253        let art = dir.path().join("artifacts");
2254        let mut seat = SeatState::new("impl-A", "a", 7);
2255        let s = command_helper("reply");
2256        let out = invoke(
2257            &s,
2258            &mut seat,
2259            &Invocation {
2260                cwd: dir.path(),
2261                prompt: "unused",
2262                timeout: Duration::from_secs(30),
2263                allow_write: true,
2264                sessions: true,
2265                artifacts: &art,
2266                stem: "impl-A",
2267                run: "test-run",
2268                node: "test",
2269                cache_dir: None,
2270                attachments: &[],
2271            },
2272        )
2273        .await
2274        .unwrap();
2275        assert!(out.usable(), "{out:?}");
2276        assert!(out.text.contains("hello impl-A"), "{}", out.text);
2277        assert_eq!(seat.turns, 1);
2278        assert!(art.join("impl-A.prompt.md").is_file());
2279        assert!(art.join("impl-A.out").is_file());
2280    }
2281
2282    #[tokio::test]
2283    async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
2284        // The whole point of threading the cache path through `Invocation`:
2285        // the compile the agent pays for lands in the directory `verify` reads
2286        // back out of its rendered commands, so one cache has one prune.
2287        let dir = tempfile::tempdir().unwrap();
2288        let cache = dir.path().join("magi-cache");
2289        let mut seat = SeatState::new("impl-A", "a", 7);
2290        let s = command_helper("cache");
2291        let out = invoke(
2292            &s,
2293            &mut seat,
2294            &Invocation {
2295                cwd: dir.path(),
2296                prompt: "unused",
2297                timeout: Duration::from_secs(30),
2298                allow_write: true,
2299                sessions: true,
2300                artifacts: &dir.path().join("artifacts"),
2301                stem: "cache",
2302                run: "test-run",
2303                node: "test",
2304                cache_dir: Some(&cache),
2305                attachments: &[],
2306            },
2307        )
2308        .await
2309        .unwrap();
2310        assert!(out.usable(), "{out:?}");
2311        assert!(
2312            out.text.contains(cache.to_string_lossy().as_ref()),
2313            "the seat must see CARGO_TARGET_DIR = the shared cache"
2314        );
2315    }
2316
2317    #[tokio::test]
2318    async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
2319        // `Command` inherits the parent's environment by default, so
2320        // `cache_dir: None` alone is not the same as a seat never seeing
2321        // `CARGO_TARGET_DIR` - it also has to be true when *this* process
2322        // (standing in for the real magi process, which normally does have
2323        // one set, from its own `[verify]` config) already has the variable
2324        // set. Simulating that is the only way to exercise the inheritance
2325        // path at all.
2326        let previous = std::env::var("CARGO_TARGET_DIR").ok();
2327        // SAFETY: this crate's tests run single-threaded
2328        // (`RUST_TEST_THREADS=1`); see
2329        // `updater::tests::env_kill_switch_semantics` for the same
2330        // reasoning applied to another process-global env var.
2331        unsafe {
2332            std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
2333        }
2334        let dir = tempfile::tempdir().unwrap();
2335        let mut seat = SeatState::new("review-1", "a", 7);
2336        let s = command_helper("no-cache");
2337        let result = invoke(
2338            &s,
2339            &mut seat,
2340            &Invocation {
2341                cwd: dir.path(),
2342                prompt: "unused",
2343                timeout: Duration::from_secs(30),
2344                allow_write: false,
2345                sessions: true,
2346                artifacts: &dir.path().join("artifacts"),
2347                stem: "no-cache",
2348                run: "test-run",
2349                node: "test",
2350                cache_dir: None,
2351                attachments: &[],
2352            },
2353        )
2354        .await;
2355        // Restored before any assertion that could panic, so a failure here
2356        // never leaks a bogus `CARGO_TARGET_DIR` into whichever test runs
2357        // next in this same process.
2358        // SAFETY: see above.
2359        unsafe {
2360            match &previous {
2361                Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
2362                None => std::env::remove_var("CARGO_TARGET_DIR"),
2363            }
2364        }
2365        let out = result.unwrap();
2366        assert!(out.usable(), "{out:?}");
2367        assert!(
2368            out.text.contains("ABSENT"),
2369            "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
2370            out.text
2371        );
2372    }
2373
2374    #[tokio::test]
2375    async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
2376        let dir = tempfile::tempdir().unwrap();
2377        let mut seat = SeatState::new("impl-A", "a", 7);
2378        // The helper never reads stdin, so an inline write_all would block once
2379        // the OS pipe buffer filled — long before the process could be waited on.
2380        let s = command_helper("ignore-stdin");
2381        let big = "x".repeat(1_000_000);
2382        let out = invoke(
2383            &s,
2384            &mut seat,
2385            &Invocation {
2386                cwd: dir.path(),
2387                prompt: &big,
2388                timeout: Duration::from_secs(60),
2389                allow_write: true,
2390                sessions: true,
2391                artifacts: &dir.path().join("artifacts"),
2392                stem: "big",
2393                run: "test-run",
2394                node: "test",
2395                cache_dir: None,
2396                attachments: &[],
2397            },
2398        )
2399        .await
2400        .unwrap();
2401        assert!(out.usable(), "{out:?}");
2402        assert!(out.text.contains("done"), "{}", out.text);
2403    }
2404
2405    #[tokio::test]
2406    async fn timeout_is_reported_not_hung() {
2407        let dir = tempfile::tempdir().unwrap();
2408        let mut seat = SeatState::new("impl-A", "a", 7);
2409        let s = command_helper("sleep");
2410        let out = invoke(
2411            &s,
2412            &mut seat,
2413            &Invocation {
2414                cwd: dir.path(),
2415                prompt: "unused",
2416                timeout: Duration::from_millis(300),
2417                allow_write: true,
2418                sessions: true,
2419                artifacts: &dir.path().join("artifacts"),
2420                stem: "slow",
2421                run: "test-run",
2422                node: "test",
2423                cache_dir: None,
2424                attachments: &[],
2425            },
2426        )
2427        .await
2428        .unwrap();
2429        assert!(out.timed_out);
2430        assert!(!out.usable());
2431    }
2432
2433    #[tokio::test]
2434    async fn a_timeout_keeps_what_the_agent_had_already_printed() {
2435        // The old implementation cancelled `wait_with_output`, which dropped
2436        // the buffers it owned, so `<stem>.out` was written empty on every
2437        // timeout. "It printed nothing" and "we discarded what it printed"
2438        // looked identical on disk — and one real hour-long stall was
2439        // diagnosed wrongly twice because of it.
2440        let dir = tempfile::tempdir().unwrap();
2441        let artifacts = dir.path().join("artifacts");
2442        let mut seat = SeatState::new("impl-A", "a", 7);
2443        let s = command_helper("chatty-sleep");
2444        let out = invoke(
2445            &s,
2446            &mut seat,
2447            &Invocation {
2448                cwd: dir.path(),
2449                prompt: "unused",
2450                // Wide enough to cover process-spawn latency inside a loaded
2451                // parallel test run, not merely the helper's first write. At
2452                // two seconds this passed alone and failed in the full suite,
2453                // which is a dice roll rather than a test.
2454                timeout: Duration::from_secs(10),
2455                allow_write: true,
2456                sessions: true,
2457                artifacts: &artifacts,
2458                stem: "chatty",
2459                run: "test-run",
2460                node: "test",
2461                cache_dir: None,
2462                attachments: &[],
2463            },
2464        )
2465        .await
2466        .unwrap();
2467
2468        assert!(out.timed_out, "{out:?}");
2469        assert!(!out.usable(), "a cut-off answer is still not an answer");
2470        let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
2471        assert!(
2472            recorded.contains("i-said-something"),
2473            "the artifact must keep what arrived before the kill, got {recorded:?}"
2474        );
2475        assert!(
2476            out.text.contains("i-said-something"),
2477            "and the graph must be able to see it too, got {:?}",
2478            out.text
2479        );
2480    }
2481
2482    #[test]
2483    fn missing_programs_reports_command_binaries() {
2484        let mut s = spec(AgentKind::Command, None);
2485        s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
2486        assert_eq!(
2487            missing_programs(&[s]),
2488            ["definitely-not-a-real-binary-xyz".to_owned()]
2489        );
2490    }
2491
2492    fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
2493        AgentSpec {
2494            id: id.to_owned(),
2495            kind,
2496            model: None,
2497            command: Vec::new(),
2498            extra_args: Vec::new(),
2499            env: BTreeMap::new(),
2500            prompt_delivery: None,
2501        }
2502    }
2503
2504    /// Availability stub: an agent is runnable unless its id was listed as
2505    /// missing. Keeps the selection tests off `PATH` entirely.
2506    fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
2507        move |a: &AgentSpec| !missing.contains(&a.id.as_str())
2508    }
2509
2510    #[test]
2511    fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
2512        let agents = [
2513            pick_spec("oc", AgentKind::Opencode),
2514            pick_spec("opus", AgentKind::Claude),
2515            pick_spec("agy", AgentKind::Antigravity),
2516        ];
2517        let got = pick(&agents, None, &without(&[])).expect("a pick");
2518        assert_eq!(got.id, "opus");
2519    }
2520
2521    #[test]
2522    fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
2523        let agents = [
2524            pick_spec("opus", AgentKind::Claude),
2525            pick_spec("oc", AgentKind::Opencode),
2526            pick_spec("agy", AgentKind::Antigravity),
2527        ];
2528        let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
2529        assert_eq!(got.id, "agy");
2530    }
2531
2532    #[test]
2533    fn pick_on_an_empty_roster_says_what_to_install() {
2534        let msg = pick(&[], None, &without(&[]))
2535            .expect_err("nobody to ask")
2536            .to_string();
2537        assert!(msg.contains("roster is empty"), "{msg}");
2538        assert!(msg.contains("claude"), "{msg}");
2539        assert!(msg.contains("magi.toml"), "{msg}");
2540    }
2541
2542    #[test]
2543    fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
2544        let agents = [
2545            pick_spec("opus", AgentKind::Claude),
2546            pick_spec("oc", AgentKind::Opencode),
2547        ];
2548        let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
2549        let msg = format!("{err:#}");
2550        assert!(msg.contains("claude"), "{msg}");
2551        assert!(msg.contains("opencode"), "{msg}");
2552    }
2553
2554    #[test]
2555    fn an_explicitly_named_agent_wins_over_the_claude_preference() {
2556        let agents = [
2557            pick_spec("opus", AgentKind::Claude),
2558            pick_spec("oc", AgentKind::Opencode),
2559        ];
2560        let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
2561        assert_eq!(got.id, "oc");
2562    }
2563
2564    #[test]
2565    fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
2566        let agents = [
2567            pick_spec("opus", AgentKind::Claude),
2568            pick_spec("oc", AgentKind::Opencode),
2569        ];
2570        let msg = pick(&agents, Some("gemini"), &without(&[]))
2571            .expect_err("no such agent")
2572            .to_string();
2573        assert!(msg.contains("gemini"), "{msg}");
2574        assert!(msg.contains("opus, oc"), "{msg}");
2575    }
2576
2577    #[test]
2578    fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
2579        let agents = [
2580            pick_spec("opus", AgentKind::Claude),
2581            pick_spec("oc", AgentKind::Opencode),
2582        ];
2583        let msg = pick(&agents, Some("oc"), &without(&["oc"]))
2584            .expect_err("must not silently substitute another model")
2585            .to_string();
2586        assert!(msg.contains("opencode"), "{msg}");
2587        assert!(msg.contains("--agent"), "{msg}");
2588    }
2589}