Skip to main content

magi/
agent.rs

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