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