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