Skip to main content

agent_abstraction/
run.rs

1//! Spawning an agent and turning its output into events and an outcome.
2//!
3//! Two entry points over the same machinery:
4//! - [`run`] waits and hands back the finished [`Outcome`].
5//! - [`stream`] hands back a [`Run`] that yields [`Event`]s as they arrive, for
6//!   a UI that shows work in progress.
7//!
8//! Both read stdout and stderr concurrently. Draining only one would deadlock
9//! the moment the other filled its pipe buffer, which for a chatty agent is a
10//! matter of seconds.
11
12use std::process::Stdio;
13
14use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
15use tokio::process::{Child, Command};
16use tokio::sync::mpsc;
17
18use crate::agent::{Continue, EnvPolicy};
19use crate::error::{Error, Result};
20use crate::event::{Event, MAX_LINE, Parser, Terminal, append_capped};
21use crate::outcome::{Outcome, Stop};
22use crate::proc::{kill_group_by_pid, kill_process_group};
23use crate::request::Request;
24
25/// Read one line, giving up on a line that never ends.
26///
27/// `AsyncBufReadExt::lines` buffers until a newline arrives, so a stream that
28/// emits megabytes without one exhausts memory before any total cap applies.
29/// This reads a bounded amount and, past the limit, returns what it has and
30/// discards the remainder of that line. Returns `None` at end of input.
31async fn read_bounded_line<R>(reader: &mut R, buf: &mut String) -> std::io::Result<Option<bool>>
32where
33    R: tokio::io::AsyncBufRead + Unpin,
34{
35    buf.clear();
36    let mut bytes = Vec::new();
37    let mut truncated = false;
38    loop {
39        let mut byte = [0u8; 1];
40        match reader.read(&mut byte).await? {
41            // End of input: a trailing fragment still counts as a line.
42            0 => {
43                if bytes.is_empty() {
44                    return Ok(None);
45                }
46                break;
47            }
48            _ if byte[0] == b'\n' => break,
49            _ => {
50                if bytes.len() < MAX_LINE {
51                    bytes.push(byte[0]);
52                } else {
53                    // Keep draining to the newline so the pipe does not block,
54                    // but stop accumulating.
55                    truncated = true;
56                }
57            }
58        }
59    }
60    // Output is not guaranteed to be valid UTF-8, and one bad byte should not
61    // end a run.
62    buf.push_str(&String::from_utf8_lossy(&bytes));
63    Ok(Some(truncated))
64}
65
66/// Aborts a task when dropped.
67///
68/// The decision forwarder holds the child's stdin, so leaving it running past
69/// the run would keep a pipe open to a process that is gone.
70struct AbortOnDrop(tokio::task::JoinHandle<()>);
71
72impl Drop for AbortOnDrop {
73    fn drop(&mut self) {
74        self.0.abort();
75    }
76}
77
78/// How many decisions may queue on the way back to the agent.
79///
80/// Small on purpose: the agent asks one question at a time and waits, so a deep
81/// queue here would only mean answers piling up for questions nobody asked.
82const APPROVAL_BUFFER: usize = 8;
83
84/// How many events may queue before the producer waits for the consumer. Deep
85/// enough that a burst of tool events does not stall the agent, shallow enough
86/// that a consumer which stops reading does not grow without bound.
87const EVENT_BUFFER: usize = 256;
88
89/// A run in progress.
90///
91/// Yields events through [`Run::recv`] and settles into an [`Outcome`] through
92/// [`Run::finish`].
93///
94/// **Dropping a `Run` kills the agent.** That is the safe default for the hosts
95/// this crate targets: closing a window or cancelling a request should stop the
96/// work, not leave an agent running invisibly, spending quota and touching
97/// files with nobody watching. Call [`Run::detach`] when background execution is
98/// genuinely what you want.
99///
100/// On Unix, dropping **synchronously signals** the run's process group and then
101/// aborts the driver task. What it cannot do is *wait*: `Drop` cannot await, so
102/// it does not block until the child has exited or its readers have been
103/// joined. Use [`Run::cancel`] when you need to know the tree has actually gone
104/// before continuing, such as before touching the files it was working on. On
105/// Windows only the direct child is signalled.
106#[derive(Debug)]
107pub struct Run {
108    events: mpsc::Receiver<Event>,
109    /// Which agent this is, so `respond` can name it in an error.
110    agent: crate::Agent,
111    /// The typed command line, kept so both the plain and redacted views come
112    /// from the same source.
113    typed: Vec<crate::agent::Arg>,
114    /// The child's pid, so `Drop` can tear the group down itself rather than
115    /// depending on an aborted task being polled.
116    pid: Option<u32>,
117    /// Set by the driver once the child has been reaped, so `Drop` never
118    /// signals a pid the OS may since have handed to someone else.
119    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
120    /// Decisions on the way back to the agent. `None` unless the request asked
121    /// for approvals, which is what makes [`Run::respond`] refuse rather than
122    /// silently do nothing on an ordinary run.
123    decisions: Option<mpsc::Sender<String>>,
124    /// Dropping or firing this asks the driver to tear down in order. Held as
125    /// an `Option` so `detach` can discard it without signalling.
126    cancel: Option<tokio::sync::oneshot::Sender<()>>,
127    /// `None` only after [`Run::finish`], [`Run::cancel`] or [`Run::detach`]
128    /// has taken ownership, which is what stops `Drop` from aborting a run that
129    /// was already settled deliberately.
130    task: Option<tokio::task::JoinHandle<Result<Outcome>>>,
131    argv: Vec<String>,
132}
133
134impl Run {
135    /// The next event, or `None` once the agent has finished producing them.
136    pub async fn recv(&mut self) -> Option<Event> {
137        self.events.recv().await
138    }
139
140    /// Answer an [`Event::ApprovalRequest`].
141    ///
142    /// The agent is blocked until this is called, so a consumer that receives an
143    /// approval request and never responds stalls the run until its timeout.
144    ///
145    /// The id must be the one from the request. The agent ignores an answer
146    /// carrying any other id and keeps waiting, so a mismatch presents as a
147    /// hang rather than an error; this passes the id straight through and does
148    /// not invent one.
149    ///
150    /// # Errors
151    /// [`Error::Unsupported`] on a run that did not ask for approvals, since
152    /// there is no channel to answer on. [`Error::Cancelled`] if the run has
153    /// already finished or been torn down, which is the same reason a decision
154    /// can no longer be delivered.
155    pub async fn respond(&self, id: &str, decision: &crate::Decision) -> Result<()> {
156        let Some(channel) = &self.decisions else {
157            return Err(Error::Unsupported {
158                agent: self.agent,
159                what: "answering an approval on a run that did not request them",
160            });
161        };
162        channel
163            .send(decision.wire(id))
164            .await
165            .map_err(|_| Error::Cancelled {
166                bin: self.argv.first().cloned().unwrap_or_default(),
167            })
168    }
169
170    /// The exact command line that was spawned.
171    ///
172    /// **This contains the prompt and any session id.** Treat it as sensitive:
173    /// logging it verbatim puts user content into your logs. Use
174    /// [`Run::redacted_argv`] for diagnostics.
175    #[must_use]
176    pub fn argv(&self) -> &[String] {
177        &self.argv
178    }
179
180    /// The command line with every non-public value replaced by a placeholder.
181    ///
182    /// Prompts, system prompts, session ids and anything from
183    /// [`crate::Request::unchecked_args`] are removed; flag names are kept so
184    /// the command stays recognisable. Sensitivity is recorded where each
185    /// argument is built rather than inferred from the finished line, so a
186    /// bare positional prompt or an opaque raw argument is covered too.
187    #[must_use]
188    pub fn redacted_argv(&self) -> Vec<String> {
189        redact(&self.typed)
190    }
191
192    /// Wait for the run to finish.
193    ///
194    /// Drains any events still queued, so a caller that only wants the result
195    /// can call this without having consumed the stream.
196    ///
197    /// # Errors
198    /// Whatever the run failed with. See [`Error`].
199    pub async fn finish(mut self) -> Result<Outcome> {
200        // The driver owns teardown from here; `Drop` must not also fire.
201        self.pid = None;
202        while self.events.recv().await.is_some() {}
203        // Taking the handle disarms the `Drop` guard: this run is settling
204        // normally, not being abandoned.
205        let Some(task) = self.task.take() else {
206            unreachable!("the handle is only taken by a consuming method")
207        };
208        match task.await {
209            Ok(result) => result,
210            // The driver task panicked or was cancelled. The process itself
211            // started fine, so this is not a spawn failure and must not claim
212            // to be one.
213            Err(join) => Err(Error::Interrupted {
214                bin: self.argv.first().cloned().unwrap_or_default(),
215                detail: if join.is_panic() {
216                    "the driver task panicked".into()
217                } else {
218                    "the driver task was cancelled".into()
219                },
220            }),
221        }
222    }
223
224    /// Stop the run and wait until the agent is actually gone.
225    ///
226    /// Cooperative rather than an abort: the driver is asked to stop, signals
227    /// the process group, reaps the child and joins its readers, and only then
228    /// does this return. So when it returns the tree really has exited, which
229    /// matters if the next thing you do touches the files it was working on.
230    ///
231    /// Returns the partial [`Outcome`] if the run happened to finish first,
232    /// otherwise [`Error::Cancelled`].
233    ///
234    /// # Errors
235    /// [`Error::Cancelled`] in the normal case, or whatever the run failed with
236    /// if it failed before the request arrived.
237    pub async fn cancel(mut self) -> Result<Outcome> {
238        // The driver tears down cooperatively and this awaits it, so `Drop`
239        // must not race that with a kill of its own.
240        self.pid = None;
241        // Dropping the sender is itself the signal, so this cannot fail in a
242        // way that leaves the driver waiting.
243        drop(self.cancel.take());
244        let Some(task) = self.task.take() else {
245            unreachable!("the handle is only taken by a consuming method")
246        };
247        match task.await {
248            Ok(result) => result,
249            Err(join) => Err(Error::Interrupted {
250                bin: self.argv.first().cloned().unwrap_or_default(),
251                detail: if join.is_panic() {
252                    "the driver task panicked".into()
253                } else {
254                    "the driver task was cancelled".into()
255                },
256            }),
257        }
258    }
259
260    /// Let the run continue after this handle goes away.
261    ///
262    /// The opposite of the default. Nothing can observe or stop the agent
263    /// afterwards, so reach for this only when an unsupervised background run
264    /// is genuinely intended.
265    pub fn detach(mut self) {
266        // Disarm `Drop` before it runs, or detaching would immediately kill the
267        // run it exists to keep alive.
268        self.pid = None;
269        // Leak the cancel signal rather than dropping it: a dropped sender is
270        // read by the driver as "stop", which is the opposite of detaching.
271        if let Some(cancel) = self.cancel.take() {
272            std::mem::forget(cancel);
273        }
274        // Dropping the handle without aborting is what detaches a tokio task.
275        drop(self.task.take());
276    }
277}
278
279impl Drop for Run {
280    fn drop(&mut self) {
281        // Abandoned rather than finished, cancelled or detached.
282        //
283        // Kill the group here, directly. Signalling the driver and aborting it
284        // is not enough on its own: that leaves teardown waiting on the runtime
285        // to poll the aborted task so its guard runs, and a dropped `Run` was
286        // observed leaving grandchildren alive and sleeping on Linux while the
287        // same teardown worked from `cancel`. `Drop` cannot await, so it does
288        // the one thing it can do synchronously.
289        if let Some(pid) = self.pid
290            && !self.reaped.load(std::sync::atomic::Ordering::SeqCst)
291        {
292            kill_group_by_pid(pid);
293        }
294        drop(self.cancel.take());
295        if let Some(task) = self.task.take() {
296            task.abort();
297        }
298    }
299}
300
301/// Placeholder substituted for a sensitive argv value.
302const REDACTED: &str = "<redacted>";
303
304/// Render a typed command line for logging, keeping flag names and replacing
305/// every value that is not `Public`.
306///
307/// Derived from the sensitivity recorded where each argument was built, so it
308/// cannot miss a case the way matching on flag names and positions can.
309fn redact(argv: &[crate::agent::Arg]) -> Vec<String> {
310    use crate::agent::Sensitivity;
311
312    argv.iter()
313        .map(|arg| match arg.sensitivity {
314            Sensitivity::Public => arg.value.clone(),
315            _ => REDACTED.to_string(),
316        })
317        .collect()
318}
319
320/// Run `request` to completion, discarding the intermediate events.
321///
322/// # Errors
323/// See [`Error`]; notably [`Error::NotInstalled`], [`Error::Timeout`],
324/// [`Error::RateLimited`] and [`Error::Failed`].
325///
326/// [`Error::Unsupported`] for a request that asked for approvals: this entry
327/// point discards events, so an approval request would reach nobody and the run
328/// would sit blocked until its timeout. Use [`stream`] instead.
329pub async fn run(request: &Request) -> Result<Outcome> {
330    if request.plan().approvals {
331        return Err(Error::Unsupported {
332            agent: request.agent,
333            what: "approvals on a run whose events are discarded; use `stream`",
334        });
335    }
336    stream(request)?.finish().await
337}
338
339/// Start `request`, returning a handle that streams its events.
340///
341/// Returns as soon as the child is spawned; the work proceeds on a task.
342///
343/// # Errors
344/// [`Error::NotInstalled`] if the binary is missing, [`Error::Unsupported`] if
345/// the agent cannot honour the request, or [`Error::Spawn`] on an OS failure.
346pub fn stream(request: &Request) -> Result<Run> {
347    // `tokio::spawn` panics outside a runtime. A fallible signature must not
348    // hide that, so the context is checked and reported as an ordinary error.
349    let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?;
350
351    // Written before the argv is built, because the argv has to name it.
352    let schema_file = match (&request.schema, request.agent.caps().schema) {
353        (Some(schema), crate::agent::SchemaSupport::File) => {
354            Some(SchemaFile::write(schema).map_err(|source| Error::Spawn {
355                bin: request.agent.bin().to_string(),
356                source,
357            })?)
358        }
359        _ => None,
360    };
361    let mut request = request.clone();
362    if let Some(file) = &schema_file {
363        request.schema_file = Some(file.0.display().to_string());
364    }
365    let request = &request;
366
367    let plan = request.plan();
368    let typed = request.typed_argv()?;
369    let argv: Vec<String> = typed.iter().map(|a| a.value.clone()).collect();
370
371    let mut command = Command::new(&argv[0]);
372    command
373        .args(&argv[1..])
374        .stdin(if plan.stdin_prompt || plan.approvals {
375            // An approvals run needs stdin for the whole turn, not just to
376            // deliver a prompt: it is the channel every decision travels back
377            // on.
378            Stdio::piped()
379        } else {
380            // Close stdin so an agent that would otherwise wait on it exits
381            // instead of hanging forever with nothing to read.
382            Stdio::null()
383        })
384        .stdout(Stdio::piped())
385        .stderr(Stdio::piped())
386        // Without this a killed run can leave the child alive holding the pipes.
387        .kill_on_drop(true);
388    if let Some(cwd) = &request.cwd {
389        command.current_dir(cwd);
390    }
391    // Narrow the environment first, then apply explicit variables, so an
392    // explicit `env()` always wins over the policy.
393    match &request.env_policy {
394        EnvPolicy::Inherit => {}
395        EnvPolicy::Minimal => {
396            command.env_clear();
397            inherit_named(&mut command, &request.agent.essential_env());
398        }
399        EnvPolicy::Only(names) => {
400            command.env_clear();
401            inherit_named(&mut command, names);
402        }
403    }
404    for (key, value) in &request.env {
405        command.env(key, value);
406    }
407
408    // Put the agent in its own process group so the whole tree can be signalled
409    // together. Killing only the CLI leaves the commands *it* spawned running:
410    // a build, a test run, a server, still holding files and credentials after
411    // the run is supposedly over.
412    // 0 means "make this child its own group leader". `tokio::process::Command`
413    // exposes this directly on unix.
414    #[cfg(unix)]
415    command.process_group(0);
416
417    // Reserve an assigned session id before the child exists. Doing it inside
418    // the driver leaves a window where a spawn that half-succeeds loses the
419    // binding, and this is the id the caller may already be showing in a UI.
420    if let Some(token) = preassigned_token(request) {
421        persist_session(request, &token)?;
422    }
423
424    let child = command.spawn().map_err(|source| {
425        // A missing binary is the common case and deserves an actionable error
426        // with an install hint. Reading it off the spawn avoids resolving PATH
427        // twice, and with it the window where the resolved path is replaced
428        // between the check and the exec.
429        if source.kind() == std::io::ErrorKind::NotFound {
430            Error::NotInstalled {
431                agent: request.agent,
432                bin: plan.bin.clone(),
433                hint: request.agent.install_hint(),
434            }
435        } else {
436            Error::Spawn {
437                bin: plan.bin.clone(),
438                source,
439            }
440        }
441    })?;
442
443    let request_agent = request.agent;
444    let pid = child.id();
445    let (tx, rx) = mpsc::channel(EVENT_BUFFER);
446    // Only created for an approvals run, so `respond` can tell "no channel" from
447    // "channel closed" and refuse the first rather than hanging on it.
448    let (decisions_tx, decisions_rx) = if plan.approvals {
449        let (tx, rx) = mpsc::channel::<String>(APPROVAL_BUFFER);
450        (Some(tx), Some(rx))
451    } else {
452        (None, None)
453    };
454    let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
455    let reaped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
456    let reaped_for_task = std::sync::Arc::clone(&reaped);
457    let request = request.clone();
458    let task = runtime.spawn(async move {
459        // Moved in so the file outlives the run and is removed with it.
460        let _schema_file = schema_file;
461        drive(child, request, tx, cancel_rx, reaped_for_task, decisions_rx).await
462    });
463    Ok(Run {
464        events: rx,
465        agent: request_agent,
466        typed,
467        pid,
468        reaped,
469        cancel: Some(cancel_tx),
470        decisions: decisions_tx,
471        task: Some(task),
472        argv,
473    })
474}
475
476/// Copy the named variables from this process into `command`, skipping any that
477/// are unset so nothing is invented.
478fn inherit_named<S: AsRef<str>>(command: &mut Command, names: &[S]) {
479    for name in names {
480        if let Some(value) = std::env::var_os(name.as_ref()) {
481            command.env(name.as_ref(), value);
482        }
483    }
484}
485
486/// A schema file written for one run, removed when the run ends.
487///
488/// Codex reads its schema from disk, so the file has to outlive the spawn and
489/// not outlive the process. Tying it to a guard means every exit path removes
490/// it, including a cancel or a timeout, without each one remembering.
491struct SchemaFile(std::path::PathBuf);
492
493impl SchemaFile {
494    /// Write `schema` somewhere the agent can read it.
495    fn write(schema: &str) -> std::io::Result<SchemaFile> {
496        use std::io::Write as _;
497        use std::sync::atomic::{AtomicU64, Ordering};
498        static COUNTER: AtomicU64 = AtomicU64::new(0);
499
500        let path = std::env::temp_dir().join(format!(
501            "agent-abstraction-schema-{}-{}.json",
502            std::process::id(),
503            COUNTER.fetch_add(1, Ordering::Relaxed)
504        ));
505        let mut options = std::fs::OpenOptions::new();
506        options.write(true).create_new(true);
507        // A schema can encode what a caller is looking for, so it is no more
508        // public than the prompt.
509        #[cfg(unix)]
510        {
511            use std::os::unix::fs::OpenOptionsExt as _;
512            options.mode(0o600);
513        }
514        options.open(&path)?.write_all(schema.as_bytes())?;
515        Ok(SchemaFile(path))
516    }
517}
518
519impl Drop for SchemaFile {
520    fn drop(&mut self) {
521        let _ = std::fs::remove_file(&self.0);
522    }
523}
524
525/// Owns the child and tears down its whole process group when dropped.
526///
527/// `kill_on_drop` alone is not enough: it kills the CLI, leaving the commands
528/// *it* spawned running. Since aborting the driver task drops this guard, the
529/// same teardown covers cancellation, a dropped [`Run`] and a timeout, without
530/// each path having to remember to do it.
531struct ChildGuard {
532    child: Child,
533    /// Cleared once the child has been reaped, so a pid the OS may since have
534    /// recycled is never signalled.
535    armed: bool,
536}
537
538impl Drop for ChildGuard {
539    fn drop(&mut self) {
540        if self.armed {
541            kill_process_group(&self.child);
542        }
543    }
544}
545
546/// Feed the child, read both its pipes, and assemble the outcome.
547#[allow(
548    clippy::too_many_lines,
549    reason = "one linear lifecycle: feed, read, wait, classify. Splitting it \
550              would thread the child, parser, buffers and cancellation state \
551              through helpers and obscure the ordering that matters, such as \
552              killing the group before reaping."
553)]
554async fn drive(
555    child: Child,
556    request: Request,
557    events: mpsc::Sender<Event>,
558    cancel: tokio::sync::oneshot::Receiver<()>,
559    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
560    decisions: Option<mpsc::Receiver<String>>,
561) -> Result<Outcome> {
562    // From here on the child is owned by a guard, so every exit path from this
563    // task, including an abort, takes the process group with it.
564    let mut child = ChildGuard { child, armed: true };
565    let plan = request.plan();
566    let bin = plan.bin.clone();
567
568    // An approvals run owns stdin for the whole turn: the handshake and the
569    // prompt go out first, then it stays open carrying decisions until the run
570    // ends. Closing it after the prompt, as the plain piped path does, would
571    // take the answer channel with it.
572    let mut decision_task = None;
573    let mut close_stdin = None;
574    if plan.approvals {
575        let Some(mut stdin) = child.child.stdin.take() else {
576            return Err(Error::Spawn {
577                bin: bin.clone(),
578                source: std::io::Error::other("stdin was not piped for an approvals run"),
579            });
580        };
581        let opening = format!(
582            "{}{}",
583            crate::approval::handshake(),
584            crate::approval::user_message(&request.agent.effective_prompt(&plan)),
585        );
586        stdin
587            .write_all(opening.as_bytes())
588            .await
589            .map_err(|source| Error::Spawn {
590                bin: bin.clone(),
591                source,
592            })?;
593        let _ = stdin.flush().await;
594        // Forwarding runs on its own task so a decision can be written while
595        // stdout is being read. It ends on whichever comes first: the channel
596        // closing, or the turn settling.
597        let (close_tx, mut close_rx) = tokio::sync::oneshot::channel::<()>();
598        close_stdin = Some(close_tx);
599        decision_task = decisions.map(|mut rx| {
600            tokio::spawn(async move {
601                loop {
602                    tokio::select! {
603                        reply = rx.recv() => {
604                            let Some(reply) = reply else { break };
605                            if stdin.write_all(reply.as_bytes()).await.is_err() {
606                                break;
607                            }
608                            let _ = stdin.flush().await;
609                        }
610                        // The turn is over. Dropping stdin is what lets claude
611                        // exit rather than wait for another message.
612                        _ = &mut close_rx => break,
613                    }
614                }
615                drop(stdin);
616            })
617        });
618    }
619
620    // Deliver a piped prompt and close the pipe, or the agent waits on EOF.
621    if plan.stdin_prompt {
622        if let Some(mut stdin) = child.child.stdin.take() {
623            let prompt = request.agent.effective_prompt(&plan);
624            stdin
625                .write_all(prompt.as_bytes())
626                .await
627                .map_err(|source| Error::Spawn {
628                    bin: bin.clone(),
629                    source,
630                })?;
631            drop(stdin);
632        }
633    }
634
635    // Drain stderr on its own task: a full stderr pipe blocks the child even
636    // while stdout still has room.
637    // Aborted on every exit path from here, so a forwarder never survives the
638    // run it belongs to.
639    let _decision_guard = decision_task.map(AbortOnDrop);
640
641    let stderr = child.child.stderr.take();
642    let stderr_task = tokio::spawn(async move {
643        let mut buf = String::new();
644        if let Some(handle) = stderr {
645            let mut reader = BufReader::new(handle);
646            let mut line = String::new();
647            // Keep draining after the cap is hit: an undrained pipe blocks the
648            // child even though we no longer want the bytes.
649            while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
650                append_capped(&mut buf, &line);
651            }
652        }
653        buf
654    });
655
656    let stdout = child.child.stdout.take();
657    let mut parser = Parser::new(request.agent, plan.format);
658    // Raw stdout is retained only as a fallback answer for a run that exited
659    // cleanly without producing a structured one, and as evidence when
660    // classifying a failure. It is capped for the same reason as everything
661    // else here: an agent can stream for hours.
662    let mut raw = String::new();
663    // Tracks the first `Started`, so the binding is written once, and carries a
664    // store failure back out instead of discarding it.
665    let mut bound = false;
666    let mut persist_result: Result<()> = Ok(());
667
668    let read_stdout = async {
669        if let Some(handle) = stdout {
670            let mut reader = BufReader::new(handle);
671            let mut line = String::new();
672            while read_bounded_line(&mut reader, &mut line).await?.is_some() {
673                append_capped(&mut raw, &line);
674                let parsed = parser.push(&line);
675                // Close stdin as soon as the turn settles. Under stream-json
676                // input claude waits for another message otherwise, so the run
677                // would only end at its timeout even though the answer already
678                // arrived.
679                if parser.saw_terminal()
680                    && let Some(close) = close_stdin.take()
681                {
682                    let _ = close.send(());
683                }
684                for event in parsed {
685                    // Bind a printed id the moment it appears rather than at the
686                    // end. Codex announces its thread before answering, so a
687                    // turn killed mid-answer stays resumable.
688                    if let Event::Started { session, .. } = &event
689                        && !bound
690                    {
691                        bound = true;
692                        persist_result = persist_session(&request, session);
693                    }
694                    // A receiver that went away is not a failure: the run should
695                    // still finish and produce its outcome.
696                    if events.send(event).await.is_err() {
697                        break;
698                    }
699                }
700            }
701        }
702        Ok::<_, std::io::Error>(())
703    };
704
705    // Race three outcomes: the run finishing, the deadline, and a cancellation
706    // request. Reading and waiting are one future so a child that produces
707    // output forever is still bounded by the timeout.
708    let work = async {
709        read_stdout.await?;
710        child.child.wait().await
711    };
712    // A timeout is optional; `pending()` makes the un-timed case the same shape
713    // rather than duplicating the whole select.
714    let deadline = async {
715        match request.timeout {
716            Some(limit) => tokio::time::sleep(limit).await,
717            None => std::future::pending().await,
718        }
719    };
720
721    let status = tokio::select! {
722        // Biased so a finished run is reported as finished even if a deadline
723        // or cancellation lands in the same tick.
724        biased;
725        result = work => result,
726        () = deadline => {
727            // Order matters: signal the group *before* reaping. Reaping clears
728            // the child's pid, and the group kill needs that pid to target the
729            // group, so the other order silently leaves grandchildren running.
730            let partial = shut_down(&mut child, stderr_task).await;
731            reaped.store(true, std::sync::atomic::Ordering::SeqCst);
732            return Err(Error::Timeout {
733                bin,
734                timeout: request.timeout.unwrap_or_default(),
735                partial: parser.finish().text,
736            })
737            .inspect_err(|_| drop(partial));
738        }
739        _ = cancel => {
740            // Cooperative teardown: the caller is waiting on this, so the tree
741            // is signalled, reaped and joined before returning.
742            shut_down(&mut child, stderr_task).await;
743            reaped.store(true, std::sync::atomic::Ordering::SeqCst);
744            return Err(Error::Cancelled { bin });
745        }
746    }
747    .map_err(|source| Error::Spawn {
748        bin: bin.clone(),
749        source,
750    })?;
751
752    // The child has been reaped, so its pid must not be signalled again, by the
753    // guard here or by `Run::drop` racing this.
754    child.armed = false;
755    reaped.store(true, std::sync::atomic::Ordering::SeqCst);
756
757    drop(events);
758    let stderr = stderr_task.await.unwrap_or_default();
759    let saw_structured = parser.saw_structured_record();
760    let saw_terminal = parser.saw_terminal_record();
761    let terminal = parser.finish();
762    let exit_code = status.code().unwrap_or(-1);
763
764    // Under a structured format, silently handing back raw stdout would turn a
765    // protocol failure into a plausible-looking answer. A run that recognized
766    // nothing, or never reached its terminal record, did not produce a result
767    // this crate can vouch for, so it is reported rather than papered over.
768    let structured = plan.format != crate::Format::Text;
769    if structured && exit_code == 0 {
770        if !saw_structured {
771            return Err(Error::Parse {
772                agent: request.agent,
773                detail: format!(
774                    "no recognizable {} records in {} lines of output;                      the CLI's output shape has probably changed",
775                    request.agent,
776                    raw.lines().count()
777                ),
778            });
779        }
780        if !saw_terminal {
781            return Err(Error::Parse {
782                agent: request.agent,
783                detail: "the stream ended without its terminal record, so the turn                          did not complete"
784                    .into(),
785            });
786        }
787    }
788
789    // Plain text has no structure to validate: the stream is the answer.
790    let mut terminal = terminal;
791    if terminal.text.is_empty() && !structured {
792        terminal.text = raw.trim().to_string();
793    }
794
795    // A provider refusal is not always an exit code. Claude can report a
796    // blocking `rate_limit_event` and still exit 0, and the crate promises that
797    // quota refusals surface as `Error::RateLimited`, so the terminal state is
798    // checked regardless of how the process exited.
799    let quota_blocked = terminal
800        .rate_limit
801        .as_ref()
802        .is_some_and(crate::outcome::RateLimit::is_blocking);
803    // An unauthenticated Claude run exits 0 and reports the problem in its
804    // result text, so checking only the exit code would hand back a successful
805    // Outcome whose answer is "Please run /login".
806    //
807    // Read from stderr and the agent's own prose rather than the raw stream, for
808    // the reason `classify` does the same with quota wording: a phrase hunted
809    // through structured output matches ids and field names, not statements.
810    let unauthenticated = looks_unauthenticated(&terminal.text) || looks_unauthenticated(&stderr);
811    // The agent saying its turn failed is as much a failure as a non-zero exit,
812    // and Claude reports an unknown model exactly this way: exit 0, `is_error`
813    // true, and the explanation where the answer would be.
814    let turn_failed = terminal.stop == Stop::Error;
815    if exit_code != 0 || quota_blocked || unauthenticated || turn_failed {
816        return Err(classify_run(
817            request.agent,
818            &bin,
819            exit_code,
820            &stderr,
821            &raw,
822            &terminal,
823        ));
824    }
825
826    // A fork lands on a *new* id the agent only reveals at the end, so the name
827    // has to be repointed once the run settles. Everything else was bound above.
828    persist_result?;
829    // Resolved before the terminal is consumed by the Outcome below.
830    let structured = terminal.structured.clone().or_else(|| {
831        request
832            .schema
833            .as_ref()
834            .and_then(|_| serde_json::from_str(&terminal.text).ok())
835    });
836    if let Some(token) = &terminal.session
837        && !bound
838    {
839        persist_session(&request, token)?;
840    }
841    Ok(Outcome {
842        agent: request.agent,
843        session: terminal.session,
844        text: terminal.text,
845        usage: terminal.usage,
846        stop: terminal.stop,
847        rate_limit: terminal.rate_limit,
848        exit_code,
849        stderr,
850        unparsed: terminal.unparsed,
851        first_unparsed: terminal.first_unparsed,
852        // Claude reports the conforming value separately; Codex returns it as
853        // the answer text, so that is parsed only when a schema was asked for.
854        // Prose is never reinterpreted as data.
855        structured,
856    })
857}
858
859/// Kill the process group, reap the child, and join the stderr reader.
860///
861/// The orderly teardown both cancellation and timeout share. Returns whatever
862/// stderr had been captured, so a caller can still report why a run was stopped.
863async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle<String>) -> String {
864    kill_process_group(&child.child);
865    // Reap, so the caller is not left with a zombie once this returns.
866    let _ = child.child.kill().await;
867    child.armed = false;
868    // The pipes are closed now that the child is gone, so this finishes
869    // promptly rather than hanging the cancellation.
870    stderr_task.await.unwrap_or_default()
871}
872
873/// Turn a failure into the most specific error available, agent included so an
874/// auth failure can carry the right login command.
875fn classify_run(
876    agent: crate::Agent,
877    bin: &str,
878    code: i32,
879    stderr: &str,
880    stdout: &str,
881    terminal: &Terminal,
882) -> Error {
883    // Checked before quota and before a plain failure: a login problem is the
884    // most specific reading of the output, and the only one a user can act on
885    // directly.
886    for source in [terminal.text.as_str(), stderr, stdout] {
887        if looks_unauthenticated(source) {
888            return Error::NotAuthenticated {
889                agent,
890                bin: bin.to_string(),
891                message: first_meaningful_line(source).unwrap_or_default(),
892                hint: agent.login_hint(),
893            };
894        }
895    }
896    classify(agent, bin, code, stderr, stdout, terminal)
897}
898
899/// Whether text is an agent saying it has no usable credentials.
900///
901/// Narrow on purpose. Mislabelling an ordinary failure as an auth problem sends
902/// someone to re-login over something unrelated, so these are phrases the CLIs
903/// actually emit rather than every string containing "auth".
904fn looks_unauthenticated(text: &str) -> bool {
905    const PHRASES: &[&str] = &[
906        // Claude, verified: an unauthenticated run answers exactly this.
907        "not logged in",
908        "please run /login",
909        // Copilot, verified: it exits 1 with plain text, and none of the other
910        // phrases here appear in it. Its wording shares no vocabulary with the
911        // other two, which is why this had to be observed rather than guessed.
912        "no authentication information",
913        "invalid api key",
914        "authentication_error",
915        "unauthorized",
916        "not authenticated",
917        "no credentials",
918        "credentials not found",
919        "please log in",
920    ];
921    let lower = text.to_ascii_lowercase();
922    PHRASES.iter().any(|needle| lower.contains(needle)) || mentions_status(&lower, "401")
923}
924
925/// Whether `code` appears as a standalone token rather than inside a longer run
926/// of characters.
927///
928/// `401` was previously matched as a bare substring, which made any Copilot
929/// failure an auth failure whenever one of the UUIDs it prints happened to
930/// contain those three digits: `"id":"1b0b1401-cb86-..."` was enough. That is
931/// not rare, since a run emits several ids, so the misdiagnosis was
932/// intermittent and told someone to re-login over an unrelated failure.
933///
934/// A status code is a word. Requiring non-alphanumeric neighbours keeps
935/// `HTTP 401` and `(status 401)` while rejecting every hex blob, and a UUID
936/// cannot produce a standalone `401` at all because its groups are four, eight
937/// or twelve characters long.
938fn mentions_status(haystack: &str, code: &str) -> bool {
939    haystack.match_indices(code).any(|(at, _)| {
940        let before = haystack[..at].chars().next_back();
941        let after = haystack[at + code.len()..].chars().next();
942        let free = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
943        free(before) && free(after)
944    })
945}
946
947/// Turn a non-zero exit into the most specific error available.
948fn classify(
949    agent: crate::Agent,
950    bin: &str,
951    code: i32,
952    stderr: &str,
953    stdout: &str,
954    terminal: &Terminal,
955) -> Error {
956    let quota_signalled = terminal
957        .rate_limit
958        .as_ref()
959        .is_some_and(crate::outcome::RateLimit::is_blocking);
960    // Scanning the *raw* stream for quota wording is a false-positive machine:
961    // under `stream-json` Claude prints a `rate_limit_event` record on every
962    // run, including one whose status is `allowed`, so the substring
963    // `rate_limit` is present in perfectly healthy output. Where the stream
964    // parsed, the parsed signal and the agent's own prose decide; the raw scan
965    // is only the fallback for output that produced neither.
966    let prose = match (&terminal.error_message, terminal.text.as_str()) {
967        (Some(message), text) => format!("{message}\n{text}"),
968        (None, text) if !text.is_empty() => text.to_string(),
969        _ => stdout.to_string(),
970    };
971    if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(&prose) {
972        return Error::RateLimited {
973            bin: bin.to_string(),
974            message: first_meaningful_line(stderr)
975                .or_else(|| first_meaningful_line(&prose))
976                .unwrap_or_else(|| "usage limit reached".to_string()),
977        };
978    }
979    // A rejected flag is not a failed request, it is this crate and the CLI
980    // disagreeing about what the CLI accepts. Naming that is the difference
981    // between "the run failed" and "your codex is a different version".
982    if let Some(detail) = rejected_flag(stderr).or_else(|| rejected_flag(stdout)) {
983        return Error::FlagRejected {
984            bin: bin.to_string(),
985            detail,
986        };
987    }
988    // Checked before the generic failure but after quota and a rejected flag,
989    // which are more specific readings of the same output.
990    if terminal.stop == Stop::Error {
991        return Error::AgentError {
992            agent,
993            bin: bin.to_string(),
994            status: terminal.error_status,
995            // Codex reports the reason apart from the answer; Claude puts it
996            // where the answer would be.
997            message: terminal
998                .error_message
999                .clone()
1000                .or_else(|| first_meaningful_line(&terminal.text))
1001                .or_else(|| first_meaningful_line(stderr))
1002                .unwrap_or_else(|| "the agent reported a failure without explaining it".into()),
1003        };
1004    }
1005
1006    Error::Failed {
1007        bin: bin.to_string(),
1008        code,
1009        // Fall back to stdout when stderr explains nothing. Codex reports a
1010        // rejected schema as an `{"type":"error"}` event on *stdout* while
1011        // stderr carries only "Reading additional input from stdin...", so
1012        // reporting stderr alone describes the failure as a status message.
1013        stderr: first_meaningful_line(stderr)
1014            .filter(|line| looks_explanatory(line))
1015            .or_else(|| first_meaningful_line(stdout))
1016            .or_else(|| first_meaningful_line(stderr))
1017            .unwrap_or_default(),
1018    }
1019}
1020
1021/// Whether a line plausibly explains a failure rather than narrating progress.
1022fn looks_explanatory(line: &str) -> bool {
1023    const NOISE: &[&str] = &[
1024        "reading additional input",
1025        "reading prompt",
1026        "waiting",
1027        "connecting",
1028        "loading",
1029    ];
1030    let lower = line.to_ascii_lowercase();
1031    !NOISE.iter().any(|noise| lower.contains(noise))
1032}
1033
1034/// The CLI's complaint, if it refused an argument.
1035///
1036/// The phrasings are clap's and commander's, which is what all three CLIs are
1037/// built on. Matched narrowly: a false positive would relabel a genuine failure
1038/// as a version problem and send someone chasing the wrong thing.
1039fn rejected_flag(text: &str) -> Option<String> {
1040    const REJECTIONS: &[&str] = &[
1041        "unexpected argument",
1042        "unknown option",
1043        "unrecognized option",
1044        "unknown flag",
1045        "invalid option",
1046        "unexpected option",
1047    ];
1048    let lower = text.to_ascii_lowercase();
1049    REJECTIONS
1050        .iter()
1051        .any(|needle| lower.contains(needle))
1052        .then(|| first_meaningful_line(text).unwrap_or_default())
1053}
1054
1055/// Whether text carries a provider quota refusal.
1056///
1057/// Deliberately a small set of unambiguous phrases: a false positive here would
1058/// relabel an ordinary failure as a quota problem and send a caller into a
1059/// pointless backoff.
1060fn looks_rate_limited(text: &str) -> bool {
1061    let lower = text.to_ascii_lowercase();
1062    [
1063        "rate limit",
1064        "rate_limit",
1065        "usage limit",
1066        "quota exceeded",
1067        "too many requests",
1068        "429",
1069    ]
1070    .iter()
1071    .any(|needle| lower.contains(needle))
1072}
1073
1074/// The most useful line of a CLI's output for an error message.
1075///
1076/// Not simply the first non-blank one. CLIs open with progress and status
1077/// chatter, so the first line is often "Reading additional input from stdin..."
1078/// while the actual cause is further down. That turns a report into a
1079/// misdirection: it looks like an explanation and is not one.
1080///
1081/// So a line that looks like an error wins, and the first non-blank line is the
1082/// fallback when nothing does.
1083fn first_meaningful_line(text: &str) -> Option<String> {
1084    const ERROR_MARKERS: &[&str] = &[
1085        "error",
1086        "failed",
1087        "fatal",
1088        "panic",
1089        "denied",
1090        "invalid",
1091        "unexpected",
1092        "cannot",
1093        "unable",
1094    ];
1095    let lines: Vec<&str> = text
1096        .lines()
1097        .map(str::trim)
1098        .filter(|line| !line.is_empty())
1099        .collect();
1100
1101    lines
1102        .iter()
1103        .find(|line| {
1104            let lower = line.to_ascii_lowercase();
1105            ERROR_MARKERS.iter().any(|marker| lower.contains(marker))
1106        })
1107        .or_else(|| lines.first())
1108        .map(|line| (*line).to_string())
1109}
1110
1111/// Write the session binding back, reporting any store failure.
1112///
1113/// Called as soon as an id is known rather than only on a clean exit. Waiting
1114/// for success would lose the binding for exactly the runs where continuity
1115/// matters most: a timeout, a crash, or a cancelled turn.
1116fn persist_session(request: &Request, token: &str) -> Result<()> {
1117    let Some(binding) = &request.binding else {
1118        return Ok(());
1119    };
1120    binding
1121        .store
1122        .bind(request.agent, &binding.project, &binding.name, token)
1123        .map(|_| ())
1124}
1125
1126/// The id this run is already known by before it starts, if any.
1127///
1128/// Only a caller-assigned id qualifies: a printed id does not exist yet. This
1129/// is what makes an assigned session survive a run that never finishes.
1130fn preassigned_token(request: &Request) -> Option<String> {
1131    match &request.plan().cont {
1132        Continue::NewWith(id) => Some(id.clone()),
1133        _ => None,
1134    }
1135}
1136
1137/// Reported by an agent that exited cleanly but said nothing useful.
1138impl Outcome {
1139    /// Whether the agent produced any answer at all.
1140    #[must_use]
1141    pub fn is_empty(&self) -> bool {
1142        self.text.trim().is_empty() && self.stop == Stop::Completed
1143    }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    use super::*;
1149    use crate::agent::Agent;
1150
1151    #[test]
1152    fn quota_phrases_are_recognized_and_ordinary_errors_are_not() {
1153        assert!(looks_rate_limited("Error: rate limit exceeded"));
1154        assert!(looks_rate_limited("HTTP 429 Too Many Requests"));
1155        assert!(looks_rate_limited("You have hit your usage limit"));
1156        // A plain failure must not be mistaken for a quota problem.
1157        assert!(!looks_rate_limited("error: no such file or directory"));
1158        assert!(!looks_rate_limited("model not found"));
1159    }
1160
1161    #[test]
1162    fn a_blocking_rate_limit_event_classifies_as_rate_limited() {
1163        let terminal = Terminal {
1164            rate_limit: Some(crate::outcome::RateLimit {
1165                status: "rejected".into(),
1166                window: Some("five_hour".into()),
1167                resets_at: None,
1168                overage_status: None,
1169                is_using_overage: None,
1170            }),
1171            ..Terminal::default()
1172        };
1173        assert!(matches!(
1174            classify(Agent::Claude, "claude", 1, "", "", &terminal),
1175            Error::RateLimited { .. }
1176        ));
1177    }
1178
1179    #[test]
1180    fn an_allowed_rate_limit_event_is_not_a_failure_cause() {
1181        let terminal = Terminal {
1182            rate_limit: Some(crate::outcome::RateLimit {
1183                status: "allowed".into(),
1184                window: None,
1185                resets_at: None,
1186                overage_status: None,
1187                is_using_overage: None,
1188            }),
1189            ..Terminal::default()
1190        };
1191        assert!(matches!(
1192            classify(Agent::Claude, "claude", 1, "boom", "", &terminal),
1193            Error::Failed { .. }
1194        ));
1195    }
1196
1197    /// The exact shape that made a Copilot run look unauthenticated: a UUID
1198    /// carrying the digits 401. Copilot prints several ids per run, so this
1199    /// misfired intermittently and told the user to re-login over a failure
1200    /// that had nothing to do with credentials.
1201    #[test]
1202    fn an_id_containing_401_is_not_an_auth_failure() {
1203        let line = r#"{"type":"session.mcp_server_status_changed","id":"1b0b1401-cb86-4276-9874-e84b94c96499"}"#;
1204        assert!(
1205            !looks_unauthenticated(line),
1206            "a hex blob is not a status code"
1207        );
1208    }
1209
1210    /// The needle still has to work where it was meant to. A status code is a
1211    /// word, and these are the forms an agent actually prints.
1212    #[test]
1213    fn a_real_401_is_still_recognized() {
1214        for text in [
1215            "HTTP 401",
1216            "request failed (status 401)",
1217            "401: unauthorized",
1218            "got a 401 from the API",
1219        ] {
1220            assert!(looks_unauthenticated(text), "should match: {text}");
1221        }
1222    }
1223
1224    /// Neighbouring digits mean it is part of some longer number, not a status.
1225    #[test]
1226    fn digits_around_401_keep_it_from_matching() {
1227        for text in ["error 4010", "code 1401", "seq 24019"] {
1228            assert!(!looks_unauthenticated(text), "should not match: {text}");
1229        }
1230    }
1231
1232    /// Verbatim from a healthy claude 2.1.205 run. Every `stream-json` run
1233    /// carries this record, and its status is `allowed`: nothing is refused.
1234    /// Scanning the raw stream for `rate_limit` matched it anyway, so any
1235    /// Claude failure was reported as a quota refusal, sending a caller to back
1236    /// off when the real cause was something they could fix.
1237    #[test]
1238    fn a_healthy_rate_limit_heartbeat_is_not_a_refusal() {
1239        let stdout = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785331800,"rateLimitType":"five_hour","overageStatus":"rejected","isUsingOverage":false}}"#;
1240        let terminal = Terminal {
1241            stop: Stop::Error,
1242            error_status: Some(404),
1243            text: "There's an issue with the selected model (bogus-model-xyz).".into(),
1244            rate_limit: Some(crate::outcome::RateLimit {
1245                status: "allowed".into(),
1246                window: Some("five_hour".into()),
1247                resets_at: Some(1_785_331_800),
1248                overage_status: None,
1249                is_using_overage: None,
1250            }),
1251            ..Terminal::default()
1252        };
1253        let err = classify_run(Agent::Claude, "claude", 0, "", stdout, &terminal);
1254        assert!(
1255            matches!(err, Error::AgentError { .. }),
1256            "the heartbeat must not mask the real cause: {err:?}"
1257        );
1258    }
1259
1260    /// The counterpart: a refusal the parser did read must still be one, even
1261    /// though it arrives with the same zero exit code.
1262    #[test]
1263    fn a_rejected_quota_signal_is_still_a_refusal() {
1264        let terminal = Terminal {
1265            rate_limit: Some(crate::outcome::RateLimit {
1266                status: "rejected".into(),
1267                window: Some("five_hour".into()),
1268                resets_at: None,
1269                overage_status: None,
1270                is_using_overage: None,
1271            }),
1272            ..Terminal::default()
1273        };
1274        assert!(matches!(
1275            classify_run(Agent::Claude, "claude", 0, "", "", &terminal),
1276            Error::RateLimited { .. }
1277        ));
1278    }
1279
1280    /// Verbatim from a real run with an unknown model. Claude exits **0** with
1281    /// `subtype: "success"` while `is_error` is true and the explanation sits
1282    /// where the answer would be, so a caller checking only `Result::is_ok`
1283    /// renders "There's an issue with the selected model" as the answer.
1284    #[test]
1285    fn a_failed_turn_is_an_error_even_though_the_process_exited_cleanly() {
1286        let terminal = Terminal {
1287            stop: Stop::Error,
1288            error_status: Some(404),
1289            text: "There's an issue with the selected model (bogus-model-xyz). \
1290                   It may not exist or you may not have access to it."
1291                .into(),
1292            ..Terminal::default()
1293        };
1294        let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1295        let Error::AgentError {
1296            agent,
1297            status,
1298            message,
1299            ..
1300        } = &err
1301        else {
1302            panic!("expected AgentError, got {err:?}")
1303        };
1304        assert_eq!(*agent, Agent::Claude);
1305        assert_eq!(*status, Some(404), "the provider status must survive");
1306        assert!(message.contains("selected model"), "{message}");
1307    }
1308
1309    /// A quota refusal and a missing login are more specific readings of the
1310    /// same shape, so they must not be swallowed by the general case.
1311    #[test]
1312    fn a_failed_turn_does_not_mask_a_more_specific_cause() {
1313        let auth = Terminal {
1314            stop: Stop::Error,
1315            text: "Not logged in · Please run /login".into(),
1316            ..Terminal::default()
1317        };
1318        assert!(
1319            classify_run(Agent::Claude, "claude", 0, "", "", &auth).is_auth_failure(),
1320            "an unauthenticated failed turn must stay an auth failure"
1321        );
1322
1323        let quota = Terminal {
1324            stop: Stop::Error,
1325            rate_limit: Some(crate::outcome::RateLimit {
1326                status: "rejected".into(),
1327                window: None,
1328                resets_at: None,
1329                overage_status: None,
1330                is_using_overage: None,
1331            }),
1332            ..Terminal::default()
1333        };
1334        assert!(
1335            matches!(
1336                classify_run(Agent::Claude, "claude", 0, "", "", &quota),
1337                Error::RateLimited { .. }
1338            ),
1339            "a quota-blocked failed turn must stay a rate limit"
1340        );
1341    }
1342
1343    /// Verified against the real CLI: with `USER` withheld, claude answers
1344    /// "Not logged in · Please run /login" and exits **0**. Checking only the
1345    /// exit code hands back a successful Outcome whose answer is a login
1346    /// prompt.
1347    #[test]
1348    fn an_unauthenticated_run_is_named_even_though_it_exits_zero() {
1349        let terminal = Terminal {
1350            text: "Not logged in · Please run /login".into(),
1351            ..Terminal::default()
1352        };
1353        let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1354        let Error::NotAuthenticated { agent, hint, .. } = &err else {
1355            panic!("expected NotAuthenticated, got {err:?}")
1356        };
1357        assert_eq!(*agent, Agent::Claude);
1358        assert!(hint.contains("/login"), "{hint}");
1359        assert!(err.is_auth_failure());
1360    }
1361
1362    /// Verbatim from an unauthenticated Copilot run, captured by pointing it at
1363    /// an empty HOME. Its wording shares no phrase with Claude's or Codex's, so
1364    /// before this was observed the phrase list did not match it at all and a
1365    /// missing Copilot login was reported as a generic failure.
1366    #[test]
1367    fn copilots_own_unauthenticated_wording_is_recognized() {
1368        let stderr = "Error: No authentication information found.\n\n\
1369                      Copilot can be authenticated with GitHub using an OAuth Token or a \
1370                      Fine-Grained Personal Access Token.\n\n\
1371                      To authenticate, you can use any of the following methods:\n\
1372                      \u{2022} Start 'copilot' and run the '/login' command\n\
1373                      \u{2022} Set the COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN \
1374                      environment variable";
1375        let err = classify_run(
1376            Agent::Copilot,
1377            "copilot",
1378            1,
1379            stderr,
1380            "",
1381            &Terminal::default(),
1382        );
1383        let Error::NotAuthenticated { agent, hint, .. } = &err else {
1384            panic!("expected NotAuthenticated, got {err:?}")
1385        };
1386        assert_eq!(*agent, Agent::Copilot);
1387        assert!(hint.contains("copilot login"), "{hint}");
1388    }
1389
1390    /// Each agent's hint has to name its own login route, since they differ:
1391    /// Codex and Copilot have `login` subcommands, Claude does not.
1392    #[test]
1393    fn every_agent_offers_its_own_login_route() {
1394        for (agent, expected) in [
1395            (Agent::Claude, "setup-token"),
1396            (Agent::Codex, "codex login"),
1397            (Agent::Copilot, "copilot login"),
1398        ] {
1399            let err = classify_run(
1400                agent,
1401                agent.bin(),
1402                1,
1403                "error: unauthorized",
1404                "",
1405                &Terminal::default(),
1406            );
1407            let Error::NotAuthenticated { hint, .. } = &err else {
1408                panic!("{agent}: expected NotAuthenticated, got {err:?}")
1409            };
1410            assert!(hint.contains(expected), "{agent}: {hint}");
1411        }
1412    }
1413
1414    /// Auth is the most specific reading, so it wins over a generic failure,
1415    /// but must not swallow unrelated errors.
1416    #[test]
1417    fn ordinary_failures_are_not_mistaken_for_auth_problems() {
1418        for stderr in [
1419            "error: no such file or directory",
1420            "model not found",
1421            "rate limit exceeded",
1422            "error: unexpected argument '--sandbox' found",
1423        ] {
1424            let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
1425            assert!(
1426                !err.is_auth_failure(),
1427                "{stderr:?} was misread as an auth failure: {err:?}"
1428            );
1429        }
1430    }
1431
1432    /// The exact failure that cost a round of debugging: `codex exec resume`
1433    /// rejects `--sandbox`, which `Error::Failed` reported as a generic
1434    /// non-zero exit naming a flag rather than a version mismatch.
1435    #[test]
1436    fn a_rejected_flag_is_named_as_a_version_mismatch() {
1437        let err = classify(
1438            Agent::Codex,
1439            "codex",
1440            2,
1441            "error: unexpected argument '--sandbox' found",
1442            "",
1443            &Terminal::default(),
1444        );
1445        let Error::FlagRejected { bin, detail } = err else {
1446            panic!("expected FlagRejected, got {err:?}")
1447        };
1448        assert_eq!(bin, "codex");
1449        assert!(detail.contains("--sandbox"), "{detail}");
1450    }
1451
1452    #[test]
1453    fn ordinary_failures_are_not_mistaken_for_version_drift() {
1454        for stderr in [
1455            "error: no such file or directory",
1456            "model not found",
1457            "permission denied",
1458        ] {
1459            assert!(
1460                matches!(
1461                    classify(Agent::Codex, "codex", 1, stderr, "", &Terminal::default()),
1462                    Error::Failed { .. }
1463                ),
1464                "{stderr:?} should stay a plain failure"
1465            );
1466        }
1467    }
1468
1469    /// Real output from a failing codex run: the first line is status, the
1470    /// cause is below it. Reporting the first line looks like an explanation
1471    /// while pointing at the wrong thing.
1472    #[test]
1473    fn a_status_line_does_not_masquerade_as_the_cause() {
1474        let stderr = "Reading additional input from stdin...\n\
1475                      error: invalid value 'nope' for '--sandbox <SANDBOX_MODE>'";
1476        let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
1477        let Error::Failed {
1478            stderr: reported, ..
1479        } = err
1480        else {
1481            panic!("expected Failed, got {err:?}")
1482        };
1483        assert!(reported.contains("invalid value"), "reported {reported:?}");
1484    }
1485
1486    /// Codex reports a rejected schema as a JSON error event on **stdout**
1487    /// while stderr carries only a status line. Reporting stderr alone
1488    /// described the failure as "Reading additional input from stdin...",
1489    /// which is not what went wrong.
1490    #[test]
1491    fn a_cause_on_stdout_is_reported_when_stderr_only_narrates() {
1492        let stdout = r#"{"type":"error","message":"invalid_json_schema: 'additionalProperties' is required to be supplied and to be false."}"#;
1493        let err = classify_run(
1494            Agent::Codex,
1495            "codex",
1496            1,
1497            "Reading additional input from stdin...",
1498            stdout,
1499            &Terminal::default(),
1500        );
1501        let Error::Failed {
1502            stderr: reported, ..
1503        } = err
1504        else {
1505            panic!("expected Failed, got {err:?}")
1506        };
1507        assert!(
1508            reported.contains("additionalProperties"),
1509            "reported {reported:?}, which explains nothing"
1510        );
1511    }
1512
1513    #[test]
1514    fn failures_report_the_first_useful_line() {
1515        let err = classify(
1516            Agent::Claude,
1517            "claude",
1518            2,
1519            "\n\n  real problem  \nstack",
1520            "",
1521            &Terminal::default(),
1522        );
1523        let Error::Failed { code, stderr, .. } = err else {
1524            panic!("expected a plain failure")
1525        };
1526        assert_eq!(code, 2);
1527        assert_eq!(stderr, "real problem");
1528    }
1529
1530    /// Prompts and session ids ride the argv, and `Run::argv` invites logging
1531    /// it. The redacted form must keep the shape while dropping the content.
1532    #[test]
1533    fn redaction_removes_prompts_and_session_ids_but_keeps_flags() {
1534        let request = crate::Request::new(Agent::Claude, "my secret prompt")
1535            .system("secret system")
1536            .session_id("11111111-2222-3333-4444-555555555555");
1537        let safe = redact(&request.typed_argv().unwrap());
1538
1539        for secret in [
1540            "my secret prompt",
1541            "secret system",
1542            "11111111-2222-3333-4444-555555555555",
1543        ] {
1544            assert!(
1545                !safe.iter().any(|a| a.contains(secret)),
1546                "{secret:?} survived redaction: {safe:?}"
1547            );
1548        }
1549        // Still recognisable as the same command.
1550        assert_eq!(safe[0], "claude");
1551        assert!(safe.contains(&"--permission-mode".to_string()));
1552        assert!(safe.contains(&"--session-id".to_string()));
1553    }
1554
1555    #[test]
1556    fn codex_trailing_prompt_is_redacted_even_without_a_flag() {
1557        let request = crate::Request::new(Agent::Codex, "my secret prompt");
1558        let safe = redact(&request.typed_argv().unwrap());
1559        assert_eq!(safe.last().unwrap(), REDACTED);
1560        assert_eq!(safe[1], "exec", "the subcommand must survive");
1561    }
1562
1563    /// Redaction must cover the two shapes positional guesswork misses: Codex's
1564    /// bare trailing prompt, and raw arguments whose contents are unknowable.
1565    #[test]
1566    fn redaction_covers_positional_prompts_and_unchecked_arguments() {
1567        let request = crate::Request::new(Agent::Codex, "my secret prompt")
1568            .unchecked_args(["-c", "api_key=hunter2"]);
1569        let safe = redact(&request.typed_argv().unwrap());
1570        assert!(!safe.iter().any(|a| a.contains("my secret prompt")));
1571        assert!(
1572            !safe.iter().any(|a| a.contains("hunter2")),
1573            "unchecked arguments may hold secrets: {safe:?}"
1574        );
1575        assert_eq!(safe[1], "exec", "the subcommand must survive");
1576    }
1577
1578    /// A resume id is a capability: it continues someone's conversation.
1579    #[test]
1580    fn redaction_covers_the_codex_positional_resume_id() {
1581        let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9");
1582        let safe = redact(&request.typed_argv().unwrap());
1583        assert!(
1584            !safe.iter().any(|a| a.contains("thread-secret-9")),
1585            "{safe:?}"
1586        );
1587        assert!(safe.contains(&"resume".to_string()));
1588    }
1589
1590    /// `stream` is synchronous but spawns a task. Outside a runtime that would
1591    /// panic, which a `Result`-returning function must not do.
1592    #[test]
1593    fn stream_outside_a_runtime_errors_instead_of_panicking() {
1594        let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err();
1595        assert!(matches!(err, Error::NoRuntime), "got {err:?}");
1596    }
1597
1598    #[tokio::test]
1599    async fn a_missing_binary_names_the_install_command() {
1600        let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz");
1601        let err = run(&request).await.unwrap_err();
1602        let Error::NotInstalled { hint, agent, .. } = err else {
1603            panic!("expected NotInstalled, got {err:?}")
1604        };
1605        assert_eq!(agent, Agent::Claude);
1606        assert!(hint.contains("claude-code"));
1607    }
1608
1609    #[test]
1610    fn transient_errors_are_distinguished_from_permanent_ones() {
1611        assert!(
1612            Error::RateLimited {
1613                bin: "claude".into(),
1614                message: String::new()
1615            }
1616            .is_transient()
1617        );
1618        assert!(
1619            !Error::NotInstalled {
1620                agent: Agent::Claude,
1621                bin: "claude".into(),
1622                hint: ""
1623            }
1624            .is_transient()
1625        );
1626    }
1627}