Skip to main content

agent_abstraction/
run.rs

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