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//!
26//! Claude is the only one magi can address before the first turn; the other two
27//! report an id back, so [`SeatState::captured_session`] stays `None` until a
28//! turn has completed and [`has_session`] answers honestly instead of
29//! optimistically.
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context as _, Result, bail};
36use serde::{Deserialize, Serialize};
37use std::sync::{Arc, Mutex};
38
39use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
40use tokio::process::Command;
41
42use crate::config::{AgentKind, AgentSpec, Delivery};
43use crate::proc::Quiet as _;
44use crate::rng::SplitMix64;
45
46/// Conversation state for one seat, persisted with the run so `magi run
47/// --resume` continues the same CLI conversations.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SeatState {
50    /// Stable seat name, e.g. `impl-A`, `judge-2`, `review-1`, `fix`.
51    pub key: String,
52    /// Agent id occupying the seat.
53    pub agent: String,
54    /// Turns already taken in this seat.
55    pub turns: usize,
56    /// Claude session uuid, minted up front so the first turn and every resume
57    /// agree on it without parsing anything back.
58    pub claude_session: Option<String>,
59    /// Session id reported by a CLI that mints its own (`opencode`, `agy`).
60    pub captured_session: Option<String>,
61}
62
63impl SeatState {
64    /// New seat. `run_seed` scopes the minted Claude uuid to this run.
65    pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
66        let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
67        Self {
68            key: key.to_owned(),
69            agent: agent.to_owned(),
70            turns: 0,
71            claude_session: Some(rng.uuid_v4()),
72            captured_session: None,
73        }
74    }
75}
76
77/// Can a follow-up prompt rely on this seat remembering the conversation?
78pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
79    if !sessions_enabled || seat.turns == 0 {
80        return false;
81    }
82    match kind {
83        AgentKind::Claude => seat.claude_session.is_some(),
84        AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex => {
85            seat.captured_session.is_some()
86        }
87        AgentKind::Command => true,
88    }
89}
90
91/// One agent invocation.
92#[derive(Debug)]
93pub struct Invocation<'a> {
94    /// Working directory. Always a real checkout, so every CLI can read the
95    /// repository without per-vendor "extra directory" flags.
96    pub cwd: &'a Path,
97    /// The full prompt.
98    pub prompt: &'a str,
99    /// Wall-clock limit; the process tree is killed when it elapses.
100    pub timeout: Duration,
101    /// May the agent modify files? Judges and reviewers may not.
102    pub allow_write: bool,
103    /// Continue this seat's conversation when the CLI supports it.
104    pub sessions: bool,
105    /// Directory for prompt / stdout / stderr artifacts.
106    pub artifacts: &'a Path,
107    /// Artifact filename stem.
108    pub stem: &'a str,
109    /// Run this invocation belongs to. Exported as `MAGI_RUN` so an agent that
110    /// files a task with `magi task add` is attributed to the run that was
111    /// paying for it, rather than looking like a human wandered by.
112    pub run: &'a str,
113    /// Graph node being executed, e.g. `implement` or `review`. Exported as
114    /// `MAGI_NODE` for the same reason: "who asked for this" is the first
115    /// question about an autonomously created task.
116    pub node: &'a str,
117    /// Shared build cache the seat should build into, from the rendered
118    /// `CARGO_TARGET_DIR=` in the verify commands. Exported as
119    /// `CARGO_TARGET_DIR` so the implementer's compile lands inside the same
120    /// directory `verify` reads back from - one cache, one prune, and the
121    /// build the agent just paid for is the build the gate reuses.
122    pub cache_dir: Option<&'a Path>,
123}
124
125/// Evidence that a CLI ran out of its rate limit / quota, distinct from an
126/// ordinary failure.
127///
128/// `reset` is free text: CLIs render the reset time in their own locale, and
129/// parsing it exactly would be a bug factory. When it is not readable we say
130/// nothing rather than invent a format.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct Quota {
133    /// Human-readable reset time, when the CLI printed one.
134    #[serde(default)]
135    pub reset: Option<String>,
136}
137
138/// A CLI hung up on its own stream while the agent was working.
139///
140/// Separate from a failure because the work was done and billed, and separate
141/// from a [`Quota`] because it is worth asking again: the answer is in the
142/// conversation, not lost to a limit that has to reset first. See
143/// [`dropped_stream`].
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct Dropped {
146    /// What the CLI said as it hung up, verbatim.
147    pub why: String,
148    /// Output tokens the CLI reported before it did - the evidence that this
149    /// was a delivery failure and not an agent that produced nothing.
150    pub output_tokens: u64,
151}
152
153/// Result of an agent invocation.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct AgentOutput {
156    /// The agent's final message, extracted from whatever the CLI printed.
157    pub text: String,
158    /// Exit status code.
159    pub exit_code: Option<i32>,
160    /// Did the invocation hit its timeout?
161    pub timed_out: bool,
162    /// Wall-clock duration.
163    pub duration_ms: u64,
164    /// Artifact file names, relative to the run's `artifacts/` directory.
165    pub artifacts: Vec<String>,
166    /// Rate-limit / quota exhaustion, when it can be told apart from a normal
167    /// failure. `None` for a normal failure, a timeout, or a CLI we cannot
168    /// read — the conservative default.
169    #[serde(default)]
170    pub quota: Option<Quota>,
171    /// The CLI hung up on its own stream after the agent had done billed
172    /// work. `None` unless that exact shape was recognised — see
173    /// [`dropped_stream`].
174    #[serde(default)]
175    pub dropped: Option<Dropped>,
176}
177
178impl AgentOutput {
179    /// Did the CLI exit cleanly with something to say?
180    pub fn usable(&self) -> bool {
181        !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
182    }
183
184    /// Did this invocation run out of the CLI's rate limit / quota?
185    pub fn quota_exhausted(&self) -> bool {
186        self.quota.is_some()
187    }
188
189    /// Did the agent work and the CLI fail to deliver it?
190    ///
191    /// Worth re-asking, unlike [`AgentOutput::quota_exhausted`]: the answer is
192    /// in a conversation this process can resume.
193    pub fn work_undelivered(&self) -> bool {
194        self.dropped.is_some()
195    }
196}
197
198/// How long to keep reading a pipe after the child is gone.
199///
200/// Bounded on purpose: a surviving grandchild can hold the write end open
201/// forever, and the graph must not hang on a process it has already killed.
202const PIPE_GRACE: Duration = Duration::from_secs(3);
203
204/// Bytes a pipe reader has accumulated so far, shared with whoever spawned it.
205type Captured = Arc<Mutex<Vec<u8>>>;
206
207/// Read `pipe` to end in its own task, appending into a buffer the caller can
208/// inspect at any time.
209///
210/// The buffer is shared rather than returned because the interesting moment is
211/// exactly the one where the reader has *not* finished: a killed agent's pipe
212/// may still be held open by a surviving grandchild, and the bytes that did
213/// arrive are the only evidence of what it was doing. An earlier version
214/// returned the buffer from the task and dropped it on timeout, which is how
215/// `<stem>.out` came to be empty on every timeout.
216fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
217where
218    R: tokio::io::AsyncRead + Unpin + Send + 'static,
219{
220    let buf: Captured = Arc::new(Mutex::new(Vec::new()));
221    let Some(mut pipe) = pipe else {
222        return (buf, None);
223    };
224    let sink = Arc::clone(&buf);
225    let handle = tokio::spawn(async move {
226        let mut chunk = [0u8; 8192];
227        loop {
228            match pipe.read(&mut chunk).await {
229                Ok(0) | Err(_) => break,
230                Ok(n) => {
231                    if let Ok(mut guard) = sink.lock() {
232                        guard.extend_from_slice(&chunk[..n]);
233                    }
234                }
235            }
236        }
237    });
238    (buf, Some(handle))
239}
240
241/// Take whatever a reader has captured, giving it at most `grace` to finish.
242///
243/// A reader still blocked after that is abandoned, not awaited — but its bytes
244/// come back either way, which is the whole point.
245async fn collect(
246    buf: &Captured,
247    handle: Option<tokio::task::JoinHandle<()>>,
248    grace: Duration,
249) -> String {
250    if let Some(handle) = handle {
251        if tokio::time::timeout(grace, handle).await.is_err() {
252            tracing::debug!("a pipe is still held open after the child exited");
253        }
254    }
255    let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
256    String::from_utf8_lossy(&bytes).into_owned()
257}
258
259/// Invoke `spec` for `seat`, updating the seat's conversation state.
260pub async fn invoke(
261    spec: &AgentSpec,
262    seat: &mut SeatState,
263    inv: &Invocation<'_>,
264) -> Result<AgentOutput> {
265    tokio::fs::create_dir_all(inv.artifacts)
266        .await
267        .with_context(|| format!("create {}", inv.artifacts.display()))?;
268    let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
269    tokio::fs::write(&prompt_path, inv.prompt)
270        .await
271        .with_context(|| format!("write {}", prompt_path.display()))?;
272
273    let plan = build_command(spec, seat, inv, &prompt_path)?;
274    tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
275
276    let started = Instant::now();
277    let mut cmd = Command::new(&plan.argv[0]);
278    cmd.args(&plan.argv[1..])
279        .current_dir(inv.cwd)
280        .envs(&spec.env)
281        .env("MAGI_SEAT", &seat.key)
282        .env("MAGI_TURN", seat.turns.to_string())
283        .env("MAGI_RUN", inv.run)
284        .env("MAGI_NODE", inv.node)
285        .env("MAGI_PROMPT_FILE", &prompt_path)
286        .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
287        .env("GIT_TERMINAL_PROMPT", "0")
288        .stdin(if plan.stdin.is_some() {
289            Stdio::piped()
290        } else {
291            Stdio::null()
292        })
293        .stdout(Stdio::piped())
294        .stderr(Stdio::piped())
295        .kill_on_drop(true)
296        // No console window. `magi web` has no console of its own, so Windows
297        // would give each agent a fresh one - and draw it. See `crate::proc`.
298        .quiet();
299    if let Some(cache) = inv.cache_dir {
300        // Same directory the verify commands build into: one cache to prune,
301        // and the compile the seat pays for is the compile the gate reuses.
302        cmd.env("CARGO_TARGET_DIR", cache);
303    }
304
305    let mut child = cmd
306        .spawn()
307        .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
308    // Feed stdin from a task rather than inline: a `command` agent that never
309    // reads its stdin, or a prompt larger than the pipe buffer, would
310    // otherwise deadlock here before the process is ever waited on.
311    if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
312        tokio::spawn(async move {
313            sink.write_all(body.as_bytes()).await.ok();
314            sink.shutdown().await.ok();
315        });
316    }
317
318    // Drain the pipes in their own tasks, and wait on the *process*, not on
319    // end-of-file. Two failures come out of conflating those:
320    //
321    // 1. `wait_with_output` returns when both pipes reach EOF, which is not
322    //    when the child exits. A CLI that leaves a helper process holding the
323    //    inherited stdout handle - normal on Windows, where a `.cmd` shim and
324    //    its grandchildren share handles - never closes the pipe, so a seat
325    //    that answered in five minutes was billed the full hour and then
326    //    recorded as a timeout. The answer was thrown away with it.
327    // 2. Cancelling `wait_with_output` at the timeout drops the buffers it
328    //    owned, so `<stem>.out` and `<stem>.err` were written empty exactly
329    //    when an operator needs them most. "It printed nothing" and "we
330    //    discarded what it printed" looked identical on disk.
331    //
332    // Now the readers own the bytes, so a timeout keeps whatever arrived, and
333    // the wait ends at exit even if a stray handle stays open.
334    let (out_buf, out_reader) = drain(child.stdout.take());
335    let (err_buf, err_reader) = drain(child.stderr.take());
336
337    let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
338        Ok(res) => {
339            let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
340            (status.code(), false)
341        }
342        Err(_) => {
343            tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
344            // Kill the tree so the readers see EOF instead of hanging with it.
345            child.start_kill().ok();
346            (None, true)
347        }
348    };
349
350    // The child is gone either way, so the readers are bounded now. A grace
351    // window rather than an unbounded await: a surviving grandchild can still
352    // hold the write end open, and losing a few trailing bytes beats hanging
353    // the graph on a process we no longer control.
354    let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
355    let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
356
357    let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
358    let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
359    tokio::fs::write(&out_path, &stdout).await.ok();
360    tokio::fs::write(&err_path, &stderr).await.ok();
361
362    let extracted = extract(spec.kind, &stdout);
363    if let Some(session) = extracted.session {
364        match spec.kind {
365            AgentKind::Claude => seat.claude_session = Some(session),
366            AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex => {
367                seat.captured_session = Some(session);
368            }
369            AgentKind::Command => {}
370        }
371    }
372    if let Some(status) = &extracted.status
373        && !status.eq_ignore_ascii_case("success")
374    {
375        tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
376    }
377    let text = if extracted.text.trim().is_empty() {
378        // A CLI that printed only to stderr still told us something.
379        if stdout.trim().is_empty() {
380            stderr.trim().to_owned()
381        } else {
382            stdout.trim().to_owned()
383        }
384    } else {
385        extracted.text
386    };
387    seat.turns += 1;
388
389    Ok(AgentOutput {
390        text,
391        exit_code: code,
392        timed_out,
393        duration_ms: started.elapsed().as_millis() as u64,
394        artifacts: vec![
395            file_name(&prompt_path),
396            file_name(&out_path),
397            file_name(&err_path),
398        ],
399        quota: extracted.quota,
400        dropped: extracted.dropped,
401    })
402}
403
404fn file_name(p: &Path) -> String {
405    p.file_name()
406        .unwrap_or_default()
407        .to_string_lossy()
408        .into_owned()
409}
410
411/// The argv plus optional stdin body for one invocation.
412#[derive(Debug)]
413struct Plan {
414    argv: Vec<String>,
415    stdin: Option<String>,
416}
417
418/// How a file-delivered prompt is pointed at, per CLI.
419///
420/// `agy` has a native file-context syntax, `@<path>`, and it is measurably the
421/// better contract: on the same trivial task it finished in 17s against 73s for
422/// the prose form, because prose makes the model spend a tool round-trip
423/// deciding to read the file. It is also the form yukimemi/rvpm proved out.
424///
425/// opencode has no equivalent, so it gets the prose. That is not a fallback
426/// worth apologising for — it works, and it is what the winning opencode
427/// candidates on this repository have been driven by all along.
428fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
429    if matches!(kind, AgentKind::Antigravity) {
430        return format!("@{}", prompt_path.display());
431    }
432    format!(
433        "Read the file at {} and follow every instruction in it exactly. That \
434         file is your complete task description; this message contains nothing \
435         else.",
436        prompt_path.display()
437    )
438}
439
440fn build_command(
441    spec: &AgentSpec,
442    seat: &SeatState,
443    inv: &Invocation<'_>,
444    prompt_path: &Path,
445) -> Result<Plan> {
446    let mut argv: Vec<String> = Vec::new();
447    let mut stdin: Option<String> = None;
448    let delivery = spec.delivery();
449    let resuming = has_session(spec.kind, seat, inv.sessions);
450
451    match spec.kind {
452        AgentKind::Claude => {
453            argv.push("claude".to_owned());
454            argv.push("-p".to_owned());
455            argv.push("--output-format".to_owned());
456            argv.push("json".to_owned());
457            if let Some(m) = &spec.model {
458                argv.push("--model".to_owned());
459                argv.push(m.clone());
460            }
461            if inv.sessions {
462                let uuid = seat
463                    .claude_session
464                    .as_deref()
465                    .context("claude seat is missing its session uuid")?;
466                argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
467                argv.push(uuid.to_owned());
468            }
469            argv.push("--permission-mode".to_owned());
470            argv.push("bypassPermissions".to_owned());
471            if !inv.allow_write {
472                argv.push("--disallowed-tools".to_owned());
473                argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
474            }
475        }
476        AgentKind::Opencode => {
477            argv.push("opencode".to_owned());
478            argv.push("run".to_owned());
479            argv.push("--format".to_owned());
480            argv.push("json".to_owned());
481            argv.push("--dir".to_owned());
482            argv.push(inv.cwd.to_string_lossy().into_owned());
483            // `--auto` gates *every* permission, reads included: without it a
484            // non-interactive opencode cannot even open the prompt file, and
485            // the seat drops out of the panel with "the user rejected
486            // permission to use this specific tool call". opencode has no
487            // read-only mode, so read-only seats rely on the prompt plus the
488            // fact that judge and reviewer worktrees are disposable — judges'
489            // are deleted after the tally, reviewers' are reset to the commit
490            // under review every round.
491            argv.push("--auto".to_owned());
492            if let Some(m) = &spec.model {
493                argv.push("-m".to_owned());
494                argv.push(m.clone());
495            }
496            if resuming {
497                argv.push("-s".to_owned());
498                argv.push(
499                    seat.captured_session
500                        .clone()
501                        .expect("has_session checked the id is present"),
502                );
503            }
504        }
505        AgentKind::Antigravity => {
506            argv.push("agy".to_owned());
507            argv.push("--output-format".to_owned());
508            argv.push("json".to_owned());
509            // agy's print mode gives up after 5 minutes by default, which is
510            // far below an implementation node's budget.
511            argv.push("--print-timeout".to_owned());
512            argv.push(format!("{}s", inv.timeout.as_secs()));
513            argv.push("--mode".to_owned());
514            argv.push(
515                if inv.allow_write {
516                    "accept-edits"
517                } else {
518                    "plan"
519                }
520                .to_owned(),
521            );
522            if inv.allow_write {
523                argv.push("--dangerously-skip-permissions".to_owned());
524            }
525            if let Some(m) = &spec.model {
526                argv.push("--model".to_owned());
527                argv.push(m.clone());
528            }
529            if resuming {
530                argv.push("--conversation".to_owned());
531                argv.push(
532                    seat.captured_session
533                        .clone()
534                        .expect("has_session checked the id is present"),
535                );
536            }
537            // The prompt file lives outside the worktree, so the workspace has
538            // to be widened to reach it.
539            if delivery == Delivery::File {
540                argv.push("--add-dir".to_owned());
541                argv.push(inv.artifacts.to_string_lossy().into_owned());
542            }
543        }
544        AgentKind::Codex => {
545            argv.push("codex".to_owned());
546            argv.push("exec".to_owned());
547            argv.push("--json".to_owned());
548            // The worktrees magi hands out are real checkouts, but a judge's
549            // is detached and a fixture's may be no repository at all.
550            argv.push("--skip-git-repo-check".to_owned());
551            argv.push("-C".to_owned());
552            argv.push(inv.cwd.to_string_lossy().into_owned());
553            // Codex is the only kind whose read-only-ness is enforced by the
554            // CLI rather than by the prompt: a judge or reviewer seat cannot
555            // write even if it decides to try. Implementers get the workspace,
556            // and nothing ever gets `--dangerously-bypass-approvals-and-sandbox`.
557            argv.push("--sandbox".to_owned());
558            argv.push(
559                if inv.allow_write {
560                    "workspace-write"
561                } else {
562                    "read-only"
563                }
564                .to_owned(),
565            );
566            // Nothing is watching to approve anything: an unattended seat that
567            // asks blocks until its timeout kills it.
568            argv.push("-c".to_owned());
569            argv.push("approval_policy=\"never\"".to_owned());
570            if let Some(m) = &spec.model {
571                argv.push("-m".to_owned());
572                argv.push(m.clone());
573            }
574            // `resume` is a subcommand of `exec`, and it rejects the flags
575            // above when they follow it - so every option is emitted first and
576            // the subcommand last. Established by hand against codex-cli
577            // 0.153.4: with the order reversed the CLI exits on
578            // `unexpected argument '--sandbox'`.
579            if resuming {
580                argv.push("resume".to_owned());
581                argv.push(
582                    seat.captured_session
583                        .clone()
584                        .expect("has_session checked the id is present"),
585                );
586            }
587        }
588        AgentKind::Command => {
589            if spec.command.is_empty() {
590                bail!("agent `{}` has kind = \"command\" but no command", spec.id);
591            }
592            let vars: BTreeMap<&str, String> = BTreeMap::from([
593                ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
594                ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
595                ("{label}", seat.key.clone()),
596                ("{session}", seat.claude_session.clone().unwrap_or_default()),
597            ]);
598            for raw in &spec.command {
599                let mut arg = raw.clone();
600                for (k, v) in &vars {
601                    if arg.contains(k) {
602                        arg = arg.replace(k, v);
603                    }
604                }
605                argv.push(arg);
606            }
607        }
608    }
609
610    argv.extend(spec.extra_args.iter().cloned());
611
612    // `agy` takes the prompt as the value of `-p`, so the flag has to be
613    // emitted right before whatever the delivery mode produces.
614    if spec.kind == AgentKind::Antigravity {
615        argv.push("-p".to_owned());
616    }
617    // `codex exec` reads stdin only when its prompt argument is `-`; without
618    // it the CLI waits on a prompt it will never be given.
619    if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
620        argv.push("-".to_owned());
621    }
622    match delivery {
623        Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
624            // agy has no text stdin path; fall back to the pointer file.
625            argv.push(pointer(spec.kind, prompt_path));
626        }
627        Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
628        Delivery::Argv => argv.push(inv.prompt.to_owned()),
629        Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
630    }
631
632    Ok(Plan { argv, stdin })
633}
634
635/// What a CLI's stdout yielded.
636#[derive(Debug, Default)]
637struct Extracted {
638    text: String,
639    session: Option<String>,
640    status: Option<String>,
641    quota: Option<Quota>,
642    dropped: Option<Dropped>,
643}
644
645/// Pull the agent's message (and any session id) out of a CLI's stdout.
646fn extract(kind: AgentKind, stdout: &str) -> Extracted {
647    match kind {
648        AgentKind::Claude => {
649            let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
650                return Extracted {
651                    text: stdout.trim().to_owned(),
652                    ..Extracted::default()
653                };
654            };
655            Extracted {
656                text: v
657                    .get("result")
658                    .and_then(|r| r.as_str())
659                    .unwrap_or_default()
660                    .to_owned(),
661                session: v
662                    .get("session_id")
663                    .and_then(|s| s.as_str())
664                    .map(str::to_owned),
665                status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
666                    if e {
667                        "error".to_owned()
668                    } else {
669                        "success".to_owned()
670                    }
671                }),
672                quota: claude_quota(&v),
673                // Claude reports a truncated stream as an ordinary error; the
674                // shape `dropped_stream` keys on is agy's.
675                dropped: None,
676            }
677        }
678        AgentKind::Opencode => {
679            // A JSONL event stream: text parts concatenated in arrival order.
680            let mut text = String::new();
681            let mut session = None;
682            for line in stdout.lines() {
683                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
684                    continue;
685                };
686                if session.is_none() {
687                    session = v
688                        .get("sessionID")
689                        .and_then(|s| s.as_str())
690                        .map(str::to_owned);
691                }
692                let part = v.get("part").unwrap_or(&serde_json::Value::Null);
693                if part.get("type").and_then(|t| t.as_str()) == Some("text")
694                    && let Some(t) = part.get("text").and_then(|t| t.as_str())
695                {
696                    if !text.is_empty() {
697                        text.push('\n');
698                    }
699                    text.push_str(t);
700                }
701            }
702            Extracted {
703                text,
704                session,
705                status: None,
706                quota: None,
707                dropped: None,
708            }
709        }
710        AgentKind::Antigravity => {
711            // agy prints warnings before the JSON object, so parse the last
712            // line that is one rather than the whole stream.
713            let obj = stdout
714                .lines()
715                .rev()
716                .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
717            let Some(v) = obj else {
718                return Extracted {
719                    text: stdout.trim().to_owned(),
720                    ..Extracted::default()
721                };
722            };
723            Extracted {
724                text: v
725                    .get("response")
726                    .and_then(|r| r.as_str())
727                    .unwrap_or_default()
728                    .trim()
729                    .to_owned(),
730                session: v
731                    .get("conversation_id")
732                    .and_then(|s| s.as_str())
733                    .map(str::to_owned),
734                status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
735                quota: None,
736                dropped: dropped_stream(&v),
737            }
738        }
739        AgentKind::Codex => {
740            // A JSONL event stream, prefixed on a real machine by tracing
741            // lines the CLI writes about its own config and skills - so
742            // non-JSON lines are skipped rather than treated as the answer.
743            //
744            // The thread id arrives once, in `thread.started`, and a resumed
745            // turn reports the same one. The answer is the last
746            // `item.completed` carrying an `agent_message`: earlier ones are
747            // the model narrating its way through the tool loop, and taking
748            // the first would hand the caller a progress note instead of a
749            // verdict.
750            let mut text = String::new();
751            let mut session = None;
752            let mut status = None;
753            for line in stdout.lines() {
754                let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
755                    continue;
756                };
757                match v.get("type").and_then(|t| t.as_str()) {
758                    Some("thread.started") => {
759                        session = v
760                            .get("thread_id")
761                            .and_then(|s| s.as_str())
762                            .map(str::to_owned);
763                    }
764                    Some("item.completed") => {
765                        let item = v.get("item").unwrap_or(&serde_json::Value::Null);
766                        if item.get("type").and_then(|t| t.as_str()) == Some("agent_message")
767                            && let Some(t) = item.get("text").and_then(|t| t.as_str())
768                        {
769                            text = t.trim().to_owned();
770                        }
771                    }
772                    Some("turn.completed") => status = Some("success".to_owned()),
773                    Some("turn.failed") => status = Some("error".to_owned()),
774                    _ => {}
775                }
776            }
777            Extracted {
778                text,
779                session,
780                status,
781                quota: None,
782                dropped: None,
783            }
784        }
785        AgentKind::Command => {
786            // A `command` agent may wrap a subscription CLI (a fixture, or a
787            // thin shim around `claude`). If its output is the claude error
788            // shape we recognise the quota the same way, so tests and wrappers
789            // do not need their own detection; anything else is just text.
790            let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
791            let quota = parsed.as_ref().and_then(claude_quota);
792            // A `command` fixture may also stand in for a CLI that hangs up on
793            // its own stream, which is how that path is tested.
794            let dropped = parsed.as_ref().and_then(dropped_stream);
795            Extracted {
796                text: stdout.trim().to_owned(),
797                session: None,
798                status: None,
799                quota,
800                dropped,
801            }
802        }
803    }
804}
805
806/// Recognise claude's rate-limit error shape, when it is present.
807///
808/// The only output we have observed is the JSON object carrying `is_error:
809/// true` and a `result` mentioning the session limit. We key on exactly that;
810/// every other CLI (and any future shape) returns `None` and is treated as an
811/// ordinary failure — the conservative side.
812fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
813    let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
814    if !is_err {
815        return None;
816    }
817    let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
818    if !result.to_lowercase().contains("session limit") {
819        return None;
820    }
821    // "…session limit · resets 4:50am (Asia/Tokyo)". The timezone read is not
822    // worth parsing exactly; keep the whole phrase after "resets" as free text.
823    let reset = result
824        .split("resets ")
825        .nth(1)
826        .map(str::trim)
827        .filter(|s| !s.is_empty())
828        .map(str::to_owned);
829    Some(Quota { reset })
830}
831
832/// Recognise a CLI that gave up on its own stream while the agent was working.
833///
834/// Observed once, verbatim, from `agy` on a candidate that produced nothing:
835///
836/// ```text
837/// {"conversation_id":"36743d06-…","status":"ERROR","response":"",
838///  "error":"the connection to the agent was interrupted before the response
839///           finished: subscriber fell behind updates, stalled for 5s",
840///  "duration_seconds":431.19,"num_turns":1,
841///  "usage":{"input_tokens":260113,"output_tokens":14267,
842///           "thinking_tokens":9695,"cache_read_tokens":2200925}}
843/// ```
844///
845/// Seven minutes of work and fourteen thousand output tokens, billed, with an
846/// empty `response`: the agent did the job and the CLI's own subscriber fell
847/// behind and hung up. That is **not** an agent that failed to implement, and
848/// counting it as one is how `agy` came to read as 0 wins in 4 entries with
849/// five empty candidates - a number that has twice been used to argue the seat
850/// out of the roster, and twice been wrong (see `cb6b830`, which reverted the
851/// first removal: *"agy does not fail to implement, it fails to report"*).
852///
853/// The distinction that matters is **billed work with nothing delivered**, so
854/// that is what this keys on: an error status, an empty response, and a usage
855/// report showing output tokens. Everything else - including an error with no
856/// usage at all - returns `None` and stays an ordinary failure, the
857/// conservative side, exactly as [`claude_quota`] treats shapes it does not
858/// recognise.
859///
860/// Unlike a quota, this **is** worth re-asking: the work exists in the
861/// conversation the CLI just abandoned, and `conversation_id` is right there.
862fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
863    let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
864    if !status.eq_ignore_ascii_case("error") {
865        return None;
866    }
867    let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
868    if !response.trim().is_empty() {
869        // It answered. Whatever the status says, there is something to read.
870        return None;
871    }
872    let produced = v
873        .get("usage")
874        .and_then(|u| u.get("output_tokens"))
875        .and_then(serde_json::Value::as_u64)
876        .unwrap_or(0);
877    if produced == 0 {
878        // An error with nothing produced is just an error.
879        return None;
880    }
881    Some(Dropped {
882        why: v
883            .get("error")
884            .and_then(|e| e.as_str())
885            .unwrap_or("the CLI ended the stream without delivering its answer")
886            .trim()
887            .to_owned(),
888        output_tokens: produced,
889    })
890}
891
892/// Preflight: which configured agents are not runnable here?
893pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
894    let mut missing = Vec::new();
895    for s in specs {
896        let program = match s.kind {
897            AgentKind::Command => s.command.first().map(String::as_str),
898            other => other.program(),
899        };
900        if let Some(p) = program
901            && !crate::config::which(p)
902            && !Path::new(p).is_file()
903            && !missing.iter().any(|m: &String| m == p)
904        {
905            missing.push(p.to_owned());
906        }
907    }
908    missing
909}
910
911/// Absolute path of a run's artifact directory.
912pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
913    run_dir.join("artifacts")
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
921        AgentSpec {
922            id: "a".to_owned(),
923            kind,
924            model: model.map(str::to_owned),
925            command: vec!["echo".to_owned(), "{label}".to_owned()],
926            extra_args: Vec::new(),
927            env: BTreeMap::new(),
928            prompt_delivery: None,
929        }
930    }
931
932    fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
933        Invocation {
934            cwd,
935            prompt: "do the thing",
936            timeout: Duration::from_secs(900),
937            allow_write,
938            sessions: true,
939            artifacts: art,
940            stem: "t",
941            run: "test-run",
942            node: "test",
943            cache_dir: None,
944        }
945    }
946
947    fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
948        build_command(
949            &spec(kind, None),
950            seat,
951            &inv(Path::new("."), Path::new("/art"), allow_write),
952            Path::new("/art/p.md"),
953        )
954        .unwrap()
955    }
956
957    #[test]
958    fn claude_mints_then_resumes_the_same_uuid() {
959        let mut seat = SeatState::new("judge-1", "a", 7);
960        let uuid = seat.claude_session.clone().unwrap();
961        let first = plan_for(AgentKind::Claude, &seat, true);
962        assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
963        assert!(!first.argv.iter().any(|a| a == "--resume"));
964
965        seat.turns = 1;
966        let second = plan_for(AgentKind::Claude, &seat, true);
967        assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
968        assert!(!second.argv.iter().any(|a| a == "--session-id"));
969    }
970
971    #[test]
972    fn read_only_seats_cannot_edit() {
973        let seat = SeatState::new("judge-1", "a", 7);
974        let claude = plan_for(AgentKind::Claude, &seat, false);
975        assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
976        assert!(
977            !plan_for(AgentKind::Claude, &seat, true)
978                .argv
979                .iter()
980                .any(|a| a == "--disallowed-tools")
981        );
982
983        let agy = plan_for(AgentKind::Antigravity, &seat, false);
984        assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
985        assert!(
986            !agy.argv
987                .iter()
988                .any(|a| a == "--dangerously-skip-permissions")
989        );
990        let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
991        assert!(
992            agy_rw
993                .argv
994                .windows(2)
995                .any(|w| w == ["--mode", "accept-edits"])
996        );
997        assert!(
998            agy_rw
999                .argv
1000                .iter()
1001                .any(|a| a == "--dangerously-skip-permissions")
1002        );
1003        // agy is pointed at its prompt with its own `@<path>` syntax, not with
1004        // prose asking it to read a file. Measured on one trivial task: 17s
1005        // against 73s, because prose costs a tool round-trip before the model
1006        // has even seen its instructions. It is also the form rvpm proved.
1007        let agy_prompt = agy_rw
1008            .argv
1009            .iter()
1010            .position(|a| a == "-p")
1011            .map(|i| agy_rw.argv[i + 1].clone())
1012            .expect("agy takes its prompt with -p");
1013        assert!(
1014            agy_prompt.starts_with('@'),
1015            "agy must get a file reference, got {agy_prompt:?}"
1016        );
1017        assert!(
1018            !agy_prompt.contains("Read the file at"),
1019            "the prose pointer is for CLIs with no file syntax"
1020        );
1021
1022        // opencode is the exception: `--auto` also gates reads, so withholding
1023        // it silently drops the seat out of the panel. Verified against the CLI
1024        // — a read-only judge failed with "the user rejected permission to use
1025        // this specific tool call" while trying to open its own prompt.
1026        for allow_write in [false, true] {
1027            assert!(
1028                plan_for(AgentKind::Opencode, &seat, allow_write)
1029                    .argv
1030                    .iter()
1031                    .any(|a| a == "--auto"),
1032                "opencode needs --auto even to read (allow_write = {allow_write})"
1033            );
1034        }
1035    }
1036
1037    /// The three things about `codex exec` that were established by hand and
1038    /// that a rewrite would silently get wrong.
1039    #[test]
1040    fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1041        let mut seat = SeatState::new("judge-1", "a", 7);
1042
1043        // 1. Read-only is enforced by the CLI, not by the prompt - the only
1044        //    roster member for which that is true - and nothing ever asks for
1045        //    the bypass.
1046        let ro = plan_for(AgentKind::Codex, &seat, false);
1047        assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1048        let rw = plan_for(AgentKind::Codex, &seat, true);
1049        assert!(
1050            rw.argv
1051                .windows(2)
1052                .any(|w| w == ["--sandbox", "workspace-write"])
1053        );
1054        for p in [&ro, &rw] {
1055            assert!(
1056                !p.argv
1057                    .iter()
1058                    .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1059                "the bypass defeats the only enforced read-only mode we have"
1060            );
1061            // Nobody is watching to approve anything.
1062            assert!(
1063                p.argv
1064                    .windows(2)
1065                    .any(|w| w == ["-c", "approval_policy=\"never\""]),
1066                "an unattended seat that asks for approval blocks until timeout"
1067            );
1068        }
1069
1070        // 2. The prompt arrives on stdin, and `-` is what makes codex read it.
1071        assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1072        assert_eq!(
1073            ro.argv.last().map(String::as_str),
1074            Some("-"),
1075            "without the `-` argument codex waits for a prompt it never gets"
1076        );
1077
1078        // 3. `resume` is a subcommand and rejects the options above when they
1079        //    follow it, so it has to be emitted after all of them - and only
1080        //    once the CLI has reported a thread id.
1081        seat.turns = 1;
1082        assert!(!has_session(AgentKind::Codex, &seat, true));
1083        assert!(
1084            !plan_for(AgentKind::Codex, &seat, true)
1085                .argv
1086                .iter()
1087                .any(|a| a == "resume")
1088        );
1089        seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1090        let resumed = plan_for(AgentKind::Codex, &seat, true);
1091        let at = resumed
1092            .argv
1093            .iter()
1094            .position(|a| a == "resume")
1095            .expect("resumes by subcommand");
1096        assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1097        assert!(
1098            resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1099            "every option precedes the subcommand"
1100        );
1101        assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1102    }
1103
1104    /// A real `codex exec --json` stream, tracing prefix included.
1105    #[test]
1106    fn codex_takes_the_last_agent_message_and_the_thread_id() {
1107        let stream = concat!(
1108            "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1109            r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1110            "\n",
1111            r#"{"type":"turn.started"}"#,
1112            "\n",
1113            r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1114            "\n",
1115            r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1116            "\n",
1117            r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1118            "\n",
1119            r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1120            "\n",
1121        );
1122        let out = extract(AgentKind::Codex, stream);
1123        assert_eq!(
1124            out.text, "{\"verdict\": \"ok\"}",
1125            "the last agent message is the answer; earlier ones narrate"
1126        );
1127        assert_eq!(
1128            out.session.as_deref(),
1129            Some("01a07440-4545-7492-85c1-024e3259a90a")
1130        );
1131        assert_eq!(out.status.as_deref(), Some("success"));
1132
1133        let failed = concat!(
1134            r#"{"type":"thread.started","thread_id":"t1"}"#,
1135            "\n",
1136            r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1137            "\n",
1138        );
1139        assert_eq!(
1140            extract(AgentKind::Codex, failed).status.as_deref(),
1141            Some("error")
1142        );
1143    }
1144
1145    #[test]
1146    fn captured_sessions_resume_only_once_reported() {
1147        let mut seat = SeatState::new("impl-A", "a", 7);
1148        seat.turns = 1;
1149        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1150            assert!(!has_session(kind, &seat, true));
1151            let p = plan_for(kind, &seat, true);
1152            assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1153        }
1154
1155        seat.captured_session = Some("sid".to_owned());
1156        assert!(has_session(AgentKind::Opencode, &seat, true));
1157        assert!(
1158            plan_for(AgentKind::Opencode, &seat, true)
1159                .argv
1160                .windows(2)
1161                .any(|w| w == ["-s", "sid"])
1162        );
1163        assert!(
1164            plan_for(AgentKind::Antigravity, &seat, true)
1165                .argv
1166                .windows(2)
1167                .any(|w| w == ["--conversation", "sid"])
1168        );
1169    }
1170
1171    #[test]
1172    fn sessions_disabled_never_resumes() {
1173        let mut seat = SeatState::new("impl-A", "a", 7);
1174        seat.turns = 3;
1175        seat.captured_session = Some("sid".to_owned());
1176        for kind in [
1177            AgentKind::Claude,
1178            AgentKind::Opencode,
1179            AgentKind::Antigravity,
1180        ] {
1181            assert!(!has_session(kind, &seat, false));
1182        }
1183    }
1184
1185    #[test]
1186    fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1187        let seat = SeatState::new("judge-1", "a", 7);
1188        for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1189            let p = plan_for(kind, &seat, false);
1190            assert!(
1191                p.argv.iter().all(|a| a != "do the thing"),
1192                "{kind:?} put the prompt on the command line"
1193            );
1194            assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1195        }
1196        // agy has no text stdin, so its `-p` must always carry something.
1197        let p = plan_for(AgentKind::Antigravity, &seat, false);
1198        let at = p.argv.iter().position(|a| a == "-p").unwrap();
1199        assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1200        assert!(p.stdin.is_none());
1201    }
1202
1203    #[test]
1204    fn agy_print_timeout_tracks_the_node_budget() {
1205        let seat = SeatState::new("impl-A", "a", 7);
1206        let p = build_command(
1207            &spec(AgentKind::Antigravity, None),
1208            &seat,
1209            &Invocation {
1210                cwd: Path::new("."),
1211                prompt: "p",
1212                timeout: Duration::from_secs(3600),
1213                allow_write: true,
1214                sessions: true,
1215                artifacts: Path::new("/art"),
1216                stem: "t",
1217                run: "test-run",
1218                node: "test",
1219                cache_dir: None,
1220            },
1221            Path::new("/art/p.md"),
1222        )
1223        .unwrap();
1224        assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1225    }
1226
1227    #[test]
1228    fn command_agents_get_placeholders_substituted() {
1229        let seat = SeatState::new("impl-A", "a", 7);
1230        let p = plan_for(AgentKind::Command, &seat, true);
1231        assert_eq!(p.argv[0], "echo");
1232        assert_eq!(p.argv[1], "impl-A");
1233        assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1234    }
1235
1236    #[test]
1237    fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1238        // The exact shape observed in the wild (run 20260831-031005-ae94).
1239        let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1240                        "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1241                        "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1242        let out = extract(AgentKind::Claude, stdout);
1243        let quota = out.quota.as_ref().expect("rate limit must be detected");
1244        assert_eq!(
1245            quota.reset.as_deref(),
1246            Some("4:50am (Asia/Tokyo)"),
1247            "reset time read from the body"
1248        );
1249    }
1250
1251    #[test]
1252    fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1253        let out = extract(
1254            AgentKind::Claude,
1255            r#"{"is_error":true,"result":"session limit reached"}"#,
1256        );
1257        let quota = out.quota.expect("rate limit detected without a reset");
1258        assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1259    }
1260
1261    #[test]
1262    fn ordinary_failures_are_never_quota() {
1263        // A normal failed claude call (is_error with a different message).
1264        let claude_fail = extract(
1265            AgentKind::Claude,
1266            r#"{"is_error":true,"result":"account does not exist"}"#,
1267        );
1268        assert!(claude_fail.quota.is_none());
1269
1270        // A command agent that exits 1 with plain text.
1271        let cmd_fail = extract(AgentKind::Command, "boom");
1272        assert!(cmd_fail.quota.is_none());
1273
1274        // A successful call is not quota even if it mentions the phrase.
1275        let success = extract(
1276            AgentKind::Command,
1277            r#"{"is_error":false,"result":"session limit is fine"}"#,
1278        );
1279        assert!(success.quota.is_none());
1280    }
1281
1282    #[test]
1283    fn command_agent_can_carry_the_claude_quota_shape() {
1284        let out = extract(
1285            AgentKind::Command,
1286            r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1287        );
1288        assert!(
1289            out.quota.is_some(),
1290            "a wrapper emitting the claude shape counts as quota"
1291        );
1292    }
1293
1294    #[test]
1295    fn claude_json_result_is_extracted() {
1296        let out = extract(
1297            AgentKind::Claude,
1298            r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1299        );
1300        assert_eq!(out.text, "all done");
1301        assert_eq!(out.session.as_deref(), Some("abc"));
1302        assert_eq!(out.status.as_deref(), Some("success"));
1303    }
1304
1305    #[test]
1306    fn opencode_event_stream_is_concatenated() {
1307        let stream = concat!(
1308            r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
1309            "\n",
1310            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
1311            "\n",
1312            "garbage line\n",
1313            r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
1314            "\n"
1315        );
1316        let out = extract(AgentKind::Opencode, stream);
1317        assert_eq!(out.text, "first\nsecond");
1318        assert_eq!(out.session.as_deref(), Some("ses_1"));
1319    }
1320
1321    #[test]
1322    fn agy_json_survives_a_leading_warning_line() {
1323        let stdout = concat!(
1324            "warning: --mode plan has no effect while slash commands are disabled.\n",
1325            r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1326            "\n"
1327        );
1328        let out = extract(AgentKind::Antigravity, stdout);
1329        assert_eq!(out.text, "persimmon");
1330        assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1331        assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1332    }
1333
1334    /// Run 26c7's candidate B, verbatim from `artifacts/impl-B.out`.
1335    ///
1336    /// The seat read as an empty candidate. It was seven minutes of work and
1337    /// 14,267 output tokens, billed, that the CLI then declined to hand over.
1338    /// Five such candidates are why `agy` reads as 0 wins in 4 entries, and
1339    /// that number has twice been used to argue the seat out of the roster.
1340    const AGY_DROPPED: &str = concat!(
1341        r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
1342        r#""response":"","error":"the connection to the agent was interrupted before "#,
1343        r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
1344        r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
1345        r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
1346        r#""total_tokens":274380}}"#
1347    );
1348
1349    #[test]
1350    fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
1351        let out = extract(AgentKind::Antigravity, AGY_DROPPED);
1352        let dropped = out.dropped.expect("recognised as undelivered work");
1353        assert_eq!(dropped.output_tokens, 14267);
1354        assert!(
1355            dropped.why.contains("subscriber fell behind"),
1356            "the CLI's own words are kept for the record: {}",
1357            dropped.why
1358        );
1359        // And the conversation is still there to resume, which is the whole
1360        // reason this is worth re-asking where a quota is not.
1361        assert_eq!(
1362            out.session.as_deref(),
1363            Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
1364        );
1365        assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
1366    }
1367
1368    #[test]
1369    fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
1370        // No usage at all: the agent never got going, so there is nothing in
1371        // the conversation to resume and nothing was billed. Treating this as
1372        // undelivered work would buy a second call for no reason.
1373        let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
1374        assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
1375
1376        // Produced tokens, but it did answer - so there is something to read
1377        // and the status is not our business.
1378        let answered = concat!(
1379            r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
1380            r#""usage":{"output_tokens":10}}"#
1381        );
1382        assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
1383
1384        // A success is a success.
1385        let ok = concat!(
1386            r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
1387            r#""usage":{"output_tokens":10}}"#
1388        );
1389        assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
1390    }
1391
1392    #[test]
1393    fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
1394        let out = AgentOutput {
1395            text: String::new(),
1396            exit_code: Some(1),
1397            timed_out: false,
1398            duration_ms: 431_194,
1399            artifacts: Vec::new(),
1400            quota: None,
1401            dropped: Some(Dropped {
1402                why: "subscriber fell behind updates".to_owned(),
1403                output_tokens: 14267,
1404            }),
1405        };
1406        assert!(!out.usable());
1407        assert!(out.work_undelivered());
1408        // The distinction the retry policy rests on: a quota fails the same way
1409        // until it resets, an abandoned conversation can be picked up.
1410        assert!(!out.quota_exhausted());
1411    }
1412
1413    #[test]
1414    fn non_json_stdout_falls_back_to_raw_text() {
1415        let out = extract(AgentKind::Antigravity, "plain answer\n");
1416        assert_eq!(out.text, "plain answer");
1417        assert!(out.session.is_none());
1418    }
1419
1420    #[tokio::test]
1421    async fn command_agent_round_trip_writes_artifacts() {
1422        let dir = tempfile::tempdir().unwrap();
1423        let art = dir.path().join("artifacts");
1424        let mut seat = SeatState::new("impl-A", "a", 7);
1425        let mut s = spec(AgentKind::Command, None);
1426        s.command = vec!["echo".to_owned(), "hello {label}".to_owned()];
1427        let out = invoke(
1428            &s,
1429            &mut seat,
1430            &Invocation {
1431                cwd: dir.path(),
1432                prompt: "unused",
1433                timeout: Duration::from_secs(30),
1434                allow_write: true,
1435                sessions: true,
1436                artifacts: &art,
1437                stem: "impl-A",
1438                run: "test-run",
1439                node: "test",
1440                cache_dir: None,
1441            },
1442        )
1443        .await
1444        .unwrap();
1445        assert!(out.usable(), "{out:?}");
1446        assert!(out.text.contains("hello impl-A"), "{}", out.text);
1447        assert_eq!(seat.turns, 1);
1448        assert!(art.join("impl-A.prompt.md").is_file());
1449        assert!(art.join("impl-A.out").is_file());
1450    }
1451
1452    #[tokio::test]
1453    async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
1454        // The whole point of threading the cache path through `Invocation`:
1455        // the compile the agent pays for lands in the directory `verify` reads
1456        // back out of its rendered commands, so one cache has one prune.
1457        let dir = tempfile::tempdir().unwrap();
1458        let cache = dir.path().join("magi-cache");
1459        let mut seat = SeatState::new("impl-A", "a", 7);
1460        let mut s = spec(AgentKind::Command, None);
1461        if cfg!(windows) {
1462            s.command = vec![
1463                "cmd".to_owned(),
1464                "/C".to_owned(),
1465                "echo %CARGO_TARGET_DIR%".to_owned(),
1466            ];
1467        } else {
1468            s.command = vec![
1469                "sh".to_owned(),
1470                "-c".to_owned(),
1471                "echo $CARGO_TARGET_DIR".to_owned(),
1472            ];
1473        }
1474        let out = invoke(
1475            &s,
1476            &mut seat,
1477            &Invocation {
1478                cwd: dir.path(),
1479                prompt: "unused",
1480                timeout: Duration::from_secs(30),
1481                allow_write: true,
1482                sessions: true,
1483                artifacts: &dir.path().join("artifacts"),
1484                stem: "cache",
1485                run: "test-run",
1486                node: "test",
1487                cache_dir: Some(&cache),
1488            },
1489        )
1490        .await
1491        .unwrap();
1492        assert!(out.usable(), "{out:?}");
1493        assert_eq!(
1494            out.text.trim(),
1495            cache.to_string_lossy(),
1496            "the seat must see CARGO_TARGET_DIR = the shared cache"
1497        );
1498    }
1499
1500    #[tokio::test]
1501    async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1502        let dir = tempfile::tempdir().unwrap();
1503        let mut seat = SeatState::new("impl-A", "a", 7);
1504        let mut s = spec(AgentKind::Command, None);
1505        // `echo` never reads stdin, so an inline write_all would block once the
1506        // OS pipe buffer filled — long before the process could be waited on.
1507        s.command = vec!["echo".to_owned(), "done".to_owned()];
1508        let big = "x".repeat(1_000_000);
1509        let out = invoke(
1510            &s,
1511            &mut seat,
1512            &Invocation {
1513                cwd: dir.path(),
1514                prompt: &big,
1515                timeout: Duration::from_secs(60),
1516                allow_write: true,
1517                sessions: true,
1518                artifacts: &dir.path().join("artifacts"),
1519                stem: "big",
1520                run: "test-run",
1521                node: "test",
1522                cache_dir: None,
1523            },
1524        )
1525        .await
1526        .unwrap();
1527        assert!(out.usable(), "{out:?}");
1528        assert_eq!(out.text, "done");
1529    }
1530
1531    #[tokio::test]
1532    async fn timeout_is_reported_not_hung() {
1533        let dir = tempfile::tempdir().unwrap();
1534        let mut seat = SeatState::new("impl-A", "a", 7);
1535        let mut s = spec(AgentKind::Command, None);
1536        s.command = vec!["sleep".to_owned(), "30".to_owned()];
1537        let out = invoke(
1538            &s,
1539            &mut seat,
1540            &Invocation {
1541                cwd: dir.path(),
1542                prompt: "unused",
1543                timeout: Duration::from_millis(300),
1544                allow_write: true,
1545                sessions: true,
1546                artifacts: &dir.path().join("artifacts"),
1547                stem: "slow",
1548                run: "test-run",
1549                node: "test",
1550                cache_dir: None,
1551            },
1552        )
1553        .await
1554        .unwrap();
1555        assert!(out.timed_out);
1556        assert!(!out.usable());
1557    }
1558
1559    #[tokio::test]
1560    async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1561        // The old implementation cancelled `wait_with_output`, which dropped
1562        // the buffers it owned, so `<stem>.out` was written empty on every
1563        // timeout. "It printed nothing" and "we discarded what it printed"
1564        // looked identical on disk — and one real hour-long stall was
1565        // diagnosed wrongly twice because of it.
1566        let dir = tempfile::tempdir().unwrap();
1567        let artifacts = dir.path().join("artifacts");
1568        let mut seat = SeatState::new("impl-A", "a", 7);
1569        let mut s = spec(AgentKind::Command, None);
1570        s.command = vec![
1571            "sh".to_owned(),
1572            "-c".to_owned(),
1573            "echo i-said-something; sleep 30".to_owned(),
1574        ];
1575        let out = invoke(
1576            &s,
1577            &mut seat,
1578            &Invocation {
1579                cwd: dir.path(),
1580                prompt: "unused",
1581                // Wide enough to cover process-spawn latency inside a loaded
1582                // parallel test run, not merely the echo. At two seconds this
1583                // passed alone and failed in the full suite, which is a dice
1584                // roll rather than a test.
1585                timeout: Duration::from_secs(10),
1586                allow_write: true,
1587                sessions: true,
1588                artifacts: &artifacts,
1589                stem: "chatty",
1590                run: "test-run",
1591                node: "test",
1592                cache_dir: None,
1593            },
1594        )
1595        .await
1596        .unwrap();
1597
1598        assert!(out.timed_out, "{out:?}");
1599        assert!(!out.usable(), "a cut-off answer is still not an answer");
1600        let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1601        assert!(
1602            recorded.contains("i-said-something"),
1603            "the artifact must keep what arrived before the kill, got {recorded:?}"
1604        );
1605        assert!(
1606            out.text.contains("i-said-something"),
1607            "and the graph must be able to see it too, got {:?}",
1608            out.text
1609        );
1610    }
1611
1612    #[test]
1613    fn missing_programs_reports_command_binaries() {
1614        let mut s = spec(AgentKind::Command, None);
1615        s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1616        assert_eq!(
1617            missing_programs(&[s]),
1618            ["definitely-not-a-real-binary-xyz".to_owned()]
1619        );
1620    }
1621}