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 | AgentKind::Codex => {
85 seat.captured_session.is_some()
86 }
87 AgentKind::Command => true,
88 }
89}
90
91#[derive(Debug)]
93pub struct Invocation<'a> {
94 pub cwd: &'a Path,
97 pub prompt: &'a str,
99 pub timeout: Duration,
101 pub allow_write: bool,
103 pub sessions: bool,
105 pub artifacts: &'a Path,
107 pub stem: &'a str,
109 pub run: &'a str,
113 pub node: &'a str,
117 pub cache_dir: Option<&'a Path>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct Quota {
133 #[serde(default)]
135 pub reset: Option<String>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct Dropped {
146 pub why: String,
148 pub output_tokens: u64,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct AgentOutput {
156 pub text: String,
158 pub exit_code: Option<i32>,
160 pub timed_out: bool,
162 pub duration_ms: u64,
164 pub artifacts: Vec<String>,
166 #[serde(default)]
170 pub quota: Option<Quota>,
171 #[serde(default)]
175 pub dropped: Option<Dropped>,
176}
177
178impl AgentOutput {
179 pub fn usable(&self) -> bool {
181 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
182 }
183
184 pub fn quota_exhausted(&self) -> bool {
186 self.quota.is_some()
187 }
188
189 pub fn work_undelivered(&self) -> bool {
194 self.dropped.is_some()
195 }
196}
197
198const PIPE_GRACE: Duration = Duration::from_secs(3);
203
204type Captured = Arc<Mutex<Vec<u8>>>;
206
207fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
217where
218 R: tokio::io::AsyncRead + Unpin + Send + 'static,
219{
220 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
221 let Some(mut pipe) = pipe else {
222 return (buf, None);
223 };
224 let sink = Arc::clone(&buf);
225 let handle = tokio::spawn(async move {
226 let mut chunk = [0u8; 8192];
227 loop {
228 match pipe.read(&mut chunk).await {
229 Ok(0) | Err(_) => break,
230 Ok(n) => {
231 if let Ok(mut guard) = sink.lock() {
232 guard.extend_from_slice(&chunk[..n]);
233 }
234 }
235 }
236 }
237 });
238 (buf, Some(handle))
239}
240
241async fn collect(
246 buf: &Captured,
247 handle: Option<tokio::task::JoinHandle<()>>,
248 grace: Duration,
249) -> String {
250 if let Some(handle) = handle {
251 if tokio::time::timeout(grace, handle).await.is_err() {
252 tracing::debug!("a pipe is still held open after the child exited");
253 }
254 }
255 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
256 String::from_utf8_lossy(&bytes).into_owned()
257}
258
259pub async fn invoke(
261 spec: &AgentSpec,
262 seat: &mut SeatState,
263 inv: &Invocation<'_>,
264) -> Result<AgentOutput> {
265 tokio::fs::create_dir_all(inv.artifacts)
266 .await
267 .with_context(|| format!("create {}", inv.artifacts.display()))?;
268 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
269 tokio::fs::write(&prompt_path, inv.prompt)
270 .await
271 .with_context(|| format!("write {}", prompt_path.display()))?;
272
273 let plan = build_command(spec, seat, inv, &prompt_path)?;
274 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
275
276 let started = Instant::now();
277 let mut cmd = Command::new(&plan.argv[0]);
278 cmd.args(&plan.argv[1..])
279 .current_dir(inv.cwd)
280 .envs(&spec.env)
281 .env("MAGI_SEAT", &seat.key)
282 .env("MAGI_TURN", seat.turns.to_string())
283 .env("MAGI_RUN", inv.run)
284 .env("MAGI_NODE", inv.node)
285 .env("MAGI_PROMPT_FILE", &prompt_path)
286 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
287 .env("GIT_TERMINAL_PROMPT", "0")
288 .stdin(if plan.stdin.is_some() {
289 Stdio::piped()
290 } else {
291 Stdio::null()
292 })
293 .stdout(Stdio::piped())
294 .stderr(Stdio::piped())
295 .kill_on_drop(true)
296 .quiet();
299 if let Some(cache) = inv.cache_dir {
300 cmd.env("CARGO_TARGET_DIR", cache);
303 }
304
305 let mut child = cmd
306 .spawn()
307 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
308 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
312 tokio::spawn(async move {
313 sink.write_all(body.as_bytes()).await.ok();
314 sink.shutdown().await.ok();
315 });
316 }
317
318 let (out_buf, out_reader) = drain(child.stdout.take());
335 let (err_buf, err_reader) = drain(child.stderr.take());
336
337 let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
338 Ok(res) => {
339 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
340 (status.code(), false)
341 }
342 Err(_) => {
343 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
344 child.start_kill().ok();
346 (None, true)
347 }
348 };
349
350 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
355 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
356
357 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
358 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
359 tokio::fs::write(&out_path, &stdout).await.ok();
360 tokio::fs::write(&err_path, &stderr).await.ok();
361
362 let extracted = extract(spec.kind, &stdout);
363 if let Some(session) = extracted.session {
364 match spec.kind {
365 AgentKind::Claude => seat.claude_session = Some(session),
366 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex => {
367 seat.captured_session = Some(session);
368 }
369 AgentKind::Command => {}
370 }
371 }
372 if let Some(status) = &extracted.status
373 && !status.eq_ignore_ascii_case("success")
374 {
375 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
376 }
377 let text = if extracted.text.trim().is_empty() {
378 if stdout.trim().is_empty() {
380 stderr.trim().to_owned()
381 } else {
382 stdout.trim().to_owned()
383 }
384 } else {
385 extracted.text
386 };
387 seat.turns += 1;
388
389 Ok(AgentOutput {
390 text,
391 exit_code: code,
392 timed_out,
393 duration_ms: started.elapsed().as_millis() as u64,
394 artifacts: vec![
395 file_name(&prompt_path),
396 file_name(&out_path),
397 file_name(&err_path),
398 ],
399 quota: extracted.quota,
400 dropped: extracted.dropped,
401 })
402}
403
404fn file_name(p: &Path) -> String {
405 p.file_name()
406 .unwrap_or_default()
407 .to_string_lossy()
408 .into_owned()
409}
410
411#[derive(Debug)]
413struct Plan {
414 argv: Vec<String>,
415 stdin: Option<String>,
416}
417
418fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
429 if matches!(kind, AgentKind::Antigravity) {
430 return format!("@{}", prompt_path.display());
431 }
432 format!(
433 "Read the file at {} and follow every instruction in it exactly. That \
434 file is your complete task description; this message contains nothing \
435 else.",
436 prompt_path.display()
437 )
438}
439
440fn build_command(
441 spec: &AgentSpec,
442 seat: &SeatState,
443 inv: &Invocation<'_>,
444 prompt_path: &Path,
445) -> Result<Plan> {
446 let mut argv: Vec<String> = Vec::new();
447 let mut stdin: Option<String> = None;
448 let delivery = spec.delivery();
449 let resuming = has_session(spec.kind, seat, inv.sessions);
450
451 match spec.kind {
452 AgentKind::Claude => {
453 argv.push("claude".to_owned());
454 argv.push("-p".to_owned());
455 argv.push("--output-format".to_owned());
456 argv.push("json".to_owned());
457 if let Some(m) = &spec.model {
458 argv.push("--model".to_owned());
459 argv.push(m.clone());
460 }
461 if inv.sessions {
462 let uuid = seat
463 .claude_session
464 .as_deref()
465 .context("claude seat is missing its session uuid")?;
466 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
467 argv.push(uuid.to_owned());
468 }
469 argv.push("--permission-mode".to_owned());
470 argv.push("bypassPermissions".to_owned());
471 if !inv.allow_write {
472 argv.push("--disallowed-tools".to_owned());
473 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
474 }
475 }
476 AgentKind::Opencode => {
477 argv.push("opencode".to_owned());
478 argv.push("run".to_owned());
479 argv.push("--format".to_owned());
480 argv.push("json".to_owned());
481 argv.push("--dir".to_owned());
482 argv.push(inv.cwd.to_string_lossy().into_owned());
483 argv.push("--auto".to_owned());
492 if let Some(m) = &spec.model {
493 argv.push("-m".to_owned());
494 argv.push(m.clone());
495 }
496 if resuming {
497 argv.push("-s".to_owned());
498 argv.push(
499 seat.captured_session
500 .clone()
501 .expect("has_session checked the id is present"),
502 );
503 }
504 }
505 AgentKind::Antigravity => {
506 argv.push("agy".to_owned());
507 argv.push("--output-format".to_owned());
508 argv.push("json".to_owned());
509 argv.push("--print-timeout".to_owned());
512 argv.push(format!("{}s", inv.timeout.as_secs()));
513 argv.push("--mode".to_owned());
514 argv.push(
515 if inv.allow_write {
516 "accept-edits"
517 } else {
518 "plan"
519 }
520 .to_owned(),
521 );
522 if inv.allow_write {
523 argv.push("--dangerously-skip-permissions".to_owned());
524 }
525 if let Some(m) = &spec.model {
526 argv.push("--model".to_owned());
527 argv.push(m.clone());
528 }
529 if resuming {
530 argv.push("--conversation".to_owned());
531 argv.push(
532 seat.captured_session
533 .clone()
534 .expect("has_session checked the id is present"),
535 );
536 }
537 if delivery == Delivery::File {
540 argv.push("--add-dir".to_owned());
541 argv.push(inv.artifacts.to_string_lossy().into_owned());
542 }
543 }
544 AgentKind::Codex => {
545 argv.push("codex".to_owned());
546 argv.push("exec".to_owned());
547 argv.push("--json".to_owned());
548 argv.push("--skip-git-repo-check".to_owned());
551 argv.push("-C".to_owned());
552 argv.push(inv.cwd.to_string_lossy().into_owned());
553 argv.push("--sandbox".to_owned());
558 argv.push(
559 if inv.allow_write {
560 "workspace-write"
561 } else {
562 "read-only"
563 }
564 .to_owned(),
565 );
566 argv.push("-c".to_owned());
569 argv.push("approval_policy=\"never\"".to_owned());
570 if let Some(m) = &spec.model {
571 argv.push("-m".to_owned());
572 argv.push(m.clone());
573 }
574 if resuming {
580 argv.push("resume".to_owned());
581 argv.push(
582 seat.captured_session
583 .clone()
584 .expect("has_session checked the id is present"),
585 );
586 }
587 }
588 AgentKind::Command => {
589 if spec.command.is_empty() {
590 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
591 }
592 let vars: BTreeMap<&str, String> = BTreeMap::from([
593 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
594 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
595 ("{label}", seat.key.clone()),
596 ("{session}", seat.claude_session.clone().unwrap_or_default()),
597 ]);
598 for raw in &spec.command {
599 let mut arg = raw.clone();
600 for (k, v) in &vars {
601 if arg.contains(k) {
602 arg = arg.replace(k, v);
603 }
604 }
605 argv.push(arg);
606 }
607 }
608 }
609
610 argv.extend(spec.extra_args.iter().cloned());
611
612 if spec.kind == AgentKind::Antigravity {
615 argv.push("-p".to_owned());
616 }
617 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
620 argv.push("-".to_owned());
621 }
622 match delivery {
623 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
624 argv.push(pointer(spec.kind, prompt_path));
626 }
627 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
628 Delivery::Argv => argv.push(inv.prompt.to_owned()),
629 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
630 }
631
632 Ok(Plan { argv, stdin })
633}
634
635#[derive(Debug, Default)]
637struct Extracted {
638 text: String,
639 session: Option<String>,
640 status: Option<String>,
641 quota: Option<Quota>,
642 dropped: Option<Dropped>,
643}
644
645fn extract(kind: AgentKind, stdout: &str) -> Extracted {
647 match kind {
648 AgentKind::Claude => {
649 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
650 return Extracted {
651 text: stdout.trim().to_owned(),
652 ..Extracted::default()
653 };
654 };
655 Extracted {
656 text: v
657 .get("result")
658 .and_then(|r| r.as_str())
659 .unwrap_or_default()
660 .to_owned(),
661 session: v
662 .get("session_id")
663 .and_then(|s| s.as_str())
664 .map(str::to_owned),
665 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
666 if e {
667 "error".to_owned()
668 } else {
669 "success".to_owned()
670 }
671 }),
672 quota: claude_quota(&v),
673 dropped: None,
676 }
677 }
678 AgentKind::Opencode => {
679 let mut text = String::new();
681 let mut session = None;
682 for line in stdout.lines() {
683 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
684 continue;
685 };
686 if session.is_none() {
687 session = v
688 .get("sessionID")
689 .and_then(|s| s.as_str())
690 .map(str::to_owned);
691 }
692 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
693 if part.get("type").and_then(|t| t.as_str()) == Some("text")
694 && let Some(t) = part.get("text").and_then(|t| t.as_str())
695 {
696 if !text.is_empty() {
697 text.push('\n');
698 }
699 text.push_str(t);
700 }
701 }
702 Extracted {
703 text,
704 session,
705 status: None,
706 quota: None,
707 dropped: None,
708 }
709 }
710 AgentKind::Antigravity => {
711 let obj = stdout
714 .lines()
715 .rev()
716 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
717 let Some(v) = obj else {
718 return Extracted {
719 text: stdout.trim().to_owned(),
720 ..Extracted::default()
721 };
722 };
723 Extracted {
724 text: v
725 .get("response")
726 .and_then(|r| r.as_str())
727 .unwrap_or_default()
728 .trim()
729 .to_owned(),
730 session: v
731 .get("conversation_id")
732 .and_then(|s| s.as_str())
733 .map(str::to_owned),
734 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
735 quota: None,
736 dropped: dropped_stream(&v),
737 }
738 }
739 AgentKind::Codex => {
740 let mut text = String::new();
751 let mut session = None;
752 let mut status = None;
753 for line in stdout.lines() {
754 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
755 continue;
756 };
757 match v.get("type").and_then(|t| t.as_str()) {
758 Some("thread.started") => {
759 session = v
760 .get("thread_id")
761 .and_then(|s| s.as_str())
762 .map(str::to_owned);
763 }
764 Some("item.completed") => {
765 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
766 if item.get("type").and_then(|t| t.as_str()) == Some("agent_message")
767 && let Some(t) = item.get("text").and_then(|t| t.as_str())
768 {
769 text = t.trim().to_owned();
770 }
771 }
772 Some("turn.completed") => status = Some("success".to_owned()),
773 Some("turn.failed") => status = Some("error".to_owned()),
774 _ => {}
775 }
776 }
777 Extracted {
778 text,
779 session,
780 status,
781 quota: None,
782 dropped: None,
783 }
784 }
785 AgentKind::Command => {
786 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
791 let quota = parsed.as_ref().and_then(claude_quota);
792 let dropped = parsed.as_ref().and_then(dropped_stream);
795 Extracted {
796 text: stdout.trim().to_owned(),
797 session: None,
798 status: None,
799 quota,
800 dropped,
801 }
802 }
803 }
804}
805
806fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
813 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
814 if !is_err {
815 return None;
816 }
817 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
818 if !result.to_lowercase().contains("session limit") {
819 return None;
820 }
821 let reset = result
824 .split("resets ")
825 .nth(1)
826 .map(str::trim)
827 .filter(|s| !s.is_empty())
828 .map(str::to_owned);
829 Some(Quota { reset })
830}
831
832fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
863 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
864 if !status.eq_ignore_ascii_case("error") {
865 return None;
866 }
867 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
868 if !response.trim().is_empty() {
869 return None;
871 }
872 let produced = v
873 .get("usage")
874 .and_then(|u| u.get("output_tokens"))
875 .and_then(serde_json::Value::as_u64)
876 .unwrap_or(0);
877 if produced == 0 {
878 return None;
880 }
881 Some(Dropped {
882 why: v
883 .get("error")
884 .and_then(|e| e.as_str())
885 .unwrap_or("the CLI ended the stream without delivering its answer")
886 .trim()
887 .to_owned(),
888 output_tokens: produced,
889 })
890}
891
892pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
894 let mut missing = Vec::new();
895 for s in specs {
896 let program = match s.kind {
897 AgentKind::Command => s.command.first().map(String::as_str),
898 other => other.program(),
899 };
900 if let Some(p) = program
901 && !crate::config::which(p)
902 && !Path::new(p).is_file()
903 && !missing.iter().any(|m: &String| m == p)
904 {
905 missing.push(p.to_owned());
906 }
907 }
908 missing
909}
910
911pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
913 run_dir.join("artifacts")
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919
920 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
921 AgentSpec {
922 id: "a".to_owned(),
923 kind,
924 model: model.map(str::to_owned),
925 command: vec!["echo".to_owned(), "{label}".to_owned()],
926 extra_args: Vec::new(),
927 env: BTreeMap::new(),
928 prompt_delivery: None,
929 }
930 }
931
932 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
933 Invocation {
934 cwd,
935 prompt: "do the thing",
936 timeout: Duration::from_secs(900),
937 allow_write,
938 sessions: true,
939 artifacts: art,
940 stem: "t",
941 run: "test-run",
942 node: "test",
943 cache_dir: None,
944 }
945 }
946
947 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
948 build_command(
949 &spec(kind, None),
950 seat,
951 &inv(Path::new("."), Path::new("/art"), allow_write),
952 Path::new("/art/p.md"),
953 )
954 .unwrap()
955 }
956
957 #[test]
958 fn claude_mints_then_resumes_the_same_uuid() {
959 let mut seat = SeatState::new("judge-1", "a", 7);
960 let uuid = seat.claude_session.clone().unwrap();
961 let first = plan_for(AgentKind::Claude, &seat, true);
962 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
963 assert!(!first.argv.iter().any(|a| a == "--resume"));
964
965 seat.turns = 1;
966 let second = plan_for(AgentKind::Claude, &seat, true);
967 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
968 assert!(!second.argv.iter().any(|a| a == "--session-id"));
969 }
970
971 #[test]
972 fn read_only_seats_cannot_edit() {
973 let seat = SeatState::new("judge-1", "a", 7);
974 let claude = plan_for(AgentKind::Claude, &seat, false);
975 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
976 assert!(
977 !plan_for(AgentKind::Claude, &seat, true)
978 .argv
979 .iter()
980 .any(|a| a == "--disallowed-tools")
981 );
982
983 let agy = plan_for(AgentKind::Antigravity, &seat, false);
984 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
985 assert!(
986 !agy.argv
987 .iter()
988 .any(|a| a == "--dangerously-skip-permissions")
989 );
990 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
991 assert!(
992 agy_rw
993 .argv
994 .windows(2)
995 .any(|w| w == ["--mode", "accept-edits"])
996 );
997 assert!(
998 agy_rw
999 .argv
1000 .iter()
1001 .any(|a| a == "--dangerously-skip-permissions")
1002 );
1003 let agy_prompt = agy_rw
1008 .argv
1009 .iter()
1010 .position(|a| a == "-p")
1011 .map(|i| agy_rw.argv[i + 1].clone())
1012 .expect("agy takes its prompt with -p");
1013 assert!(
1014 agy_prompt.starts_with('@'),
1015 "agy must get a file reference, got {agy_prompt:?}"
1016 );
1017 assert!(
1018 !agy_prompt.contains("Read the file at"),
1019 "the prose pointer is for CLIs with no file syntax"
1020 );
1021
1022 for allow_write in [false, true] {
1027 assert!(
1028 plan_for(AgentKind::Opencode, &seat, allow_write)
1029 .argv
1030 .iter()
1031 .any(|a| a == "--auto"),
1032 "opencode needs --auto even to read (allow_write = {allow_write})"
1033 );
1034 }
1035 }
1036
1037 #[test]
1040 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1041 let mut seat = SeatState::new("judge-1", "a", 7);
1042
1043 let ro = plan_for(AgentKind::Codex, &seat, false);
1047 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1048 let rw = plan_for(AgentKind::Codex, &seat, true);
1049 assert!(
1050 rw.argv
1051 .windows(2)
1052 .any(|w| w == ["--sandbox", "workspace-write"])
1053 );
1054 for p in [&ro, &rw] {
1055 assert!(
1056 !p.argv
1057 .iter()
1058 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1059 "the bypass defeats the only enforced read-only mode we have"
1060 );
1061 assert!(
1063 p.argv
1064 .windows(2)
1065 .any(|w| w == ["-c", "approval_policy=\"never\""]),
1066 "an unattended seat that asks for approval blocks until timeout"
1067 );
1068 }
1069
1070 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1072 assert_eq!(
1073 ro.argv.last().map(String::as_str),
1074 Some("-"),
1075 "without the `-` argument codex waits for a prompt it never gets"
1076 );
1077
1078 seat.turns = 1;
1082 assert!(!has_session(AgentKind::Codex, &seat, true));
1083 assert!(
1084 !plan_for(AgentKind::Codex, &seat, true)
1085 .argv
1086 .iter()
1087 .any(|a| a == "resume")
1088 );
1089 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1090 let resumed = plan_for(AgentKind::Codex, &seat, true);
1091 let at = resumed
1092 .argv
1093 .iter()
1094 .position(|a| a == "resume")
1095 .expect("resumes by subcommand");
1096 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1097 assert!(
1098 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1099 "every option precedes the subcommand"
1100 );
1101 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1102 }
1103
1104 #[test]
1106 fn codex_takes_the_last_agent_message_and_the_thread_id() {
1107 let stream = concat!(
1108 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1109 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1110 "\n",
1111 r#"{"type":"turn.started"}"#,
1112 "\n",
1113 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1114 "\n",
1115 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1116 "\n",
1117 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1118 "\n",
1119 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1120 "\n",
1121 );
1122 let out = extract(AgentKind::Codex, stream);
1123 assert_eq!(
1124 out.text, "{\"verdict\": \"ok\"}",
1125 "the last agent message is the answer; earlier ones narrate"
1126 );
1127 assert_eq!(
1128 out.session.as_deref(),
1129 Some("01a07440-4545-7492-85c1-024e3259a90a")
1130 );
1131 assert_eq!(out.status.as_deref(), Some("success"));
1132
1133 let failed = concat!(
1134 r#"{"type":"thread.started","thread_id":"t1"}"#,
1135 "\n",
1136 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1137 "\n",
1138 );
1139 assert_eq!(
1140 extract(AgentKind::Codex, failed).status.as_deref(),
1141 Some("error")
1142 );
1143 }
1144
1145 #[test]
1146 fn captured_sessions_resume_only_once_reported() {
1147 let mut seat = SeatState::new("impl-A", "a", 7);
1148 seat.turns = 1;
1149 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1150 assert!(!has_session(kind, &seat, true));
1151 let p = plan_for(kind, &seat, true);
1152 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1153 }
1154
1155 seat.captured_session = Some("sid".to_owned());
1156 assert!(has_session(AgentKind::Opencode, &seat, true));
1157 assert!(
1158 plan_for(AgentKind::Opencode, &seat, true)
1159 .argv
1160 .windows(2)
1161 .any(|w| w == ["-s", "sid"])
1162 );
1163 assert!(
1164 plan_for(AgentKind::Antigravity, &seat, true)
1165 .argv
1166 .windows(2)
1167 .any(|w| w == ["--conversation", "sid"])
1168 );
1169 }
1170
1171 #[test]
1172 fn sessions_disabled_never_resumes() {
1173 let mut seat = SeatState::new("impl-A", "a", 7);
1174 seat.turns = 3;
1175 seat.captured_session = Some("sid".to_owned());
1176 for kind in [
1177 AgentKind::Claude,
1178 AgentKind::Opencode,
1179 AgentKind::Antigravity,
1180 ] {
1181 assert!(!has_session(kind, &seat, false));
1182 }
1183 }
1184
1185 #[test]
1186 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1187 let seat = SeatState::new("judge-1", "a", 7);
1188 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1189 let p = plan_for(kind, &seat, false);
1190 assert!(
1191 p.argv.iter().all(|a| a != "do the thing"),
1192 "{kind:?} put the prompt on the command line"
1193 );
1194 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1195 }
1196 let p = plan_for(AgentKind::Antigravity, &seat, false);
1198 let at = p.argv.iter().position(|a| a == "-p").unwrap();
1199 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1200 assert!(p.stdin.is_none());
1201 }
1202
1203 #[test]
1204 fn agy_print_timeout_tracks_the_node_budget() {
1205 let seat = SeatState::new("impl-A", "a", 7);
1206 let p = build_command(
1207 &spec(AgentKind::Antigravity, None),
1208 &seat,
1209 &Invocation {
1210 cwd: Path::new("."),
1211 prompt: "p",
1212 timeout: Duration::from_secs(3600),
1213 allow_write: true,
1214 sessions: true,
1215 artifacts: Path::new("/art"),
1216 stem: "t",
1217 run: "test-run",
1218 node: "test",
1219 cache_dir: None,
1220 },
1221 Path::new("/art/p.md"),
1222 )
1223 .unwrap();
1224 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1225 }
1226
1227 #[test]
1228 fn command_agents_get_placeholders_substituted() {
1229 let seat = SeatState::new("impl-A", "a", 7);
1230 let p = plan_for(AgentKind::Command, &seat, true);
1231 assert_eq!(p.argv[0], "echo");
1232 assert_eq!(p.argv[1], "impl-A");
1233 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1234 }
1235
1236 #[test]
1237 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1238 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1240 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1241 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1242 let out = extract(AgentKind::Claude, stdout);
1243 let quota = out.quota.as_ref().expect("rate limit must be detected");
1244 assert_eq!(
1245 quota.reset.as_deref(),
1246 Some("4:50am (Asia/Tokyo)"),
1247 "reset time read from the body"
1248 );
1249 }
1250
1251 #[test]
1252 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1253 let out = extract(
1254 AgentKind::Claude,
1255 r#"{"is_error":true,"result":"session limit reached"}"#,
1256 );
1257 let quota = out.quota.expect("rate limit detected without a reset");
1258 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1259 }
1260
1261 #[test]
1262 fn ordinary_failures_are_never_quota() {
1263 let claude_fail = extract(
1265 AgentKind::Claude,
1266 r#"{"is_error":true,"result":"account does not exist"}"#,
1267 );
1268 assert!(claude_fail.quota.is_none());
1269
1270 let cmd_fail = extract(AgentKind::Command, "boom");
1272 assert!(cmd_fail.quota.is_none());
1273
1274 let success = extract(
1276 AgentKind::Command,
1277 r#"{"is_error":false,"result":"session limit is fine"}"#,
1278 );
1279 assert!(success.quota.is_none());
1280 }
1281
1282 #[test]
1283 fn command_agent_can_carry_the_claude_quota_shape() {
1284 let out = extract(
1285 AgentKind::Command,
1286 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1287 );
1288 assert!(
1289 out.quota.is_some(),
1290 "a wrapper emitting the claude shape counts as quota"
1291 );
1292 }
1293
1294 #[test]
1295 fn claude_json_result_is_extracted() {
1296 let out = extract(
1297 AgentKind::Claude,
1298 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1299 );
1300 assert_eq!(out.text, "all done");
1301 assert_eq!(out.session.as_deref(), Some("abc"));
1302 assert_eq!(out.status.as_deref(), Some("success"));
1303 }
1304
1305 #[test]
1306 fn opencode_event_stream_is_concatenated() {
1307 let stream = concat!(
1308 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
1309 "\n",
1310 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
1311 "\n",
1312 "garbage line\n",
1313 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
1314 "\n"
1315 );
1316 let out = extract(AgentKind::Opencode, stream);
1317 assert_eq!(out.text, "first\nsecond");
1318 assert_eq!(out.session.as_deref(), Some("ses_1"));
1319 }
1320
1321 #[test]
1322 fn agy_json_survives_a_leading_warning_line() {
1323 let stdout = concat!(
1324 "warning: --mode plan has no effect while slash commands are disabled.\n",
1325 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1326 "\n"
1327 );
1328 let out = extract(AgentKind::Antigravity, stdout);
1329 assert_eq!(out.text, "persimmon");
1330 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1331 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1332 }
1333
1334 const AGY_DROPPED: &str = concat!(
1341 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
1342 r#""response":"","error":"the connection to the agent was interrupted before "#,
1343 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
1344 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
1345 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
1346 r#""total_tokens":274380}}"#
1347 );
1348
1349 #[test]
1350 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
1351 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
1352 let dropped = out.dropped.expect("recognised as undelivered work");
1353 assert_eq!(dropped.output_tokens, 14267);
1354 assert!(
1355 dropped.why.contains("subscriber fell behind"),
1356 "the CLI's own words are kept for the record: {}",
1357 dropped.why
1358 );
1359 assert_eq!(
1362 out.session.as_deref(),
1363 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
1364 );
1365 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
1366 }
1367
1368 #[test]
1369 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
1370 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
1374 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
1375
1376 let answered = concat!(
1379 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
1380 r#""usage":{"output_tokens":10}}"#
1381 );
1382 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
1383
1384 let ok = concat!(
1386 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
1387 r#""usage":{"output_tokens":10}}"#
1388 );
1389 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
1390 }
1391
1392 #[test]
1393 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
1394 let out = AgentOutput {
1395 text: String::new(),
1396 exit_code: Some(1),
1397 timed_out: false,
1398 duration_ms: 431_194,
1399 artifacts: Vec::new(),
1400 quota: None,
1401 dropped: Some(Dropped {
1402 why: "subscriber fell behind updates".to_owned(),
1403 output_tokens: 14267,
1404 }),
1405 };
1406 assert!(!out.usable());
1407 assert!(out.work_undelivered());
1408 assert!(!out.quota_exhausted());
1411 }
1412
1413 #[test]
1414 fn non_json_stdout_falls_back_to_raw_text() {
1415 let out = extract(AgentKind::Antigravity, "plain answer\n");
1416 assert_eq!(out.text, "plain answer");
1417 assert!(out.session.is_none());
1418 }
1419
1420 #[tokio::test]
1421 async fn command_agent_round_trip_writes_artifacts() {
1422 let dir = tempfile::tempdir().unwrap();
1423 let art = dir.path().join("artifacts");
1424 let mut seat = SeatState::new("impl-A", "a", 7);
1425 let mut s = spec(AgentKind::Command, None);
1426 s.command = vec!["echo".to_owned(), "hello {label}".to_owned()];
1427 let out = invoke(
1428 &s,
1429 &mut seat,
1430 &Invocation {
1431 cwd: dir.path(),
1432 prompt: "unused",
1433 timeout: Duration::from_secs(30),
1434 allow_write: true,
1435 sessions: true,
1436 artifacts: &art,
1437 stem: "impl-A",
1438 run: "test-run",
1439 node: "test",
1440 cache_dir: None,
1441 },
1442 )
1443 .await
1444 .unwrap();
1445 assert!(out.usable(), "{out:?}");
1446 assert!(out.text.contains("hello impl-A"), "{}", out.text);
1447 assert_eq!(seat.turns, 1);
1448 assert!(art.join("impl-A.prompt.md").is_file());
1449 assert!(art.join("impl-A.out").is_file());
1450 }
1451
1452 #[tokio::test]
1453 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
1454 let dir = tempfile::tempdir().unwrap();
1458 let cache = dir.path().join("magi-cache");
1459 let mut seat = SeatState::new("impl-A", "a", 7);
1460 let mut s = spec(AgentKind::Command, None);
1461 if cfg!(windows) {
1462 s.command = vec![
1463 "cmd".to_owned(),
1464 "/C".to_owned(),
1465 "echo %CARGO_TARGET_DIR%".to_owned(),
1466 ];
1467 } else {
1468 s.command = vec![
1469 "sh".to_owned(),
1470 "-c".to_owned(),
1471 "echo $CARGO_TARGET_DIR".to_owned(),
1472 ];
1473 }
1474 let out = invoke(
1475 &s,
1476 &mut seat,
1477 &Invocation {
1478 cwd: dir.path(),
1479 prompt: "unused",
1480 timeout: Duration::from_secs(30),
1481 allow_write: true,
1482 sessions: true,
1483 artifacts: &dir.path().join("artifacts"),
1484 stem: "cache",
1485 run: "test-run",
1486 node: "test",
1487 cache_dir: Some(&cache),
1488 },
1489 )
1490 .await
1491 .unwrap();
1492 assert!(out.usable(), "{out:?}");
1493 assert_eq!(
1494 out.text.trim(),
1495 cache.to_string_lossy(),
1496 "the seat must see CARGO_TARGET_DIR = the shared cache"
1497 );
1498 }
1499
1500 #[tokio::test]
1501 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1502 let dir = tempfile::tempdir().unwrap();
1503 let mut seat = SeatState::new("impl-A", "a", 7);
1504 let mut s = spec(AgentKind::Command, None);
1505 s.command = vec!["echo".to_owned(), "done".to_owned()];
1508 let big = "x".repeat(1_000_000);
1509 let out = invoke(
1510 &s,
1511 &mut seat,
1512 &Invocation {
1513 cwd: dir.path(),
1514 prompt: &big,
1515 timeout: Duration::from_secs(60),
1516 allow_write: true,
1517 sessions: true,
1518 artifacts: &dir.path().join("artifacts"),
1519 stem: "big",
1520 run: "test-run",
1521 node: "test",
1522 cache_dir: None,
1523 },
1524 )
1525 .await
1526 .unwrap();
1527 assert!(out.usable(), "{out:?}");
1528 assert_eq!(out.text, "done");
1529 }
1530
1531 #[tokio::test]
1532 async fn timeout_is_reported_not_hung() {
1533 let dir = tempfile::tempdir().unwrap();
1534 let mut seat = SeatState::new("impl-A", "a", 7);
1535 let mut s = spec(AgentKind::Command, None);
1536 s.command = vec!["sleep".to_owned(), "30".to_owned()];
1537 let out = invoke(
1538 &s,
1539 &mut seat,
1540 &Invocation {
1541 cwd: dir.path(),
1542 prompt: "unused",
1543 timeout: Duration::from_millis(300),
1544 allow_write: true,
1545 sessions: true,
1546 artifacts: &dir.path().join("artifacts"),
1547 stem: "slow",
1548 run: "test-run",
1549 node: "test",
1550 cache_dir: None,
1551 },
1552 )
1553 .await
1554 .unwrap();
1555 assert!(out.timed_out);
1556 assert!(!out.usable());
1557 }
1558
1559 #[tokio::test]
1560 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1561 let dir = tempfile::tempdir().unwrap();
1567 let artifacts = dir.path().join("artifacts");
1568 let mut seat = SeatState::new("impl-A", "a", 7);
1569 let mut s = spec(AgentKind::Command, None);
1570 s.command = vec![
1571 "sh".to_owned(),
1572 "-c".to_owned(),
1573 "echo i-said-something; sleep 30".to_owned(),
1574 ];
1575 let out = invoke(
1576 &s,
1577 &mut seat,
1578 &Invocation {
1579 cwd: dir.path(),
1580 prompt: "unused",
1581 timeout: Duration::from_secs(10),
1586 allow_write: true,
1587 sessions: true,
1588 artifacts: &artifacts,
1589 stem: "chatty",
1590 run: "test-run",
1591 node: "test",
1592 cache_dir: None,
1593 },
1594 )
1595 .await
1596 .unwrap();
1597
1598 assert!(out.timed_out, "{out:?}");
1599 assert!(!out.usable(), "a cut-off answer is still not an answer");
1600 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1601 assert!(
1602 recorded.contains("i-said-something"),
1603 "the artifact must keep what arrived before the kill, got {recorded:?}"
1604 );
1605 assert!(
1606 out.text.contains("i-said-something"),
1607 "and the graph must be able to see it too, got {:?}",
1608 out.text
1609 );
1610 }
1611
1612 #[test]
1613 fn missing_programs_reports_command_binaries() {
1614 let mut s = spec(AgentKind::Command, None);
1615 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1616 assert_eq!(
1617 missing_programs(&[s]),
1618 ["definitely-not-a-real-binary-xyz".to_owned()]
1619 );
1620 }
1621}