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 pub attachments: &'a [PathBuf],
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct Quota {
140 #[serde(default)]
142 pub reset: Option<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct Dropped {
153 pub why: String,
155 pub output_tokens: u64,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct AgentOutput {
163 pub text: String,
165 pub exit_code: Option<i32>,
167 pub timed_out: bool,
169 pub duration_ms: u64,
171 pub artifacts: Vec<String>,
173 #[serde(default)]
177 pub quota: Option<Quota>,
178 #[serde(default)]
182 pub dropped: Option<Dropped>,
183}
184
185impl AgentOutput {
186 pub fn usable(&self) -> bool {
188 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
189 }
190
191 pub fn quota_exhausted(&self) -> bool {
193 self.quota.is_some()
194 }
195
196 pub fn work_undelivered(&self) -> bool {
201 self.dropped.is_some()
202 }
203}
204
205const PIPE_GRACE: Duration = Duration::from_secs(3);
210
211type Captured = Arc<Mutex<Vec<u8>>>;
213
214fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
224where
225 R: tokio::io::AsyncRead + Unpin + Send + 'static,
226{
227 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
228 let Some(mut pipe) = pipe else {
229 return (buf, None);
230 };
231 let sink = Arc::clone(&buf);
232 let handle = tokio::spawn(async move {
233 let mut chunk = [0u8; 8192];
234 loop {
235 match pipe.read(&mut chunk).await {
236 Ok(0) | Err(_) => break,
237 Ok(n) => {
238 if let Ok(mut guard) = sink.lock() {
239 guard.extend_from_slice(&chunk[..n]);
240 }
241 }
242 }
243 }
244 });
245 (buf, Some(handle))
246}
247
248async fn collect(
253 buf: &Captured,
254 handle: Option<tokio::task::JoinHandle<()>>,
255 grace: Duration,
256) -> String {
257 if let Some(handle) = handle {
258 if tokio::time::timeout(grace, handle).await.is_err() {
259 tracing::debug!("a pipe is still held open after the child exited");
260 }
261 }
262 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
263 String::from_utf8_lossy(&bytes).into_owned()
264}
265
266pub async fn invoke(
268 spec: &AgentSpec,
269 seat: &mut SeatState,
270 inv: &Invocation<'_>,
271) -> Result<AgentOutput> {
272 tokio::fs::create_dir_all(inv.artifacts)
273 .await
274 .with_context(|| format!("create {}", inv.artifacts.display()))?;
275 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
276 tokio::fs::write(&prompt_path, inv.prompt)
277 .await
278 .with_context(|| format!("write {}", prompt_path.display()))?;
279
280 let plan = build_command(spec, seat, inv, &prompt_path)?;
281 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
282
283 let started = Instant::now();
284 let mut cmd = Command::new(&plan.argv[0]);
285 cmd.args(&plan.argv[1..])
286 .current_dir(inv.cwd)
287 .envs(&spec.env)
288 .env("MAGI_SEAT", &seat.key)
289 .env("MAGI_TURN", seat.turns.to_string())
290 .env("MAGI_RUN", inv.run)
291 .env("MAGI_NODE", inv.node)
292 .env("MAGI_PROMPT_FILE", &prompt_path)
293 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
294 .env("GIT_TERMINAL_PROMPT", "0")
295 .stdin(if plan.stdin.is_some() {
296 Stdio::piped()
297 } else {
298 Stdio::null()
299 })
300 .stdout(Stdio::piped())
301 .stderr(Stdio::piped())
302 .kill_on_drop(true)
303 .quiet();
306 if let Some(cache) = inv.cache_dir {
307 cmd.env("CARGO_TARGET_DIR", cache);
310 }
311
312 let mut child = cmd
313 .spawn()
314 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
315 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
319 tokio::spawn(async move {
320 sink.write_all(body.as_bytes()).await.ok();
321 sink.shutdown().await.ok();
322 });
323 }
324
325 let (out_buf, out_reader) = drain(child.stdout.take());
342 let (err_buf, err_reader) = drain(child.stderr.take());
343
344 let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
345 Ok(res) => {
346 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
347 (status.code(), false)
348 }
349 Err(_) => {
350 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
351 child.start_kill().ok();
353 (None, true)
354 }
355 };
356
357 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
362 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
363
364 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
365 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
366 tokio::fs::write(&out_path, &stdout).await.ok();
367 tokio::fs::write(&err_path, &stderr).await.ok();
368
369 let extracted = extract(spec.kind, &stdout);
370 if let Some(session) = extracted.session {
371 match spec.kind {
372 AgentKind::Claude => seat.claude_session = Some(session),
373 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex => {
374 seat.captured_session = Some(session);
375 }
376 AgentKind::Command => {}
377 }
378 }
379 if let Some(status) = &extracted.status
380 && !status.eq_ignore_ascii_case("success")
381 {
382 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
383 }
384 let text = if extracted.text.trim().is_empty() {
385 if stdout.trim().is_empty() {
387 stderr.trim().to_owned()
388 } else {
389 stdout.trim().to_owned()
390 }
391 } else {
392 extracted.text
393 };
394 seat.turns += 1;
395
396 Ok(AgentOutput {
397 text,
398 exit_code: code,
399 timed_out,
400 duration_ms: started.elapsed().as_millis() as u64,
401 artifacts: vec![
402 file_name(&prompt_path),
403 file_name(&out_path),
404 file_name(&err_path),
405 ],
406 quota: extracted.quota,
407 dropped: extracted.dropped,
408 })
409}
410
411fn file_name(p: &Path) -> String {
412 p.file_name()
413 .unwrap_or_default()
414 .to_string_lossy()
415 .into_owned()
416}
417
418#[derive(Debug)]
420struct Plan {
421 argv: Vec<String>,
422 stdin: Option<String>,
423}
424
425fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
436 if matches!(kind, AgentKind::Antigravity) {
437 return format!("@{}", prompt_path.display());
438 }
439 format!(
440 "Read the file at {} and follow every instruction in it exactly. That \
441 file is your complete task description; this message contains nothing \
442 else.",
443 prompt_path.display()
444 )
445}
446
447fn build_command(
448 spec: &AgentSpec,
449 seat: &SeatState,
450 inv: &Invocation<'_>,
451 prompt_path: &Path,
452) -> Result<Plan> {
453 let mut argv: Vec<String> = Vec::new();
454 let mut stdin: Option<String> = None;
455 let delivery = spec.delivery();
456 let resuming = has_session(spec.kind, seat, inv.sessions);
457
458 match spec.kind {
459 AgentKind::Claude => {
460 argv.push("claude".to_owned());
465 argv.push("-p".to_owned());
466 argv.push("--output-format".to_owned());
467 argv.push("json".to_owned());
468 if let Some(m) = &spec.model {
469 argv.push("--model".to_owned());
470 argv.push(m.clone());
471 }
472 if inv.sessions {
473 let uuid = seat
474 .claude_session
475 .as_deref()
476 .context("claude seat is missing its session uuid")?;
477 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
478 argv.push(uuid.to_owned());
479 }
480 argv.push("--permission-mode".to_owned());
481 argv.push("bypassPermissions".to_owned());
482 if !inv.allow_write {
483 argv.push("--disallowed-tools".to_owned());
484 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
485 }
486 }
487 AgentKind::Opencode => {
488 argv.push("opencode".to_owned());
492 argv.push("run".to_owned());
493 argv.push("--format".to_owned());
494 argv.push("json".to_owned());
495 argv.push("--dir".to_owned());
496 argv.push(inv.cwd.to_string_lossy().into_owned());
497 argv.push("--auto".to_owned());
506 if let Some(m) = &spec.model {
507 argv.push("-m".to_owned());
508 argv.push(m.clone());
509 }
510 if resuming {
511 argv.push("-s".to_owned());
512 argv.push(
513 seat.captured_session
514 .clone()
515 .expect("has_session checked the id is present"),
516 );
517 }
518 }
519 AgentKind::Antigravity => {
520 argv.push("agy".to_owned());
521 argv.push("--output-format".to_owned());
522 argv.push("json".to_owned());
523 argv.push("--print-timeout".to_owned());
526 argv.push(format!("{}s", inv.timeout.as_secs()));
527 argv.push("--mode".to_owned());
528 argv.push(
529 if inv.allow_write {
530 "accept-edits"
531 } else {
532 "plan"
533 }
534 .to_owned(),
535 );
536 if inv.allow_write {
537 argv.push("--dangerously-skip-permissions".to_owned());
538 }
539 if let Some(m) = &spec.model {
540 argv.push("--model".to_owned());
541 argv.push(m.clone());
542 }
543 if resuming {
544 argv.push("--conversation".to_owned());
545 argv.push(
546 seat.captured_session
547 .clone()
548 .expect("has_session checked the id is present"),
549 );
550 }
551 let mut add_dirs: Vec<String> = Vec::new();
561 if delivery == Delivery::File || !inv.attachments.is_empty() {
562 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
563 }
564 for path in inv.attachments {
565 let Some(parent) = path.parent() else {
566 continue;
567 };
568 if parent.starts_with(inv.artifacts) {
569 continue;
570 }
571 let dir = parent.to_string_lossy().into_owned();
572 if !add_dirs.contains(&dir) {
573 add_dirs.push(dir);
574 }
575 }
576 for dir in add_dirs {
577 argv.push("--add-dir".to_owned());
578 argv.push(dir);
579 }
580 }
581 AgentKind::Codex => {
582 argv.push("codex".to_owned());
588 argv.push("exec".to_owned());
589 argv.push("--json".to_owned());
590 argv.push("--skip-git-repo-check".to_owned());
593 argv.push("-C".to_owned());
594 argv.push(inv.cwd.to_string_lossy().into_owned());
595 argv.push("--sandbox".to_owned());
600 argv.push(
601 if inv.allow_write {
602 "workspace-write"
603 } else {
604 "read-only"
605 }
606 .to_owned(),
607 );
608 argv.push("-c".to_owned());
611 argv.push("approval_policy=\"never\"".to_owned());
612 if let Some(m) = &spec.model {
613 argv.push("-m".to_owned());
614 argv.push(m.clone());
615 }
616 if resuming {
622 argv.push("resume".to_owned());
623 argv.push(
624 seat.captured_session
625 .clone()
626 .expect("has_session checked the id is present"),
627 );
628 }
629 }
630 AgentKind::Command => {
631 if spec.command.is_empty() {
636 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
637 }
638 let vars: BTreeMap<&str, String> = BTreeMap::from([
639 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
640 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
641 ("{label}", seat.key.clone()),
642 ("{session}", seat.claude_session.clone().unwrap_or_default()),
643 ]);
644 for raw in &spec.command {
645 let mut arg = raw.clone();
646 for (k, v) in &vars {
647 if arg.contains(k) {
648 arg = arg.replace(k, v);
649 }
650 }
651 argv.push(arg);
652 }
653 }
654 }
655
656 argv.extend(spec.extra_args.iter().cloned());
657
658 if spec.kind == AgentKind::Antigravity {
661 argv.push("-p".to_owned());
662 }
663 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
666 argv.push("-".to_owned());
667 }
668 match delivery {
669 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
670 argv.push(pointer(spec.kind, prompt_path));
672 }
673 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
674 Delivery::Argv => argv.push(inv.prompt.to_owned()),
675 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
676 }
677
678 Ok(Plan { argv, stdin })
679}
680
681#[derive(Debug, Default)]
683struct Extracted {
684 text: String,
685 session: Option<String>,
686 status: Option<String>,
687 quota: Option<Quota>,
688 dropped: Option<Dropped>,
689}
690
691fn extract(kind: AgentKind, stdout: &str) -> Extracted {
693 match kind {
694 AgentKind::Claude => {
695 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
696 return Extracted {
697 text: stdout.trim().to_owned(),
698 ..Extracted::default()
699 };
700 };
701 Extracted {
702 text: v
703 .get("result")
704 .and_then(|r| r.as_str())
705 .unwrap_or_default()
706 .to_owned(),
707 session: v
708 .get("session_id")
709 .and_then(|s| s.as_str())
710 .map(str::to_owned),
711 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
712 if e {
713 "error".to_owned()
714 } else {
715 "success".to_owned()
716 }
717 }),
718 quota: claude_quota(&v),
719 dropped: None,
722 }
723 }
724 AgentKind::Opencode => {
725 let mut text = String::new();
727 let mut session = None;
728 for line in stdout.lines() {
729 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
730 continue;
731 };
732 if session.is_none() {
733 session = v
734 .get("sessionID")
735 .and_then(|s| s.as_str())
736 .map(str::to_owned);
737 }
738 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
739 if part.get("type").and_then(|t| t.as_str()) == Some("text")
740 && let Some(t) = part.get("text").and_then(|t| t.as_str())
741 {
742 if !text.is_empty() {
743 text.push('\n');
744 }
745 text.push_str(t);
746 }
747 }
748 Extracted {
749 text,
750 session,
751 status: None,
752 quota: None,
753 dropped: None,
754 }
755 }
756 AgentKind::Antigravity => {
757 let obj = stdout
760 .lines()
761 .rev()
762 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
763 let Some(v) = obj else {
764 return Extracted {
765 text: stdout.trim().to_owned(),
766 ..Extracted::default()
767 };
768 };
769 Extracted {
770 text: v
771 .get("response")
772 .and_then(|r| r.as_str())
773 .unwrap_or_default()
774 .trim()
775 .to_owned(),
776 session: v
777 .get("conversation_id")
778 .and_then(|s| s.as_str())
779 .map(str::to_owned),
780 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
781 quota: None,
782 dropped: dropped_stream(&v),
783 }
784 }
785 AgentKind::Codex => {
786 let mut text = String::new();
797 let mut session = None;
798 let mut status = None;
799 for line in stdout.lines() {
800 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
801 continue;
802 };
803 match v.get("type").and_then(|t| t.as_str()) {
804 Some("thread.started") => {
805 session = v
806 .get("thread_id")
807 .and_then(|s| s.as_str())
808 .map(str::to_owned);
809 }
810 Some("item.completed") => {
811 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
812 if item.get("type").and_then(|t| t.as_str()) == Some("agent_message")
813 && let Some(t) = item.get("text").and_then(|t| t.as_str())
814 {
815 text = t.trim().to_owned();
816 }
817 }
818 Some("turn.completed") => status = Some("success".to_owned()),
819 Some("turn.failed") => status = Some("error".to_owned()),
820 _ => {}
821 }
822 }
823 Extracted {
824 text,
825 session,
826 status,
827 quota: None,
828 dropped: None,
829 }
830 }
831 AgentKind::Command => {
832 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
837 let quota = parsed.as_ref().and_then(claude_quota);
838 let dropped = parsed.as_ref().and_then(dropped_stream);
841 Extracted {
842 text: stdout.trim().to_owned(),
843 session: None,
844 status: None,
845 quota,
846 dropped,
847 }
848 }
849 }
850}
851
852fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
859 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
860 if !is_err {
861 return None;
862 }
863 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
864 if !result.to_lowercase().contains("session limit") {
865 return None;
866 }
867 let reset = result
870 .split("resets ")
871 .nth(1)
872 .map(str::trim)
873 .filter(|s| !s.is_empty())
874 .map(str::to_owned);
875 Some(Quota { reset })
876}
877
878fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
909 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
910 if !status.eq_ignore_ascii_case("error") {
911 return None;
912 }
913 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
914 if !response.trim().is_empty() {
915 return None;
917 }
918 let produced = v
919 .get("usage")
920 .and_then(|u| u.get("output_tokens"))
921 .and_then(serde_json::Value::as_u64)
922 .unwrap_or(0);
923 if produced == 0 {
924 return None;
926 }
927 Some(Dropped {
928 why: v
929 .get("error")
930 .and_then(|e| e.as_str())
931 .unwrap_or("the CLI ended the stream without delivering its answer")
932 .trim()
933 .to_owned(),
934 output_tokens: produced,
935 })
936}
937
938pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
940 let mut missing = Vec::new();
941 for s in specs {
942 let program = match s.kind {
943 AgentKind::Command => s.command.first().map(String::as_str),
944 other => other.program(),
945 };
946 if let Some(p) = program
947 && !crate::config::which(p)
948 && !Path::new(p).is_file()
949 && !missing.iter().any(|m: &String| m == p)
950 {
951 missing.push(p.to_owned());
952 }
953 }
954 missing
955}
956
957pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
959 run_dir.join("artifacts")
960}
961
962pub fn installed(spec: &AgentSpec) -> bool {
964 spec.kind.program().is_none_or(crate::config::which)
967}
968
969pub fn pick(
991 agents: &[AgentSpec],
992 want: Option<&str>,
993 available: &dyn Fn(&AgentSpec) -> bool,
994) -> Result<AgentSpec> {
995 if let Some(id) = want {
996 let spec = agents
997 .iter()
998 .find(|a| a.id == id)
999 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1000 if !available(spec) {
1001 bail!(
1002 "agent `{}` needs `{}` on PATH; install it or pass a different \
1003 --agent",
1004 spec.id,
1005 spec.kind.program().unwrap_or("its command")
1006 );
1007 }
1008 return Ok(spec.clone());
1009 }
1010
1011 if agents.is_empty() {
1012 bail!(
1013 "the agent roster is empty, so there is nobody to ask: install one \
1014 of claude, opencode or agy - magi derives a roster from what is on \
1015 PATH - or add an [[agents]] entry to magi.toml."
1016 );
1017 }
1018
1019 if let Some(spec) = agents
1020 .iter()
1021 .find(|a| a.kind == AgentKind::Claude && available(a))
1022 {
1023 return Ok(spec.clone());
1024 }
1025
1026 agents
1027 .iter()
1028 .find(|a| available(a))
1029 .cloned()
1030 .with_context(|| {
1031 let missing = agents
1032 .iter()
1033 .filter_map(|a| a.kind.program())
1034 .collect::<Vec<_>>()
1035 .join(", ");
1036 format!(
1037 "no agent in the roster can be run here: install one of \
1038 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1039 you do have"
1040 )
1041 })
1042}
1043
1044fn ids(agents: &[AgentSpec]) -> String {
1045 if agents.is_empty() {
1046 return "no agents at all".to_owned();
1047 }
1048 agents
1049 .iter()
1050 .map(|a| a.id.clone())
1051 .collect::<Vec<_>>()
1052 .join(", ")
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057 use super::*;
1058
1059 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1060 AgentSpec {
1061 id: "a".to_owned(),
1062 kind,
1063 model: model.map(str::to_owned),
1064 command: vec!["echo".to_owned(), "{label}".to_owned()],
1065 extra_args: Vec::new(),
1066 env: BTreeMap::new(),
1067 prompt_delivery: None,
1068 }
1069 }
1070
1071 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1072 Invocation {
1073 cwd,
1074 prompt: "do the thing",
1075 timeout: Duration::from_secs(900),
1076 allow_write,
1077 sessions: true,
1078 artifacts: art,
1079 stem: "t",
1080 run: "test-run",
1081 node: "test",
1082 cache_dir: None,
1083 attachments: &[],
1084 }
1085 }
1086
1087 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1088 build_command(
1089 &spec(kind, None),
1090 seat,
1091 &inv(Path::new("."), Path::new("/art"), allow_write),
1092 Path::new("/art/p.md"),
1093 )
1094 .unwrap()
1095 }
1096
1097 #[test]
1098 fn claude_mints_then_resumes_the_same_uuid() {
1099 let mut seat = SeatState::new("judge-1", "a", 7);
1100 let uuid = seat.claude_session.clone().unwrap();
1101 let first = plan_for(AgentKind::Claude, &seat, true);
1102 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1103 assert!(!first.argv.iter().any(|a| a == "--resume"));
1104
1105 seat.turns = 1;
1106 let second = plan_for(AgentKind::Claude, &seat, true);
1107 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1108 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1109 }
1110
1111 #[test]
1112 fn read_only_seats_cannot_edit() {
1113 let seat = SeatState::new("judge-1", "a", 7);
1114 let claude = plan_for(AgentKind::Claude, &seat, false);
1115 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1116 assert!(
1117 !plan_for(AgentKind::Claude, &seat, true)
1118 .argv
1119 .iter()
1120 .any(|a| a == "--disallowed-tools")
1121 );
1122
1123 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1124 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1125 assert!(
1126 !agy.argv
1127 .iter()
1128 .any(|a| a == "--dangerously-skip-permissions")
1129 );
1130 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1131 assert!(
1132 agy_rw
1133 .argv
1134 .windows(2)
1135 .any(|w| w == ["--mode", "accept-edits"])
1136 );
1137 assert!(
1138 agy_rw
1139 .argv
1140 .iter()
1141 .any(|a| a == "--dangerously-skip-permissions")
1142 );
1143 let agy_prompt = agy_rw
1148 .argv
1149 .iter()
1150 .position(|a| a == "-p")
1151 .map(|i| agy_rw.argv[i + 1].clone())
1152 .expect("agy takes its prompt with -p");
1153 assert!(
1154 agy_prompt.starts_with('@'),
1155 "agy must get a file reference, got {agy_prompt:?}"
1156 );
1157 assert!(
1158 !agy_prompt.contains("Read the file at"),
1159 "the prose pointer is for CLIs with no file syntax"
1160 );
1161
1162 for allow_write in [false, true] {
1167 assert!(
1168 plan_for(AgentKind::Opencode, &seat, allow_write)
1169 .argv
1170 .iter()
1171 .any(|a| a == "--auto"),
1172 "opencode needs --auto even to read (allow_write = {allow_write})"
1173 );
1174 }
1175 }
1176
1177 #[test]
1180 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1181 let mut seat = SeatState::new("judge-1", "a", 7);
1182
1183 let ro = plan_for(AgentKind::Codex, &seat, false);
1187 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1188 let rw = plan_for(AgentKind::Codex, &seat, true);
1189 assert!(
1190 rw.argv
1191 .windows(2)
1192 .any(|w| w == ["--sandbox", "workspace-write"])
1193 );
1194 for p in [&ro, &rw] {
1195 assert!(
1196 !p.argv
1197 .iter()
1198 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1199 "the bypass defeats the only enforced read-only mode we have"
1200 );
1201 assert!(
1203 p.argv
1204 .windows(2)
1205 .any(|w| w == ["-c", "approval_policy=\"never\""]),
1206 "an unattended seat that asks for approval blocks until timeout"
1207 );
1208 }
1209
1210 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1212 assert_eq!(
1213 ro.argv.last().map(String::as_str),
1214 Some("-"),
1215 "without the `-` argument codex waits for a prompt it never gets"
1216 );
1217
1218 seat.turns = 1;
1222 assert!(!has_session(AgentKind::Codex, &seat, true));
1223 assert!(
1224 !plan_for(AgentKind::Codex, &seat, true)
1225 .argv
1226 .iter()
1227 .any(|a| a == "resume")
1228 );
1229 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1230 let resumed = plan_for(AgentKind::Codex, &seat, true);
1231 let at = resumed
1232 .argv
1233 .iter()
1234 .position(|a| a == "resume")
1235 .expect("resumes by subcommand");
1236 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1237 assert!(
1238 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1239 "every option precedes the subcommand"
1240 );
1241 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1242 }
1243
1244 #[test]
1246 fn codex_takes_the_last_agent_message_and_the_thread_id() {
1247 let stream = concat!(
1248 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1249 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1250 "\n",
1251 r#"{"type":"turn.started"}"#,
1252 "\n",
1253 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1254 "\n",
1255 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1256 "\n",
1257 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1258 "\n",
1259 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1260 "\n",
1261 );
1262 let out = extract(AgentKind::Codex, stream);
1263 assert_eq!(
1264 out.text, "{\"verdict\": \"ok\"}",
1265 "the last agent message is the answer; earlier ones narrate"
1266 );
1267 assert_eq!(
1268 out.session.as_deref(),
1269 Some("01a07440-4545-7492-85c1-024e3259a90a")
1270 );
1271 assert_eq!(out.status.as_deref(), Some("success"));
1272
1273 let failed = concat!(
1274 r#"{"type":"thread.started","thread_id":"t1"}"#,
1275 "\n",
1276 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1277 "\n",
1278 );
1279 assert_eq!(
1280 extract(AgentKind::Codex, failed).status.as_deref(),
1281 Some("error")
1282 );
1283 }
1284
1285 #[test]
1286 fn captured_sessions_resume_only_once_reported() {
1287 let mut seat = SeatState::new("impl-A", "a", 7);
1288 seat.turns = 1;
1289 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1290 assert!(!has_session(kind, &seat, true));
1291 let p = plan_for(kind, &seat, true);
1292 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1293 }
1294
1295 seat.captured_session = Some("sid".to_owned());
1296 assert!(has_session(AgentKind::Opencode, &seat, true));
1297 assert!(
1298 plan_for(AgentKind::Opencode, &seat, true)
1299 .argv
1300 .windows(2)
1301 .any(|w| w == ["-s", "sid"])
1302 );
1303 assert!(
1304 plan_for(AgentKind::Antigravity, &seat, true)
1305 .argv
1306 .windows(2)
1307 .any(|w| w == ["--conversation", "sid"])
1308 );
1309 }
1310
1311 #[test]
1312 fn sessions_disabled_never_resumes() {
1313 let mut seat = SeatState::new("impl-A", "a", 7);
1314 seat.turns = 3;
1315 seat.captured_session = Some("sid".to_owned());
1316 for kind in [
1317 AgentKind::Claude,
1318 AgentKind::Opencode,
1319 AgentKind::Antigravity,
1320 ] {
1321 assert!(!has_session(kind, &seat, false));
1322 }
1323 }
1324
1325 #[test]
1326 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1327 let seat = SeatState::new("judge-1", "a", 7);
1328 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1329 let p = plan_for(kind, &seat, false);
1330 assert!(
1331 p.argv.iter().all(|a| a != "do the thing"),
1332 "{kind:?} put the prompt on the command line"
1333 );
1334 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1335 }
1336 let p = plan_for(AgentKind::Antigravity, &seat, false);
1338 let at = p.argv.iter().position(|a| a == "-p").unwrap();
1339 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1340 assert!(p.stdin.is_none());
1341 }
1342
1343 #[test]
1344 fn agy_print_timeout_tracks_the_node_budget() {
1345 let seat = SeatState::new("impl-A", "a", 7);
1346 let p = build_command(
1347 &spec(AgentKind::Antigravity, None),
1348 &seat,
1349 &Invocation {
1350 cwd: Path::new("."),
1351 prompt: "p",
1352 timeout: Duration::from_secs(3600),
1353 allow_write: true,
1354 sessions: true,
1355 artifacts: Path::new("/art"),
1356 stem: "t",
1357 run: "test-run",
1358 node: "test",
1359 cache_dir: None,
1360 attachments: &[],
1361 },
1362 Path::new("/art/p.md"),
1363 )
1364 .unwrap();
1365 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1366 }
1367
1368 #[test]
1375 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
1376 let mut s = spec(AgentKind::Antigravity, None);
1377 s.prompt_delivery = Some(Delivery::Argv);
1378 let seat = SeatState::new("talk", "a", 7);
1379 let atts = [PathBuf::from("/art/attachments/abc.png")];
1380
1381 let without = build_command(
1382 &s,
1383 &seat,
1384 &Invocation {
1385 attachments: &[],
1386 ..inv(Path::new("."), Path::new("/art"), true)
1387 },
1388 Path::new("/art/p.md"),
1389 )
1390 .unwrap();
1391 assert!(
1392 !without.argv.iter().any(|a| a == "--add-dir"),
1393 "no attachment, no reason to widen the sandbox: {without:?}"
1394 );
1395
1396 let with = build_command(
1397 &s,
1398 &seat,
1399 &Invocation {
1400 attachments: &atts,
1401 ..inv(Path::new("."), Path::new("/art"), true)
1402 },
1403 Path::new("/art/p.md"),
1404 )
1405 .unwrap();
1406 assert!(
1407 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1408 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
1409 );
1410 }
1411
1412 #[test]
1418 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
1419 let seat = SeatState::new("plan", "a", 7);
1420 let atts = [
1421 PathBuf::from("/art/attachments/own.png"),
1422 PathBuf::from("/other-chat/attachments/inherited.png"),
1423 ];
1424
1425 let p = build_command(
1426 &spec(AgentKind::Antigravity, None),
1427 &seat,
1428 &Invocation {
1429 attachments: &atts,
1430 ..inv(Path::new("."), Path::new("/art"), true)
1431 },
1432 Path::new("/art/p.md"),
1433 )
1434 .unwrap();
1435
1436 assert!(
1437 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1438 "this conversation's own artifacts dir must still be granted: {p:?}"
1439 );
1440 assert!(
1441 p.argv
1442 .windows(2)
1443 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
1444 "the inherited attachment's own directory must be granted too: {p:?}"
1445 );
1446 }
1447
1448 #[test]
1449 fn command_agents_get_placeholders_substituted() {
1450 let seat = SeatState::new("impl-A", "a", 7);
1451 let p = plan_for(AgentKind::Command, &seat, true);
1452 assert_eq!(p.argv[0], "echo");
1453 assert_eq!(p.argv[1], "impl-A");
1454 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1455 }
1456
1457 #[test]
1458 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1459 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1461 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1462 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1463 let out = extract(AgentKind::Claude, stdout);
1464 let quota = out.quota.as_ref().expect("rate limit must be detected");
1465 assert_eq!(
1466 quota.reset.as_deref(),
1467 Some("4:50am (Asia/Tokyo)"),
1468 "reset time read from the body"
1469 );
1470 }
1471
1472 #[test]
1473 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1474 let out = extract(
1475 AgentKind::Claude,
1476 r#"{"is_error":true,"result":"session limit reached"}"#,
1477 );
1478 let quota = out.quota.expect("rate limit detected without a reset");
1479 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1480 }
1481
1482 #[test]
1483 fn ordinary_failures_are_never_quota() {
1484 let claude_fail = extract(
1486 AgentKind::Claude,
1487 r#"{"is_error":true,"result":"account does not exist"}"#,
1488 );
1489 assert!(claude_fail.quota.is_none());
1490
1491 let cmd_fail = extract(AgentKind::Command, "boom");
1493 assert!(cmd_fail.quota.is_none());
1494
1495 let success = extract(
1497 AgentKind::Command,
1498 r#"{"is_error":false,"result":"session limit is fine"}"#,
1499 );
1500 assert!(success.quota.is_none());
1501 }
1502
1503 #[test]
1504 fn command_agent_can_carry_the_claude_quota_shape() {
1505 let out = extract(
1506 AgentKind::Command,
1507 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1508 );
1509 assert!(
1510 out.quota.is_some(),
1511 "a wrapper emitting the claude shape counts as quota"
1512 );
1513 }
1514
1515 #[test]
1516 fn claude_json_result_is_extracted() {
1517 let out = extract(
1518 AgentKind::Claude,
1519 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1520 );
1521 assert_eq!(out.text, "all done");
1522 assert_eq!(out.session.as_deref(), Some("abc"));
1523 assert_eq!(out.status.as_deref(), Some("success"));
1524 }
1525
1526 #[test]
1527 fn opencode_event_stream_is_concatenated() {
1528 let stream = concat!(
1529 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
1530 "\n",
1531 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
1532 "\n",
1533 "garbage line\n",
1534 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
1535 "\n"
1536 );
1537 let out = extract(AgentKind::Opencode, stream);
1538 assert_eq!(out.text, "first\nsecond");
1539 assert_eq!(out.session.as_deref(), Some("ses_1"));
1540 }
1541
1542 #[test]
1543 fn agy_json_survives_a_leading_warning_line() {
1544 let stdout = concat!(
1545 "warning: --mode plan has no effect while slash commands are disabled.\n",
1546 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1547 "\n"
1548 );
1549 let out = extract(AgentKind::Antigravity, stdout);
1550 assert_eq!(out.text, "persimmon");
1551 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1552 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1553 }
1554
1555 const AGY_DROPPED: &str = concat!(
1562 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
1563 r#""response":"","error":"the connection to the agent was interrupted before "#,
1564 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
1565 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
1566 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
1567 r#""total_tokens":274380}}"#
1568 );
1569
1570 #[test]
1571 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
1572 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
1573 let dropped = out.dropped.expect("recognised as undelivered work");
1574 assert_eq!(dropped.output_tokens, 14267);
1575 assert!(
1576 dropped.why.contains("subscriber fell behind"),
1577 "the CLI's own words are kept for the record: {}",
1578 dropped.why
1579 );
1580 assert_eq!(
1583 out.session.as_deref(),
1584 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
1585 );
1586 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
1587 }
1588
1589 #[test]
1590 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
1591 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
1595 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
1596
1597 let answered = concat!(
1600 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
1601 r#""usage":{"output_tokens":10}}"#
1602 );
1603 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
1604
1605 let ok = concat!(
1607 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
1608 r#""usage":{"output_tokens":10}}"#
1609 );
1610 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
1611 }
1612
1613 #[test]
1614 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
1615 let out = AgentOutput {
1616 text: String::new(),
1617 exit_code: Some(1),
1618 timed_out: false,
1619 duration_ms: 431_194,
1620 artifacts: Vec::new(),
1621 quota: None,
1622 dropped: Some(Dropped {
1623 why: "subscriber fell behind updates".to_owned(),
1624 output_tokens: 14267,
1625 }),
1626 };
1627 assert!(!out.usable());
1628 assert!(out.work_undelivered());
1629 assert!(!out.quota_exhausted());
1632 }
1633
1634 #[test]
1635 fn non_json_stdout_falls_back_to_raw_text() {
1636 let out = extract(AgentKind::Antigravity, "plain answer\n");
1637 assert_eq!(out.text, "plain answer");
1638 assert!(out.session.is_none());
1639 }
1640
1641 #[tokio::test]
1642 async fn command_agent_round_trip_writes_artifacts() {
1643 let dir = tempfile::tempdir().unwrap();
1644 let art = dir.path().join("artifacts");
1645 let mut seat = SeatState::new("impl-A", "a", 7);
1646 let mut s = spec(AgentKind::Command, None);
1647 s.command = vec!["echo".to_owned(), "hello {label}".to_owned()];
1648 let out = invoke(
1649 &s,
1650 &mut seat,
1651 &Invocation {
1652 cwd: dir.path(),
1653 prompt: "unused",
1654 timeout: Duration::from_secs(30),
1655 allow_write: true,
1656 sessions: true,
1657 artifacts: &art,
1658 stem: "impl-A",
1659 run: "test-run",
1660 node: "test",
1661 cache_dir: None,
1662 attachments: &[],
1663 },
1664 )
1665 .await
1666 .unwrap();
1667 assert!(out.usable(), "{out:?}");
1668 assert!(out.text.contains("hello impl-A"), "{}", out.text);
1669 assert_eq!(seat.turns, 1);
1670 assert!(art.join("impl-A.prompt.md").is_file());
1671 assert!(art.join("impl-A.out").is_file());
1672 }
1673
1674 #[tokio::test]
1675 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
1676 let dir = tempfile::tempdir().unwrap();
1680 let cache = dir.path().join("magi-cache");
1681 let mut seat = SeatState::new("impl-A", "a", 7);
1682 let mut s = spec(AgentKind::Command, None);
1683 if cfg!(windows) {
1684 s.command = vec![
1685 "cmd".to_owned(),
1686 "/C".to_owned(),
1687 "echo %CARGO_TARGET_DIR%".to_owned(),
1688 ];
1689 } else {
1690 s.command = vec![
1691 "sh".to_owned(),
1692 "-c".to_owned(),
1693 "echo $CARGO_TARGET_DIR".to_owned(),
1694 ];
1695 }
1696 let out = invoke(
1697 &s,
1698 &mut seat,
1699 &Invocation {
1700 cwd: dir.path(),
1701 prompt: "unused",
1702 timeout: Duration::from_secs(30),
1703 allow_write: true,
1704 sessions: true,
1705 artifacts: &dir.path().join("artifacts"),
1706 stem: "cache",
1707 run: "test-run",
1708 node: "test",
1709 cache_dir: Some(&cache),
1710 attachments: &[],
1711 },
1712 )
1713 .await
1714 .unwrap();
1715 assert!(out.usable(), "{out:?}");
1716 assert_eq!(
1717 out.text.trim(),
1718 cache.to_string_lossy(),
1719 "the seat must see CARGO_TARGET_DIR = the shared cache"
1720 );
1721 }
1722
1723 #[tokio::test]
1724 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1725 let dir = tempfile::tempdir().unwrap();
1726 let mut seat = SeatState::new("impl-A", "a", 7);
1727 let mut s = spec(AgentKind::Command, None);
1728 s.command = vec!["echo".to_owned(), "done".to_owned()];
1731 let big = "x".repeat(1_000_000);
1732 let out = invoke(
1733 &s,
1734 &mut seat,
1735 &Invocation {
1736 cwd: dir.path(),
1737 prompt: &big,
1738 timeout: Duration::from_secs(60),
1739 allow_write: true,
1740 sessions: true,
1741 artifacts: &dir.path().join("artifacts"),
1742 stem: "big",
1743 run: "test-run",
1744 node: "test",
1745 cache_dir: None,
1746 attachments: &[],
1747 },
1748 )
1749 .await
1750 .unwrap();
1751 assert!(out.usable(), "{out:?}");
1752 assert_eq!(out.text, "done");
1753 }
1754
1755 #[tokio::test]
1756 async fn timeout_is_reported_not_hung() {
1757 let dir = tempfile::tempdir().unwrap();
1758 let mut seat = SeatState::new("impl-A", "a", 7);
1759 let mut s = spec(AgentKind::Command, None);
1760 s.command = vec!["sleep".to_owned(), "30".to_owned()];
1761 let out = invoke(
1762 &s,
1763 &mut seat,
1764 &Invocation {
1765 cwd: dir.path(),
1766 prompt: "unused",
1767 timeout: Duration::from_millis(300),
1768 allow_write: true,
1769 sessions: true,
1770 artifacts: &dir.path().join("artifacts"),
1771 stem: "slow",
1772 run: "test-run",
1773 node: "test",
1774 cache_dir: None,
1775 attachments: &[],
1776 },
1777 )
1778 .await
1779 .unwrap();
1780 assert!(out.timed_out);
1781 assert!(!out.usable());
1782 }
1783
1784 #[tokio::test]
1785 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1786 let dir = tempfile::tempdir().unwrap();
1792 let artifacts = dir.path().join("artifacts");
1793 let mut seat = SeatState::new("impl-A", "a", 7);
1794 let mut s = spec(AgentKind::Command, None);
1795 s.command = vec![
1796 "sh".to_owned(),
1797 "-c".to_owned(),
1798 "echo i-said-something; sleep 30".to_owned(),
1799 ];
1800 let out = invoke(
1801 &s,
1802 &mut seat,
1803 &Invocation {
1804 cwd: dir.path(),
1805 prompt: "unused",
1806 timeout: Duration::from_secs(10),
1811 allow_write: true,
1812 sessions: true,
1813 artifacts: &artifacts,
1814 stem: "chatty",
1815 run: "test-run",
1816 node: "test",
1817 cache_dir: None,
1818 attachments: &[],
1819 },
1820 )
1821 .await
1822 .unwrap();
1823
1824 assert!(out.timed_out, "{out:?}");
1825 assert!(!out.usable(), "a cut-off answer is still not an answer");
1826 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1827 assert!(
1828 recorded.contains("i-said-something"),
1829 "the artifact must keep what arrived before the kill, got {recorded:?}"
1830 );
1831 assert!(
1832 out.text.contains("i-said-something"),
1833 "and the graph must be able to see it too, got {:?}",
1834 out.text
1835 );
1836 }
1837
1838 #[test]
1839 fn missing_programs_reports_command_binaries() {
1840 let mut s = spec(AgentKind::Command, None);
1841 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1842 assert_eq!(
1843 missing_programs(&[s]),
1844 ["definitely-not-a-real-binary-xyz".to_owned()]
1845 );
1846 }
1847
1848 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
1849 AgentSpec {
1850 id: id.to_owned(),
1851 kind,
1852 model: None,
1853 command: Vec::new(),
1854 extra_args: Vec::new(),
1855 env: BTreeMap::new(),
1856 prompt_delivery: None,
1857 }
1858 }
1859
1860 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
1863 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
1864 }
1865
1866 #[test]
1867 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
1868 let agents = [
1869 pick_spec("oc", AgentKind::Opencode),
1870 pick_spec("opus", AgentKind::Claude),
1871 pick_spec("agy", AgentKind::Antigravity),
1872 ];
1873 let got = pick(&agents, None, &without(&[])).expect("a pick");
1874 assert_eq!(got.id, "opus");
1875 }
1876
1877 #[test]
1878 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
1879 let agents = [
1880 pick_spec("opus", AgentKind::Claude),
1881 pick_spec("oc", AgentKind::Opencode),
1882 pick_spec("agy", AgentKind::Antigravity),
1883 ];
1884 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
1885 assert_eq!(got.id, "agy");
1886 }
1887
1888 #[test]
1889 fn pick_on_an_empty_roster_says_what_to_install() {
1890 let msg = pick(&[], None, &without(&[]))
1891 .expect_err("nobody to ask")
1892 .to_string();
1893 assert!(msg.contains("roster is empty"), "{msg}");
1894 assert!(msg.contains("claude"), "{msg}");
1895 assert!(msg.contains("magi.toml"), "{msg}");
1896 }
1897
1898 #[test]
1899 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
1900 let agents = [
1901 pick_spec("opus", AgentKind::Claude),
1902 pick_spec("oc", AgentKind::Opencode),
1903 ];
1904 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
1905 let msg = format!("{err:#}");
1906 assert!(msg.contains("claude"), "{msg}");
1907 assert!(msg.contains("opencode"), "{msg}");
1908 }
1909
1910 #[test]
1911 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
1912 let agents = [
1913 pick_spec("opus", AgentKind::Claude),
1914 pick_spec("oc", AgentKind::Opencode),
1915 ];
1916 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
1917 assert_eq!(got.id, "oc");
1918 }
1919
1920 #[test]
1921 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
1922 let agents = [
1923 pick_spec("opus", AgentKind::Claude),
1924 pick_spec("oc", AgentKind::Opencode),
1925 ];
1926 let msg = pick(&agents, Some("gemini"), &without(&[]))
1927 .expect_err("no such agent")
1928 .to_string();
1929 assert!(msg.contains("gemini"), "{msg}");
1930 assert!(msg.contains("opus, oc"), "{msg}");
1931 }
1932
1933 #[test]
1934 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
1935 let agents = [
1936 pick_spec("opus", AgentKind::Claude),
1937 pick_spec("oc", AgentKind::Opencode),
1938 ];
1939 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
1940 .expect_err("must not silently substitute another model")
1941 .to_string();
1942 assert!(msg.contains("opencode"), "{msg}");
1943 assert!(msg.contains("--agent"), "{msg}");
1944 }
1945}