Skip to main content

magi/
agent.rs

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