Skip to main content

magi/
agent.rs

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