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 let unauthenticated = looks_unauthenticated(&terminal.text) || looks_unauthenticated(&stderr);
851 // The agent saying its turn failed is as much a failure as a non-zero exit,
852 // and Claude reports an unknown model exactly this way: exit 0, `is_error`
853 // true, and the explanation where the answer would be.
854 let turn_failed = terminal.stop == Stop::Error;
855 if exit_code != 0 || quota_blocked || unauthenticated || turn_failed {
856 return Err(classify_run(
857 request.agent,
858 &bin,
859 exit_code,
860 &stderr,
861 &raw,
862 &terminal,
863 ));
864 }
865
866 // A fork lands on a *new* id the agent only reveals at the end, so the name
867 // has to be repointed once the run settles. Everything else was bound above.
868 persist_result?;
869 // Resolved before the terminal is consumed by the Outcome below.
870 let structured = terminal.structured.clone().or_else(|| {
871 request
872 .schema
873 .as_ref()
874 .and_then(|_| serde_json::from_str(&terminal.text).ok())
875 });
876 if let Some(token) = &terminal.session
877 && !bound
878 {
879 persist_session(&request, token)?;
880 }
881 Ok(Outcome {
882 agent: request.agent,
883 session: terminal.session,
884 text: terminal.text,
885 usage: terminal.usage,
886 stop: terminal.stop,
887 rate_limit: terminal.rate_limit,
888 exit_code,
889 stderr,
890 unparsed: terminal.unparsed,
891 first_unparsed: terminal.first_unparsed,
892 // Claude reports the conforming value separately; Codex returns it as
893 // the answer text, so that is parsed only when a schema was asked for.
894 // Prose is never reinterpreted as data.
895 structured,
896 })
897}
898
899/// Kill the process group, reap the child, and join the stderr reader.
900///
901/// The orderly teardown both cancellation and timeout share. Returns whatever
902/// stderr had been captured, so a caller can still report why a run was stopped.
903async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle<String>) -> String {
904 kill_process_group(&child.child);
905 // Reap, so the caller is not left with a zombie once this returns.
906 let _ = child.child.kill().await;
907 child.armed = false;
908 // The pipes are closed now that the child is gone, so this finishes
909 // promptly rather than hanging the cancellation.
910 stderr_task.await.unwrap_or_default()
911}
912
913/// Turn a failure into the most specific error available, agent included so an
914/// auth failure can carry the right login command.
915fn classify_run(
916 agent: crate::Agent,
917 bin: &str,
918 code: i32,
919 stderr: &str,
920 stdout: &str,
921 terminal: &Terminal,
922) -> Error {
923 // Checked before quota and before a plain failure: a login problem is the
924 // most specific reading of the output, and the only one a user can act on
925 // directly.
926 for source in [terminal.text.as_str(), stderr, stdout] {
927 if looks_unauthenticated(source) {
928 return Error::NotAuthenticated {
929 agent,
930 bin: bin.to_string(),
931 message: first_meaningful_line(source).unwrap_or_default(),
932 hint: agent.login_hint(),
933 };
934 }
935 }
936 classify(agent, bin, code, stderr, stdout, terminal)
937}
938
939/// Whether text is an agent saying it has no usable credentials.
940///
941/// Narrow on purpose. Mislabelling an ordinary failure as an auth problem sends
942/// someone to re-login over something unrelated, so these are phrases the CLIs
943/// actually emit rather than every string containing "auth".
944fn looks_unauthenticated(text: &str) -> bool {
945 const PHRASES: &[&str] = &[
946 // Claude, verified: an unauthenticated run answers exactly this.
947 "not logged in",
948 "please run /login",
949 // Copilot, verified: it exits 1 with plain text, and none of the other
950 // phrases here appear in it. Its wording shares no vocabulary with the
951 // other two, which is why this had to be observed rather than guessed.
952 "no authentication information",
953 "invalid api key",
954 "authentication_error",
955 "unauthorized",
956 "not authenticated",
957 "no credentials",
958 "credentials not found",
959 "please log in",
960 ];
961 let lower = text.to_ascii_lowercase();
962 PHRASES.iter().any(|needle| lower.contains(needle)) || mentions_status(&lower, "401")
963}
964
965/// Whether `code` appears as a standalone token rather than inside a longer run
966/// of characters.
967///
968/// `401` was previously matched as a bare substring, which made any Copilot
969/// failure an auth failure whenever one of the UUIDs it prints happened to
970/// contain those three digits: `"id":"1b0b1401-cb86-..."` was enough. That is
971/// not rare, since a run emits several ids, so the misdiagnosis was
972/// intermittent and told someone to re-login over an unrelated failure.
973///
974/// A status code is a word. Requiring non-alphanumeric neighbours keeps
975/// `HTTP 401` and `(status 401)` while rejecting every hex blob, and a UUID
976/// cannot produce a standalone `401` at all because its groups are four, eight
977/// or twelve characters long.
978fn mentions_status(haystack: &str, code: &str) -> bool {
979 haystack.match_indices(code).any(|(at, _)| {
980 let before = haystack[..at].chars().next_back();
981 let after = haystack[at + code.len()..].chars().next();
982 let free = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
983 free(before) && free(after)
984 })
985}
986
987/// Turn a non-zero exit into the most specific error available.
988fn classify(
989 agent: crate::Agent,
990 bin: &str,
991 code: i32,
992 stderr: &str,
993 stdout: &str,
994 terminal: &Terminal,
995) -> Error {
996 let quota_signalled = terminal
997 .rate_limit
998 .as_ref()
999 .is_some_and(crate::outcome::RateLimit::is_blocking);
1000 // Scanning the *raw* stream for quota wording is a false-positive machine:
1001 // under `stream-json` Claude prints a `rate_limit_event` record on every
1002 // run, including one whose status is `allowed`, so the substring
1003 // `rate_limit` is present in perfectly healthy output. Where the stream
1004 // parsed, the parsed signal and the agent's own prose decide; the raw scan
1005 // is only the fallback for output that produced neither.
1006 let prose = match (&terminal.error_message, terminal.text.as_str()) {
1007 (Some(message), text) => format!("{message}\n{text}"),
1008 (None, text) if !text.is_empty() => text.to_string(),
1009 _ => stdout.to_string(),
1010 };
1011 if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(&prose) {
1012 return Error::RateLimited {
1013 bin: bin.to_string(),
1014 message: first_meaningful_line(stderr)
1015 .or_else(|| first_meaningful_line(&prose))
1016 .unwrap_or_else(|| "usage limit reached".to_string()),
1017 };
1018 }
1019 // A rejected flag is not a failed request, it is this crate and the CLI
1020 // disagreeing about what the CLI accepts. Naming that is the difference
1021 // between "the run failed" and "your codex is a different version".
1022 if let Some(detail) = rejected_flag(stderr).or_else(|| rejected_flag(stdout)) {
1023 return Error::FlagRejected {
1024 bin: bin.to_string(),
1025 detail,
1026 };
1027 }
1028 // Checked before the generic failure but after quota and a rejected flag,
1029 // which are more specific readings of the same output.
1030 if terminal.stop == Stop::Error {
1031 return Error::AgentError {
1032 agent,
1033 bin: bin.to_string(),
1034 status: terminal.error_status,
1035 // Codex reports the reason apart from the answer; Claude puts it
1036 // where the answer would be.
1037 message: terminal
1038 .error_message
1039 .clone()
1040 .or_else(|| first_meaningful_line(&terminal.text))
1041 .or_else(|| first_meaningful_line(stderr))
1042 .unwrap_or_else(|| "the agent reported a failure without explaining it".into()),
1043 };
1044 }
1045
1046 Error::Failed {
1047 bin: bin.to_string(),
1048 code,
1049 // Fall back to stdout when stderr explains nothing. Codex reports a
1050 // rejected schema as an `{"type":"error"}` event on *stdout* while
1051 // stderr carries only "Reading additional input from stdin...", so
1052 // reporting stderr alone describes the failure as a status message.
1053 stderr: first_meaningful_line(stderr)
1054 .filter(|line| looks_explanatory(line))
1055 .or_else(|| first_meaningful_line(stdout))
1056 .or_else(|| first_meaningful_line(stderr))
1057 .unwrap_or_default(),
1058 }
1059}
1060
1061/// Whether a line plausibly explains a failure rather than narrating progress.
1062fn looks_explanatory(line: &str) -> bool {
1063 const NOISE: &[&str] = &[
1064 "reading additional input",
1065 "reading prompt",
1066 "waiting",
1067 "connecting",
1068 "loading",
1069 ];
1070 let lower = line.to_ascii_lowercase();
1071 !NOISE.iter().any(|noise| lower.contains(noise))
1072}
1073
1074/// The CLI's complaint, if it refused an argument.
1075///
1076/// The phrasings are clap's and commander's, which is what all three CLIs are
1077/// built on. Matched narrowly: a false positive would relabel a genuine failure
1078/// as a version problem and send someone chasing the wrong thing.
1079fn rejected_flag(text: &str) -> Option<String> {
1080 const REJECTIONS: &[&str] = &[
1081 "unexpected argument",
1082 "unknown option",
1083 "unrecognized option",
1084 "unknown flag",
1085 "invalid option",
1086 "unexpected option",
1087 ];
1088 let lower = text.to_ascii_lowercase();
1089 REJECTIONS
1090 .iter()
1091 .any(|needle| lower.contains(needle))
1092 .then(|| first_meaningful_line(text).unwrap_or_default())
1093}
1094
1095/// Whether text carries a provider quota refusal.
1096///
1097/// Deliberately a small set of unambiguous phrases: a false positive here would
1098/// relabel an ordinary failure as a quota problem and send a caller into a
1099/// pointless backoff.
1100fn looks_rate_limited(text: &str) -> bool {
1101 let lower = text.to_ascii_lowercase();
1102 [
1103 "rate limit",
1104 "rate_limit",
1105 "usage limit",
1106 "quota exceeded",
1107 "too many requests",
1108 "429",
1109 ]
1110 .iter()
1111 .any(|needle| lower.contains(needle))
1112}
1113
1114/// The most useful line of a CLI's output for an error message.
1115///
1116/// Not simply the first non-blank one. CLIs open with progress and status
1117/// chatter, so the first line is often "Reading additional input from stdin..."
1118/// while the actual cause is further down. That turns a report into a
1119/// misdirection: it looks like an explanation and is not one.
1120///
1121/// So a line that looks like an error wins, and the first non-blank line is the
1122/// fallback when nothing does.
1123fn first_meaningful_line(text: &str) -> Option<String> {
1124 const ERROR_MARKERS: &[&str] = &[
1125 "error",
1126 "failed",
1127 "fatal",
1128 "panic",
1129 "denied",
1130 "invalid",
1131 "unexpected",
1132 "cannot",
1133 "unable",
1134 ];
1135 let lines: Vec<&str> = text
1136 .lines()
1137 .map(str::trim)
1138 .filter(|line| !line.is_empty())
1139 .collect();
1140
1141 lines
1142 .iter()
1143 .find(|line| {
1144 let lower = line.to_ascii_lowercase();
1145 ERROR_MARKERS.iter().any(|marker| lower.contains(marker))
1146 })
1147 .or_else(|| lines.first())
1148 .map(|line| (*line).to_string())
1149}
1150
1151/// Write the session binding back, reporting any store failure.
1152///
1153/// Called as soon as an id is known rather than only on a clean exit. Waiting
1154/// for success would lose the binding for exactly the runs where continuity
1155/// matters most: a timeout, a crash, or a cancelled turn.
1156fn persist_session(request: &Request, token: &str) -> Result<()> {
1157 let Some(binding) = &request.binding else {
1158 return Ok(());
1159 };
1160 binding
1161 .store
1162 .bind(request.agent, &binding.project, &binding.name, token)
1163 .map(|_| ())
1164}
1165
1166/// The id this run is already known by before it starts, if any.
1167///
1168/// Only a caller-assigned id qualifies: a printed id does not exist yet. This
1169/// is what makes an assigned session survive a run that never finishes.
1170fn preassigned_token(request: &Request) -> Option<String> {
1171 match &request.plan().cont {
1172 Continue::NewWith(id) => Some(id.clone()),
1173 _ => None,
1174 }
1175}
1176
1177/// Reported by an agent that exited cleanly but said nothing useful.
1178impl Outcome {
1179 /// Whether the agent produced any answer at all.
1180 #[must_use]
1181 pub fn is_empty(&self) -> bool {
1182 self.text.trim().is_empty() && self.stop == Stop::Completed
1183 }
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189 use crate::agent::Agent;
1190
1191 #[test]
1192 fn quota_phrases_are_recognized_and_ordinary_errors_are_not() {
1193 assert!(looks_rate_limited("Error: rate limit exceeded"));
1194 assert!(looks_rate_limited("HTTP 429 Too Many Requests"));
1195 assert!(looks_rate_limited("You have hit your usage limit"));
1196 // A plain failure must not be mistaken for a quota problem.
1197 assert!(!looks_rate_limited("error: no such file or directory"));
1198 assert!(!looks_rate_limited("model not found"));
1199 }
1200
1201 #[test]
1202 fn a_blocking_rate_limit_event_classifies_as_rate_limited() {
1203 let terminal = Terminal {
1204 rate_limit: Some(crate::outcome::RateLimit {
1205 status: "rejected".into(),
1206 window: Some("five_hour".into()),
1207 resets_at: None,
1208 overage_status: None,
1209 is_using_overage: None,
1210 }),
1211 ..Terminal::default()
1212 };
1213 assert!(matches!(
1214 classify(Agent::Claude, "claude", 1, "", "", &terminal),
1215 Error::RateLimited { .. }
1216 ));
1217 }
1218
1219 #[test]
1220 fn an_allowed_rate_limit_event_is_not_a_failure_cause() {
1221 let terminal = Terminal {
1222 rate_limit: Some(crate::outcome::RateLimit {
1223 status: "allowed".into(),
1224 window: None,
1225 resets_at: None,
1226 overage_status: None,
1227 is_using_overage: None,
1228 }),
1229 ..Terminal::default()
1230 };
1231 assert!(matches!(
1232 classify(Agent::Claude, "claude", 1, "boom", "", &terminal),
1233 Error::Failed { .. }
1234 ));
1235 }
1236
1237 /// The exact shape that made a Copilot run look unauthenticated: a UUID
1238 /// carrying the digits 401. Copilot prints several ids per run, so this
1239 /// misfired intermittently and told the user to re-login over a failure
1240 /// that had nothing to do with credentials.
1241 #[test]
1242 fn an_id_containing_401_is_not_an_auth_failure() {
1243 let line = r#"{"type":"session.mcp_server_status_changed","id":"1b0b1401-cb86-4276-9874-e84b94c96499"}"#;
1244 assert!(
1245 !looks_unauthenticated(line),
1246 "a hex blob is not a status code"
1247 );
1248 }
1249
1250 /// The needle still has to work where it was meant to. A status code is a
1251 /// word, and these are the forms an agent actually prints.
1252 #[test]
1253 fn a_real_401_is_still_recognized() {
1254 for text in [
1255 "HTTP 401",
1256 "request failed (status 401)",
1257 "401: unauthorized",
1258 "got a 401 from the API",
1259 ] {
1260 assert!(looks_unauthenticated(text), "should match: {text}");
1261 }
1262 }
1263
1264 /// Neighbouring digits mean it is part of some longer number, not a status.
1265 #[test]
1266 fn digits_around_401_keep_it_from_matching() {
1267 for text in ["error 4010", "code 1401", "seq 24019"] {
1268 assert!(!looks_unauthenticated(text), "should not match: {text}");
1269 }
1270 }
1271
1272 /// Verbatim from a healthy claude 2.1.205 run. Every `stream-json` run
1273 /// carries this record, and its status is `allowed`: nothing is refused.
1274 /// Scanning the raw stream for `rate_limit` matched it anyway, so any
1275 /// Claude failure was reported as a quota refusal, sending a caller to back
1276 /// off when the real cause was something they could fix.
1277 #[test]
1278 fn a_healthy_rate_limit_heartbeat_is_not_a_refusal() {
1279 let stdout = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785331800,"rateLimitType":"five_hour","overageStatus":"rejected","isUsingOverage":false}}"#;
1280 let terminal = Terminal {
1281 stop: Stop::Error,
1282 error_status: Some(404),
1283 text: "There's an issue with the selected model (bogus-model-xyz).".into(),
1284 rate_limit: Some(crate::outcome::RateLimit {
1285 status: "allowed".into(),
1286 window: Some("five_hour".into()),
1287 resets_at: Some(1_785_331_800),
1288 overage_status: None,
1289 is_using_overage: None,
1290 }),
1291 ..Terminal::default()
1292 };
1293 let err = classify_run(Agent::Claude, "claude", 0, "", stdout, &terminal);
1294 assert!(
1295 matches!(err, Error::AgentError { .. }),
1296 "the heartbeat must not mask the real cause: {err:?}"
1297 );
1298 }
1299
1300 /// The counterpart: a refusal the parser did read must still be one, even
1301 /// though it arrives with the same zero exit code.
1302 #[test]
1303 fn a_rejected_quota_signal_is_still_a_refusal() {
1304 let terminal = Terminal {
1305 rate_limit: Some(crate::outcome::RateLimit {
1306 status: "rejected".into(),
1307 window: Some("five_hour".into()),
1308 resets_at: None,
1309 overage_status: None,
1310 is_using_overage: None,
1311 }),
1312 ..Terminal::default()
1313 };
1314 assert!(matches!(
1315 classify_run(Agent::Claude, "claude", 0, "", "", &terminal),
1316 Error::RateLimited { .. }
1317 ));
1318 }
1319
1320 /// Verbatim from a real run with an unknown model. Claude exits **0** with
1321 /// `subtype: "success"` while `is_error` is true and the explanation sits
1322 /// where the answer would be, so a caller checking only `Result::is_ok`
1323 /// renders "There's an issue with the selected model" as the answer.
1324 #[test]
1325 fn a_failed_turn_is_an_error_even_though_the_process_exited_cleanly() {
1326 let terminal = Terminal {
1327 stop: Stop::Error,
1328 error_status: Some(404),
1329 text: "There's an issue with the selected model (bogus-model-xyz). \
1330 It may not exist or you may not have access to it."
1331 .into(),
1332 ..Terminal::default()
1333 };
1334 let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1335 let Error::AgentError {
1336 agent,
1337 status,
1338 message,
1339 ..
1340 } = &err
1341 else {
1342 panic!("expected AgentError, got {err:?}")
1343 };
1344 assert_eq!(*agent, Agent::Claude);
1345 assert_eq!(*status, Some(404), "the provider status must survive");
1346 assert!(message.contains("selected model"), "{message}");
1347 }
1348
1349 /// A quota refusal and a missing login are more specific readings of the
1350 /// same shape, so they must not be swallowed by the general case.
1351 #[test]
1352 fn a_failed_turn_does_not_mask_a_more_specific_cause() {
1353 let auth = Terminal {
1354 stop: Stop::Error,
1355 text: "Not logged in · Please run /login".into(),
1356 ..Terminal::default()
1357 };
1358 assert!(
1359 classify_run(Agent::Claude, "claude", 0, "", "", &auth).is_auth_failure(),
1360 "an unauthenticated failed turn must stay an auth failure"
1361 );
1362
1363 let quota = Terminal {
1364 stop: Stop::Error,
1365 rate_limit: Some(crate::outcome::RateLimit {
1366 status: "rejected".into(),
1367 window: None,
1368 resets_at: None,
1369 overage_status: None,
1370 is_using_overage: None,
1371 }),
1372 ..Terminal::default()
1373 };
1374 assert!(
1375 matches!(
1376 classify_run(Agent::Claude, "claude", 0, "", "", "a),
1377 Error::RateLimited { .. }
1378 ),
1379 "a quota-blocked failed turn must stay a rate limit"
1380 );
1381 }
1382
1383 /// Verified against the real CLI: with `USER` withheld, claude answers
1384 /// "Not logged in · Please run /login" and exits **0**. Checking only the
1385 /// exit code hands back a successful Outcome whose answer is a login
1386 /// prompt.
1387 #[test]
1388 fn an_unauthenticated_run_is_named_even_though_it_exits_zero() {
1389 let terminal = Terminal {
1390 text: "Not logged in · Please run /login".into(),
1391 ..Terminal::default()
1392 };
1393 let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
1394 let Error::NotAuthenticated { agent, hint, .. } = &err else {
1395 panic!("expected NotAuthenticated, got {err:?}")
1396 };
1397 assert_eq!(*agent, Agent::Claude);
1398 assert!(hint.contains("/login"), "{hint}");
1399 assert!(err.is_auth_failure());
1400 }
1401
1402 /// Verbatim from an unauthenticated Copilot run, captured by pointing it at
1403 /// an empty HOME. Its wording shares no phrase with Claude's or Codex's, so
1404 /// before this was observed the phrase list did not match it at all and a
1405 /// missing Copilot login was reported as a generic failure.
1406 #[test]
1407 fn copilots_own_unauthenticated_wording_is_recognized() {
1408 let stderr = "Error: No authentication information found.\n\n\
1409 Copilot can be authenticated with GitHub using an OAuth Token or a \
1410 Fine-Grained Personal Access Token.\n\n\
1411 To authenticate, you can use any of the following methods:\n\
1412 \u{2022} Start 'copilot' and run the '/login' command\n\
1413 \u{2022} Set the COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN \
1414 environment variable";
1415 let err = classify_run(
1416 Agent::Copilot,
1417 "copilot",
1418 1,
1419 stderr,
1420 "",
1421 &Terminal::default(),
1422 );
1423 let Error::NotAuthenticated { agent, hint, .. } = &err else {
1424 panic!("expected NotAuthenticated, got {err:?}")
1425 };
1426 assert_eq!(*agent, Agent::Copilot);
1427 assert!(hint.contains("copilot login"), "{hint}");
1428 }
1429
1430 /// Each agent's hint has to name its own login route, since they differ:
1431 /// Codex and Copilot have `login` subcommands, Claude does not.
1432 #[test]
1433 fn every_agent_offers_its_own_login_route() {
1434 for (agent, expected) in [
1435 (Agent::Claude, "setup-token"),
1436 (Agent::Codex, "codex login"),
1437 (Agent::Copilot, "copilot login"),
1438 ] {
1439 let err = classify_run(
1440 agent,
1441 agent.bin(),
1442 1,
1443 "error: unauthorized",
1444 "",
1445 &Terminal::default(),
1446 );
1447 let Error::NotAuthenticated { hint, .. } = &err else {
1448 panic!("{agent}: expected NotAuthenticated, got {err:?}")
1449 };
1450 assert!(hint.contains(expected), "{agent}: {hint}");
1451 }
1452 }
1453
1454 /// Auth is the most specific reading, so it wins over a generic failure,
1455 /// but must not swallow unrelated errors.
1456 #[test]
1457 fn ordinary_failures_are_not_mistaken_for_auth_problems() {
1458 for stderr in [
1459 "error: no such file or directory",
1460 "model not found",
1461 "rate limit exceeded",
1462 "error: unexpected argument '--sandbox' found",
1463 ] {
1464 let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
1465 assert!(
1466 !err.is_auth_failure(),
1467 "{stderr:?} was misread as an auth failure: {err:?}"
1468 );
1469 }
1470 }
1471
1472 /// The exact failure that cost a round of debugging: `codex exec resume`
1473 /// rejects `--sandbox`, which `Error::Failed` reported as a generic
1474 /// non-zero exit naming a flag rather than a version mismatch.
1475 #[test]
1476 fn a_rejected_flag_is_named_as_a_version_mismatch() {
1477 let err = classify(
1478 Agent::Codex,
1479 "codex",
1480 2,
1481 "error: unexpected argument '--sandbox' found",
1482 "",
1483 &Terminal::default(),
1484 );
1485 let Error::FlagRejected { bin, detail } = err else {
1486 panic!("expected FlagRejected, got {err:?}")
1487 };
1488 assert_eq!(bin, "codex");
1489 assert!(detail.contains("--sandbox"), "{detail}");
1490 }
1491
1492 #[test]
1493 fn ordinary_failures_are_not_mistaken_for_version_drift() {
1494 for stderr in [
1495 "error: no such file or directory",
1496 "model not found",
1497 "permission denied",
1498 ] {
1499 assert!(
1500 matches!(
1501 classify(Agent::Codex, "codex", 1, stderr, "", &Terminal::default()),
1502 Error::Failed { .. }
1503 ),
1504 "{stderr:?} should stay a plain failure"
1505 );
1506 }
1507 }
1508
1509 /// Real output from a failing codex run: the first line is status, the
1510 /// cause is below it. Reporting the first line looks like an explanation
1511 /// while pointing at the wrong thing.
1512 #[test]
1513 fn a_status_line_does_not_masquerade_as_the_cause() {
1514 let stderr = "Reading additional input from stdin...\n\
1515 error: invalid value 'nope' for '--sandbox <SANDBOX_MODE>'";
1516 let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
1517 let Error::Failed {
1518 stderr: reported, ..
1519 } = err
1520 else {
1521 panic!("expected Failed, got {err:?}")
1522 };
1523 assert!(reported.contains("invalid value"), "reported {reported:?}");
1524 }
1525
1526 /// Codex reports a rejected schema as a JSON error event on **stdout**
1527 /// while stderr carries only a status line. Reporting stderr alone
1528 /// described the failure as "Reading additional input from stdin...",
1529 /// which is not what went wrong.
1530 #[test]
1531 fn a_cause_on_stdout_is_reported_when_stderr_only_narrates() {
1532 let stdout = r#"{"type":"error","message":"invalid_json_schema: 'additionalProperties' is required to be supplied and to be false."}"#;
1533 let err = classify_run(
1534 Agent::Codex,
1535 "codex",
1536 1,
1537 "Reading additional input from stdin...",
1538 stdout,
1539 &Terminal::default(),
1540 );
1541 let Error::Failed {
1542 stderr: reported, ..
1543 } = err
1544 else {
1545 panic!("expected Failed, got {err:?}")
1546 };
1547 assert!(
1548 reported.contains("additionalProperties"),
1549 "reported {reported:?}, which explains nothing"
1550 );
1551 }
1552
1553 #[test]
1554 fn failures_report_the_first_useful_line() {
1555 let err = classify(
1556 Agent::Claude,
1557 "claude",
1558 2,
1559 "\n\n real problem \nstack",
1560 "",
1561 &Terminal::default(),
1562 );
1563 let Error::Failed { code, stderr, .. } = err else {
1564 panic!("expected a plain failure")
1565 };
1566 assert_eq!(code, 2);
1567 assert_eq!(stderr, "real problem");
1568 }
1569
1570 /// Prompts and session ids ride the argv, and `Run::argv` invites logging
1571 /// it. The redacted form must keep the shape while dropping the content.
1572 #[test]
1573 fn redaction_removes_prompts_and_session_ids_but_keeps_flags() {
1574 let request = crate::Request::new(Agent::Claude, "my secret prompt")
1575 .system("secret system")
1576 .session_id("11111111-2222-3333-4444-555555555555");
1577 let safe = redact(&request.typed_argv().unwrap());
1578
1579 for secret in [
1580 "my secret prompt",
1581 "secret system",
1582 "11111111-2222-3333-4444-555555555555",
1583 ] {
1584 assert!(
1585 !safe.iter().any(|a| a.contains(secret)),
1586 "{secret:?} survived redaction: {safe:?}"
1587 );
1588 }
1589 // Still recognisable as the same command.
1590 assert_eq!(safe[0], "claude");
1591 assert!(safe.contains(&"--permission-mode".to_string()));
1592 assert!(safe.contains(&"--session-id".to_string()));
1593 }
1594
1595 #[test]
1596 fn codex_trailing_prompt_is_redacted_even_without_a_flag() {
1597 let request = crate::Request::new(Agent::Codex, "my secret prompt");
1598 let safe = redact(&request.typed_argv().unwrap());
1599 assert_eq!(safe.last().unwrap(), REDACTED);
1600 assert_eq!(safe[1], "exec", "the subcommand must survive");
1601 }
1602
1603 /// Redaction must cover the two shapes positional guesswork misses: Codex's
1604 /// bare trailing prompt, and raw arguments whose contents are unknowable.
1605 #[test]
1606 fn redaction_covers_positional_prompts_and_unchecked_arguments() {
1607 let request = crate::Request::new(Agent::Codex, "my secret prompt")
1608 .unchecked_args(["-c", "api_key=hunter2"]);
1609 let safe = redact(&request.typed_argv().unwrap());
1610 assert!(!safe.iter().any(|a| a.contains("my secret prompt")));
1611 assert!(
1612 !safe.iter().any(|a| a.contains("hunter2")),
1613 "unchecked arguments may hold secrets: {safe:?}"
1614 );
1615 assert_eq!(safe[1], "exec", "the subcommand must survive");
1616 }
1617
1618 /// A resume id is a capability: it continues someone's conversation.
1619 #[test]
1620 fn redaction_covers_the_codex_positional_resume_id() {
1621 let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9");
1622 let safe = redact(&request.typed_argv().unwrap());
1623 assert!(
1624 !safe.iter().any(|a| a.contains("thread-secret-9")),
1625 "{safe:?}"
1626 );
1627 assert!(safe.contains(&"resume".to_string()));
1628 }
1629
1630 /// `stream` is synchronous but spawns a task. Outside a runtime that would
1631 /// panic, which a `Result`-returning function must not do.
1632 #[test]
1633 fn stream_outside_a_runtime_errors_instead_of_panicking() {
1634 let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err();
1635 assert!(matches!(err, Error::NoRuntime), "got {err:?}");
1636 }
1637
1638 #[tokio::test]
1639 async fn a_missing_binary_names_the_install_command() {
1640 let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz");
1641 let err = run(&request).await.unwrap_err();
1642 let Error::NotInstalled { hint, agent, .. } = err else {
1643 panic!("expected NotInstalled, got {err:?}")
1644 };
1645 assert_eq!(agent, Agent::Claude);
1646 assert!(hint.contains("claude-code"));
1647 }
1648
1649 #[test]
1650 fn transient_errors_are_distinguished_from_permanent_ones() {
1651 assert!(
1652 Error::RateLimited {
1653 bin: "claude".into(),
1654 message: String::new()
1655 }
1656 .is_transient()
1657 );
1658 assert!(
1659 !Error::NotInstalled {
1660 agent: Agent::Claude,
1661 bin: "claude".into(),
1662 hint: ""
1663 }
1664 .is_transient()
1665 );
1666 }
1667}