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