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