Skip to main content

agent_abstraction/
run.rs

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