agent_abstraction/run.rs
1//! Spawning an agent and turning its output into events and an outcome.
2//!
3//! Two entry points over the same machinery:
4//! - [`run`] waits and hands back the finished [`Outcome`].
5//! - [`stream`] hands back a [`Run`] that yields [`Event`]s as they arrive, for
6//! a UI that shows work in progress.
7//!
8//! Both read stdout and stderr concurrently. Draining only one would deadlock
9//! the moment the other filled its pipe buffer, which for a chatty agent is a
10//! matter of seconds.
11
12use std::process::Stdio;
13
14use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
15use tokio::process::{Child, Command};
16use tokio::sync::mpsc;
17
18use crate::agent::{Continue, EnvPolicy};
19use crate::error::{Error, Result};
20use crate::event::{Event, MAX_LINE, Parser, Terminal, append_capped};
21use crate::outcome::{Outcome, Stop};
22use crate::proc::kill_process_group;
23use crate::request::Request;
24
25/// Read one line, giving up on a line that never ends.
26///
27/// `AsyncBufReadExt::lines` buffers until a newline arrives, so a stream that
28/// emits megabytes without one exhausts memory before any total cap applies.
29/// This reads a bounded amount and, past the limit, returns what it has and
30/// discards the remainder of that line. Returns `None` at end of input.
31async fn read_bounded_line<R>(reader: &mut R, buf: &mut String) -> std::io::Result<Option<bool>>
32where
33 R: tokio::io::AsyncBufRead + Unpin,
34{
35 buf.clear();
36 let mut bytes = Vec::new();
37 let mut truncated = false;
38 loop {
39 let mut byte = [0u8; 1];
40 match reader.read(&mut byte).await? {
41 // End of input: a trailing fragment still counts as a line.
42 0 => {
43 if bytes.is_empty() {
44 return Ok(None);
45 }
46 break;
47 }
48 _ if byte[0] == b'\n' => break,
49 _ => {
50 if bytes.len() < MAX_LINE {
51 bytes.push(byte[0]);
52 } else {
53 // Keep draining to the newline so the pipe does not block,
54 // but stop accumulating.
55 truncated = true;
56 }
57 }
58 }
59 }
60 // Output is not guaranteed to be valid UTF-8, and one bad byte should not
61 // end a run.
62 buf.push_str(&String::from_utf8_lossy(&bytes));
63 Ok(Some(truncated))
64}
65
66/// How many events may queue before the producer waits for the consumer. Deep
67/// enough that a burst of tool events does not stall the agent, shallow enough
68/// that a consumer which stops reading does not grow without bound.
69const EVENT_BUFFER: usize = 256;
70
71/// A run in progress.
72///
73/// Yields events through [`Run::recv`] and settles into an [`Outcome`] through
74/// [`Run::finish`].
75///
76/// **Dropping a `Run` kills the agent.** That is the safe default for the hosts
77/// this crate targets: closing a window or cancelling a request should stop the
78/// work, not leave an agent running invisibly, spending quota and touching
79/// files with nobody watching. Call [`Run::detach`] when background execution is
80/// genuinely what you want, or [`Run::cancel`] to stop one deterministically and
81/// wait for it to die.
82#[derive(Debug)]
83pub struct Run {
84 events: mpsc::Receiver<Event>,
85 /// The typed command line, kept so both the plain and redacted views come
86 /// from the same source.
87 typed: Vec<crate::agent::Arg>,
88 /// Dropping or firing this asks the driver to tear down in order. Held as
89 /// an `Option` so `detach` can discard it without signalling.
90 cancel: Option<tokio::sync::oneshot::Sender<()>>,
91 /// `None` only after [`Run::finish`], [`Run::cancel`] or [`Run::detach`]
92 /// has taken ownership, which is what stops `Drop` from aborting a run that
93 /// was already settled deliberately.
94 task: Option<tokio::task::JoinHandle<Result<Outcome>>>,
95 argv: Vec<String>,
96}
97
98impl Run {
99 /// The next event, or `None` once the agent has finished producing them.
100 pub async fn recv(&mut self) -> Option<Event> {
101 self.events.recv().await
102 }
103
104 /// The exact command line that was spawned.
105 ///
106 /// **This contains the prompt and any session id.** Treat it as sensitive:
107 /// logging it verbatim puts user content into your logs. Use
108 /// [`Run::redacted_argv`] for diagnostics.
109 #[must_use]
110 pub fn argv(&self) -> &[String] {
111 &self.argv
112 }
113
114 /// The command line with every non-public value replaced by a placeholder.
115 ///
116 /// Prompts, system prompts, session ids and anything from
117 /// [`crate::Request::unchecked_args`] are removed; flag names are kept so
118 /// the command stays recognisable. Sensitivity is recorded where each
119 /// argument is built rather than inferred from the finished line, so a
120 /// bare positional prompt or an opaque raw argument is covered too.
121 #[must_use]
122 pub fn redacted_argv(&self) -> Vec<String> {
123 redact(&self.typed)
124 }
125
126 /// Wait for the run to finish.
127 ///
128 /// Drains any events still queued, so a caller that only wants the result
129 /// can call this without having consumed the stream.
130 ///
131 /// # Errors
132 /// Whatever the run failed with. See [`Error`].
133 pub async fn finish(mut self) -> Result<Outcome> {
134 while self.events.recv().await.is_some() {}
135 // Taking the handle disarms the `Drop` guard: this run is settling
136 // normally, not being abandoned.
137 let Some(task) = self.task.take() else {
138 unreachable!("the handle is only taken by a consuming method")
139 };
140 match task.await {
141 Ok(result) => result,
142 // The driver task panicked or was cancelled. The process itself
143 // started fine, so this is not a spawn failure and must not claim
144 // to be one.
145 Err(join) => Err(Error::Interrupted {
146 bin: self.argv.first().cloned().unwrap_or_default(),
147 detail: if join.is_panic() {
148 "the driver task panicked".into()
149 } else {
150 "the driver task was cancelled".into()
151 },
152 }),
153 }
154 }
155
156 /// Stop the run and wait until the agent is actually gone.
157 ///
158 /// Cooperative rather than an abort: the driver is asked to stop, signals
159 /// the process group, reaps the child and joins its readers, and only then
160 /// does this return. So when it returns the tree really has exited, which
161 /// matters if the next thing you do touches the files it was working on.
162 ///
163 /// Returns the partial [`Outcome`] if the run happened to finish first,
164 /// otherwise [`Error::Cancelled`].
165 ///
166 /// # Errors
167 /// [`Error::Cancelled`] in the normal case, or whatever the run failed with
168 /// if it failed before the request arrived.
169 pub async fn cancel(mut self) -> Result<Outcome> {
170 // Dropping the sender is itself the signal, so this cannot fail in a
171 // way that leaves the driver waiting.
172 drop(self.cancel.take());
173 let Some(task) = self.task.take() else {
174 unreachable!("the handle is only taken by a consuming method")
175 };
176 match task.await {
177 Ok(result) => result,
178 Err(join) => Err(Error::Interrupted {
179 bin: self.argv.first().cloned().unwrap_or_default(),
180 detail: if join.is_panic() {
181 "the driver task panicked".into()
182 } else {
183 "the driver task was cancelled".into()
184 },
185 }),
186 }
187 }
188
189 /// Let the run continue after this handle goes away.
190 ///
191 /// The opposite of the default. Nothing can observe or stop the agent
192 /// afterwards, so reach for this only when an unsupervised background run
193 /// is genuinely intended.
194 pub fn detach(mut self) {
195 // Leak the cancel signal rather than dropping it: a dropped sender is
196 // read by the driver as "stop", which is the opposite of detaching.
197 if let Some(cancel) = self.cancel.take() {
198 std::mem::forget(cancel);
199 }
200 // Dropping the handle without aborting is what detaches a tokio task.
201 drop(self.task.take());
202 }
203}
204
205impl Drop for Run {
206 fn drop(&mut self) {
207 // Abandoned rather than finished, cancelled or detached. Signal the
208 // driver so it tears down in order if it gets the chance, then abort so
209 // the teardown happens even if nothing polls it again. `Drop` cannot
210 // await, so abort remains the backstop: it drops the driver's
211 // `ChildGuard`, which kills the process group synchronously.
212 drop(self.cancel.take());
213 if let Some(task) = self.task.take() {
214 task.abort();
215 }
216 }
217}
218
219/// Placeholder substituted for a sensitive argv value.
220const REDACTED: &str = "<redacted>";
221
222/// Render a typed command line for logging, keeping flag names and replacing
223/// every value that is not `Public`.
224///
225/// Derived from the sensitivity recorded where each argument was built, so it
226/// cannot miss a case the way matching on flag names and positions can.
227fn redact(argv: &[crate::agent::Arg]) -> Vec<String> {
228 use crate::agent::Sensitivity;
229
230 argv.iter()
231 .map(|arg| match arg.sensitivity {
232 Sensitivity::Public => arg.value.clone(),
233 _ => REDACTED.to_string(),
234 })
235 .collect()
236}
237
238/// Run `request` to completion, discarding the intermediate events.
239///
240/// # Errors
241/// See [`Error`]; notably [`Error::NotInstalled`], [`Error::Timeout`],
242/// [`Error::RateLimited`] and [`Error::Failed`].
243pub async fn run(request: &Request) -> Result<Outcome> {
244 stream(request)?.finish().await
245}
246
247/// Start `request`, returning a handle that streams its events.
248///
249/// Returns as soon as the child is spawned; the work proceeds on a task.
250///
251/// # Errors
252/// [`Error::NotInstalled`] if the binary is missing, [`Error::Unsupported`] if
253/// the agent cannot honour the request, or [`Error::Spawn`] on an OS failure.
254pub fn stream(request: &Request) -> Result<Run> {
255 // `tokio::spawn` panics outside a runtime. A fallible signature must not
256 // hide that, so the context is checked and reported as an ordinary error.
257 let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?;
258
259 let plan = request.plan();
260 let typed = request.typed_argv()?;
261 let argv: Vec<String> = typed.iter().map(|a| a.value.clone()).collect();
262
263 let mut command = Command::new(&argv[0]);
264 command
265 .args(&argv[1..])
266 .stdin(if plan.stdin_prompt {
267 Stdio::piped()
268 } else {
269 // Close stdin so an agent that would otherwise wait on it exits
270 // instead of hanging forever with nothing to read.
271 Stdio::null()
272 })
273 .stdout(Stdio::piped())
274 .stderr(Stdio::piped())
275 // Without this a killed run can leave the child alive holding the pipes.
276 .kill_on_drop(true);
277 if let Some(cwd) = &request.cwd {
278 command.current_dir(cwd);
279 }
280 // Narrow the environment first, then apply explicit variables, so an
281 // explicit `env()` always wins over the policy.
282 match &request.env_policy {
283 EnvPolicy::Inherit => {}
284 EnvPolicy::Minimal => {
285 command.env_clear();
286 inherit_named(&mut command, &request.agent.essential_env());
287 }
288 EnvPolicy::Only(names) => {
289 command.env_clear();
290 inherit_named(&mut command, names);
291 }
292 }
293 for (key, value) in &request.env {
294 command.env(key, value);
295 }
296
297 // Put the agent in its own process group so the whole tree can be signalled
298 // together. Killing only the CLI leaves the commands *it* spawned running:
299 // a build, a test run, a server, still holding files and credentials after
300 // the run is supposedly over.
301 // 0 means "make this child its own group leader". `tokio::process::Command`
302 // exposes this directly on unix.
303 #[cfg(unix)]
304 command.process_group(0);
305
306 // Reserve an assigned session id before the child exists. Doing it inside
307 // the driver leaves a window where a spawn that half-succeeds loses the
308 // binding, and this is the id the caller may already be showing in a UI.
309 if let Some(token) = preassigned_token(request) {
310 persist_session(request, &token)?;
311 }
312
313 let child = command.spawn().map_err(|source| {
314 // A missing binary is the common case and deserves an actionable error
315 // with an install hint. Reading it off the spawn avoids resolving PATH
316 // twice, and with it the window where the resolved path is replaced
317 // between the check and the exec.
318 if source.kind() == std::io::ErrorKind::NotFound {
319 Error::NotInstalled {
320 agent: request.agent,
321 bin: plan.bin.clone(),
322 hint: request.agent.install_hint(),
323 }
324 } else {
325 Error::Spawn {
326 bin: plan.bin.clone(),
327 source,
328 }
329 }
330 })?;
331
332 let (tx, rx) = mpsc::channel(EVENT_BUFFER);
333 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
334 let request = request.clone();
335 let task = runtime.spawn(drive(child, request, tx, cancel_rx));
336 Ok(Run {
337 events: rx,
338 typed,
339 cancel: Some(cancel_tx),
340 task: Some(task),
341 argv,
342 })
343}
344
345/// Copy the named variables from this process into `command`, skipping any that
346/// are unset so nothing is invented.
347fn inherit_named<S: AsRef<str>>(command: &mut Command, names: &[S]) {
348 for name in names {
349 if let Some(value) = std::env::var_os(name.as_ref()) {
350 command.env(name.as_ref(), value);
351 }
352 }
353}
354
355/// Owns the child and tears down its whole process group when dropped.
356///
357/// `kill_on_drop` alone is not enough: it kills the CLI, leaving the commands
358/// *it* spawned running. Since aborting the driver task drops this guard, the
359/// same teardown covers cancellation, a dropped [`Run`] and a timeout, without
360/// each path having to remember to do it.
361struct ChildGuard {
362 child: Child,
363 /// Cleared once the child has been reaped, so a pid the OS may since have
364 /// recycled is never signalled.
365 armed: bool,
366}
367
368impl Drop for ChildGuard {
369 fn drop(&mut self) {
370 if self.armed {
371 kill_process_group(&self.child);
372 }
373 }
374}
375
376/// Feed the child, read both its pipes, and assemble the outcome.
377#[allow(
378 clippy::too_many_lines,
379 reason = "one linear lifecycle: feed, read, wait, classify. Splitting it \
380 would thread the child, parser, buffers and cancellation state \
381 through helpers and obscure the ordering that matters, such as \
382 killing the group before reaping."
383)]
384async fn drive(
385 child: Child,
386 request: Request,
387 events: mpsc::Sender<Event>,
388 cancel: tokio::sync::oneshot::Receiver<()>,
389) -> Result<Outcome> {
390 // From here on the child is owned by a guard, so every exit path from this
391 // task, including an abort, takes the process group with it.
392 let mut child = ChildGuard { child, armed: true };
393 let plan = request.plan();
394 let bin = plan.bin.clone();
395
396 // Deliver a piped prompt and close the pipe, or the agent waits on EOF.
397 if plan.stdin_prompt {
398 if let Some(mut stdin) = child.child.stdin.take() {
399 let prompt = request.agent.effective_prompt(&plan);
400 stdin
401 .write_all(prompt.as_bytes())
402 .await
403 .map_err(|source| Error::Spawn {
404 bin: bin.clone(),
405 source,
406 })?;
407 drop(stdin);
408 }
409 }
410
411 // Drain stderr on its own task: a full stderr pipe blocks the child even
412 // while stdout still has room.
413 let stderr = child.child.stderr.take();
414 let stderr_task = tokio::spawn(async move {
415 let mut buf = String::new();
416 if let Some(handle) = stderr {
417 let mut reader = BufReader::new(handle);
418 let mut line = String::new();
419 // Keep draining after the cap is hit: an undrained pipe blocks the
420 // child even though we no longer want the bytes.
421 while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await {
422 append_capped(&mut buf, &line);
423 }
424 }
425 buf
426 });
427
428 let stdout = child.child.stdout.take();
429 let mut parser = Parser::new(request.agent, plan.format);
430 // Raw stdout is retained only as a fallback answer for a run that exited
431 // cleanly without producing a structured one, and as evidence when
432 // classifying a failure. It is capped for the same reason as everything
433 // else here: an agent can stream for hours.
434 let mut raw = String::new();
435 // Tracks the first `Started`, so the binding is written once, and carries a
436 // store failure back out instead of discarding it.
437 let mut bound = false;
438 let mut persist_result: Result<()> = Ok(());
439
440 let read_stdout = async {
441 if let Some(handle) = stdout {
442 let mut reader = BufReader::new(handle);
443 let mut line = String::new();
444 while read_bounded_line(&mut reader, &mut line).await?.is_some() {
445 append_capped(&mut raw, &line);
446 for event in parser.push(&line) {
447 // Bind a printed id the moment it appears rather than at the
448 // end. Codex announces its thread before answering, so a
449 // turn killed mid-answer stays resumable.
450 if let Event::Started { session, .. } = &event
451 && !bound
452 {
453 bound = true;
454 persist_result = persist_session(&request, session);
455 }
456 // A receiver that went away is not a failure: the run should
457 // still finish and produce its outcome.
458 if events.send(event).await.is_err() {
459 break;
460 }
461 }
462 }
463 }
464 Ok::<_, std::io::Error>(())
465 };
466
467 // Race three outcomes: the run finishing, the deadline, and a cancellation
468 // request. Reading and waiting are one future so a child that produces
469 // output forever is still bounded by the timeout.
470 let work = async {
471 read_stdout.await?;
472 child.child.wait().await
473 };
474 // A timeout is optional; `pending()` makes the un-timed case the same shape
475 // rather than duplicating the whole select.
476 let deadline = async {
477 match request.timeout {
478 Some(limit) => tokio::time::sleep(limit).await,
479 None => std::future::pending().await,
480 }
481 };
482
483 let status = tokio::select! {
484 // Biased so a finished run is reported as finished even if a deadline
485 // or cancellation lands in the same tick.
486 biased;
487 result = work => result,
488 () = deadline => {
489 // Order matters: signal the group *before* reaping. Reaping clears
490 // the child's pid, and the group kill needs that pid to target the
491 // group, so the other order silently leaves grandchildren running.
492 let partial = shut_down(&mut child, stderr_task).await;
493 return Err(Error::Timeout {
494 bin,
495 timeout: request.timeout.unwrap_or_default(),
496 partial: parser.finish().text,
497 })
498 .inspect_err(|_| drop(partial));
499 }
500 _ = cancel => {
501 // Cooperative teardown: the caller is waiting on this, so the tree
502 // is signalled, reaped and joined before returning.
503 shut_down(&mut child, stderr_task).await;
504 return Err(Error::Cancelled { bin });
505 }
506 }
507 .map_err(|source| Error::Spawn {
508 bin: bin.clone(),
509 source,
510 })?;
511
512 // The child has been reaped, so its pid must not be signalled again.
513 child.armed = false;
514
515 drop(events);
516 let stderr = stderr_task.await.unwrap_or_default();
517 let saw_structured = parser.saw_structured_record();
518 let saw_terminal = parser.saw_terminal_record();
519 let terminal = parser.finish();
520 let exit_code = status.code().unwrap_or(-1);
521
522 // Under a structured format, silently handing back raw stdout would turn a
523 // protocol failure into a plausible-looking answer. A run that recognized
524 // nothing, or never reached its terminal record, did not produce a result
525 // this crate can vouch for, so it is reported rather than papered over.
526 let structured = plan.format != crate::Format::Text;
527 if structured && exit_code == 0 {
528 if !saw_structured {
529 return Err(Error::Parse {
530 agent: request.agent,
531 detail: format!(
532 "no recognizable {} records in {} lines of output; the CLI's output shape has probably changed",
533 request.agent,
534 raw.lines().count()
535 ),
536 });
537 }
538 if !saw_terminal {
539 return Err(Error::Parse {
540 agent: request.agent,
541 detail: "the stream ended without its terminal record, so the turn did not complete"
542 .into(),
543 });
544 }
545 }
546
547 // Plain text has no structure to validate: the stream is the answer.
548 let mut terminal = terminal;
549 if terminal.text.is_empty() && !structured {
550 terminal.text = raw.trim().to_string();
551 }
552
553 // A provider refusal is not always an exit code. Claude can report a
554 // blocking `rate_limit_event` and still exit 0, and the crate promises that
555 // quota refusals surface as `Error::RateLimited`, so the terminal state is
556 // checked regardless of how the process exited.
557 let quota_blocked = terminal
558 .rate_limit
559 .as_ref()
560 .is_some_and(crate::outcome::RateLimit::is_blocking);
561 if exit_code != 0 || quota_blocked {
562 return Err(classify(&bin, exit_code, &stderr, &raw, &terminal));
563 }
564
565 // A fork lands on a *new* id the agent only reveals at the end, so the name
566 // has to be repointed once the run settles. Everything else was bound above.
567 persist_result?;
568 if let Some(token) = &terminal.session
569 && !bound
570 {
571 persist_session(&request, token)?;
572 }
573 Ok(Outcome {
574 agent: request.agent,
575 session: terminal.session,
576 text: terminal.text,
577 usage: terminal.usage,
578 stop: terminal.stop,
579 rate_limit: terminal.rate_limit,
580 exit_code,
581 stderr,
582 unparsed: terminal.unparsed,
583 first_unparsed: terminal.first_unparsed,
584 })
585}
586
587/// Kill the process group, reap the child, and join the stderr reader.
588///
589/// The orderly teardown both cancellation and timeout share. Returns whatever
590/// stderr had been captured, so a caller can still report why a run was stopped.
591async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle<String>) -> String {
592 kill_process_group(&child.child);
593 // Reap, so the caller is not left with a zombie once this returns.
594 let _ = child.child.kill().await;
595 child.armed = false;
596 // The pipes are closed now that the child is gone, so this finishes
597 // promptly rather than hanging the cancellation.
598 stderr_task.await.unwrap_or_default()
599}
600
601/// Turn a non-zero exit into the most specific error available.
602fn classify(bin: &str, code: i32, stderr: &str, stdout: &str, terminal: &Terminal) -> Error {
603 let quota_signalled = terminal
604 .rate_limit
605 .as_ref()
606 .is_some_and(crate::outcome::RateLimit::is_blocking);
607 if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(stdout) {
608 return Error::RateLimited {
609 bin: bin.to_string(),
610 message: first_meaningful_line(stderr)
611 .or_else(|| first_meaningful_line(stdout))
612 .unwrap_or_else(|| "usage limit reached".to_string()),
613 };
614 }
615 Error::Failed {
616 bin: bin.to_string(),
617 code,
618 stderr: first_meaningful_line(stderr).unwrap_or_default(),
619 }
620}
621
622/// Whether text carries a provider quota refusal.
623///
624/// Deliberately a small set of unambiguous phrases: a false positive here would
625/// relabel an ordinary failure as a quota problem and send a caller into a
626/// pointless backoff.
627fn looks_rate_limited(text: &str) -> bool {
628 let lower = text.to_ascii_lowercase();
629 [
630 "rate limit",
631 "rate_limit",
632 "usage limit",
633 "quota exceeded",
634 "too many requests",
635 "429",
636 ]
637 .iter()
638 .any(|needle| lower.contains(needle))
639}
640
641/// The first non-blank line, trimmed. Enough to identify a failure without
642/// pasting an entire stack trace into an error message.
643fn first_meaningful_line(text: &str) -> Option<String> {
644 text.lines()
645 .map(str::trim)
646 .find(|line| !line.is_empty())
647 .map(str::to_string)
648}
649
650/// Write the session binding back, reporting any store failure.
651///
652/// Called as soon as an id is known rather than only on a clean exit. Waiting
653/// for success would lose the binding for exactly the runs where continuity
654/// matters most: a timeout, a crash, or a cancelled turn.
655fn persist_session(request: &Request, token: &str) -> Result<()> {
656 let Some(binding) = &request.binding else {
657 return Ok(());
658 };
659 binding
660 .store
661 .bind(request.agent, &binding.project, &binding.name, token)
662 .map(|_| ())
663}
664
665/// The id this run is already known by before it starts, if any.
666///
667/// Only a caller-assigned id qualifies: a printed id does not exist yet. This
668/// is what makes an assigned session survive a run that never finishes.
669fn preassigned_token(request: &Request) -> Option<String> {
670 match &request.plan().cont {
671 Continue::NewWith(id) => Some(id.clone()),
672 _ => None,
673 }
674}
675
676/// Reported by an agent that exited cleanly but said nothing useful.
677impl Outcome {
678 /// Whether the agent produced any answer at all.
679 #[must_use]
680 pub fn is_empty(&self) -> bool {
681 self.text.trim().is_empty() && self.stop == Stop::Completed
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::agent::Agent;
689
690 #[test]
691 fn quota_phrases_are_recognized_and_ordinary_errors_are_not() {
692 assert!(looks_rate_limited("Error: rate limit exceeded"));
693 assert!(looks_rate_limited("HTTP 429 Too Many Requests"));
694 assert!(looks_rate_limited("You have hit your usage limit"));
695 // A plain failure must not be mistaken for a quota problem.
696 assert!(!looks_rate_limited("error: no such file or directory"));
697 assert!(!looks_rate_limited("model not found"));
698 }
699
700 #[test]
701 fn a_blocking_rate_limit_event_classifies_as_rate_limited() {
702 let terminal = Terminal {
703 rate_limit: Some(crate::outcome::RateLimit {
704 status: "rejected".into(),
705 window: Some("five_hour".into()),
706 resets_at: None,
707 }),
708 ..Terminal::default()
709 };
710 assert!(matches!(
711 classify("claude", 1, "", "", &terminal),
712 Error::RateLimited { .. }
713 ));
714 }
715
716 #[test]
717 fn an_allowed_rate_limit_event_is_not_a_failure_cause() {
718 let terminal = Terminal {
719 rate_limit: Some(crate::outcome::RateLimit {
720 status: "allowed".into(),
721 window: None,
722 resets_at: None,
723 }),
724 ..Terminal::default()
725 };
726 assert!(matches!(
727 classify("claude", 1, "boom", "", &terminal),
728 Error::Failed { .. }
729 ));
730 }
731
732 #[test]
733 fn failures_report_the_first_useful_line() {
734 let err = classify(
735 "claude",
736 2,
737 "\n\n real problem \nstack",
738 "",
739 &Terminal::default(),
740 );
741 let Error::Failed { code, stderr, .. } = err else {
742 panic!("expected a plain failure")
743 };
744 assert_eq!(code, 2);
745 assert_eq!(stderr, "real problem");
746 }
747
748 /// Prompts and session ids ride the argv, and `Run::argv` invites logging
749 /// it. The redacted form must keep the shape while dropping the content.
750 #[test]
751 fn redaction_removes_prompts_and_session_ids_but_keeps_flags() {
752 let request = crate::Request::new(Agent::Claude, "my secret prompt")
753 .system("secret system")
754 .session_id("11111111-2222-3333-4444-555555555555");
755 let safe = redact(&request.typed_argv().unwrap());
756
757 for secret in [
758 "my secret prompt",
759 "secret system",
760 "11111111-2222-3333-4444-555555555555",
761 ] {
762 assert!(
763 !safe.iter().any(|a| a.contains(secret)),
764 "{secret:?} survived redaction: {safe:?}"
765 );
766 }
767 // Still recognisable as the same command.
768 assert_eq!(safe[0], "claude");
769 assert!(safe.contains(&"--permission-mode".to_string()));
770 assert!(safe.contains(&"--session-id".to_string()));
771 }
772
773 #[test]
774 fn codex_trailing_prompt_is_redacted_even_without_a_flag() {
775 let request = crate::Request::new(Agent::Codex, "my secret prompt");
776 let safe = redact(&request.typed_argv().unwrap());
777 assert_eq!(safe.last().unwrap(), REDACTED);
778 assert_eq!(safe[1], "exec", "the subcommand must survive");
779 }
780
781 /// Redaction must cover the two shapes positional guesswork misses: Codex's
782 /// bare trailing prompt, and raw arguments whose contents are unknowable.
783 #[test]
784 fn redaction_covers_positional_prompts_and_unchecked_arguments() {
785 let request = crate::Request::new(Agent::Codex, "my secret prompt")
786 .unchecked_args(["-c", "api_key=hunter2"]);
787 let safe = redact(&request.typed_argv().unwrap());
788 assert!(!safe.iter().any(|a| a.contains("my secret prompt")));
789 assert!(
790 !safe.iter().any(|a| a.contains("hunter2")),
791 "unchecked arguments may hold secrets: {safe:?}"
792 );
793 assert_eq!(safe[1], "exec", "the subcommand must survive");
794 }
795
796 /// A resume id is a capability: it continues someone's conversation.
797 #[test]
798 fn redaction_covers_the_codex_positional_resume_id() {
799 let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9");
800 let safe = redact(&request.typed_argv().unwrap());
801 assert!(
802 !safe.iter().any(|a| a.contains("thread-secret-9")),
803 "{safe:?}"
804 );
805 assert!(safe.contains(&"resume".to_string()));
806 }
807
808 /// `stream` is synchronous but spawns a task. Outside a runtime that would
809 /// panic, which a `Result`-returning function must not do.
810 #[test]
811 fn stream_outside_a_runtime_errors_instead_of_panicking() {
812 let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err();
813 assert!(matches!(err, Error::NoRuntime), "got {err:?}");
814 }
815
816 #[tokio::test]
817 async fn a_missing_binary_names_the_install_command() {
818 let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz");
819 let err = run(&request).await.unwrap_err();
820 let Error::NotInstalled { hint, agent, .. } = err else {
821 panic!("expected NotInstalled, got {err:?}")
822 };
823 assert_eq!(agent, Agent::Claude);
824 assert!(hint.contains("claude-code"));
825 }
826
827 #[test]
828 fn transient_errors_are_distinguished_from_permanent_ones() {
829 assert!(
830 Error::RateLimited {
831 bin: "claude".into(),
832 message: String::new()
833 }
834 .is_transient()
835 );
836 assert!(
837 !Error::NotInstalled {
838 agent: Agent::Claude,
839 bin: "claude".into(),
840 hint: ""
841 }
842 .is_transient()
843 );
844 }
845}