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