Skip to main content

magi/
agent.rs

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