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