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