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