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