Skip to main content

magi/
agent.rs

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