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