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