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