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