1use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34
35use anyhow::{Context as _, Result, bail};
36use serde::{Deserialize, Serialize};
37use std::sync::{Arc, Mutex};
38
39use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
40use tokio::process::Command;
41
42use crate::config::{AgentKind, AgentSpec, Delivery};
43use crate::proc::Quiet as _;
44use crate::rng::SplitMix64;
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SeatState {
50 pub key: String,
52 pub agent: String,
54 pub turns: usize,
56 pub claude_session: Option<String>,
59 pub captured_session: Option<String>,
61}
62
63impl SeatState {
64 pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
66 let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
67 Self {
68 key: key.to_owned(),
69 agent: agent.to_owned(),
70 turns: 0,
71 claude_session: Some(rng.uuid_v4()),
72 captured_session: None,
73 }
74 }
75}
76
77pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
79 if !sessions_enabled || seat.turns == 0 {
80 return false;
81 }
82 match kind {
83 AgentKind::Claude => seat.claude_session.is_some(),
84 AgentKind::Opencode | AgentKind::Antigravity => seat.captured_session.is_some(),
85 AgentKind::Command => true,
86 }
87}
88
89#[derive(Debug)]
91pub struct Invocation<'a> {
92 pub cwd: &'a Path,
95 pub prompt: &'a str,
97 pub timeout: Duration,
99 pub allow_write: bool,
101 pub sessions: bool,
103 pub artifacts: &'a Path,
105 pub stem: &'a str,
107 pub run: &'a str,
111 pub node: &'a str,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124pub struct Quota {
125 #[serde(default)]
127 pub reset: Option<String>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct Dropped {
138 pub why: String,
140 pub output_tokens: u64,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct AgentOutput {
148 pub text: String,
150 pub exit_code: Option<i32>,
152 pub timed_out: bool,
154 pub duration_ms: u64,
156 pub artifacts: Vec<String>,
158 #[serde(default)]
162 pub quota: Option<Quota>,
163 #[serde(default)]
167 pub dropped: Option<Dropped>,
168}
169
170impl AgentOutput {
171 pub fn usable(&self) -> bool {
173 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
174 }
175
176 pub fn quota_exhausted(&self) -> bool {
178 self.quota.is_some()
179 }
180
181 pub fn work_undelivered(&self) -> bool {
186 self.dropped.is_some()
187 }
188}
189
190const PIPE_GRACE: Duration = Duration::from_secs(3);
195
196type Captured = Arc<Mutex<Vec<u8>>>;
198
199fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
209where
210 R: tokio::io::AsyncRead + Unpin + Send + 'static,
211{
212 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
213 let Some(mut pipe) = pipe else {
214 return (buf, None);
215 };
216 let sink = Arc::clone(&buf);
217 let handle = tokio::spawn(async move {
218 let mut chunk = [0u8; 8192];
219 loop {
220 match pipe.read(&mut chunk).await {
221 Ok(0) | Err(_) => break,
222 Ok(n) => {
223 if let Ok(mut guard) = sink.lock() {
224 guard.extend_from_slice(&chunk[..n]);
225 }
226 }
227 }
228 }
229 });
230 (buf, Some(handle))
231}
232
233async fn collect(
238 buf: &Captured,
239 handle: Option<tokio::task::JoinHandle<()>>,
240 grace: Duration,
241) -> String {
242 if let Some(handle) = handle {
243 if tokio::time::timeout(grace, handle).await.is_err() {
244 tracing::debug!("a pipe is still held open after the child exited");
245 }
246 }
247 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
248 String::from_utf8_lossy(&bytes).into_owned()
249}
250
251pub async fn invoke(
253 spec: &AgentSpec,
254 seat: &mut SeatState,
255 inv: &Invocation<'_>,
256) -> Result<AgentOutput> {
257 tokio::fs::create_dir_all(inv.artifacts)
258 .await
259 .with_context(|| format!("create {}", inv.artifacts.display()))?;
260 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
261 tokio::fs::write(&prompt_path, inv.prompt)
262 .await
263 .with_context(|| format!("write {}", prompt_path.display()))?;
264
265 let plan = build_command(spec, seat, inv, &prompt_path)?;
266 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
267
268 let started = Instant::now();
269 let mut cmd = Command::new(&plan.argv[0]);
270 cmd.args(&plan.argv[1..])
271 .current_dir(inv.cwd)
272 .envs(&spec.env)
273 .env("MAGI_SEAT", &seat.key)
274 .env("MAGI_TURN", seat.turns.to_string())
275 .env("MAGI_RUN", inv.run)
276 .env("MAGI_NODE", inv.node)
277 .env("MAGI_PROMPT_FILE", &prompt_path)
278 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
279 .env("GIT_TERMINAL_PROMPT", "0")
280 .stdin(if plan.stdin.is_some() {
281 Stdio::piped()
282 } else {
283 Stdio::null()
284 })
285 .stdout(Stdio::piped())
286 .stderr(Stdio::piped())
287 .kill_on_drop(true)
288 .quiet();
291
292 let mut child = cmd
293 .spawn()
294 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
295 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
299 tokio::spawn(async move {
300 sink.write_all(body.as_bytes()).await.ok();
301 sink.shutdown().await.ok();
302 });
303 }
304
305 let (out_buf, out_reader) = drain(child.stdout.take());
322 let (err_buf, err_reader) = drain(child.stderr.take());
323
324 let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
325 Ok(res) => {
326 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
327 (status.code(), false)
328 }
329 Err(_) => {
330 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
331 child.start_kill().ok();
333 (None, true)
334 }
335 };
336
337 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
342 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
343
344 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
345 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
346 tokio::fs::write(&out_path, &stdout).await.ok();
347 tokio::fs::write(&err_path, &stderr).await.ok();
348
349 let extracted = extract(spec.kind, &stdout);
350 if let Some(session) = extracted.session {
351 match spec.kind {
352 AgentKind::Claude => seat.claude_session = Some(session),
353 AgentKind::Opencode | AgentKind::Antigravity => seat.captured_session = Some(session),
354 AgentKind::Command => {}
355 }
356 }
357 if let Some(status) = &extracted.status
358 && !status.eq_ignore_ascii_case("success")
359 {
360 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
361 }
362 let text = if extracted.text.trim().is_empty() {
363 if stdout.trim().is_empty() {
365 stderr.trim().to_owned()
366 } else {
367 stdout.trim().to_owned()
368 }
369 } else {
370 extracted.text
371 };
372 seat.turns += 1;
373
374 Ok(AgentOutput {
375 text,
376 exit_code: code,
377 timed_out,
378 duration_ms: started.elapsed().as_millis() as u64,
379 artifacts: vec![
380 file_name(&prompt_path),
381 file_name(&out_path),
382 file_name(&err_path),
383 ],
384 quota: extracted.quota,
385 dropped: extracted.dropped,
386 })
387}
388
389fn file_name(p: &Path) -> String {
390 p.file_name()
391 .unwrap_or_default()
392 .to_string_lossy()
393 .into_owned()
394}
395
396#[derive(Debug)]
398struct Plan {
399 argv: Vec<String>,
400 stdin: Option<String>,
401}
402
403fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
414 if matches!(kind, AgentKind::Antigravity) {
415 return format!("@{}", prompt_path.display());
416 }
417 format!(
418 "Read the file at {} and follow every instruction in it exactly. That \
419 file is your complete task description; this message contains nothing \
420 else.",
421 prompt_path.display()
422 )
423}
424
425fn build_command(
426 spec: &AgentSpec,
427 seat: &SeatState,
428 inv: &Invocation<'_>,
429 prompt_path: &Path,
430) -> Result<Plan> {
431 let mut argv: Vec<String> = Vec::new();
432 let mut stdin: Option<String> = None;
433 let delivery = spec.delivery();
434 let resuming = has_session(spec.kind, seat, inv.sessions);
435
436 match spec.kind {
437 AgentKind::Claude => {
438 argv.push("claude".to_owned());
439 argv.push("-p".to_owned());
440 argv.push("--output-format".to_owned());
441 argv.push("json".to_owned());
442 if let Some(m) = &spec.model {
443 argv.push("--model".to_owned());
444 argv.push(m.clone());
445 }
446 if inv.sessions {
447 let uuid = seat
448 .claude_session
449 .as_deref()
450 .context("claude seat is missing its session uuid")?;
451 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
452 argv.push(uuid.to_owned());
453 }
454 argv.push("--permission-mode".to_owned());
455 argv.push("bypassPermissions".to_owned());
456 if !inv.allow_write {
457 argv.push("--disallowed-tools".to_owned());
458 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
459 }
460 }
461 AgentKind::Opencode => {
462 argv.push("opencode".to_owned());
463 argv.push("run".to_owned());
464 argv.push("--format".to_owned());
465 argv.push("json".to_owned());
466 argv.push("--dir".to_owned());
467 argv.push(inv.cwd.to_string_lossy().into_owned());
468 argv.push("--auto".to_owned());
477 if let Some(m) = &spec.model {
478 argv.push("-m".to_owned());
479 argv.push(m.clone());
480 }
481 if resuming {
482 argv.push("-s".to_owned());
483 argv.push(
484 seat.captured_session
485 .clone()
486 .expect("has_session checked the id is present"),
487 );
488 }
489 }
490 AgentKind::Antigravity => {
491 argv.push("agy".to_owned());
492 argv.push("--output-format".to_owned());
493 argv.push("json".to_owned());
494 argv.push("--print-timeout".to_owned());
497 argv.push(format!("{}s", inv.timeout.as_secs()));
498 argv.push("--mode".to_owned());
499 argv.push(
500 if inv.allow_write {
501 "accept-edits"
502 } else {
503 "plan"
504 }
505 .to_owned(),
506 );
507 if inv.allow_write {
508 argv.push("--dangerously-skip-permissions".to_owned());
509 }
510 if let Some(m) = &spec.model {
511 argv.push("--model".to_owned());
512 argv.push(m.clone());
513 }
514 if resuming {
515 argv.push("--conversation".to_owned());
516 argv.push(
517 seat.captured_session
518 .clone()
519 .expect("has_session checked the id is present"),
520 );
521 }
522 if delivery == Delivery::File {
525 argv.push("--add-dir".to_owned());
526 argv.push(inv.artifacts.to_string_lossy().into_owned());
527 }
528 }
529 AgentKind::Command => {
530 if spec.command.is_empty() {
531 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
532 }
533 let vars: BTreeMap<&str, String> = BTreeMap::from([
534 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
535 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
536 ("{label}", seat.key.clone()),
537 ("{session}", seat.claude_session.clone().unwrap_or_default()),
538 ]);
539 for raw in &spec.command {
540 let mut arg = raw.clone();
541 for (k, v) in &vars {
542 if arg.contains(k) {
543 arg = arg.replace(k, v);
544 }
545 }
546 argv.push(arg);
547 }
548 }
549 }
550
551 argv.extend(spec.extra_args.iter().cloned());
552
553 if spec.kind == AgentKind::Antigravity {
556 argv.push("-p".to_owned());
557 }
558 match delivery {
559 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
560 argv.push(pointer(spec.kind, prompt_path));
562 }
563 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
564 Delivery::Argv => argv.push(inv.prompt.to_owned()),
565 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
566 }
567
568 Ok(Plan { argv, stdin })
569}
570
571#[derive(Debug, Default)]
573struct Extracted {
574 text: String,
575 session: Option<String>,
576 status: Option<String>,
577 quota: Option<Quota>,
578 dropped: Option<Dropped>,
579}
580
581fn extract(kind: AgentKind, stdout: &str) -> Extracted {
583 match kind {
584 AgentKind::Claude => {
585 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
586 return Extracted {
587 text: stdout.trim().to_owned(),
588 ..Extracted::default()
589 };
590 };
591 Extracted {
592 text: v
593 .get("result")
594 .and_then(|r| r.as_str())
595 .unwrap_or_default()
596 .to_owned(),
597 session: v
598 .get("session_id")
599 .and_then(|s| s.as_str())
600 .map(str::to_owned),
601 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
602 if e {
603 "error".to_owned()
604 } else {
605 "success".to_owned()
606 }
607 }),
608 quota: claude_quota(&v),
609 dropped: None,
612 }
613 }
614 AgentKind::Opencode => {
615 let mut text = String::new();
617 let mut session = None;
618 for line in stdout.lines() {
619 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
620 continue;
621 };
622 if session.is_none() {
623 session = v
624 .get("sessionID")
625 .and_then(|s| s.as_str())
626 .map(str::to_owned);
627 }
628 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
629 if part.get("type").and_then(|t| t.as_str()) == Some("text")
630 && let Some(t) = part.get("text").and_then(|t| t.as_str())
631 {
632 if !text.is_empty() {
633 text.push('\n');
634 }
635 text.push_str(t);
636 }
637 }
638 Extracted {
639 text,
640 session,
641 status: None,
642 quota: None,
643 dropped: None,
644 }
645 }
646 AgentKind::Antigravity => {
647 let obj = stdout
650 .lines()
651 .rev()
652 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
653 let Some(v) = obj else {
654 return Extracted {
655 text: stdout.trim().to_owned(),
656 ..Extracted::default()
657 };
658 };
659 Extracted {
660 text: v
661 .get("response")
662 .and_then(|r| r.as_str())
663 .unwrap_or_default()
664 .trim()
665 .to_owned(),
666 session: v
667 .get("conversation_id")
668 .and_then(|s| s.as_str())
669 .map(str::to_owned),
670 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
671 quota: None,
672 dropped: dropped_stream(&v),
673 }
674 }
675 AgentKind::Command => {
676 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
681 let quota = parsed.as_ref().and_then(claude_quota);
682 let dropped = parsed.as_ref().and_then(dropped_stream);
685 Extracted {
686 text: stdout.trim().to_owned(),
687 session: None,
688 status: None,
689 quota,
690 dropped,
691 }
692 }
693 }
694}
695
696fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
703 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
704 if !is_err {
705 return None;
706 }
707 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
708 if !result.to_lowercase().contains("session limit") {
709 return None;
710 }
711 let reset = result
714 .split("resets ")
715 .nth(1)
716 .map(str::trim)
717 .filter(|s| !s.is_empty())
718 .map(str::to_owned);
719 Some(Quota { reset })
720}
721
722fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
753 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
754 if !status.eq_ignore_ascii_case("error") {
755 return None;
756 }
757 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
758 if !response.trim().is_empty() {
759 return None;
761 }
762 let produced = v
763 .get("usage")
764 .and_then(|u| u.get("output_tokens"))
765 .and_then(serde_json::Value::as_u64)
766 .unwrap_or(0);
767 if produced == 0 {
768 return None;
770 }
771 Some(Dropped {
772 why: v
773 .get("error")
774 .and_then(|e| e.as_str())
775 .unwrap_or("the CLI ended the stream without delivering its answer")
776 .trim()
777 .to_owned(),
778 output_tokens: produced,
779 })
780}
781
782pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
784 let mut missing = Vec::new();
785 for s in specs {
786 let program = match s.kind {
787 AgentKind::Command => s.command.first().map(String::as_str),
788 other => other.program(),
789 };
790 if let Some(p) = program
791 && !crate::config::which(p)
792 && !Path::new(p).is_file()
793 && !missing.iter().any(|m: &String| m == p)
794 {
795 missing.push(p.to_owned());
796 }
797 }
798 missing
799}
800
801pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
803 run_dir.join("artifacts")
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809
810 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
811 AgentSpec {
812 id: "a".to_owned(),
813 kind,
814 model: model.map(str::to_owned),
815 command: vec!["echo".to_owned(), "{label}".to_owned()],
816 extra_args: Vec::new(),
817 env: BTreeMap::new(),
818 prompt_delivery: None,
819 }
820 }
821
822 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
823 Invocation {
824 cwd,
825 prompt: "do the thing",
826 timeout: Duration::from_secs(900),
827 allow_write,
828 sessions: true,
829 artifacts: art,
830 stem: "t",
831 run: "test-run",
832 node: "test",
833 }
834 }
835
836 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
837 build_command(
838 &spec(kind, None),
839 seat,
840 &inv(Path::new("."), Path::new("/art"), allow_write),
841 Path::new("/art/p.md"),
842 )
843 .unwrap()
844 }
845
846 #[test]
847 fn claude_mints_then_resumes_the_same_uuid() {
848 let mut seat = SeatState::new("judge-1", "a", 7);
849 let uuid = seat.claude_session.clone().unwrap();
850 let first = plan_for(AgentKind::Claude, &seat, true);
851 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
852 assert!(!first.argv.iter().any(|a| a == "--resume"));
853
854 seat.turns = 1;
855 let second = plan_for(AgentKind::Claude, &seat, true);
856 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
857 assert!(!second.argv.iter().any(|a| a == "--session-id"));
858 }
859
860 #[test]
861 fn read_only_seats_cannot_edit() {
862 let seat = SeatState::new("judge-1", "a", 7);
863 let claude = plan_for(AgentKind::Claude, &seat, false);
864 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
865 assert!(
866 !plan_for(AgentKind::Claude, &seat, true)
867 .argv
868 .iter()
869 .any(|a| a == "--disallowed-tools")
870 );
871
872 let agy = plan_for(AgentKind::Antigravity, &seat, false);
873 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
874 assert!(
875 !agy.argv
876 .iter()
877 .any(|a| a == "--dangerously-skip-permissions")
878 );
879 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
880 assert!(
881 agy_rw
882 .argv
883 .windows(2)
884 .any(|w| w == ["--mode", "accept-edits"])
885 );
886 assert!(
887 agy_rw
888 .argv
889 .iter()
890 .any(|a| a == "--dangerously-skip-permissions")
891 );
892 let agy_prompt = agy_rw
897 .argv
898 .iter()
899 .position(|a| a == "-p")
900 .map(|i| agy_rw.argv[i + 1].clone())
901 .expect("agy takes its prompt with -p");
902 assert!(
903 agy_prompt.starts_with('@'),
904 "agy must get a file reference, got {agy_prompt:?}"
905 );
906 assert!(
907 !agy_prompt.contains("Read the file at"),
908 "the prose pointer is for CLIs with no file syntax"
909 );
910
911 for allow_write in [false, true] {
916 assert!(
917 plan_for(AgentKind::Opencode, &seat, allow_write)
918 .argv
919 .iter()
920 .any(|a| a == "--auto"),
921 "opencode needs --auto even to read (allow_write = {allow_write})"
922 );
923 }
924 }
925
926 #[test]
927 fn captured_sessions_resume_only_once_reported() {
928 let mut seat = SeatState::new("impl-A", "a", 7);
929 seat.turns = 1;
930 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
931 assert!(!has_session(kind, &seat, true));
932 let p = plan_for(kind, &seat, true);
933 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
934 }
935
936 seat.captured_session = Some("sid".to_owned());
937 assert!(has_session(AgentKind::Opencode, &seat, true));
938 assert!(
939 plan_for(AgentKind::Opencode, &seat, true)
940 .argv
941 .windows(2)
942 .any(|w| w == ["-s", "sid"])
943 );
944 assert!(
945 plan_for(AgentKind::Antigravity, &seat, true)
946 .argv
947 .windows(2)
948 .any(|w| w == ["--conversation", "sid"])
949 );
950 }
951
952 #[test]
953 fn sessions_disabled_never_resumes() {
954 let mut seat = SeatState::new("impl-A", "a", 7);
955 seat.turns = 3;
956 seat.captured_session = Some("sid".to_owned());
957 for kind in [
958 AgentKind::Claude,
959 AgentKind::Opencode,
960 AgentKind::Antigravity,
961 ] {
962 assert!(!has_session(kind, &seat, false));
963 }
964 }
965
966 #[test]
967 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
968 let seat = SeatState::new("judge-1", "a", 7);
969 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
970 let p = plan_for(kind, &seat, false);
971 assert!(
972 p.argv.iter().all(|a| a != "do the thing"),
973 "{kind:?} put the prompt on the command line"
974 );
975 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
976 }
977 let p = plan_for(AgentKind::Antigravity, &seat, false);
979 let at = p.argv.iter().position(|a| a == "-p").unwrap();
980 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
981 assert!(p.stdin.is_none());
982 }
983
984 #[test]
985 fn agy_print_timeout_tracks_the_node_budget() {
986 let seat = SeatState::new("impl-A", "a", 7);
987 let p = build_command(
988 &spec(AgentKind::Antigravity, None),
989 &seat,
990 &Invocation {
991 cwd: Path::new("."),
992 prompt: "p",
993 timeout: Duration::from_secs(3600),
994 allow_write: true,
995 sessions: true,
996 artifacts: Path::new("/art"),
997 stem: "t",
998 run: "test-run",
999 node: "test",
1000 },
1001 Path::new("/art/p.md"),
1002 )
1003 .unwrap();
1004 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1005 }
1006
1007 #[test]
1008 fn command_agents_get_placeholders_substituted() {
1009 let seat = SeatState::new("impl-A", "a", 7);
1010 let p = plan_for(AgentKind::Command, &seat, true);
1011 assert_eq!(p.argv[0], "echo");
1012 assert_eq!(p.argv[1], "impl-A");
1013 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1014 }
1015
1016 #[test]
1017 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1018 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1020 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1021 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1022 let out = extract(AgentKind::Claude, stdout);
1023 let quota = out.quota.as_ref().expect("rate limit must be detected");
1024 assert_eq!(
1025 quota.reset.as_deref(),
1026 Some("4:50am (Asia/Tokyo)"),
1027 "reset time read from the body"
1028 );
1029 }
1030
1031 #[test]
1032 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1033 let out = extract(
1034 AgentKind::Claude,
1035 r#"{"is_error":true,"result":"session limit reached"}"#,
1036 );
1037 let quota = out.quota.expect("rate limit detected without a reset");
1038 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1039 }
1040
1041 #[test]
1042 fn ordinary_failures_are_never_quota() {
1043 let claude_fail = extract(
1045 AgentKind::Claude,
1046 r#"{"is_error":true,"result":"account does not exist"}"#,
1047 );
1048 assert!(claude_fail.quota.is_none());
1049
1050 let cmd_fail = extract(AgentKind::Command, "boom");
1052 assert!(cmd_fail.quota.is_none());
1053
1054 let success = extract(
1056 AgentKind::Command,
1057 r#"{"is_error":false,"result":"session limit is fine"}"#,
1058 );
1059 assert!(success.quota.is_none());
1060 }
1061
1062 #[test]
1063 fn command_agent_can_carry_the_claude_quota_shape() {
1064 let out = extract(
1065 AgentKind::Command,
1066 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1067 );
1068 assert!(
1069 out.quota.is_some(),
1070 "a wrapper emitting the claude shape counts as quota"
1071 );
1072 }
1073
1074 #[test]
1075 fn claude_json_result_is_extracted() {
1076 let out = extract(
1077 AgentKind::Claude,
1078 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1079 );
1080 assert_eq!(out.text, "all done");
1081 assert_eq!(out.session.as_deref(), Some("abc"));
1082 assert_eq!(out.status.as_deref(), Some("success"));
1083 }
1084
1085 #[test]
1086 fn opencode_event_stream_is_concatenated() {
1087 let stream = concat!(
1088 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
1089 "\n",
1090 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
1091 "\n",
1092 "garbage line\n",
1093 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
1094 "\n"
1095 );
1096 let out = extract(AgentKind::Opencode, stream);
1097 assert_eq!(out.text, "first\nsecond");
1098 assert_eq!(out.session.as_deref(), Some("ses_1"));
1099 }
1100
1101 #[test]
1102 fn agy_json_survives_a_leading_warning_line() {
1103 let stdout = concat!(
1104 "warning: --mode plan has no effect while slash commands are disabled.\n",
1105 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1106 "\n"
1107 );
1108 let out = extract(AgentKind::Antigravity, stdout);
1109 assert_eq!(out.text, "persimmon");
1110 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1111 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1112 }
1113
1114 const AGY_DROPPED: &str = concat!(
1121 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
1122 r#""response":"","error":"the connection to the agent was interrupted before "#,
1123 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
1124 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
1125 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
1126 r#""total_tokens":274380}}"#
1127 );
1128
1129 #[test]
1130 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
1131 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
1132 let dropped = out.dropped.expect("recognised as undelivered work");
1133 assert_eq!(dropped.output_tokens, 14267);
1134 assert!(
1135 dropped.why.contains("subscriber fell behind"),
1136 "the CLI's own words are kept for the record: {}",
1137 dropped.why
1138 );
1139 assert_eq!(
1142 out.session.as_deref(),
1143 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
1144 );
1145 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
1146 }
1147
1148 #[test]
1149 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
1150 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
1154 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
1155
1156 let answered = concat!(
1159 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
1160 r#""usage":{"output_tokens":10}}"#
1161 );
1162 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
1163
1164 let ok = concat!(
1166 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
1167 r#""usage":{"output_tokens":10}}"#
1168 );
1169 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
1170 }
1171
1172 #[test]
1173 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
1174 let out = AgentOutput {
1175 text: String::new(),
1176 exit_code: Some(1),
1177 timed_out: false,
1178 duration_ms: 431_194,
1179 artifacts: Vec::new(),
1180 quota: None,
1181 dropped: Some(Dropped {
1182 why: "subscriber fell behind updates".to_owned(),
1183 output_tokens: 14267,
1184 }),
1185 };
1186 assert!(!out.usable());
1187 assert!(out.work_undelivered());
1188 assert!(!out.quota_exhausted());
1191 }
1192
1193 #[test]
1194 fn non_json_stdout_falls_back_to_raw_text() {
1195 let out = extract(AgentKind::Antigravity, "plain answer\n");
1196 assert_eq!(out.text, "plain answer");
1197 assert!(out.session.is_none());
1198 }
1199
1200 #[tokio::test]
1201 async fn command_agent_round_trip_writes_artifacts() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let art = dir.path().join("artifacts");
1204 let mut seat = SeatState::new("impl-A", "a", 7);
1205 let mut s = spec(AgentKind::Command, None);
1206 s.command = vec!["echo".to_owned(), "hello {label}".to_owned()];
1207 let out = invoke(
1208 &s,
1209 &mut seat,
1210 &Invocation {
1211 cwd: dir.path(),
1212 prompt: "unused",
1213 timeout: Duration::from_secs(30),
1214 allow_write: true,
1215 sessions: true,
1216 artifacts: &art,
1217 stem: "impl-A",
1218 run: "test-run",
1219 node: "test",
1220 },
1221 )
1222 .await
1223 .unwrap();
1224 assert!(out.usable(), "{out:?}");
1225 assert!(out.text.contains("hello impl-A"), "{}", out.text);
1226 assert_eq!(seat.turns, 1);
1227 assert!(art.join("impl-A.prompt.md").is_file());
1228 assert!(art.join("impl-A.out").is_file());
1229 }
1230
1231 #[tokio::test]
1232 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1233 let dir = tempfile::tempdir().unwrap();
1234 let mut seat = SeatState::new("impl-A", "a", 7);
1235 let mut s = spec(AgentKind::Command, None);
1236 s.command = vec!["echo".to_owned(), "done".to_owned()];
1239 let big = "x".repeat(1_000_000);
1240 let out = invoke(
1241 &s,
1242 &mut seat,
1243 &Invocation {
1244 cwd: dir.path(),
1245 prompt: &big,
1246 timeout: Duration::from_secs(60),
1247 allow_write: true,
1248 sessions: true,
1249 artifacts: &dir.path().join("artifacts"),
1250 stem: "big",
1251 run: "test-run",
1252 node: "test",
1253 },
1254 )
1255 .await
1256 .unwrap();
1257 assert!(out.usable(), "{out:?}");
1258 assert_eq!(out.text, "done");
1259 }
1260
1261 #[tokio::test]
1262 async fn timeout_is_reported_not_hung() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let mut seat = SeatState::new("impl-A", "a", 7);
1265 let mut s = spec(AgentKind::Command, None);
1266 s.command = vec!["sleep".to_owned(), "30".to_owned()];
1267 let out = invoke(
1268 &s,
1269 &mut seat,
1270 &Invocation {
1271 cwd: dir.path(),
1272 prompt: "unused",
1273 timeout: Duration::from_millis(300),
1274 allow_write: true,
1275 sessions: true,
1276 artifacts: &dir.path().join("artifacts"),
1277 stem: "slow",
1278 run: "test-run",
1279 node: "test",
1280 },
1281 )
1282 .await
1283 .unwrap();
1284 assert!(out.timed_out);
1285 assert!(!out.usable());
1286 }
1287
1288 #[tokio::test]
1289 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1290 let dir = tempfile::tempdir().unwrap();
1296 let artifacts = dir.path().join("artifacts");
1297 let mut seat = SeatState::new("impl-A", "a", 7);
1298 let mut s = spec(AgentKind::Command, None);
1299 s.command = vec![
1300 "sh".to_owned(),
1301 "-c".to_owned(),
1302 "echo i-said-something; sleep 30".to_owned(),
1303 ];
1304 let out = invoke(
1305 &s,
1306 &mut seat,
1307 &Invocation {
1308 cwd: dir.path(),
1309 prompt: "unused",
1310 timeout: Duration::from_secs(10),
1315 allow_write: true,
1316 sessions: true,
1317 artifacts: &artifacts,
1318 stem: "chatty",
1319 run: "test-run",
1320 node: "test",
1321 },
1322 )
1323 .await
1324 .unwrap();
1325
1326 assert!(out.timed_out, "{out:?}");
1327 assert!(!out.usable(), "a cut-off answer is still not an answer");
1328 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1329 assert!(
1330 recorded.contains("i-said-something"),
1331 "the artifact must keep what arrived before the kill, got {recorded:?}"
1332 );
1333 assert!(
1334 out.text.contains("i-said-something"),
1335 "and the graph must be able to see it too, got {:?}",
1336 out.text
1337 );
1338 }
1339
1340 #[test]
1341 fn missing_programs_reports_command_binaries() {
1342 let mut s = spec(AgentKind::Command, None);
1343 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1344 assert_eq!(
1345 missing_programs(&[s]),
1346 ["definitely-not-a-real-binary-xyz".to_owned()]
1347 );
1348 }
1349}