use std::collections::VecDeque;
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::mpsc;
use crate::agent::{Continue, EnvPolicy};
use crate::error::{Error, Result};
use crate::event::{Event, MAX_LINE, Parser, Terminal, append_capped};
use crate::outcome::{Outcome, Stop};
use crate::proc::{kill_group_by_pid, kill_process_group};
use crate::request::Request;
async fn read_bounded_line<R>(reader: &mut R, buf: &mut String) -> std::io::Result<Option<bool>>
where
R: tokio::io::AsyncBufRead + Unpin,
{
buf.clear();
let mut bytes = Vec::new();
let mut truncated = false;
loop {
let mut byte = [0u8; 1];
match reader.read(&mut byte).await? {
0 => {
if bytes.is_empty() {
return Ok(None);
}
break;
}
_ if byte[0] == b'\n' => break,
_ => {
if bytes.len() < MAX_LINE {
bytes.push(byte[0]);
} else {
truncated = true;
}
}
}
}
buf.push_str(&String::from_utf8_lossy(&bytes));
Ok(Some(truncated))
}
struct AbortOnDrop(tokio::task::JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
const APPROVAL_BUFFER: usize = 8;
const EVENT_BUFFER: usize = 256;
#[derive(Debug)]
enum Control {
Message(String),
Approval {
id: String,
decision: crate::Decision,
},
}
#[derive(Debug)]
pub struct Run {
events: mpsc::Receiver<Event>,
agent: crate::Agent,
typed: Vec<crate::agent::Arg>,
pid: Option<u32>,
reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
to_agent: Option<mpsc::Sender<Control>>,
cancel: Option<tokio::sync::oneshot::Sender<()>>,
task: Option<tokio::task::JoinHandle<Result<Outcome>>>,
argv: Vec<String>,
}
impl Run {
pub async fn recv(&mut self) -> Option<Event> {
self.events.recv().await
}
pub async fn send(&self, message: &str) -> Result<()> {
let Some(channel) = &self.to_agent else {
return Err(Error::Unsupported {
agent: self.agent,
what: "sending a follow-up on a run that is not interactive",
});
};
channel
.send(Control::Message(message.to_string()))
.await
.map_err(|_| Error::Cancelled {
bin: self.argv.first().cloned().unwrap_or_default(),
})
}
pub async fn respond(&self, id: &str, decision: &crate::Decision) -> Result<()> {
let Some(channel) = &self.to_agent else {
return Err(Error::Unsupported {
agent: self.agent,
what: "answering an approval on a run that did not request them",
});
};
channel
.send(Control::Approval {
id: id.to_string(),
decision: decision.clone(),
})
.await
.map_err(|_| Error::Cancelled {
bin: self.argv.first().cloned().unwrap_or_default(),
})
}
#[must_use]
pub fn argv(&self) -> &[String] {
&self.argv
}
#[must_use]
pub fn redacted_argv(&self) -> Vec<String> {
redact(&self.typed)
}
pub async fn finish(mut self) -> Result<Outcome> {
self.pid = None;
while self.events.recv().await.is_some() {}
let Some(task) = self.task.take() else {
unreachable!("the handle is only taken by a consuming method")
};
match task.await {
Ok(result) => result,
Err(join) => Err(Error::Interrupted {
bin: self.argv.first().cloned().unwrap_or_default(),
detail: if join.is_panic() {
"the driver task panicked".into()
} else {
"the driver task was cancelled".into()
},
}),
}
}
pub async fn cancel(mut self) -> Result<Outcome> {
self.pid = None;
drop(self.cancel.take());
let Some(task) = self.task.take() else {
unreachable!("the handle is only taken by a consuming method")
};
match task.await {
Ok(result) => result,
Err(join) => Err(Error::Interrupted {
bin: self.argv.first().cloned().unwrap_or_default(),
detail: if join.is_panic() {
"the driver task panicked".into()
} else {
"the driver task was cancelled".into()
},
}),
}
}
pub fn detach(mut self) {
self.pid = None;
if let Some(cancel) = self.cancel.take() {
std::mem::forget(cancel);
}
drop(self.task.take());
}
}
impl Drop for Run {
fn drop(&mut self) {
if let Some(pid) = self.pid
&& !self.reaped.load(std::sync::atomic::Ordering::SeqCst)
{
kill_group_by_pid(pid);
}
drop(self.cancel.take());
if let Some(task) = self.task.take() {
task.abort();
}
}
}
const REDACTED: &str = "<redacted>";
fn redact(argv: &[crate::agent::Arg]) -> Vec<String> {
use crate::agent::Sensitivity;
argv.iter()
.map(|arg| match arg.sensitivity {
Sensitivity::Public => arg.value.clone(),
_ => REDACTED.to_string(),
})
.collect()
}
pub async fn run(request: &Request) -> Result<Outcome> {
if request.plan().approvals {
return Err(Error::Unsupported {
agent: request.agent,
what: "approvals on a run whose events are discarded; use `stream`",
});
}
stream(request)?.finish().await
}
#[allow(
clippy::too_many_lines,
reason = "one spawn boundary keeps command posture, pipes, process group, and driver selection together"
)]
pub fn stream(request: &Request) -> Result<Run> {
let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?;
let initial_plan = request.plan();
let codex_app_server =
request.agent == crate::Agent::Codex && (initial_plan.duplex || initial_plan.approvals);
let schema_file = match (&request.schema, request.agent.caps().schema) {
(Some(_), _) if codex_app_server => None,
(Some(schema), crate::agent::SchemaSupport::File) => {
Some(SchemaFile::write(schema).map_err(|source| Error::Spawn {
bin: request.agent.bin().to_string(),
source,
})?)
}
_ => None,
};
let mut request = request.clone();
if let Some(file) = &schema_file {
request.schema_file = Some(file.0.display().to_string());
}
let request = &request;
let plan = request.plan();
let typed = request.typed_argv()?;
let argv: Vec<String> = typed.iter().map(|a| a.value.clone()).collect();
let mut command = Command::new(&argv[0]);
command
.args(&argv[1..])
.stdin(if plan.stdin_prompt || plan.duplex || plan.approvals {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = &request.cwd {
command.current_dir(cwd);
}
match &request.env_policy {
EnvPolicy::Inherit => {}
EnvPolicy::Minimal => {
command.env_clear();
inherit_named(&mut command, &request.agent.essential_env());
}
EnvPolicy::Only(names) => {
command.env_clear();
inherit_named(&mut command, names);
}
}
for (key, value) in &request.env {
command.env(key, value);
}
if let Some((key, value)) = request.agent.thinking_env(request.thinking) {
command.env(key, value);
}
#[cfg(unix)]
command.process_group(0);
if let Some(token) = preassigned_token(request) {
persist_session(request, &token)?;
}
let child = command.spawn().map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Error::NotInstalled {
agent: request.agent,
bin: plan.bin.clone(),
hint: request.agent.install_hint(),
}
} else {
Error::Spawn {
bin: plan.bin.clone(),
source,
}
}
})?;
let request_agent = request.agent;
let pid = child.id();
let (tx, rx) = mpsc::channel(EVENT_BUFFER);
let (decisions_tx, decisions_rx) = if plan.duplex || plan.approvals {
let (tx, rx) = mpsc::channel::<Control>(APPROVAL_BUFFER);
(Some(tx), Some(rx))
} else {
(None, None)
};
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
let reaped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reaped_for_task = std::sync::Arc::clone(&reaped);
let request = request.clone();
let task = runtime.spawn(async move {
let _schema_file = schema_file;
if codex_app_server {
drive_codex_app_server(child, request, tx, cancel_rx, reaped_for_task, decisions_rx)
.await
} else {
drive(child, request, tx, cancel_rx, reaped_for_task, decisions_rx).await
}
});
Ok(Run {
events: rx,
agent: request_agent,
typed,
pid,
reaped,
cancel: Some(cancel_tx),
to_agent: decisions_tx,
task: Some(task),
argv,
})
}
fn inherit_named<S: AsRef<str>>(command: &mut Command, names: &[S]) {
for name in names {
if let Some(value) = std::env::var_os(name.as_ref()) {
command.env(name.as_ref(), value);
}
}
}
struct SchemaFile(std::path::PathBuf);
impl SchemaFile {
fn write(schema: &str) -> std::io::Result<SchemaFile> {
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"agent-abstraction-schema-{}-{}.json",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
options.open(&path)?.write_all(schema.as_bytes())?;
Ok(SchemaFile(path))
}
}
impl Drop for SchemaFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
struct ChildGuard {
child: Child,
armed: bool,
}
impl Drop for ChildGuard {
fn drop(&mut self) {
if self.armed {
kill_process_group(&self.child);
}
}
}
#[allow(
clippy::too_many_lines,
reason = "one linear lifecycle: feed, read, wait, classify. Splitting it \
would thread the child, parser, buffers and cancellation state \
through helpers and obscure the ordering that matters, such as \
killing the group before reaping."
)]
async fn drive(
child: Child,
request: Request,
events: mpsc::Sender<Event>,
cancel: tokio::sync::oneshot::Receiver<()>,
reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
decisions: Option<mpsc::Receiver<Control>>,
) -> Result<Outcome> {
let mut child = ChildGuard { child, armed: true };
let plan = request.plan();
let bin = plan.bin.clone();
let mut decision_task = None;
let mut close_stdin = None;
if plan.duplex || plan.approvals {
let Some(mut stdin) = child.child.stdin.take() else {
return Err(Error::Spawn {
bin: bin.clone(),
source: std::io::Error::other("stdin was not piped for an interactive run"),
});
};
let opening = format!(
"{}{}",
crate::approval::handshake(),
crate::approval::user_message(&request.agent.effective_prompt(&plan)),
);
stdin
.write_all(opening.as_bytes())
.await
.map_err(|source| Error::Spawn {
bin: bin.clone(),
source,
})?;
let _ = stdin.flush().await;
let (close_tx, mut close_rx) = tokio::sync::oneshot::channel::<()>();
close_stdin = Some(close_tx);
decision_task = decisions.map(|mut rx| {
tokio::spawn(async move {
loop {
tokio::select! {
reply = rx.recv() => {
let Some(control) = reply else { break };
let reply = match control {
Control::Message(message) => {
crate::approval::user_message(&message)
}
Control::Approval { id, decision } => decision.wire(&id),
};
if stdin.write_all(reply.as_bytes()).await.is_err() {
break;
}
let _ = stdin.flush().await;
}
_ = &mut close_rx => break,
}
}
drop(stdin);
})
});
}
if plan.stdin_prompt {
if let Some(mut stdin) = child.child.stdin.take() {
let prompt = request.agent.effective_prompt(&plan);
stdin
.write_all(prompt.as_bytes())
.await
.map_err(|source| Error::Spawn {
bin: bin.clone(),
source,
})?;
drop(stdin);
}
}
let _decision_guard = decision_task.map(AbortOnDrop);
let stderr = child.child.stderr.take();
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
if let Some(handle) = stderr {
let mut reader = BufReader::new(handle);
let mut line = String::new();
while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
append_capped(&mut buf, &line);
}
}
buf
});
let stdout = child.child.stdout.take();
let mut parser = Parser::new(request.agent, plan.format);
let mut raw = String::new();
let mut bound = false;
let mut persist_result: Result<()> = Ok(());
let read_stdout = async {
if let Some(handle) = stdout {
let mut reader = BufReader::new(handle);
let mut line = String::new();
while read_bounded_line(&mut reader, &mut line).await?.is_some() {
append_capped(&mut raw, &line);
let parsed = parser.push(&line);
if parser.saw_terminal()
&& let Some(close) = close_stdin.take()
{
let _ = close.send(());
}
for event in parsed {
if let Event::Started { session, .. } = &event
&& !bound
{
bound = true;
persist_result = persist_session(&request, session);
}
if events.send(event).await.is_err() {
break;
}
}
}
}
Ok::<_, std::io::Error>(())
};
let work = async {
read_stdout.await?;
child.child.wait().await
};
let deadline = async {
match request.timeout {
Some(limit) => tokio::time::sleep(limit).await,
None => std::future::pending().await,
}
};
let status = tokio::select! {
biased;
result = work => result,
() = deadline => {
let partial = shut_down(&mut child, stderr_task).await;
reaped.store(true, std::sync::atomic::Ordering::SeqCst);
return Err(Error::Timeout {
bin,
timeout: request.timeout.unwrap_or_default(),
partial: parser.finish().text,
})
.inspect_err(|_| drop(partial));
}
_ = cancel => {
shut_down(&mut child, stderr_task).await;
reaped.store(true, std::sync::atomic::Ordering::SeqCst);
return Err(Error::Cancelled { bin });
}
}
.map_err(|source| Error::Spawn {
bin: bin.clone(),
source,
})?;
child.armed = false;
reaped.store(true, std::sync::atomic::Ordering::SeqCst);
drop(events);
let stderr = stderr_task.await.unwrap_or_default();
let saw_structured = parser.saw_structured_record();
let saw_terminal = parser.saw_terminal_record();
let terminal = parser.finish();
let exit_code = status.code().unwrap_or(-1);
let structured = plan.format != crate::Format::Text;
if structured && exit_code == 0 {
if !saw_structured {
return Err(Error::Parse {
agent: request.agent,
detail: format!(
"no recognizable {} records in {} lines of output; the CLI's output shape has probably changed",
request.agent,
raw.lines().count()
),
});
}
if !saw_terminal {
return Err(Error::Parse {
agent: request.agent,
detail: "the stream ended without its terminal record, so the turn did not complete"
.into(),
});
}
}
let mut terminal = terminal;
if terminal.text.is_empty() && !structured {
terminal.text = raw.trim().to_string();
}
let quota_blocked = terminal
.rate_limit
.as_ref()
.is_some_and(crate::outcome::RateLimit::is_blocking);
let unauthenticated =
answer_reports_no_credentials(&terminal.text) || looks_unauthenticated(&stderr);
let turn_failed = terminal.stop == Stop::Error;
if exit_code != 0 || quota_blocked || unauthenticated || turn_failed {
let error = classify_run(request.agent, &bin, exit_code, &stderr, &raw, &terminal);
let exited_clean = exit_code == 0 && !quota_blocked && !turn_failed;
if !exited_clean || names_a_failure(&error) {
return Err(error);
}
}
persist_result?;
let structured = terminal.structured.clone().or_else(|| {
request
.schema
.as_ref()
.and_then(|_| serde_json::from_str(&terminal.text).ok())
});
if let Some(token) = &terminal.session
&& !bound
{
persist_session(&request, token)?;
}
Ok(Outcome {
agent: request.agent,
session: terminal.session,
text: terminal.text,
usage: terminal.usage,
stop: terminal.stop,
rate_limit: terminal.rate_limit,
exit_code,
stderr,
unparsed: terminal.unparsed,
first_unparsed: terminal.first_unparsed,
structured,
})
}
#[allow(
clippy::too_many_lines,
reason = "one select loop owns the protocol, control channel, deadline, and child lifecycle"
)]
async fn drive_codex_app_server(
child: Child,
request: Request,
events: mpsc::Sender<Event>,
cancel: tokio::sync::oneshot::Receiver<()>,
reaped: std::sync::Arc<std::sync::atomic::AtomicBool>,
controls: Option<mpsc::Receiver<Control>>,
) -> Result<Outcome> {
let mut child = ChildGuard { child, armed: true };
let plan = request.plan();
let bin = plan.bin.clone();
let Some(mut stdin) = child.child.stdin.take() else {
return Err(Error::Spawn {
bin,
source: std::io::Error::other("stdin was not piped for Codex app-server"),
});
};
let Some(stdout) = child.child.stdout.take() else {
return Err(Error::Spawn {
bin,
source: std::io::Error::other("stdout was not piped for Codex app-server"),
});
};
let Some(mut controls) = controls else {
return Err(Error::Spawn {
bin,
source: std::io::Error::other("Codex app-server has no host control channel"),
});
};
let stderr = child.child.stderr.take();
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
if let Some(handle) = stderr {
let mut reader = BufReader::new(handle);
let mut line = String::new();
while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
append_capped(&mut buf, &line);
}
}
buf
});
let mut protocol = crate::codex_app_server::Protocol::new(request.clone());
for opening in protocol.opening() {
stdin
.write_all(opening.as_bytes())
.await
.map_err(|source| Error::Spawn {
bin: bin.clone(),
source,
})?;
}
stdin.flush().await.map_err(|source| Error::Spawn {
bin: bin.clone(),
source,
})?;
let mut reader = BufReader::new(stdout);
let mut line = String::new();
let mut raw = String::new();
let mut pending = VecDeque::new();
let mut bound = false;
let mut persist_result: Result<()> = Ok(());
let deadline = async {
match request.timeout {
Some(limit) => tokio::time::sleep(limit).await,
None => std::future::pending().await,
}
};
tokio::pin!(deadline);
tokio::pin!(cancel);
while !protocol.finished {
tokio::select! {
biased;
control = controls.recv() => {
let Some(control) = control else {
continue;
};
pending.push_back(control);
flush_codex_controls(&mut protocol, &mut pending, &mut stdin, &bin).await?;
stdin.flush().await.map_err(|source| Error::Spawn {
bin: bin.clone(), source
})?;
}
record = read_bounded_line(&mut reader, &mut line) => {
if record.map_err(|source| Error::Spawn { bin: bin.clone(), source })?.is_some() {
append_capped(&mut raw, &line);
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
let step = protocol.push(&value);
for event in step.events {
if let Event::Started { session, .. } = &event
&& !bound
{
bound = true;
persist_result = persist_session(&request, session);
}
let _ = events.send(event).await;
}
for write in step.writes {
stdin.write_all(write.as_bytes()).await.map_err(|source| {
Error::Spawn { bin: bin.clone(), source }
})?;
}
flush_codex_controls(&mut protocol, &mut pending, &mut stdin, &bin)
.await?;
stdin.flush().await.map_err(|source| Error::Spawn {
bin: bin.clone(), source
})?;
} else {
protocol.terminal.unparsed += 1;
if protocol.terminal.first_unparsed.is_none() {
protocol.terminal.first_unparsed = Some(line.clone());
}
}
} else {
protocol.failure.get_or_insert_with(|| {
"app-server closed stdout before turn/completed".to_string()
});
protocol.finished = true;
}
}
() = &mut deadline => {
let partial = protocol.terminal.text.clone();
shut_down(&mut child, stderr_task).await;
reaped.store(true, std::sync::atomic::Ordering::SeqCst);
return Err(Error::Timeout {
bin,
timeout: request.timeout.unwrap_or_default(),
partial,
});
}
_ = &mut cancel => {
shut_down(&mut child, stderr_task).await;
reaped.store(true, std::sync::atomic::Ordering::SeqCst);
return Err(Error::Cancelled { bin });
}
}
}
drop(stdin);
if tokio::time::timeout(std::time::Duration::from_secs(2), child.child.wait())
.await
.is_err()
{
kill_process_group(&child.child);
let _ = child.child.kill().await;
}
child.armed = false;
reaped.store(true, std::sync::atomic::Ordering::SeqCst);
drop(events);
let stderr = stderr_task.await.unwrap_or_default();
persist_result?;
if let Some(detail) = protocol.failure {
return Err(Error::Parse {
agent: request.agent,
detail,
});
}
let terminal = protocol.terminal;
if terminal.stop == Stop::Error {
return Err(classify_run(
request.agent,
&bin,
0,
&stderr,
&raw,
&terminal,
));
}
let structured = terminal.structured.clone().or_else(|| {
request
.schema
.as_ref()
.and_then(|_| serde_json::from_str(&terminal.text).ok())
});
Ok(Outcome {
agent: request.agent,
session: terminal.session,
text: terminal.text,
usage: terminal.usage,
stop: terminal.stop,
rate_limit: terminal.rate_limit,
exit_code: 0,
stderr,
unparsed: terminal.unparsed,
first_unparsed: terminal.first_unparsed,
structured,
})
}
async fn flush_codex_controls(
protocol: &mut crate::codex_app_server::Protocol,
pending: &mut VecDeque<Control>,
stdin: &mut tokio::process::ChildStdin,
bin: &str,
) -> Result<()> {
let mut waiting = VecDeque::new();
while let Some(control) = pending.pop_front() {
let encoded = match &control {
Control::Message(message) => protocol.steer(message),
Control::Approval { id, decision } => protocol.respond(id, decision),
};
if let Some(encoded) = encoded {
stdin
.write_all(encoded.as_bytes())
.await
.map_err(|source| Error::Spawn {
bin: bin.to_string(),
source,
})?;
} else {
waiting.push_back(control);
}
}
pending.append(&mut waiting);
Ok(())
}
async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle<String>) -> String {
kill_process_group(&child.child);
let _ = child.child.kill().await;
child.armed = false;
stderr_task.await.unwrap_or_default()
}
fn classify_run(
agent: crate::Agent,
bin: &str,
code: i32,
stderr: &str,
stdout: &str,
terminal: &Terminal,
) -> Error {
let named = |source: &str| Error::NotAuthenticated {
agent,
bin: bin.to_string(),
message: first_meaningful_line(source).unwrap_or_default(),
hint: agent.login_hint(),
};
if looks_unauthenticated(stderr) {
return named(stderr);
}
for source in [terminal.text.as_str(), stdout] {
if answer_reports_no_credentials(source) {
return named(&opening_lines(source, OPENING_LINES));
}
}
classify(agent, bin, code, stderr, stdout, terminal)
}
const NOTICE_MAX: usize = 240;
const OPENING_LINES: usize = 3;
fn opening_lines(text: &str, count: usize) -> String {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.take(count)
.collect::<Vec<_>>()
.join("\n")
}
fn answer_reports_no_credentials(text: &str) -> bool {
let opening = opening_lines(text, OPENING_LINES);
opening.len() <= NOTICE_MAX && looks_unauthenticated(&opening)
}
fn names_a_failure(error: &Error) -> bool {
!matches!(error, Error::Failed { .. })
}
fn looks_unauthenticated(text: &str) -> bool {
const PHRASES: &[&str] = &[
"not logged in",
"please run /login",
"no authentication information",
"invalid api key",
"authentication_error",
"unauthorized",
"not authenticated",
"no credentials",
"credentials not found",
"please log in",
];
let lower = text.to_ascii_lowercase();
PHRASES.iter().any(|needle| lower.contains(needle)) || mentions_status(&lower, "401")
}
fn mentions_status(haystack: &str, code: &str) -> bool {
haystack.match_indices(code).any(|(at, _)| {
let before = haystack[..at].chars().next_back();
let after = haystack[at + code.len()..].chars().next();
let free = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
free(before) && free(after)
})
}
fn classify(
agent: crate::Agent,
bin: &str,
code: i32,
stderr: &str,
stdout: &str,
terminal: &Terminal,
) -> Error {
let quota_signalled = terminal
.rate_limit
.as_ref()
.is_some_and(crate::outcome::RateLimit::is_blocking);
let reported = terminal.error_message.clone().unwrap_or_default();
let answered = if terminal.text.len() <= NOTICE_MAX {
opening_lines(&terminal.text, OPENING_LINES)
} else {
String::new()
};
let prose = if terminal.text.is_empty() {
format!("{reported}\n{}", opening_lines(stdout, OPENING_LINES))
} else {
format!("{reported}\n{answered}")
};
if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(&prose) {
return Error::RateLimited {
bin: bin.to_string(),
message: first_meaningful_line(stderr)
.or_else(|| first_meaningful_line(&prose))
.unwrap_or_else(|| "usage limit reached".to_string()),
};
}
if let Some(detail) = rejected_flag(stderr).or_else(|| rejected_flag(stdout)) {
return Error::FlagRejected {
bin: bin.to_string(),
detail,
};
}
if terminal.stop == Stop::Error {
return Error::AgentError {
agent,
bin: bin.to_string(),
status: terminal.error_status,
message: terminal
.error_message
.clone()
.or_else(|| first_meaningful_line(&terminal.text))
.or_else(|| first_meaningful_line(stderr))
.unwrap_or_else(|| "the agent reported a failure without explaining it".into()),
};
}
Error::Failed {
bin: bin.to_string(),
code,
stderr: first_meaningful_line(stderr)
.filter(|line| looks_explanatory(line))
.or_else(|| first_meaningful_line(stdout))
.or_else(|| first_meaningful_line(stderr))
.unwrap_or_default(),
}
}
fn looks_explanatory(line: &str) -> bool {
const NOISE: &[&str] = &[
"reading additional input",
"reading prompt",
"waiting",
"connecting",
"loading",
];
let lower = line.to_ascii_lowercase();
!NOISE.iter().any(|noise| lower.contains(noise))
}
fn rejected_flag(text: &str) -> Option<String> {
const REJECTIONS: &[&str] = &[
"unexpected argument",
"unknown option",
"unrecognized option",
"unknown flag",
"invalid option",
"unexpected option",
];
let lower = text.to_ascii_lowercase();
REJECTIONS
.iter()
.any(|needle| lower.contains(needle))
.then(|| first_meaningful_line(text).unwrap_or_default())
}
fn looks_rate_limited(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
[
"rate limit",
"rate_limit",
"usage limit",
"quota exceeded",
"too many requests",
]
.iter()
.any(|needle| lower.contains(needle))
|| mentions_status(&lower, "429")
}
fn first_meaningful_line(text: &str) -> Option<String> {
const ERROR_MARKERS: &[&str] = &[
"error",
"failed",
"fatal",
"panic",
"denied",
"invalid",
"unexpected",
"cannot",
"unable",
];
let lines: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
lines
.iter()
.find(|line| {
let lower = line.to_ascii_lowercase();
ERROR_MARKERS.iter().any(|marker| lower.contains(marker))
})
.or_else(|| lines.first())
.map(|line| (*line).to_string())
}
fn persist_session(request: &Request, token: &str) -> Result<()> {
let Some(binding) = &request.binding else {
return Ok(());
};
binding
.store
.bind(request.agent, &binding.project, &binding.name, token)
.map(|_| ())
}
fn preassigned_token(request: &Request) -> Option<String> {
match &request.plan().cont {
Continue::NewWith(id) => Some(id.clone()),
_ => None,
}
}
impl Outcome {
#[must_use]
pub fn is_empty(&self) -> bool {
self.text.trim().is_empty() && self.stop == Stop::Completed
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::Agent;
#[test]
fn quota_phrases_are_recognized_and_ordinary_errors_are_not() {
assert!(looks_rate_limited("Error: rate limit exceeded"));
assert!(looks_rate_limited("HTTP 429 Too Many Requests"));
assert!(looks_rate_limited("You have hit your usage limit"));
assert!(!looks_rate_limited("error: no such file or directory"));
assert!(!looks_rate_limited("model not found"));
}
#[test]
fn a_blocking_rate_limit_event_classifies_as_rate_limited() {
let terminal = Terminal {
rate_limit: Some(crate::outcome::RateLimit {
status: "rejected".into(),
window: Some("five_hour".into()),
resets_at: None,
overage_status: None,
is_using_overage: None,
}),
..Terminal::default()
};
assert!(matches!(
classify(Agent::Claude, "claude", 1, "", "", &terminal),
Error::RateLimited { .. }
));
}
#[test]
fn an_allowed_rate_limit_event_is_not_a_failure_cause() {
let terminal = Terminal {
rate_limit: Some(crate::outcome::RateLimit {
status: "allowed".into(),
window: None,
resets_at: None,
overage_status: None,
is_using_overage: None,
}),
..Terminal::default()
};
assert!(matches!(
classify(Agent::Claude, "claude", 1, "boom", "", &terminal),
Error::Failed { .. }
));
}
#[test]
fn an_id_containing_401_is_not_an_auth_failure() {
let line = r#"{"type":"session.mcp_server_status_changed","id":"1b0b1401-cb86-4276-9874-e84b94c96499"}"#;
assert!(
!looks_unauthenticated(line),
"a hex blob is not a status code"
);
}
#[test]
fn a_real_401_is_still_recognized() {
for text in [
"HTTP 401",
"request failed (status 401)",
"401: unauthorized",
"got a 401 from the API",
] {
assert!(looks_unauthenticated(text), "should match: {text}");
}
}
#[test]
fn digits_around_401_keep_it_from_matching() {
for text in ["error 4010", "code 1401", "seq 24019"] {
assert!(!looks_unauthenticated(text), "should not match: {text}");
}
}
#[test]
fn a_healthy_rate_limit_heartbeat_is_not_a_refusal() {
let stdout = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785331800,"rateLimitType":"five_hour","overageStatus":"rejected","isUsingOverage":false}}"#;
let terminal = Terminal {
stop: Stop::Error,
error_status: Some(404),
text: "There's an issue with the selected model (bogus-model-xyz).".into(),
rate_limit: Some(crate::outcome::RateLimit {
status: "allowed".into(),
window: Some("five_hour".into()),
resets_at: Some(1_785_331_800),
overage_status: None,
is_using_overage: None,
}),
..Terminal::default()
};
let err = classify_run(Agent::Claude, "claude", 0, "", stdout, &terminal);
assert!(
matches!(err, Error::AgentError { .. }),
"the heartbeat must not mask the real cause: {err:?}"
);
}
#[test]
fn a_rejected_quota_signal_is_still_a_refusal() {
let terminal = Terminal {
rate_limit: Some(crate::outcome::RateLimit {
status: "rejected".into(),
window: Some("five_hour".into()),
resets_at: None,
overage_status: None,
is_using_overage: None,
}),
..Terminal::default()
};
assert!(matches!(
classify_run(Agent::Claude, "claude", 0, "", "", &terminal),
Error::RateLimited { .. }
));
}
#[test]
fn a_failed_turn_is_an_error_even_though_the_process_exited_cleanly() {
let terminal = Terminal {
stop: Stop::Error,
error_status: Some(404),
text: "There's an issue with the selected model (bogus-model-xyz). \
It may not exist or you may not have access to it."
.into(),
..Terminal::default()
};
let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
let Error::AgentError {
agent,
status,
message,
..
} = &err
else {
panic!("expected AgentError, got {err:?}")
};
assert_eq!(*agent, Agent::Claude);
assert_eq!(*status, Some(404), "the provider status must survive");
assert!(message.contains("selected model"), "{message}");
}
#[test]
fn a_failed_turn_does_not_mask_a_more_specific_cause() {
let auth = Terminal {
stop: Stop::Error,
text: "Not logged in · Please run /login".into(),
..Terminal::default()
};
assert!(
classify_run(Agent::Claude, "claude", 0, "", "", &auth).is_auth_failure(),
"an unauthenticated failed turn must stay an auth failure"
);
let quota = Terminal {
stop: Stop::Error,
rate_limit: Some(crate::outcome::RateLimit {
status: "rejected".into(),
window: None,
resets_at: None,
overage_status: None,
is_using_overage: None,
}),
..Terminal::default()
};
assert!(
matches!(
classify_run(Agent::Claude, "claude", 0, "", "", "a),
Error::RateLimited { .. }
),
"a quota-blocked failed turn must stay a rate limit"
);
}
#[test]
fn an_unauthenticated_run_is_named_even_though_it_exits_zero() {
let terminal = Terminal {
text: "Not logged in · Please run /login".into(),
..Terminal::default()
};
let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
let Error::NotAuthenticated { agent, hint, .. } = &err else {
panic!("expected NotAuthenticated, got {err:?}")
};
assert_eq!(*agent, Agent::Claude);
assert!(hint.contains("/login"), "{hint}");
assert!(err.is_auth_failure());
}
#[test]
fn copilots_own_unauthenticated_wording_is_recognized() {
let stderr = "Error: No authentication information found.\n\n\
Copilot can be authenticated with GitHub using an OAuth Token or a \
Fine-Grained Personal Access Token.\n\n\
To authenticate, you can use any of the following methods:\n\
\u{2022} Start 'copilot' and run the '/login' command\n\
\u{2022} Set the COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN \
environment variable";
let err = classify_run(
Agent::Copilot,
"copilot",
1,
stderr,
"",
&Terminal::default(),
);
let Error::NotAuthenticated { agent, hint, .. } = &err else {
panic!("expected NotAuthenticated, got {err:?}")
};
assert_eq!(*agent, Agent::Copilot);
assert!(hint.contains("copilot login"), "{hint}");
}
#[test]
fn every_agent_offers_its_own_login_route() {
for (agent, expected) in [
(Agent::Claude, "setup-token"),
(Agent::Codex, "codex login"),
(Agent::Copilot, "copilot login"),
] {
let err = classify_run(
agent,
agent.bin(),
1,
"error: unauthorized",
"",
&Terminal::default(),
);
let Error::NotAuthenticated { hint, .. } = &err else {
panic!("{agent}: expected NotAuthenticated, got {err:?}")
};
assert!(hint.contains(expected), "{agent}: {hint}");
}
}
#[test]
fn an_agent_writing_about_authentication_is_not_an_auth_failure() {
let answer = "The publish was refused before it ran.\n\n\
What denied it was the auto mode classifier, not a missing \
credential.\n\
In auto mode there is no human to receive the prompt, so an \
ask collapses into a refusal.\n\
The message said the CLI was not authenticated, which is \
unrelated: an unauthorized upload is exactly what the rule \
is there to stop.";
let terminal = Terminal {
text: answer.into(),
..Terminal::default()
};
let err = classify_run(Agent::Claude, "claude", 1, "", answer, &terminal);
assert!(
!err.is_auth_failure(),
"an answer that discusses auth was read as an auth failure: {err:?}"
);
}
#[test]
fn the_notice_is_still_caught_when_it_is_the_whole_answer() {
for text in [
"Not logged in · Please run /login",
"claude 2.1.212\n\nNot logged in · Please run /login",
] {
let terminal = Terminal {
text: text.into(),
..Terminal::default()
};
let err = classify_run(Agent::Claude, "claude", 0, "", "", &terminal);
assert!(
err.is_auth_failure(),
"{text:?} was not read as auth: {err:?}"
);
}
}
#[test]
fn an_answer_mentioning_auth_late_does_not_open_the_error_path() {
let answer = "So the duplicate pastes cost nothing.\n\
The publish chain is done: 0.4.1 and 0.4.2 are both on the \
registry and tagged.\n\
Everything downstream already consumes them.\n\
The only thing still open anywhere is the crate PR, \
`pathscale/RustAgentAbstraction#18`, which is the auth error \
that ate your reply: the run was reported as `not \
authenticated` and the hint sent you to /login, while the \
credentials were fine the whole time.";
assert!(
!answer_reports_no_credentials(answer),
"an answer discussing auth opened the error path"
);
assert!(answer_reports_no_credentials(
"Not logged in \u{b7} Please run /login"
));
}
#[test]
fn a_generic_failure_does_not_name_a_failure() {
let unnamed = Error::Failed {
bin: "claude".into(),
code: 0,
stderr: String::new(),
};
assert!(
!names_a_failure(&unnamed),
"a generic failure was treated as a diagnosis, which discards the answer"
);
assert!(names_a_failure(&Error::RateLimited {
bin: "claude".into(),
message: "usage limit reached".into(),
}));
assert!(names_a_failure(&Error::NotAuthenticated {
agent: Agent::Claude,
bin: "claude".into(),
message: "Not logged in".into(),
hint: Agent::Claude.login_hint(),
}));
}
#[test]
fn an_agent_writing_about_limits_is_not_a_limit() {
let answer = "The three retries cost nothing extra, so that is not where it \
came from.\n\n\
Providers do not rate limit on repetition: the limit is on \
tokens per window, and a 429 is what you would see if one had \
actually been reached. Each retry does re-send the whole \
conversation, which is real spend, but spend is not the same \
thing as a block and the run reported no blocking signal at \
all.\n\
Nothing here suggests the usage limit was reached, and the \
banner quoted a sentence of this answer back as though a \
provider had written it.";
let terminal = Terminal {
text: answer.into(),
..Terminal::default()
};
let err = classify(Agent::Claude, "claude", 1, "", answer, &terminal);
assert!(
!matches!(err, Error::RateLimited { .. }),
"an answer discussing limits was read as one: {err:?}"
);
}
#[test]
fn a_bare_429_in_prose_is_not_a_status_code() {
assert!(!looks_rate_limited("see run.rs:4291 for the caller"));
assert!(!looks_rate_limited("sha 8f429ac"));
assert!(looks_rate_limited("HTTP 429"));
assert!(looks_rate_limited("(status 429)"));
assert!(looks_rate_limited("Error: too many requests"));
}
#[test]
fn ordinary_failures_are_not_mistaken_for_auth_problems() {
for stderr in [
"error: no such file or directory",
"model not found",
"rate limit exceeded",
"error: unexpected argument '--sandbox' found",
] {
let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
assert!(
!err.is_auth_failure(),
"{stderr:?} was misread as an auth failure: {err:?}"
);
}
}
#[test]
fn a_rejected_flag_is_named_as_a_version_mismatch() {
let err = classify(
Agent::Codex,
"codex",
2,
"error: unexpected argument '--sandbox' found",
"",
&Terminal::default(),
);
let Error::FlagRejected { bin, detail } = err else {
panic!("expected FlagRejected, got {err:?}")
};
assert_eq!(bin, "codex");
assert!(detail.contains("--sandbox"), "{detail}");
}
#[test]
fn ordinary_failures_are_not_mistaken_for_version_drift() {
for stderr in [
"error: no such file or directory",
"model not found",
"permission denied",
] {
assert!(
matches!(
classify(Agent::Codex, "codex", 1, stderr, "", &Terminal::default()),
Error::Failed { .. }
),
"{stderr:?} should stay a plain failure"
);
}
}
#[test]
fn a_status_line_does_not_masquerade_as_the_cause() {
let stderr = "Reading additional input from stdin...\n\
error: invalid value 'nope' for '--sandbox <SANDBOX_MODE>'";
let err = classify_run(Agent::Codex, "codex", 1, stderr, "", &Terminal::default());
let Error::Failed {
stderr: reported, ..
} = err
else {
panic!("expected Failed, got {err:?}")
};
assert!(reported.contains("invalid value"), "reported {reported:?}");
}
#[test]
fn a_cause_on_stdout_is_reported_when_stderr_only_narrates() {
let stdout = r#"{"type":"error","message":"invalid_json_schema: 'additionalProperties' is required to be supplied and to be false."}"#;
let err = classify_run(
Agent::Codex,
"codex",
1,
"Reading additional input from stdin...",
stdout,
&Terminal::default(),
);
let Error::Failed {
stderr: reported, ..
} = err
else {
panic!("expected Failed, got {err:?}")
};
assert!(
reported.contains("additionalProperties"),
"reported {reported:?}, which explains nothing"
);
}
#[test]
fn failures_report_the_first_useful_line() {
let err = classify(
Agent::Claude,
"claude",
2,
"\n\n real problem \nstack",
"",
&Terminal::default(),
);
let Error::Failed { code, stderr, .. } = err else {
panic!("expected a plain failure")
};
assert_eq!(code, 2);
assert_eq!(stderr, "real problem");
}
#[test]
fn redaction_removes_prompts_and_session_ids_but_keeps_flags() {
let request = crate::Request::new(Agent::Claude, "my secret prompt")
.system("secret system")
.session_id("11111111-2222-3333-4444-555555555555");
let safe = redact(&request.typed_argv().unwrap());
for secret in [
"my secret prompt",
"secret system",
"11111111-2222-3333-4444-555555555555",
] {
assert!(
!safe.iter().any(|a| a.contains(secret)),
"{secret:?} survived redaction: {safe:?}"
);
}
assert_eq!(safe[0], "claude");
assert!(safe.contains(&"--permission-mode".to_string()));
assert!(safe.contains(&"--session-id".to_string()));
}
#[test]
fn codex_trailing_prompt_is_redacted_even_without_a_flag() {
let request = crate::Request::new(Agent::Codex, "my secret prompt");
let safe = redact(&request.typed_argv().unwrap());
assert_eq!(safe.last().unwrap(), REDACTED);
assert_eq!(safe[1], "exec", "the subcommand must survive");
}
#[test]
fn redaction_covers_positional_prompts_and_unchecked_arguments() {
let request = crate::Request::new(Agent::Codex, "my secret prompt")
.unchecked_args(["-c", "api_key=hunter2"]);
let safe = redact(&request.typed_argv().unwrap());
assert!(!safe.iter().any(|a| a.contains("my secret prompt")));
assert!(
!safe.iter().any(|a| a.contains("hunter2")),
"unchecked arguments may hold secrets: {safe:?}"
);
assert_eq!(safe[1], "exec", "the subcommand must survive");
}
#[test]
fn redaction_covers_the_codex_positional_resume_id() {
let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9");
let safe = redact(&request.typed_argv().unwrap());
assert!(
!safe.iter().any(|a| a.contains("thread-secret-9")),
"{safe:?}"
);
assert!(safe.contains(&"resume".to_string()));
}
#[test]
fn stream_outside_a_runtime_errors_instead_of_panicking() {
let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err();
assert!(matches!(err, Error::NoRuntime), "got {err:?}");
}
#[tokio::test]
async fn a_missing_binary_names_the_install_command() {
let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz");
let err = run(&request).await.unwrap_err();
let Error::NotInstalled { hint, agent, .. } = err else {
panic!("expected NotInstalled, got {err:?}")
};
assert_eq!(agent, Agent::Claude);
assert!(hint.contains("claude-code"));
}
#[test]
fn transient_errors_are_distinguished_from_permanent_ones() {
assert!(
Error::RateLimited {
bin: "claude".into(),
message: String::new()
}
.is_transient()
);
assert!(
!Error::NotInstalled {
agent: Agent::Claude,
bin: "claude".into(),
hint: ""
}
.is_transient()
);
}
}