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