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