Skip to main content

magi/
agent.rs

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