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