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