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