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::collections::VecDeque;
13use std::process::Stdio;
14
15use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, Command};
17use tokio::sync::mpsc;
18
19use crate::agent::{Continue, EnvPolicy};
20use crate::error::{Error, Result};
21use crate::event::{Event, MAX_LINE, Parser, Terminal, append_capped};
22use crate::outcome::{Outcome, Stop};
23use crate::proc::{kill_group_by_pid, kill_process_group};
24use crate::request::Request;
25
26/// Read one line, giving up on a line that never ends.
27///
28/// `AsyncBufReadExt::lines` buffers until a newline arrives, so a stream that
29/// emits megabytes without one exhausts memory before any total cap applies.
30/// This reads a bounded amount and, past the limit, returns what it has and
31/// discards the remainder of that line. Returns `None` at end of input.
32async fn read_bounded_line<R>(reader: &mut R, buf: &mut String) -> std::io::Result<Option<bool>>
33where
34    R: tokio::io::AsyncBufRead + Unpin,
35{
36    buf.clear();
37    let mut bytes = Vec::new();
38    let mut truncated = false;
39    loop {
40        let mut byte = [0u8; 1];
41        match reader.read(&mut byte).await? {
42            // End of input: a trailing fragment still counts as a line.
43            0 => {
44                if bytes.is_empty() {
45                    return Ok(None);
46                }
47                break;
48            }
49            _ if byte[0] == b'\n' => break,
50            _ => {
51                if bytes.len() < MAX_LINE {
52                    bytes.push(byte[0]);
53                } else {
54                    // Keep draining to the newline so the pipe does not block,
55                    // but stop accumulating.
56                    truncated = true;
57                }
58            }
59        }
60    }
61    // Output is not guaranteed to be valid UTF-8, and one bad byte should not
62    // end a run.
63    buf.push_str(&String::from_utf8_lossy(&bytes));
64    Ok(Some(truncated))
65}
66
67/// Aborts a task when dropped.
68///
69/// The decision forwarder holds the child's stdin, so leaving it running past
70/// the run would keep a pipe open to a process that is gone.
71struct AbortOnDrop(tokio::task::JoinHandle<()>);
72
73impl Drop for AbortOnDrop {
74    fn drop(&mut self) {
75        self.0.abort();
76    }
77}
78
79/// How many decisions may queue on the way back to the agent.
80///
81/// Small on purpose: the agent asks one question at a time and waits, so a deep
82/// queue here would only mean answers piling up for questions nobody asked.
83const APPROVAL_BUFFER: usize = 8;
84
85/// How many events may queue before the producer waits for the consumer. Deep
86/// enough that a burst of tool events does not stall the agent, shallow enough
87/// that a consumer which stops reading does not grow without bound.
88const EVENT_BUFFER: usize = 256;
89
90/// A host action travelling back to an interactive agent.
91///
92/// Claude and Codex encode these differently, so the public handle preserves
93/// the intent and lets the selected transport serialize it at the boundary.
94#[derive(Debug)]
95enum Control {
96    Message(String),
97    Approval {
98        id: String,
99        decision: crate::Decision,
100    },
101}
102
103/// A run in progress.
104///
105/// Yields events through [`Run::recv`] and settles into an [`Outcome`] through
106/// [`Run::finish`].
107///
108/// **Dropping a `Run` kills the agent.** That is the safe default for the hosts
109/// this crate targets: closing a window or cancelling a request should stop the
110/// work, not leave an agent running invisibly, spending quota and touching
111/// files with nobody watching. Call [`Run::detach`] when background execution is
112/// genuinely what you want.
113///
114/// On Unix, dropping **synchronously signals** the run's process group and then
115/// aborts the driver task. What it cannot do is *wait*: `Drop` cannot await, so
116/// it does not block until the child has exited or its readers have been
117/// joined. Use [`Run::cancel`] when you need to know the tree has actually gone
118/// before continuing, such as before touching the files it was working on. On
119/// Windows only the direct child is signalled.
120#[derive(Debug)]
121pub struct Run {
122    events: mpsc::Receiver<Event>,
123    /// Which agent this is, so `respond` can name it in an error.
124    agent: crate::Agent,
125    /// The typed command line, kept so both the plain and redacted views come
126    /// from the same source.
127    typed: Vec<crate::agent::Arg>,
128    /// The child's pid, so `Drop` can tear the group down itself rather than
129    /// depending on an aborted task being polled.
130    pid: Option<u32>,
131    /// Set by the driver once the child has been reaped, so `Drop` never
132    /// signals a pid the OS may since have handed to someone else.
133    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
134    /// Lines on the way back to the agent: follow-up messages and approval
135    /// decisions share one channel because they share one stdin. `None` unless
136    /// the request opened it, which is what lets [`Run::send`] and
137    /// [`Run::respond`] refuse rather than silently do nothing.
138    to_agent: Option<mpsc::Sender<Control>>,
139    /// Dropping or firing this asks the driver to tear down in order. Held as
140    /// an `Option` so `detach` can discard it without signalling.
141    cancel: Option<tokio::sync::oneshot::Sender<()>>,
142    /// `None` only after [`Run::finish`], [`Run::cancel`] or [`Run::detach`]
143    /// has taken ownership, which is what stops `Drop` from aborting a run that
144    /// was already settled deliberately.
145    task: Option<tokio::task::JoinHandle<Result<Outcome>>>,
146    argv: Vec<String>,
147}
148
149impl Run {
150    /// The next event, or `None` once the agent has finished producing them.
151    pub async fn recv(&mut self) -> Option<Event> {
152        self.events.recv().await
153    }
154
155    /// Send another message while the agent is still working.
156    ///
157    /// The whole point of [`crate::Request::interactive`]: a user who types a
158    /// correction mid-turn should not have to wait for the turn to finish.
159    ///
160    /// The agent takes it at its **next step boundary**, not mid-token, so an
161    /// answer already being written finishes first and a long tool-using task
162    /// changes course at its next step. Verified against claude 2.1.212 and
163    /// codex-cli 0.145.0.
164    ///
165    /// # Ordering, and why there is no acknowledgement
166    ///
167    /// The caller already knows what it sent, so the intended pattern is to
168    /// append the message to the transcript immediately, below the user's
169    /// previous one, and carry on. This deliberately does not ask the agent to
170    /// echo the message back for sequencing: an echo would only tell a UI
171    /// something it already knew, and waiting for one would delay the very
172    /// thing this exists to make immediate.
173    ///
174    /// # Errors
175    /// [`Error::Unsupported`] on a run that did not open the channel with
176    /// [`crate::Request::interactive`]. [`Error::Cancelled`] once the channel
177    /// has closed, which happens when the turn settles or the run is torn down:
178    /// **a message sent after the turn ends is too late** and belongs in a new
179    /// run resuming the session, so this reports it rather than dropping it.
180    pub async fn send(&self, message: &str) -> Result<()> {
181        let Some(channel) = &self.to_agent else {
182            return Err(Error::Unsupported {
183                agent: self.agent,
184                what: "sending a follow-up on a run that is not interactive",
185            });
186        };
187        channel
188            .send(Control::Message(message.to_string()))
189            .await
190            .map_err(|_| Error::Cancelled {
191                bin: self.argv.first().cloned().unwrap_or_default(),
192            })
193    }
194
195    /// Answer an [`Event::ApprovalRequest`].
196    ///
197    /// The agent is blocked until this is called, so a consumer that receives an
198    /// approval request and never responds stalls the run until its timeout.
199    ///
200    /// The id must be the one from the request. The agent ignores an answer
201    /// carrying any other id and keeps waiting, so a mismatch presents as a
202    /// hang rather than an error; this passes the id straight through and does
203    /// not invent one.
204    ///
205    /// # Errors
206    /// [`Error::Unsupported`] on a run that did not ask for approvals, since
207    /// there is no channel to answer on. [`Error::Cancelled`] if the run has
208    /// already finished or been torn down, which is the same reason a decision
209    /// can no longer be delivered.
210    pub async fn respond(&self, id: &str, decision: &crate::Decision) -> Result<()> {
211        let Some(channel) = &self.to_agent else {
212            return Err(Error::Unsupported {
213                agent: self.agent,
214                what: "answering an approval on a run that did not request them",
215            });
216        };
217        channel
218            .send(Control::Approval {
219                id: id.to_string(),
220                decision: decision.clone(),
221            })
222            .await
223            .map_err(|_| Error::Cancelled {
224                bin: self.argv.first().cloned().unwrap_or_default(),
225            })
226    }
227
228    /// The exact command line that was spawned.
229    ///
230    /// **This contains the prompt and any session id.** Treat it as sensitive:
231    /// logging it verbatim puts user content into your logs. Use
232    /// [`Run::redacted_argv`] for diagnostics.
233    #[must_use]
234    pub fn argv(&self) -> &[String] {
235        &self.argv
236    }
237
238    /// The command line with every non-public value replaced by a placeholder.
239    ///
240    /// Prompts, system prompts, session ids and anything from
241    /// [`crate::Request::unchecked_args`] are removed; flag names are kept so
242    /// the command stays recognisable. Sensitivity is recorded where each
243    /// argument is built rather than inferred from the finished line, so a
244    /// bare positional prompt or an opaque raw argument is covered too.
245    #[must_use]
246    pub fn redacted_argv(&self) -> Vec<String> {
247        redact(&self.typed)
248    }
249
250    /// Wait for the run to finish.
251    ///
252    /// Drains any events still queued, so a caller that only wants the result
253    /// can call this without having consumed the stream.
254    ///
255    /// # Errors
256    /// Whatever the run failed with. See [`Error`].
257    pub async fn finish(mut self) -> Result<Outcome> {
258        // The driver owns teardown from here; `Drop` must not also fire.
259        self.pid = None;
260        while self.events.recv().await.is_some() {}
261        // Taking the handle disarms the `Drop` guard: this run is settling
262        // normally, not being abandoned.
263        let Some(task) = self.task.take() else {
264            unreachable!("the handle is only taken by a consuming method")
265        };
266        match task.await {
267            Ok(result) => result,
268            // The driver task panicked or was cancelled. The process itself
269            // started fine, so this is not a spawn failure and must not claim
270            // to be one.
271            Err(join) => Err(Error::Interrupted {
272                bin: self.argv.first().cloned().unwrap_or_default(),
273                detail: if join.is_panic() {
274                    "the driver task panicked".into()
275                } else {
276                    "the driver task was cancelled".into()
277                },
278            }),
279        }
280    }
281
282    /// Stop the run and wait until the agent is actually gone.
283    ///
284    /// Cooperative rather than an abort: the driver is asked to stop, signals
285    /// the process group, reaps the child and joins its readers, and only then
286    /// does this return. So when it returns the tree really has exited, which
287    /// matters if the next thing you do touches the files it was working on.
288    ///
289    /// Returns the partial [`Outcome`] if the run happened to finish first,
290    /// otherwise [`Error::Cancelled`].
291    ///
292    /// # Errors
293    /// [`Error::Cancelled`] in the normal case, or whatever the run failed with
294    /// if it failed before the request arrived.
295    pub async fn cancel(mut self) -> Result<Outcome> {
296        // The driver tears down cooperatively and this awaits it, so `Drop`
297        // must not race that with a kill of its own.
298        self.pid = None;
299        // Dropping the sender is itself the signal, so this cannot fail in a
300        // way that leaves the driver waiting.
301        drop(self.cancel.take());
302        let Some(task) = self.task.take() else {
303            unreachable!("the handle is only taken by a consuming method")
304        };
305        match task.await {
306            Ok(result) => result,
307            Err(join) => Err(Error::Interrupted {
308                bin: self.argv.first().cloned().unwrap_or_default(),
309                detail: if join.is_panic() {
310                    "the driver task panicked".into()
311                } else {
312                    "the driver task was cancelled".into()
313                },
314            }),
315        }
316    }
317
318    /// Let the run continue after this handle goes away.
319    ///
320    /// The opposite of the default. Nothing can observe or stop the agent
321    /// afterwards, so reach for this only when an unsupervised background run
322    /// is genuinely intended.
323    pub fn detach(mut self) {
324        // Disarm `Drop` before it runs, or detaching would immediately kill the
325        // run it exists to keep alive.
326        self.pid = None;
327        // Leak the cancel signal rather than dropping it: a dropped sender is
328        // read by the driver as "stop", which is the opposite of detaching.
329        if let Some(cancel) = self.cancel.take() {
330            std::mem::forget(cancel);
331        }
332        // Dropping the handle without aborting is what detaches a tokio task.
333        drop(self.task.take());
334    }
335}
336
337impl Drop for Run {
338    fn drop(&mut self) {
339        // Abandoned rather than finished, cancelled or detached.
340        //
341        // Kill the group here, directly. Signalling the driver and aborting it
342        // is not enough on its own: that leaves teardown waiting on the runtime
343        // to poll the aborted task so its guard runs, and a dropped `Run` was
344        // observed leaving grandchildren alive and sleeping on Linux while the
345        // same teardown worked from `cancel`. `Drop` cannot await, so it does
346        // the one thing it can do synchronously.
347        if let Some(pid) = self.pid
348            && !self.reaped.load(std::sync::atomic::Ordering::SeqCst)
349        {
350            kill_group_by_pid(pid);
351        }
352        drop(self.cancel.take());
353        if let Some(task) = self.task.take() {
354            task.abort();
355        }
356    }
357}
358
359/// Placeholder substituted for a sensitive argv value.
360const REDACTED: &str = "<redacted>";
361
362/// Render a typed command line for logging, keeping flag names and replacing
363/// every value that is not `Public`.
364///
365/// Derived from the sensitivity recorded where each argument was built, so it
366/// cannot miss a case the way matching on flag names and positions can.
367fn redact(argv: &[crate::agent::Arg]) -> Vec<String> {
368    use crate::agent::Sensitivity;
369
370    argv.iter()
371        .map(|arg| match arg.sensitivity {
372            Sensitivity::Public => arg.value.clone(),
373            _ => REDACTED.to_string(),
374        })
375        .collect()
376}
377
378/// Run `request` to completion, discarding the intermediate events.
379///
380/// # Errors
381/// See [`Error`]; notably [`Error::NotInstalled`], [`Error::Timeout`],
382/// [`Error::RateLimited`] and [`Error::Failed`].
383///
384/// [`Error::Unsupported`] for a request that asked for approvals: this entry
385/// point discards events, so an approval request would reach nobody and the run
386/// would sit blocked until its timeout. Use [`stream`] instead.
387pub async fn run(request: &Request) -> Result<Outcome> {
388    if request.plan().approvals {
389        return Err(Error::Unsupported {
390            agent: request.agent,
391            what: "approvals on a run whose events are discarded; use `stream`",
392        });
393    }
394    stream(request)?.finish().await
395}
396
397/// Start `request`, returning a handle that streams its events.
398///
399/// Returns as soon as the child is spawned; the work proceeds on a task.
400///
401/// # Errors
402/// [`Error::NotInstalled`] if the binary is missing, [`Error::Unsupported`] if
403/// the agent cannot honour the request, or [`Error::Spawn`] on an OS failure.
404#[allow(
405    clippy::too_many_lines,
406    reason = "one spawn boundary keeps command posture, pipes, process group, and driver selection together"
407)]
408pub fn stream(request: &Request) -> Result<Run> {
409    // `tokio::spawn` panics outside a runtime. A fallible signature must not
410    // hide that, so the context is checked and reported as an ordinary error.
411    let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?;
412
413    let initial_plan = request.plan();
414    let codex_app_server =
415        request.agent == crate::Agent::Codex && (initial_plan.duplex || initial_plan.approvals);
416
417    // Written before the argv is built, because the argv has to name it. The
418    // app-server protocol accepts the schema inline instead.
419    let schema_file = match (&request.schema, request.agent.caps().schema) {
420        (Some(_), _) if codex_app_server => None,
421        (Some(schema), crate::agent::SchemaSupport::File) => {
422            Some(SchemaFile::write(schema).map_err(|source| Error::Spawn {
423                bin: request.agent.bin().to_string(),
424                source,
425            })?)
426        }
427        _ => None,
428    };
429    let mut request = request.clone();
430    if let Some(file) = &schema_file {
431        request.schema_file = Some(file.0.display().to_string());
432    }
433    let request = &request;
434
435    let plan = request.plan();
436    let typed = request.typed_argv()?;
437    let argv: Vec<String> = typed.iter().map(|a| a.value.clone()).collect();
438
439    let mut command = Command::new(&argv[0]);
440    command
441        .args(&argv[1..])
442        .stdin(if plan.stdin_prompt || plan.duplex || plan.approvals {
443            // An interactive run needs stdin for the whole turn, not just to
444            // deliver a prompt: it is the channel follow-up messages and
445            // approval decisions travel back on.
446            Stdio::piped()
447        } else {
448            // Close stdin so an agent that would otherwise wait on it exits
449            // instead of hanging forever with nothing to read.
450            Stdio::null()
451        })
452        .stdout(Stdio::piped())
453        .stderr(Stdio::piped())
454        // Without this a killed run can leave the child alive holding the pipes.
455        .kill_on_drop(true);
456    if let Some(cwd) = &request.cwd {
457        command.current_dir(cwd);
458    }
459    // Narrow the environment first, then apply explicit variables, so an
460    // explicit `env()` always wins over the policy.
461    match &request.env_policy {
462        EnvPolicy::Inherit => {}
463        EnvPolicy::Minimal => {
464            command.env_clear();
465            inherit_named(&mut command, &request.agent.essential_env());
466        }
467        EnvPolicy::Only(names) => {
468            command.env_clear();
469            inherit_named(&mut command, names);
470        }
471    }
472    for (key, value) in &request.env {
473        command.env(key, value);
474    }
475
476    // Put the agent in its own process group so the whole tree can be signalled
477    // together. Killing only the CLI leaves the commands *it* spawned running:
478    // a build, a test run, a server, still holding files and credentials after
479    // the run is supposedly over.
480    // 0 means "make this child its own group leader". `tokio::process::Command`
481    // exposes this directly on unix.
482    #[cfg(unix)]
483    command.process_group(0);
484
485    // Reserve an assigned session id before the child exists. Doing it inside
486    // the driver leaves a window where a spawn that half-succeeds loses the
487    // binding, and this is the id the caller may already be showing in a UI.
488    if let Some(token) = preassigned_token(request) {
489        persist_session(request, &token)?;
490    }
491
492    let child = command.spawn().map_err(|source| {
493        // A missing binary is the common case and deserves an actionable error
494        // with an install hint. Reading it off the spawn avoids resolving PATH
495        // twice, and with it the window where the resolved path is replaced
496        // between the check and the exec.
497        if source.kind() == std::io::ErrorKind::NotFound {
498            Error::NotInstalled {
499                agent: request.agent,
500                bin: plan.bin.clone(),
501                hint: request.agent.install_hint(),
502            }
503        } else {
504            Error::Spawn {
505                bin: plan.bin.clone(),
506                source,
507            }
508        }
509    })?;
510
511    let request_agent = request.agent;
512    let pid = child.id();
513    let (tx, rx) = mpsc::channel(EVENT_BUFFER);
514    // Only created for an approvals run, so `respond` can tell "no channel" from
515    // "channel closed" and refuse the first rather than hanging on it.
516    let (decisions_tx, decisions_rx) = if plan.duplex || plan.approvals {
517        let (tx, rx) = mpsc::channel::<Control>(APPROVAL_BUFFER);
518        (Some(tx), Some(rx))
519    } else {
520        (None, None)
521    };
522    let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
523    let reaped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
524    let reaped_for_task = std::sync::Arc::clone(&reaped);
525    let request = request.clone();
526    let task = runtime.spawn(async move {
527        // Moved in so the file outlives the run and is removed with it.
528        let _schema_file = schema_file;
529        if codex_app_server {
530            drive_codex_app_server(child, request, tx, cancel_rx, reaped_for_task, decisions_rx)
531                .await
532        } else {
533            drive(child, request, tx, cancel_rx, reaped_for_task, decisions_rx).await
534        }
535    });
536    Ok(Run {
537        events: rx,
538        agent: request_agent,
539        typed,
540        pid,
541        reaped,
542        cancel: Some(cancel_tx),
543        to_agent: decisions_tx,
544        task: Some(task),
545        argv,
546    })
547}
548
549/// Copy the named variables from this process into `command`, skipping any that
550/// are unset so nothing is invented.
551fn inherit_named<S: AsRef<str>>(command: &mut Command, names: &[S]) {
552    for name in names {
553        if let Some(value) = std::env::var_os(name.as_ref()) {
554            command.env(name.as_ref(), value);
555        }
556    }
557}
558
559/// A schema file written for one run, removed when the run ends.
560///
561/// Codex reads its schema from disk, so the file has to outlive the spawn and
562/// not outlive the process. Tying it to a guard means every exit path removes
563/// it, including a cancel or a timeout, without each one remembering.
564struct SchemaFile(std::path::PathBuf);
565
566impl SchemaFile {
567    /// Write `schema` somewhere the agent can read it.
568    fn write(schema: &str) -> std::io::Result<SchemaFile> {
569        use std::io::Write as _;
570        use std::sync::atomic::{AtomicU64, Ordering};
571        static COUNTER: AtomicU64 = AtomicU64::new(0);
572
573        let path = std::env::temp_dir().join(format!(
574            "agent-abstraction-schema-{}-{}.json",
575            std::process::id(),
576            COUNTER.fetch_add(1, Ordering::Relaxed)
577        ));
578        let mut options = std::fs::OpenOptions::new();
579        options.write(true).create_new(true);
580        // A schema can encode what a caller is looking for, so it is no more
581        // public than the prompt.
582        #[cfg(unix)]
583        {
584            use std::os::unix::fs::OpenOptionsExt as _;
585            options.mode(0o600);
586        }
587        options.open(&path)?.write_all(schema.as_bytes())?;
588        Ok(SchemaFile(path))
589    }
590}
591
592impl Drop for SchemaFile {
593    fn drop(&mut self) {
594        let _ = std::fs::remove_file(&self.0);
595    }
596}
597
598/// Owns the child and tears down its whole process group when dropped.
599///
600/// `kill_on_drop` alone is not enough: it kills the CLI, leaving the commands
601/// *it* spawned running. Since aborting the driver task drops this guard, the
602/// same teardown covers cancellation, a dropped [`Run`] and a timeout, without
603/// each path having to remember to do it.
604struct ChildGuard {
605    child: Child,
606    /// Cleared once the child has been reaped, so a pid the OS may since have
607    /// recycled is never signalled.
608    armed: bool,
609}
610
611impl Drop for ChildGuard {
612    fn drop(&mut self) {
613        if self.armed {
614            kill_process_group(&self.child);
615        }
616    }
617}
618
619/// Feed the child, read both its pipes, and assemble the outcome.
620#[allow(
621    clippy::too_many_lines,
622    reason = "one linear lifecycle: feed, read, wait, classify. Splitting it \
623              would thread the child, parser, buffers and cancellation state \
624              through helpers and obscure the ordering that matters, such as \
625              killing the group before reaping."
626)]
627async fn drive(
628    child: Child,
629    request: Request,
630    events: mpsc::Sender<Event>,
631    cancel: tokio::sync::oneshot::Receiver<()>,
632    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
633    decisions: Option<mpsc::Receiver<Control>>,
634) -> Result<Outcome> {
635    // From here on the child is owned by a guard, so every exit path from this
636    // task, including an abort, takes the process group with it.
637    let mut child = ChildGuard { child, armed: true };
638    let plan = request.plan();
639    let bin = plan.bin.clone();
640
641    // An approvals run owns stdin for the whole turn: the handshake and the
642    // prompt go out first, then it stays open carrying decisions until the run
643    // ends. Closing it after the prompt, as the plain piped path does, would
644    // take the answer channel with it.
645    let mut decision_task = None;
646    let mut close_stdin = None;
647    if plan.duplex || plan.approvals {
648        let Some(mut stdin) = child.child.stdin.take() else {
649            return Err(Error::Spawn {
650                bin: bin.clone(),
651                source: std::io::Error::other("stdin was not piped for an interactive run"),
652            });
653        };
654        let opening = format!(
655            "{}{}",
656            crate::approval::handshake(),
657            crate::approval::user_message(&request.agent.effective_prompt(&plan)),
658        );
659        stdin
660            .write_all(opening.as_bytes())
661            .await
662            .map_err(|source| Error::Spawn {
663                bin: bin.clone(),
664                source,
665            })?;
666        let _ = stdin.flush().await;
667        // Forwarding runs on its own task so a decision can be written while
668        // stdout is being read. It ends on whichever comes first: the channel
669        // closing, or the turn settling.
670        let (close_tx, mut close_rx) = tokio::sync::oneshot::channel::<()>();
671        close_stdin = Some(close_tx);
672        decision_task = decisions.map(|mut rx| {
673            tokio::spawn(async move {
674                loop {
675                    tokio::select! {
676                        reply = rx.recv() => {
677                            let Some(control) = reply else { break };
678                            let reply = match control {
679                                Control::Message(message) => {
680                                    crate::approval::user_message(&message)
681                                }
682                                Control::Approval { id, decision } => decision.wire(&id),
683                            };
684                            if stdin.write_all(reply.as_bytes()).await.is_err() {
685                                break;
686                            }
687                            let _ = stdin.flush().await;
688                        }
689                        // The turn is over. Dropping stdin is what lets claude
690                        // exit rather than wait for another message.
691                        _ = &mut close_rx => break,
692                    }
693                }
694                drop(stdin);
695            })
696        });
697    }
698
699    // Deliver a piped prompt and close the pipe, or the agent waits on EOF.
700    if plan.stdin_prompt {
701        if let Some(mut stdin) = child.child.stdin.take() {
702            let prompt = request.agent.effective_prompt(&plan);
703            stdin
704                .write_all(prompt.as_bytes())
705                .await
706                .map_err(|source| Error::Spawn {
707                    bin: bin.clone(),
708                    source,
709                })?;
710            drop(stdin);
711        }
712    }
713
714    // Drain stderr on its own task: a full stderr pipe blocks the child even
715    // while stdout still has room.
716    // Aborted on every exit path from here, so a forwarder never survives the
717    // run it belongs to.
718    let _decision_guard = decision_task.map(AbortOnDrop);
719
720    let stderr = child.child.stderr.take();
721    let stderr_task = tokio::spawn(async move {
722        let mut buf = String::new();
723        if let Some(handle) = stderr {
724            let mut reader = BufReader::new(handle);
725            let mut line = String::new();
726            // Keep draining after the cap is hit: an undrained pipe blocks the
727            // child even though we no longer want the bytes.
728            while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
729                append_capped(&mut buf, &line);
730            }
731        }
732        buf
733    });
734
735    let stdout = child.child.stdout.take();
736    let mut parser = Parser::new(request.agent, plan.format);
737    // Raw stdout is retained only as a fallback answer for a run that exited
738    // cleanly without producing a structured one, and as evidence when
739    // classifying a failure. It is capped for the same reason as everything
740    // else here: an agent can stream for hours.
741    let mut raw = String::new();
742    // Tracks the first `Started`, so the binding is written once, and carries a
743    // store failure back out instead of discarding it.
744    let mut bound = false;
745    let mut persist_result: Result<()> = Ok(());
746
747    let read_stdout = async {
748        if let Some(handle) = stdout {
749            let mut reader = BufReader::new(handle);
750            let mut line = String::new();
751            while read_bounded_line(&mut reader, &mut line).await?.is_some() {
752                append_capped(&mut raw, &line);
753                let parsed = parser.push(&line);
754                // Close stdin as soon as the turn settles. Under stream-json
755                // input claude waits for another message otherwise, so the run
756                // would only end at its timeout even though the answer already
757                // arrived.
758                if parser.saw_terminal()
759                    && let Some(close) = close_stdin.take()
760                {
761                    let _ = close.send(());
762                }
763                for event in parsed {
764                    // Bind a printed id the moment it appears rather than at the
765                    // end. Codex announces its thread before answering, so a
766                    // turn killed mid-answer stays resumable.
767                    if let Event::Started { session, .. } = &event
768                        && !bound
769                    {
770                        bound = true;
771                        persist_result = persist_session(&request, session);
772                    }
773                    // A receiver that went away is not a failure: the run should
774                    // still finish and produce its outcome.
775                    if events.send(event).await.is_err() {
776                        break;
777                    }
778                }
779            }
780        }
781        Ok::<_, std::io::Error>(())
782    };
783
784    // Race three outcomes: the run finishing, the deadline, and a cancellation
785    // request. Reading and waiting are one future so a child that produces
786    // output forever is still bounded by the timeout.
787    let work = async {
788        read_stdout.await?;
789        child.child.wait().await
790    };
791    // A timeout is optional; `pending()` makes the un-timed case the same shape
792    // rather than duplicating the whole select.
793    let deadline = async {
794        match request.timeout {
795            Some(limit) => tokio::time::sleep(limit).await,
796            None => std::future::pending().await,
797        }
798    };
799
800    let status = tokio::select! {
801        // Biased so a finished run is reported as finished even if a deadline
802        // or cancellation lands in the same tick.
803        biased;
804        result = work => result,
805        () = deadline => {
806            // Order matters: signal the group *before* reaping. Reaping clears
807            // the child's pid, and the group kill needs that pid to target the
808            // group, so the other order silently leaves grandchildren running.
809            let partial = shut_down(&mut child, stderr_task).await;
810            reaped.store(true, std::sync::atomic::Ordering::SeqCst);
811            return Err(Error::Timeout {
812                bin,
813                timeout: request.timeout.unwrap_or_default(),
814                partial: parser.finish().text,
815            })
816            .inspect_err(|_| drop(partial));
817        }
818        _ = cancel => {
819            // Cooperative teardown: the caller is waiting on this, so the tree
820            // is signalled, reaped and joined before returning.
821            shut_down(&mut child, stderr_task).await;
822            reaped.store(true, std::sync::atomic::Ordering::SeqCst);
823            return Err(Error::Cancelled { bin });
824        }
825    }
826    .map_err(|source| Error::Spawn {
827        bin: bin.clone(),
828        source,
829    })?;
830
831    // The child has been reaped, so its pid must not be signalled again, by the
832    // guard here or by `Run::drop` racing this.
833    child.armed = false;
834    reaped.store(true, std::sync::atomic::Ordering::SeqCst);
835
836    drop(events);
837    let stderr = stderr_task.await.unwrap_or_default();
838    let saw_structured = parser.saw_structured_record();
839    let saw_terminal = parser.saw_terminal_record();
840    let terminal = parser.finish();
841    let exit_code = status.code().unwrap_or(-1);
842
843    // Under a structured format, silently handing back raw stdout would turn a
844    // protocol failure into a plausible-looking answer. A run that recognized
845    // nothing, or never reached its terminal record, did not produce a result
846    // this crate can vouch for, so it is reported rather than papered over.
847    let structured = plan.format != crate::Format::Text;
848    if structured && exit_code == 0 {
849        if !saw_structured {
850            return Err(Error::Parse {
851                agent: request.agent,
852                detail: format!(
853                    "no recognizable {} records in {} lines of output;                      the CLI's output shape has probably changed",
854                    request.agent,
855                    raw.lines().count()
856                ),
857            });
858        }
859        if !saw_terminal {
860            return Err(Error::Parse {
861                agent: request.agent,
862                detail: "the stream ended without its terminal record, so the turn                          did not complete"
863                    .into(),
864            });
865        }
866    }
867
868    // Plain text has no structure to validate: the stream is the answer.
869    let mut terminal = terminal;
870    if terminal.text.is_empty() && !structured {
871        terminal.text = raw.trim().to_string();
872    }
873
874    // A provider refusal is not always an exit code. Claude can report a
875    // blocking `rate_limit_event` and still exit 0, and the crate promises that
876    // quota refusals surface as `Error::RateLimited`, so the terminal state is
877    // checked regardless of how the process exited.
878    let quota_blocked = terminal
879        .rate_limit
880        .as_ref()
881        .is_some_and(crate::outcome::RateLimit::is_blocking);
882    // An unauthenticated Claude run exits 0 and reports the problem in its
883    // result text, so checking only the exit code would hand back a successful
884    // Outcome whose answer is "Please run /login".
885    //
886    // Read from stderr and the agent's own prose rather than the raw stream, for
887    // the reason `classify` does the same with quota wording: a phrase hunted
888    // through structured output matches ids and field names, not statements.
889    //
890    // The answer is read at its opening only, by the same rule and the same
891    // helper `classify_run` uses. This gate used to read `terminal.text` whole
892    // while the classifier it guards read three lines, so the two could
893    // disagree: a healthy answer that merely discussed logging in opened the
894    // error path, no classifier would name it, and the turn fell out the far
895    // end as `Error::Failed` with exit code 0 and nothing to report. One rule,
896    // one helper, so that disagreement cannot exist.
897    let unauthenticated =
898        answer_reports_no_credentials(&terminal.text) || looks_unauthenticated(&stderr);
899    // The agent saying its turn failed is as much a failure as a non-zero exit,
900    // and Claude reports an unknown model exactly this way: exit 0, `is_error`
901    // true, and the explanation where the answer would be.
902    let turn_failed = terminal.stop == Stop::Error;
903    if exit_code != 0 || quota_blocked || unauthenticated || turn_failed {
904        let error = classify_run(request.agent, &bin, exit_code, &stderr, &raw, &terminal);
905        /*
906         * The backstop, and the reason a false positive can no longer cost an
907         * answer.
908         *
909         * Everything above is a heuristic reading of text the agent wrote, and
910         * a heuristic will be wrong eventually: these phrases are ordinary
911         * English, and an agent asked about rate limits or logging in answers
912         * in exactly the vocabulary that describes being rate limited or
913         * logged out. What must never follow from being wrong is discarding a
914         * finished answer.
915         *
916         * So a run whose process exited cleanly, whose terminal record says
917         * the turn completed, and which carries no parsed quota block is only
918         * ever failed by a classifier that can *name* the failure. A generic
919         * `Failed` on that run is the classifiers disagreeing with the gate,
920         * not evidence, and the answer stands.
921         */
922        let exited_clean = exit_code == 0 && !quota_blocked && !turn_failed;
923        if !exited_clean || names_a_failure(&error) {
924            return Err(error);
925        }
926    }
927
928    // A fork lands on a *new* id the agent only reveals at the end, so the name
929    // has to be repointed once the run settles. Everything else was bound above.
930    persist_result?;
931    // Resolved before the terminal is consumed by the Outcome below.
932    let structured = terminal.structured.clone().or_else(|| {
933        request
934            .schema
935            .as_ref()
936            .and_then(|_| serde_json::from_str(&terminal.text).ok())
937    });
938    if let Some(token) = &terminal.session
939        && !bound
940    {
941        persist_session(&request, token)?;
942    }
943    Ok(Outcome {
944        agent: request.agent,
945        session: terminal.session,
946        text: terminal.text,
947        usage: terminal.usage,
948        stop: terminal.stop,
949        rate_limit: terminal.rate_limit,
950        exit_code,
951        stderr,
952        unparsed: terminal.unparsed,
953        first_unparsed: terminal.first_unparsed,
954        // Claude reports the conforming value separately; Codex returns it as
955        // the answer text, so that is parsed only when a schema was asked for.
956        // Prose is never reinterpreted as data.
957        structured,
958    })
959}
960
961/// Drive one interactive Codex turn over app-server's JSON-RPC transport.
962///
963/// Unlike `codex exec`, app-server remains alive after a turn completes. This
964/// driver therefore treats `turn/completed` as the terminal record, closes the
965/// protocol pipe, and reaps the service process itself.
966#[allow(
967    clippy::too_many_lines,
968    reason = "one select loop owns the protocol, control channel, deadline, and child lifecycle"
969)]
970async fn drive_codex_app_server(
971    child: Child,
972    request: Request,
973    events: mpsc::Sender<Event>,
974    cancel: tokio::sync::oneshot::Receiver<()>,
975    reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
976    controls: Option<mpsc::Receiver<Control>>,
977) -> Result<Outcome> {
978    let mut child = ChildGuard { child, armed: true };
979    let plan = request.plan();
980    let bin = plan.bin.clone();
981    let Some(mut stdin) = child.child.stdin.take() else {
982        return Err(Error::Spawn {
983            bin,
984            source: std::io::Error::other("stdin was not piped for Codex app-server"),
985        });
986    };
987    let Some(stdout) = child.child.stdout.take() else {
988        return Err(Error::Spawn {
989            bin,
990            source: std::io::Error::other("stdout was not piped for Codex app-server"),
991        });
992    };
993    let Some(mut controls) = controls else {
994        return Err(Error::Spawn {
995            bin,
996            source: std::io::Error::other("Codex app-server has no host control channel"),
997        });
998    };
999
1000    let stderr = child.child.stderr.take();
1001    let stderr_task = tokio::spawn(async move {
1002        let mut buf = String::new();
1003        if let Some(handle) = stderr {
1004            let mut reader = BufReader::new(handle);
1005            let mut line = String::new();
1006            while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
1007                append_capped(&mut buf, &line);
1008            }
1009        }
1010        buf
1011    });
1012
1013    let mut protocol = crate::codex_app_server::Protocol::new(request.clone());
1014    for opening in protocol.opening() {
1015        stdin
1016            .write_all(opening.as_bytes())
1017            .await
1018            .map_err(|source| Error::Spawn {
1019                bin: bin.clone(),
1020                source,
1021            })?;
1022    }
1023    stdin.flush().await.map_err(|source| Error::Spawn {
1024        bin: bin.clone(),
1025        source,
1026    })?;
1027
1028    let mut reader = BufReader::new(stdout);
1029    let mut line = String::new();
1030    let mut raw = String::new();
1031    let mut pending = VecDeque::new();
1032    let mut bound = false;
1033    let mut persist_result: Result<()> = Ok(());
1034    let deadline = async {
1035        match request.timeout {
1036            Some(limit) => tokio::time::sleep(limit).await,
1037            None => std::future::pending().await,
1038        }
1039    };
1040    tokio::pin!(deadline);
1041    tokio::pin!(cancel);
1042
1043    while !protocol.finished {
1044        tokio::select! {
1045            biased;
1046            record = read_bounded_line(&mut reader, &mut line) => {
1047                if record.map_err(|source| Error::Spawn { bin: bin.clone(), source })?.is_some() {
1048                    append_capped(&mut raw, &line);
1049                    if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
1050                        let step = protocol.push(&value);
1051                        for event in step.events {
1052                            if let Event::Started { session, .. } = &event
1053                                && !bound
1054                            {
1055                                bound = true;
1056                                persist_result = persist_session(&request, session);
1057                            }
1058                            let _ = events.send(event).await;
1059                        }
1060                        for write in step.writes {
1061                            stdin.write_all(write.as_bytes()).await.map_err(|source| {
1062                                Error::Spawn { bin: bin.clone(), source }
1063                            })?;
1064                        }
1065                        flush_codex_controls(&mut protocol, &mut pending, &mut stdin, &bin)
1066                            .await?;
1067                        stdin.flush().await.map_err(|source| Error::Spawn {
1068                            bin: bin.clone(), source
1069                        })?;
1070                    } else {
1071                        protocol.terminal.unparsed += 1;
1072                        if protocol.terminal.first_unparsed.is_none() {
1073                            protocol.terminal.first_unparsed = Some(line.clone());
1074                        }
1075                    }
1076                } else {
1077                    protocol.failure.get_or_insert_with(|| {
1078                        "app-server closed stdout before turn/completed".to_string()
1079                    });
1080                    protocol.finished = true;
1081                }
1082            }
1083            control = controls.recv() => {
1084                let Some(control) = control else {
1085                    continue;
1086                };
1087                pending.push_back(control);
1088                flush_codex_controls(&mut protocol, &mut pending, &mut stdin, &bin).await?;
1089                stdin.flush().await.map_err(|source| Error::Spawn {
1090                    bin: bin.clone(), source
1091                })?;
1092            }
1093            () = &mut deadline => {
1094                let partial = protocol.terminal.text.clone();
1095                shut_down(&mut child, stderr_task).await;
1096                reaped.store(true, std::sync::atomic::Ordering::SeqCst);
1097                return Err(Error::Timeout {
1098                    bin,
1099                    timeout: request.timeout.unwrap_or_default(),
1100                    partial,
1101                });
1102            }
1103            _ = &mut cancel => {
1104                shut_down(&mut child, stderr_task).await;
1105                reaped.store(true, std::sync::atomic::Ordering::SeqCst);
1106                return Err(Error::Cancelled { bin });
1107            }
1108        }
1109    }
1110
1111    // app-server is a service rather than a one-shot process. EOF asks it to
1112    // stop cleanly; the short fallback prevents a completed turn from hanging
1113    // because a future CLI release keeps serving after its input closes.
1114    drop(stdin);
1115    if tokio::time::timeout(std::time::Duration::from_secs(2), child.child.wait())
1116        .await
1117        .is_err()
1118    {
1119        kill_process_group(&child.child);
1120        let _ = child.child.kill().await;
1121    }
1122    child.armed = false;
1123    reaped.store(true, std::sync::atomic::Ordering::SeqCst);
1124    drop(events);
1125    let stderr = stderr_task.await.unwrap_or_default();
1126
1127    persist_result?;
1128    if let Some(detail) = protocol.failure {
1129        return Err(Error::Parse {
1130            agent: request.agent,
1131            detail,
1132        });
1133    }
1134
1135    let terminal = protocol.terminal;
1136    if terminal.stop == Stop::Error {
1137        return Err(classify_run(
1138            request.agent,
1139            &bin,
1140            0,
1141            &stderr,
1142            &raw,
1143            &terminal,
1144        ));
1145    }
1146    let structured = terminal.structured.clone().or_else(|| {
1147        request
1148            .schema
1149            .as_ref()
1150            .and_then(|_| serde_json::from_str(&terminal.text).ok())
1151    });
1152    Ok(Outcome {
1153        agent: request.agent,
1154        session: terminal.session,
1155        text: terminal.text,
1156        usage: terminal.usage,
1157        stop: terminal.stop,
1158        rate_limit: terminal.rate_limit,
1159        exit_code: 0,
1160        stderr,
1161        unparsed: terminal.unparsed,
1162        first_unparsed: terminal.first_unparsed,
1163        structured,
1164    })
1165}
1166
1167/// Write every control whose protocol ids are available, preserving earlier
1168/// messages until thread and turn startup have both completed.
1169async fn flush_codex_controls(
1170    protocol: &mut crate::codex_app_server::Protocol,
1171    pending: &mut VecDeque<Control>,
1172    stdin: &mut tokio::process::ChildStdin,
1173    bin: &str,
1174) -> Result<()> {
1175    let mut waiting = VecDeque::new();
1176    while let Some(control) = pending.pop_front() {
1177        let encoded = match &control {
1178            Control::Message(message) => protocol.steer(message),
1179            Control::Approval { id, decision } => protocol.respond(id, decision),
1180        };
1181        if let Some(encoded) = encoded {
1182            stdin
1183                .write_all(encoded.as_bytes())
1184                .await
1185                .map_err(|source| Error::Spawn {
1186                    bin: bin.to_string(),
1187                    source,
1188                })?;
1189        } else {
1190            waiting.push_back(control);
1191        }
1192    }
1193    pending.append(&mut waiting);
1194    Ok(())
1195}
1196
1197/// Kill the process group, reap the child, and join the stderr reader.
1198///
1199/// The orderly teardown both cancellation and timeout share. Returns whatever
1200/// stderr had been captured, so a caller can still report why a run was stopped.
1201async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle<String>) -> String {
1202    kill_process_group(&child.child);
1203    // Reap, so the caller is not left with a zombie once this returns.
1204    let _ = child.child.kill().await;
1205    child.armed = false;
1206    // The pipes are closed now that the child is gone, so this finishes
1207    // promptly rather than hanging the cancellation.
1208    stderr_task.await.unwrap_or_default()
1209}
1210
1211/// Turn a failure into the most specific error available, agent included so an
1212/// auth failure can carry the right login command.
1213fn classify_run(
1214    agent: crate::Agent,
1215    bin: &str,
1216    code: i32,
1217    stderr: &str,
1218    stdout: &str,
1219    terminal: &Terminal,
1220) -> Error {
1221    // Checked before quota and before a plain failure: a login problem is the
1222    // most specific reading of the output, and the only one a user can act on
1223    // directly.
1224    let named = |source: &str| Error::NotAuthenticated {
1225        agent,
1226        bin: bin.to_string(),
1227        message: first_meaningful_line(source).unwrap_or_default(),
1228        hint: agent.login_hint(),
1229    };
1230
1231    // The CLI's own channel, read whole: Copilot's notice runs to five lines.
1232    if looks_unauthenticated(stderr) {
1233        return named(stderr);
1234    }
1235
1236    /*
1237     * The agent's own answer, read only at the top.
1238     *
1239     * An agent with no credentials has nothing to say but the notice, so the
1240     * phrase is in its opening lines and the whole answer is those lines.
1241     * An agent that *writes about* logging in buries the same words in
1242     * paragraphs, and reading the whole answer counted that as a login
1243     * failure: a reply explaining why a publish had been refused mentioned
1244     * not being authenticated, so the run was reported as an auth error, the
1245     * hint told the user to run `/login`, and the answer itself was replaced
1246     * by the report. An agent's prose is not a diagnosis of the agent.
1247     */
1248    for source in [terminal.text.as_str(), stdout] {
1249        if answer_reports_no_credentials(source) {
1250            return named(&opening_lines(source, OPENING_LINES));
1251        }
1252    }
1253    classify(agent, bin, code, stderr, stdout, terminal)
1254}
1255
1256/// The longest an agent's answer may be and still be read as a notice.
1257///
1258/// A CLI that has been stopped says so briefly: Claude's is one sentence and a
1259/// reset time. An answer that *discusses* limits runs to paragraphs and uses
1260/// exactly the same words, so length is the only thing separating them.
1261const NOTICE_MAX: usize = 240;
1262
1263/// How far into an agent's own output a diagnosis may be read from.
1264///
1265/// Three rather than one, because a CLI is entitled to a banner line before it
1266/// says what is wrong, and three rather than more, because past that an agent
1267/// is answering the question it was asked.
1268const OPENING_LINES: usize = 3;
1269
1270/// The first `count` non-blank lines, trimmed and rejoined.
1271fn opening_lines(text: &str, count: usize) -> String {
1272    text.lines()
1273        .map(str::trim)
1274        .filter(|line| !line.is_empty())
1275        .take(count)
1276        .collect::<Vec<_>>()
1277        .join("\n")
1278}
1279
1280/// Whether an agent's own answer is a credentials notice rather than an answer
1281/// that happens to discuss credentials.
1282///
1283/// The single rule for reading an answer as a diagnosis of the run, shared by
1284/// the gate in `run` and by `classify_run`. They read the same text for the
1285/// same phrases and used to apply different rules to it: whole text at the
1286/// gate, opening lines in the classifier. A healthy answer about logging in
1287/// satisfied one and not the other, which opened the error path for a run no
1288/// classifier would then name.
1289///
1290/// Short *and* at the top, which is the same rule the quota branch applies,
1291/// and it takes both halves. Lines alone were not enough: asked to explain the
1292/// difference between a rate limit and an auth failure, a live Claude answered
1293/// in one 900-character paragraph, so "the first three lines" was the entire
1294/// essay and the phrase inside it convicted the run. Prose wraps at the
1295/// window, not at a newline, so length is what distinguishes a notice from an
1296/// answer. A real notice is a sentence: `Not logged in, please run /login`.
1297fn answer_reports_no_credentials(text: &str) -> bool {
1298    let opening = opening_lines(text, OPENING_LINES);
1299    opening.len() <= NOTICE_MAX && looks_unauthenticated(&opening)
1300}
1301
1302/// Whether a classifier named the failure rather than falling through to the
1303/// generic one.
1304///
1305/// `Error::Failed` is what `classify` returns when nothing more specific fits.
1306/// On a run that exited cleanly that is not a diagnosis, it is the absence of
1307/// one, and an answer must not be discarded for it.
1308fn names_a_failure(error: &Error) -> bool {
1309    !matches!(error, Error::Failed { .. })
1310}
1311
1312/// Whether text is an agent saying it has no usable credentials.
1313///
1314/// Narrow on purpose. Mislabelling an ordinary failure as an auth problem sends
1315/// someone to re-login over something unrelated, so these are phrases the CLIs
1316/// actually emit rather than every string containing "auth".
1317fn looks_unauthenticated(text: &str) -> bool {
1318    const PHRASES: &[&str] = &[
1319        // Claude, verified: an unauthenticated run answers exactly this.
1320        "not logged in",
1321        "please run /login",
1322        // Copilot, verified: it exits 1 with plain text, and none of the other
1323        // phrases here appear in it. Its wording shares no vocabulary with the
1324        // other two, which is why this had to be observed rather than guessed.
1325        "no authentication information",
1326        "invalid api key",
1327        "authentication_error",
1328        "unauthorized",
1329        "not authenticated",
1330        "no credentials",
1331        "credentials not found",
1332        "please log in",
1333    ];
1334    let lower = text.to_ascii_lowercase();
1335    PHRASES.iter().any(|needle| lower.contains(needle)) || mentions_status(&lower, "401")
1336}
1337
1338/// Whether `code` appears as a standalone token rather than inside a longer run
1339/// of characters.
1340///
1341/// `401` was previously matched as a bare substring, which made any Copilot
1342/// failure an auth failure whenever one of the UUIDs it prints happened to
1343/// contain those three digits: `"id":"1b0b1401-cb86-..."` was enough. That is
1344/// not rare, since a run emits several ids, so the misdiagnosis was
1345/// intermittent and told someone to re-login over an unrelated failure.
1346///
1347/// A status code is a word. Requiring non-alphanumeric neighbours keeps
1348/// `HTTP 401` and `(status 401)` while rejecting every hex blob, and a UUID
1349/// cannot produce a standalone `401` at all because its groups are four, eight
1350/// or twelve characters long.
1351fn mentions_status(haystack: &str, code: &str) -> bool {
1352    haystack.match_indices(code).any(|(at, _)| {
1353        let before = haystack[..at].chars().next_back();
1354        let after = haystack[at + code.len()..].chars().next();
1355        let free = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
1356        free(before) && free(after)
1357    })
1358}
1359
1360/// Turn a non-zero exit into the most specific error available.
1361fn classify(
1362    agent: crate::Agent,
1363    bin: &str,
1364    code: i32,
1365    stderr: &str,
1366    stdout: &str,
1367    terminal: &Terminal,
1368) -> Error {
1369    let quota_signalled = terminal
1370        .rate_limit
1371        .as_ref()
1372        .is_some_and(crate::outcome::RateLimit::is_blocking);
1373    // Scanning the *raw* stream for quota wording is a false-positive machine:
1374    // under `stream-json` Claude prints a `rate_limit_event` record on every
1375    // run, including one whose status is `allowed`, so the substring
1376    // `rate_limit` is present in perfectly healthy output. Where the stream
1377    // parsed, the parsed signal and the agent's own prose decide; the raw scan
1378    // is only the fallback for output that produced neither.
1379    /*
1380     * The agent's own answer is evidence about the *topic*, not about the run.
1381     *
1382     * `terminal.text` is what the agent said. A turn that discusses quotas at
1383     * any length contains the vocabulary this function searches for, so a
1384     * finished, successful answer on that subject classified its own run as
1385     * blocked and replaced itself with a banner quoting one of its own
1386     * sentences. The same shape as the auth misclassification fixed in 0.4.2,
1387     * one branch further down the same function.
1388     *
1389     * So the run's own channels stay authoritative. `error_message` is the
1390     * CLI's own field rather than the model's words, and is read whole. The
1391     * answer is read only when it is short enough to *be* a notice: a run that
1392     * was really stopped has the notice and nothing else to say, in a couple
1393     * of lines, while an answer that discusses the subject runs to paragraphs.
1394     * Length is the one thing that separates them, because the vocabulary is
1395     * identical by definition.
1396     */
1397    let reported = terminal.error_message.clone().unwrap_or_default();
1398    let answered = if terminal.text.len() <= NOTICE_MAX {
1399        opening_lines(&terminal.text, OPENING_LINES)
1400    } else {
1401        String::new()
1402    };
1403    let prose = if terminal.text.is_empty() {
1404        format!("{reported}\n{}", opening_lines(stdout, OPENING_LINES))
1405    } else {
1406        format!("{reported}\n{answered}")
1407    };
1408    if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(&prose) {
1409        return Error::RateLimited {
1410            bin: bin.to_string(),
1411            message: first_meaningful_line(stderr)
1412                .or_else(|| first_meaningful_line(&prose))
1413                .unwrap_or_else(|| "usage limit reached".to_string()),
1414        };
1415    }
1416    // A rejected flag is not a failed request, it is this crate and the CLI
1417    // disagreeing about what the CLI accepts. Naming that is the difference
1418    // between "the run failed" and "your codex is a different version".
1419    if let Some(detail) = rejected_flag(stderr).or_else(|| rejected_flag(stdout)) {
1420        return Error::FlagRejected {
1421            bin: bin.to_string(),
1422            detail,
1423        };
1424    }
1425    // Checked before the generic failure but after quota and a rejected flag,
1426    // which are more specific readings of the same output.
1427    if terminal.stop == Stop::Error {
1428        return Error::AgentError {
1429            agent,
1430            bin: bin.to_string(),
1431            status: terminal.error_status,
1432            // Codex reports the reason apart from the answer; Claude puts it
1433            // where the answer would be.
1434            message: terminal
1435                .error_message
1436                .clone()
1437                .or_else(|| first_meaningful_line(&terminal.text))
1438                .or_else(|| first_meaningful_line(stderr))
1439                .unwrap_or_else(|| "the agent reported a failure without explaining it".into()),
1440        };
1441    }
1442
1443    Error::Failed {
1444        bin: bin.to_string(),
1445        code,
1446        // Fall back to stdout when stderr explains nothing. Codex reports a
1447        // rejected schema as an `{"type":"error"}` event on *stdout* while
1448        // stderr carries only "Reading additional input from stdin...", so
1449        // reporting stderr alone describes the failure as a status message.
1450        stderr: first_meaningful_line(stderr)
1451            .filter(|line| looks_explanatory(line))
1452            .or_else(|| first_meaningful_line(stdout))
1453            .or_else(|| first_meaningful_line(stderr))
1454            .unwrap_or_default(),
1455    }
1456}
1457
1458/// Whether a line plausibly explains a failure rather than narrating progress.
1459fn looks_explanatory(line: &str) -> bool {
1460    const NOISE: &[&str] = &[
1461        "reading additional input",
1462        "reading prompt",
1463        "waiting",
1464        "connecting",
1465        "loading",
1466    ];
1467    let lower = line.to_ascii_lowercase();
1468    !NOISE.iter().any(|noise| lower.contains(noise))
1469}
1470
1471/// The CLI's complaint, if it refused an argument.
1472///
1473/// The phrasings are clap's and commander's, which is what all three CLIs are
1474/// built on. Matched narrowly: a false positive would relabel a genuine failure
1475/// as a version problem and send someone chasing the wrong thing.
1476fn rejected_flag(text: &str) -> Option<String> {
1477    const REJECTIONS: &[&str] = &[
1478        "unexpected argument",
1479        "unknown option",
1480        "unrecognized option",
1481        "unknown flag",
1482        "invalid option",
1483        "unexpected option",
1484    ];
1485    let lower = text.to_ascii_lowercase();
1486    REJECTIONS
1487        .iter()
1488        .any(|needle| lower.contains(needle))
1489        .then(|| first_meaningful_line(text).unwrap_or_default())
1490}
1491
1492/// Whether text carries a provider quota refusal.
1493///
1494/// Deliberately a small set of unambiguous phrases: a false positive here would
1495/// relabel an ordinary failure as a quota problem and send a caller into a
1496/// pointless backoff.
1497fn looks_rate_limited(text: &str) -> bool {
1498    let lower = text.to_ascii_lowercase();
1499    [
1500        "rate limit",
1501        "rate_limit",
1502        "usage limit",
1503        "quota exceeded",
1504        "too many requests",
1505    ]
1506    .iter()
1507    .any(|needle| lower.contains(needle))
1508        // A status code is a word, and `429` as a bare substring is in every
1509        // line number, byte count, sha fragment and identifier that happens to
1510        // contain those digits. `401` was already given this treatment after it
1511        // matched inside a UUID and sent someone to re-login; this is the same
1512        // rule, applied to the code that had been left as a substring.
1513        || mentions_status(&lower, "429")
1514}
1515
1516/// The most useful line of a CLI's output for an error message.
1517///
1518/// Not simply the first non-blank one. CLIs open with progress and status
1519/// chatter, so the first line is often "Reading additional input from stdin..."
1520/// while the actual cause is further down. That turns a report into a
1521/// misdirection: it looks like an explanation and is not one.
1522///
1523/// So a line that looks like an error wins, and the first non-blank line is the
1524/// fallback when nothing does.
1525fn first_meaningful_line(text: &str) -> Option<String> {
1526    const ERROR_MARKERS: &[&str] = &[
1527        "error",
1528        "failed",
1529        "fatal",
1530        "panic",
1531        "denied",
1532        "invalid",
1533        "unexpected",
1534        "cannot",
1535        "unable",
1536    ];
1537    let lines: Vec<&str> = text
1538        .lines()
1539        .map(str::trim)
1540        .filter(|line| !line.is_empty())
1541        .collect();
1542
1543    lines
1544        .iter()
1545        .find(|line| {
1546            let lower = line.to_ascii_lowercase();
1547            ERROR_MARKERS.iter().any(|marker| lower.contains(marker))
1548        })
1549        .or_else(|| lines.first())
1550        .map(|line| (*line).to_string())
1551}
1552
1553/// Write the session binding back, reporting any store failure.
1554///
1555/// Called as soon as an id is known rather than only on a clean exit. Waiting
1556/// for success would lose the binding for exactly the runs where continuity
1557/// matters most: a timeout, a crash, or a cancelled turn.
1558fn persist_session(request: &Request, token: &str) -> Result<()> {
1559    let Some(binding) = &request.binding else {
1560        return Ok(());
1561    };
1562    binding
1563        .store
1564        .bind(request.agent, &binding.project, &binding.name, token)
1565        .map(|_| ())
1566}
1567
1568/// The id this run is already known by before it starts, if any.
1569///
1570/// Only a caller-assigned id qualifies: a printed id does not exist yet. This
1571/// is what makes an assigned session survive a run that never finishes.
1572fn preassigned_token(request: &Request) -> Option<String> {
1573    match &request.plan().cont {
1574        Continue::NewWith(id) => Some(id.clone()),
1575        _ => None,
1576    }
1577}
1578
1579/// Reported by an agent that exited cleanly but said nothing useful.
1580impl Outcome {
1581    /// Whether the agent produced any answer at all.
1582    #[must_use]
1583    pub fn is_empty(&self) -> bool {
1584        self.text.trim().is_empty() && self.stop == Stop::Completed
1585    }
1586}
1587
1588#[cfg(test)]
1589mod tests {
1590    use super::*;
1591    use crate::agent::Agent;
1592
1593    #[test]
1594    fn quota_phrases_are_recognized_and_ordinary_errors_are_not() {
1595        assert!(looks_rate_limited("Error: rate limit exceeded"));
1596        assert!(looks_rate_limited("HTTP 429 Too Many Requests"));
1597        assert!(looks_rate_limited("You have hit your usage limit"));
1598        // A plain failure must not be mistaken for a quota problem.
1599        assert!(!looks_rate_limited("error: no such file or directory"));
1600        assert!(!looks_rate_limited("model not found"));
1601    }
1602
1603    #[test]
1604    fn a_blocking_rate_limit_event_classifies_as_rate_limited() {
1605        let terminal = Terminal {
1606            rate_limit: Some(crate::outcome::RateLimit {
1607                status: "rejected".into(),
1608                window: Some("five_hour".into()),
1609                resets_at: None,
1610                overage_status: None,
1611                is_using_overage: None,
1612            }),
1613            ..Terminal::default()
1614        };
1615        assert!(matches!(
1616            classify(Agent::Claude, "claude", 1, "", "", &terminal),
1617            Error::RateLimited { .. }
1618        ));
1619    }
1620
1621    #[test]
1622    fn an_allowed_rate_limit_event_is_not_a_failure_cause() {
1623        let terminal = Terminal {
1624            rate_limit: Some(crate::outcome::RateLimit {
1625                status: "allowed".into(),
1626                window: None,
1627                resets_at: None,
1628                overage_status: None,
1629                is_using_overage: None,
1630            }),
1631            ..Terminal::default()
1632        };
1633        assert!(matches!(
1634            classify(Agent::Claude, "claude", 1, "boom", "", &terminal),
1635            Error::Failed { .. }
1636        ));
1637    }
1638
1639    /// The exact shape that made a Copilot run look unauthenticated: a UUID
1640    /// carrying the digits 401. Copilot prints several ids per run, so this
1641    /// misfired intermittently and told the user to re-login over a failure
1642    /// that had nothing to do with credentials.
1643    #[test]
1644    fn an_id_containing_401_is_not_an_auth_failure() {
1645        let line = r#"{"type":"session.mcp_server_status_changed","id":"1b0b1401-cb86-4276-9874-e84b94c96499"}"#;
1646        assert!(
1647            !looks_unauthenticated(line),
1648            "a hex blob is not a status code"
1649        );
1650    }
1651
1652    /// The needle still has to work where it was meant to. A status code is a
1653    /// word, and these are the forms an agent actually prints.
1654    #[test]
1655    fn a_real_401_is_still_recognized() {
1656        for text in [
1657            "HTTP 401",
1658            "request failed (status 401)",
1659            "401: unauthorized",
1660            "got a 401 from the API",
1661        ] {
1662            assert!(looks_unauthenticated(text), "should match: {text}");
1663        }
1664    }
1665
1666    /// Neighbouring digits mean it is part of some longer number, not a status.
1667    #[test]
1668    fn digits_around_401_keep_it_from_matching() {
1669        for text in ["error 4010", "code 1401", "seq 24019"] {
1670            assert!(!looks_unauthenticated(text), "should not match: {text}");
1671        }
1672    }
1673
1674    /// Verbatim from a healthy claude 2.1.205 run. Every `stream-json` run
1675    /// carries this record, and its status is `allowed`: nothing is refused.
1676    /// Scanning the raw stream for `rate_limit` matched it anyway, so any
1677    /// Claude failure was reported as a quota refusal, sending a caller to back
1678    /// off when the real cause was something they could fix.
1679    #[test]
1680    fn a_healthy_rate_limit_heartbeat_is_not_a_refusal() {
1681        let stdout = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785331800,"rateLimitType":"five_hour","overageStatus":"rejected","isUsingOverage":false}}"#;
1682        let terminal = Terminal {
1683            stop: Stop::Error,
1684            error_status: Some(404),
1685            text: "There's an issue with the selected model (bogus-model-xyz).".into(),
1686            rate_limit: Some(crate::outcome::RateLimit {
1687                status: "allowed".into(),
1688                window: Some("five_hour".into()),
1689                resets_at: Some(1_785_331_800),
1690                overage_status: None,
1691                is_using_overage: None,
1692            }),
1693            ..Terminal::default()
1694        };
1695        let err = classify_run(Agent::Claude, "claude", 0, "", stdout, &terminal);
1696        assert!(
1697            matches!(err, Error::AgentError { .. }),
1698            "the heartbeat must not mask the real cause: {err:?}"
1699        );
1700    }
1701
1702    /// The counterpart: a refusal the parser did read must still be one, even
1703    /// though it arrives with the same zero exit code.
1704    #[test]
1705    fn a_rejected_quota_signal_is_still_a_refusal() {
1706        let terminal = Terminal {
1707            rate_limit: Some(crate::outcome::RateLimit {
1708                status: "rejected".into(),
1709                window: Some("five_hour".into()),
1710                resets_at: None,
1711                overage_status: None,
1712                is_using_overage: None,
1713            }),
1714            ..Terminal::default()
1715        };
1716        assert!(matches!(
1717            classify_run(Agent::Claude, "claude", 0, "", "", &terminal),
1718            Error::RateLimited { .. }
1719        ));
1720    }
1721
1722    /// Verbatim from a real run with an unknown model. Claude exits **0** with
1723    /// `subtype: "success"` while `is_error` is true and the explanation sits
1724    /// where the answer would be, so a caller checking only `Result::is_ok`
1725    /// renders "There's an issue with the selected model" as the answer.
1726    #[test]
1727    fn a_failed_turn_is_an_error_even_though_the_process_exited_cleanly() {
1728        let terminal = Terminal {
1729            stop: Stop::Error,
1730            error_status: Some(404),
1731            text: "There's an issue with the selected model (bogus-model-xyz). \
1732                   It may not exist or you may not have access to it."
1733                .into(),
1734            ..Terminal::default()
1735        };
1736        let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1737        let Error::AgentError {
1738            agent,
1739            status,
1740            message,
1741            ..
1742        } = &err
1743        else {
1744            panic!("expected AgentError, got {err:?}")
1745        };
1746        assert_eq!(*agent, Agent::Claude);
1747        assert_eq!(*status, Some(404), "the provider status must survive");
1748        assert!(message.contains("selected model"), "{message}");
1749    }
1750
1751    /// A quota refusal and a missing login are more specific readings of the
1752    /// same shape, so they must not be swallowed by the general case.
1753    #[test]
1754    fn a_failed_turn_does_not_mask_a_more_specific_cause() {
1755        let auth = Terminal {
1756            stop: Stop::Error,
1757            text: "Not logged in · Please run /login".into(),
1758            ..Terminal::default()
1759        };
1760        assert!(
1761            classify_run(Agent::Claude, "claude", 0, "", "", &auth).is_auth_failure(),
1762            "an unauthenticated failed turn must stay an auth failure"
1763        );
1764
1765        let quota = Terminal {
1766            stop: Stop::Error,
1767            rate_limit: Some(crate::outcome::RateLimit {
1768                status: "rejected".into(),
1769                window: None,
1770                resets_at: None,
1771                overage_status: None,
1772                is_using_overage: None,
1773            }),
1774            ..Terminal::default()
1775        };
1776        assert!(
1777            matches!(
1778                classify_run(Agent::Claude, "claude", 0, "", "", &quota),
1779                Error::RateLimited { .. }
1780            ),
1781            "a quota-blocked failed turn must stay a rate limit"
1782        );
1783    }
1784
1785    /// Verified against the real CLI: with `USER` withheld, claude answers
1786    /// "Not logged in · Please run /login" and exits **0**. Checking only the
1787    /// exit code hands back a successful Outcome whose answer is a login
1788    /// prompt.
1789    #[test]
1790    fn an_unauthenticated_run_is_named_even_though_it_exits_zero() {
1791        let terminal = Terminal {
1792            text: "Not logged in · Please run /login".into(),
1793            ..Terminal::default()
1794        };
1795        let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1796        let Error::NotAuthenticated { agent, hint, .. } = &err else {
1797            panic!("expected NotAuthenticated, got {err:?}")
1798        };
1799        assert_eq!(*agent, Agent::Claude);
1800        assert!(hint.contains("/login"), "{hint}");
1801        assert!(err.is_auth_failure());
1802    }
1803
1804    /// Verbatim from an unauthenticated Copilot run, captured by pointing it at
1805    /// an empty HOME. Its wording shares no phrase with Claude's or Codex's, so
1806    /// before this was observed the phrase list did not match it at all and a
1807    /// missing Copilot login was reported as a generic failure.
1808    #[test]
1809    fn copilots_own_unauthenticated_wording_is_recognized() {
1810        let stderr = "Error: No authentication information found.\n\n\
1811                      Copilot can be authenticated with GitHub using an OAuth Token or a \
1812                      Fine-Grained Personal Access Token.\n\n\
1813                      To authenticate, you can use any of the following methods:\n\
1814                      \u{2022} Start 'copilot' and run the '/login' command\n\
1815                      \u{2022} Set the COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN \
1816                      environment variable";
1817        let err = classify_run(
1818            Agent::Copilot,
1819            "copilot",
1820            1,
1821            stderr,
1822            "",
1823            &Terminal::default(),
1824        );
1825        let Error::NotAuthenticated { agent, hint, .. } = &err else {
1826            panic!("expected NotAuthenticated, got {err:?}")
1827        };
1828        assert_eq!(*agent, Agent::Copilot);
1829        assert!(hint.contains("copilot login"), "{hint}");
1830    }
1831
1832    /// Each agent's hint has to name its own login route, since they differ:
1833    /// Codex and Copilot have `login` subcommands, Claude does not.
1834    #[test]
1835    fn every_agent_offers_its_own_login_route() {
1836        for (agent, expected) in [
1837            (Agent::Claude, "setup-token"),
1838            (Agent::Codex, "codex login"),
1839            (Agent::Copilot, "copilot login"),
1840        ] {
1841            let err = classify_run(
1842                agent,
1843                agent.bin(),
1844                1,
1845                "error: unauthorized",
1846                "",
1847                &Terminal::default(),
1848            );
1849            let Error::NotAuthenticated { hint, .. } = &err else {
1850                panic!("{agent}: expected NotAuthenticated, got {err:?}")
1851            };
1852            assert!(hint.contains(expected), "{agent}: {hint}");
1853        }
1854    }
1855
1856    /// Reported from the field: a run was stopped, the user was told `claude`
1857    /// was not authenticated, and the answer was replaced by a login hint. The
1858    /// agent had been explaining why a `cargo publish` was refused, and its own
1859    /// prose contained the phrases this classifier looks for. An answer is not
1860    /// a diagnosis of the thing that produced it.
1861    #[test]
1862    fn an_agent_writing_about_authentication_is_not_an_auth_failure() {
1863        let answer = "The publish was refused before it ran.\n\n\
1864                      What denied it was the auto mode classifier, not a missing \
1865                      credential.\n\
1866                      In auto mode there is no human to receive the prompt, so an \
1867                      ask collapses into a refusal.\n\
1868                      The message said the CLI was not authenticated, which is \
1869                      unrelated: an unauthorized upload is exactly what the rule \
1870                      is there to stop.";
1871        let terminal = Terminal {
1872            text: answer.into(),
1873            ..Terminal::default()
1874        };
1875        let err = classify_run(Agent::Claude, "claude", 1, "", answer, &terminal);
1876        assert!(
1877            !err.is_auth_failure(),
1878            "an answer that discusses auth was read as an auth failure: {err:?}"
1879        );
1880    }
1881
1882    /// The other half of the same rule: the notice itself still has to be
1883    /// caught, and it arrives as the agent's entire answer.
1884    #[test]
1885    fn the_notice_is_still_caught_when_it_is_the_whole_answer() {
1886        for text in [
1887            "Not logged in · Please run /login",
1888            // A banner first, which is why the opening is three lines deep.
1889            "claude 2.1.212\n\nNot logged in · Please run /login",
1890        ] {
1891            let terminal = Terminal {
1892                text: text.into(),
1893                ..Terminal::default()
1894            };
1895            let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1896            assert!(
1897                err.is_auth_failure(),
1898                "{text:?} was not read as auth: {err:?}"
1899            );
1900        }
1901    }
1902
1903    /// The gate into the error path and the classifier behind it must read an
1904    /// answer by the same rule.
1905    ///
1906    /// Shaped after the report from the field: a healthy, finished turn about
1907    /// a stalled crate release, which mentions an auth error in a later
1908    /// paragraph because that was the subject. Read whole, as the gate used
1909    /// to, the phrase convicts the run; read at its opening, as everything
1910    /// now does, the answer is an answer. An agent with no credentials leads
1911    /// with the notice, which is what makes the opening the honest place to
1912    /// look.
1913    #[test]
1914    fn an_answer_mentioning_auth_late_does_not_open_the_error_path() {
1915        let answer = "So the duplicate pastes cost nothing.\n\
1916                      The publish chain is done: 0.4.1 and 0.4.2 are both on the \
1917                      registry and tagged.\n\
1918                      Everything downstream already consumes them.\n\
1919                      The only thing still open anywhere is the crate PR, \
1920                      `pathscale/RustAgentAbstraction#18`, which is the auth error \
1921                      that ate your reply: the run was reported as `not \
1922                      authenticated` and the hint sent you to /login, while the \
1923                      credentials were fine the whole time.";
1924        assert!(
1925            !answer_reports_no_credentials(answer),
1926            "an answer discussing auth opened the error path"
1927        );
1928        // And the notice itself, which is what the rule exists to catch.
1929        assert!(answer_reports_no_credentials(
1930            "Not logged in \u{b7} Please run /login"
1931        ));
1932    }
1933
1934    /// The backstop, which is what makes a false positive survivable at all.
1935    ///
1936    /// Every phrase check here is a heuristic over ordinary English and will
1937    /// be wrong eventually. When it is, the run reaches a classifier that
1938    /// cannot name any failure and returns the generic one. On a process that
1939    /// exited cleanly with a completed turn, that verdict is the absence of
1940    /// evidence rather than evidence, and the finished answer must stand.
1941    #[test]
1942    fn a_generic_failure_does_not_name_a_failure() {
1943        let unnamed = Error::Failed {
1944            bin: "claude".into(),
1945            code: 0,
1946            stderr: String::new(),
1947        };
1948        assert!(
1949            !names_a_failure(&unnamed),
1950            "a generic failure was treated as a diagnosis, which discards the answer"
1951        );
1952        // Everything a classifier can actually name still stands on its own.
1953        assert!(names_a_failure(&Error::RateLimited {
1954            bin: "claude".into(),
1955            message: "usage limit reached".into(),
1956        }));
1957        assert!(names_a_failure(&Error::NotAuthenticated {
1958            agent: Agent::Claude,
1959            bin: "claude".into(),
1960            message: "Not logged in".into(),
1961            hint: Agent::Claude.login_hint(),
1962        }));
1963    }
1964
1965    /// Reported from the field: a finished, successful turn that happened to be
1966    /// *about* usage limits classified its own run as blocked, and the banner
1967    /// quoted one of the answer's own sentences back as the provider's message.
1968    /// An answer is evidence about its topic, not about the run that produced it.
1969    #[test]
1970    fn an_agent_writing_about_limits_is_not_a_limit() {
1971        let answer = "The three retries cost nothing extra, so that is not where it \
1972                      came from.\n\n\
1973                      Providers do not rate limit on repetition: the limit is on \
1974                      tokens per window, and a 429 is what you would see if one had \
1975                      actually been reached. Each retry does re-send the whole \
1976                      conversation, which is real spend, but spend is not the same \
1977                      thing as a block and the run reported no blocking signal at \
1978                      all.\n\
1979                      Nothing here suggests the usage limit was reached, and the \
1980                      banner quoted a sentence of this answer back as though a \
1981                      provider had written it.";
1982        let terminal = Terminal {
1983            text: answer.into(),
1984            ..Terminal::default()
1985        };
1986        let err = classify(Agent::Claude, "claude", 1, "", answer, &terminal);
1987        assert!(
1988            !matches!(err, Error::RateLimited { .. }),
1989            "an answer discussing limits was read as one: {err:?}"
1990        );
1991    }
1992
1993    /// A status code is a word. These are the shapes that used to trip it.
1994    #[test]
1995    fn a_bare_429_in_prose_is_not_a_status_code() {
1996        assert!(!looks_rate_limited("see run.rs:4291 for the caller"));
1997        assert!(!looks_rate_limited("sha 8f429ac"));
1998        assert!(looks_rate_limited("HTTP 429"));
1999        assert!(looks_rate_limited("(status 429)"));
2000        assert!(looks_rate_limited("Error: too many requests"));
2001    }
2002
2003    /// Auth is the most specific reading, so it wins over a generic failure,
2004    /// but must not swallow unrelated errors.
2005    #[test]
2006    fn ordinary_failures_are_not_mistaken_for_auth_problems() {
2007        for stderr in [
2008            "error: no such file or directory",
2009            "model not found",
2010            "rate limit exceeded",
2011            "error: unexpected argument '--sandbox' found",
2012        ] {
2013            let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
2014            assert!(
2015                !err.is_auth_failure(),
2016                "{stderr:?} was misread as an auth failure: {err:?}"
2017            );
2018        }
2019    }
2020
2021    /// The exact failure that cost a round of debugging: `codex exec resume`
2022    /// rejects `--sandbox`, which `Error::Failed` reported as a generic
2023    /// non-zero exit naming a flag rather than a version mismatch.
2024    #[test]
2025    fn a_rejected_flag_is_named_as_a_version_mismatch() {
2026        let err = classify(
2027            Agent::Codex,
2028            "codex",
2029            2,
2030            "error: unexpected argument '--sandbox' found",
2031            "",
2032            &Terminal::default(),
2033        );
2034        let Error::FlagRejected { bin, detail } = err else {
2035            panic!("expected FlagRejected, got {err:?}")
2036        };
2037        assert_eq!(bin, "codex");
2038        assert!(detail.contains("--sandbox"), "{detail}");
2039    }
2040
2041    #[test]
2042    fn ordinary_failures_are_not_mistaken_for_version_drift() {
2043        for stderr in [
2044            "error: no such file or directory",
2045            "model not found",
2046            "permission denied",
2047        ] {
2048            assert!(
2049                matches!(
2050                    classify(Agent::Codex, "codex", 1, stderr, "", &Terminal::default()),
2051                    Error::Failed { .. }
2052                ),
2053                "{stderr:?} should stay a plain failure"
2054            );
2055        }
2056    }
2057
2058    /// Real output from a failing codex run: the first line is status, the
2059    /// cause is below it. Reporting the first line looks like an explanation
2060    /// while pointing at the wrong thing.
2061    #[test]
2062    fn a_status_line_does_not_masquerade_as_the_cause() {
2063        let stderr = "Reading additional input from stdin...\n\
2064                      error: invalid value 'nope' for '--sandbox <SANDBOX_MODE>'";
2065        let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
2066        let Error::Failed {
2067            stderr: reported, ..
2068        } = err
2069        else {
2070            panic!("expected Failed, got {err:?}")
2071        };
2072        assert!(reported.contains("invalid value"), "reported {reported:?}");
2073    }
2074
2075    /// Codex reports a rejected schema as a JSON error event on **stdout**
2076    /// while stderr carries only a status line. Reporting stderr alone
2077    /// described the failure as "Reading additional input from stdin...",
2078    /// which is not what went wrong.
2079    #[test]
2080    fn a_cause_on_stdout_is_reported_when_stderr_only_narrates() {
2081        let stdout = r#"{"type":"error","message":"invalid_json_schema: 'additionalProperties' is required to be supplied and to be false."}"#;
2082        let err = classify_run(
2083            Agent::Codex,
2084            "codex",
2085            1,
2086            "Reading additional input from stdin...",
2087            stdout,
2088            &Terminal::default(),
2089        );
2090        let Error::Failed {
2091            stderr: reported, ..
2092        } = err
2093        else {
2094            panic!("expected Failed, got {err:?}")
2095        };
2096        assert!(
2097            reported.contains("additionalProperties"),
2098            "reported {reported:?}, which explains nothing"
2099        );
2100    }
2101
2102    #[test]
2103    fn failures_report_the_first_useful_line() {
2104        let err = classify(
2105            Agent::Claude,
2106            "claude",
2107            2,
2108            "\n\n  real problem  \nstack",
2109            "",
2110            &Terminal::default(),
2111        );
2112        let Error::Failed { code, stderr, .. } = err else {
2113            panic!("expected a plain failure")
2114        };
2115        assert_eq!(code, 2);
2116        assert_eq!(stderr, "real problem");
2117    }
2118
2119    /// Prompts and session ids ride the argv, and `Run::argv` invites logging
2120    /// it. The redacted form must keep the shape while dropping the content.
2121    #[test]
2122    fn redaction_removes_prompts_and_session_ids_but_keeps_flags() {
2123        let request = crate::Request::new(Agent::Claude, "my secret prompt")
2124            .system("secret system")
2125            .session_id("11111111-2222-3333-4444-555555555555");
2126        let safe = redact(&request.typed_argv().unwrap());
2127
2128        for secret in [
2129            "my secret prompt",
2130            "secret system",
2131            "11111111-2222-3333-4444-555555555555",
2132        ] {
2133            assert!(
2134                !safe.iter().any(|a| a.contains(secret)),
2135                "{secret:?} survived redaction: {safe:?}"
2136            );
2137        }
2138        // Still recognisable as the same command.
2139        assert_eq!(safe[0], "claude");
2140        assert!(safe.contains(&"--permission-mode".to_string()));
2141        assert!(safe.contains(&"--session-id".to_string()));
2142    }
2143
2144    #[test]
2145    fn codex_trailing_prompt_is_redacted_even_without_a_flag() {
2146        let request = crate::Request::new(Agent::Codex, "my secret prompt");
2147        let safe = redact(&request.typed_argv().unwrap());
2148        assert_eq!(safe.last().unwrap(), REDACTED);
2149        assert_eq!(safe[1], "exec", "the subcommand must survive");
2150    }
2151
2152    /// Redaction must cover the two shapes positional guesswork misses: Codex's
2153    /// bare trailing prompt, and raw arguments whose contents are unknowable.
2154    #[test]
2155    fn redaction_covers_positional_prompts_and_unchecked_arguments() {
2156        let request = crate::Request::new(Agent::Codex, "my secret prompt")
2157            .unchecked_args(["-c", "api_key=hunter2"]);
2158        let safe = redact(&request.typed_argv().unwrap());
2159        assert!(!safe.iter().any(|a| a.contains("my secret prompt")));
2160        assert!(
2161            !safe.iter().any(|a| a.contains("hunter2")),
2162            "unchecked arguments may hold secrets: {safe:?}"
2163        );
2164        assert_eq!(safe[1], "exec", "the subcommand must survive");
2165    }
2166
2167    /// A resume id is a capability: it continues someone's conversation.
2168    #[test]
2169    fn redaction_covers_the_codex_positional_resume_id() {
2170        let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9");
2171        let safe = redact(&request.typed_argv().unwrap());
2172        assert!(
2173            !safe.iter().any(|a| a.contains("thread-secret-9")),
2174            "{safe:?}"
2175        );
2176        assert!(safe.contains(&"resume".to_string()));
2177    }
2178
2179    /// `stream` is synchronous but spawns a task. Outside a runtime that would
2180    /// panic, which a `Result`-returning function must not do.
2181    #[test]
2182    fn stream_outside_a_runtime_errors_instead_of_panicking() {
2183        let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err();
2184        assert!(matches!(err, Error::NoRuntime), "got {err:?}");
2185    }
2186
2187    #[tokio::test]
2188    async fn a_missing_binary_names_the_install_command() {
2189        let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz");
2190        let err = run(&request).await.unwrap_err();
2191        let Error::NotInstalled { hint, agent, .. } = err else {
2192            panic!("expected NotInstalled, got {err:?}")
2193        };
2194        assert_eq!(agent, Agent::Claude);
2195        assert!(hint.contains("claude-code"));
2196    }
2197
2198    #[test]
2199    fn transient_errors_are_distinguished_from_permanent_ones() {
2200        assert!(
2201            Error::RateLimited {
2202                bin: "claude".into(),
2203                message: String::new()
2204            }
2205            .is_transient()
2206        );
2207        assert!(
2208            !Error::NotInstalled {
2209                agent: Agent::Claude,
2210                bin: "claude".into(),
2211                hint: ""
2212            }
2213            .is_transient()
2214        );
2215    }
2216}