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 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1060
1061 fn command_helper(mode: &str) -> AgentSpec {
1064 AgentSpec {
1065 id: "helper".to_owned(),
1066 kind: AgentKind::Command,
1067 model: None,
1068 command: vec![
1069 std::env::current_exe()
1070 .expect("locate test helper")
1071 .to_string_lossy()
1072 .into_owned(),
1073 "--exact".to_owned(),
1074 "agent::tests::command_agent_test_helper".to_owned(),
1075 "--nocapture".to_owned(),
1076 ],
1077 extra_args: Vec::new(),
1078 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1079 prompt_delivery: None,
1080 }
1081 }
1082
1083 #[test]
1084 fn command_agent_test_helper() {
1085 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1086 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1087 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1088 Ok("ignore-stdin") => println!("done"),
1089 Ok("chatty-sleep") => {
1090 println!("i-said-something");
1091 std::thread::sleep(Duration::from_secs(30));
1092 }
1093 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1094 Ok(other) => panic!("unknown command helper mode {other}"),
1095 Err(_) => {}
1096 }
1097 }
1098
1099 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1100 AgentSpec {
1101 id: "a".to_owned(),
1102 kind,
1103 model: model.map(str::to_owned),
1104 command: vec!["echo".to_owned(), "{label}".to_owned()],
1105 extra_args: Vec::new(),
1106 env: BTreeMap::new(),
1107 prompt_delivery: None,
1108 }
1109 }
1110
1111 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1112 Invocation {
1113 cwd,
1114 prompt: "do the thing",
1115 timeout: Duration::from_secs(900),
1116 allow_write,
1117 sessions: true,
1118 artifacts: art,
1119 stem: "t",
1120 run: "test-run",
1121 node: "test",
1122 cache_dir: None,
1123 attachments: &[],
1124 }
1125 }
1126
1127 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1128 build_command(
1129 &spec(kind, None),
1130 seat,
1131 &inv(Path::new("."), Path::new("/art"), allow_write),
1132 Path::new("/art/p.md"),
1133 )
1134 .unwrap()
1135 }
1136
1137 #[test]
1138 fn claude_mints_then_resumes_the_same_uuid() {
1139 let mut seat = SeatState::new("judge-1", "a", 7);
1140 let uuid = seat.claude_session.clone().unwrap();
1141 let first = plan_for(AgentKind::Claude, &seat, true);
1142 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1143 assert!(!first.argv.iter().any(|a| a == "--resume"));
1144
1145 seat.turns = 1;
1146 let second = plan_for(AgentKind::Claude, &seat, true);
1147 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1148 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1149 }
1150
1151 #[test]
1152 fn read_only_seats_cannot_edit() {
1153 let seat = SeatState::new("judge-1", "a", 7);
1154 let claude = plan_for(AgentKind::Claude, &seat, false);
1155 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1156 assert!(
1157 !plan_for(AgentKind::Claude, &seat, true)
1158 .argv
1159 .iter()
1160 .any(|a| a == "--disallowed-tools")
1161 );
1162
1163 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1164 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1165 assert!(
1166 !agy.argv
1167 .iter()
1168 .any(|a| a == "--dangerously-skip-permissions")
1169 );
1170 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1171 assert!(
1172 agy_rw
1173 .argv
1174 .windows(2)
1175 .any(|w| w == ["--mode", "accept-edits"])
1176 );
1177 assert!(
1178 agy_rw
1179 .argv
1180 .iter()
1181 .any(|a| a == "--dangerously-skip-permissions")
1182 );
1183 let agy_prompt = agy_rw
1188 .argv
1189 .iter()
1190 .position(|a| a == "-p")
1191 .map(|i| agy_rw.argv[i + 1].clone())
1192 .expect("agy takes its prompt with -p");
1193 assert!(
1194 agy_prompt.starts_with('@'),
1195 "agy must get a file reference, got {agy_prompt:?}"
1196 );
1197 assert!(
1198 !agy_prompt.contains("Read the file at"),
1199 "the prose pointer is for CLIs with no file syntax"
1200 );
1201
1202 for allow_write in [false, true] {
1207 assert!(
1208 plan_for(AgentKind::Opencode, &seat, allow_write)
1209 .argv
1210 .iter()
1211 .any(|a| a == "--auto"),
1212 "opencode needs --auto even to read (allow_write = {allow_write})"
1213 );
1214 }
1215 }
1216
1217 #[test]
1220 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1221 let mut seat = SeatState::new("judge-1", "a", 7);
1222
1223 let ro = plan_for(AgentKind::Codex, &seat, false);
1227 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1228 let rw = plan_for(AgentKind::Codex, &seat, true);
1229 assert!(
1230 rw.argv
1231 .windows(2)
1232 .any(|w| w == ["--sandbox", "workspace-write"])
1233 );
1234 for p in [&ro, &rw] {
1235 assert!(
1236 !p.argv
1237 .iter()
1238 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1239 "the bypass defeats the only enforced read-only mode we have"
1240 );
1241 assert!(
1243 p.argv
1244 .windows(2)
1245 .any(|w| w == ["-c", "approval_policy=\"never\""]),
1246 "an unattended seat that asks for approval blocks until timeout"
1247 );
1248 }
1249
1250 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1252 assert_eq!(
1253 ro.argv.last().map(String::as_str),
1254 Some("-"),
1255 "without the `-` argument codex waits for a prompt it never gets"
1256 );
1257
1258 seat.turns = 1;
1262 assert!(!has_session(AgentKind::Codex, &seat, true));
1263 assert!(
1264 !plan_for(AgentKind::Codex, &seat, true)
1265 .argv
1266 .iter()
1267 .any(|a| a == "resume")
1268 );
1269 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1270 let resumed = plan_for(AgentKind::Codex, &seat, true);
1271 let at = resumed
1272 .argv
1273 .iter()
1274 .position(|a| a == "resume")
1275 .expect("resumes by subcommand");
1276 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1277 assert!(
1278 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1279 "every option precedes the subcommand"
1280 );
1281 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1282 }
1283
1284 #[test]
1286 fn codex_takes_the_last_agent_message_and_the_thread_id() {
1287 let stream = concat!(
1288 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1289 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1290 "\n",
1291 r#"{"type":"turn.started"}"#,
1292 "\n",
1293 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1294 "\n",
1295 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1296 "\n",
1297 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1298 "\n",
1299 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1300 "\n",
1301 );
1302 let out = extract(AgentKind::Codex, stream);
1303 assert_eq!(
1304 out.text, "{\"verdict\": \"ok\"}",
1305 "the last agent message is the answer; earlier ones narrate"
1306 );
1307 assert_eq!(
1308 out.session.as_deref(),
1309 Some("01a07440-4545-7492-85c1-024e3259a90a")
1310 );
1311 assert_eq!(out.status.as_deref(), Some("success"));
1312
1313 let failed = concat!(
1314 r#"{"type":"thread.started","thread_id":"t1"}"#,
1315 "\n",
1316 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1317 "\n",
1318 );
1319 assert_eq!(
1320 extract(AgentKind::Codex, failed).status.as_deref(),
1321 Some("error")
1322 );
1323 }
1324
1325 #[test]
1326 fn captured_sessions_resume_only_once_reported() {
1327 let mut seat = SeatState::new("impl-A", "a", 7);
1328 seat.turns = 1;
1329 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1330 assert!(!has_session(kind, &seat, true));
1331 let p = plan_for(kind, &seat, true);
1332 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1333 }
1334
1335 seat.captured_session = Some("sid".to_owned());
1336 assert!(has_session(AgentKind::Opencode, &seat, true));
1337 assert!(
1338 plan_for(AgentKind::Opencode, &seat, true)
1339 .argv
1340 .windows(2)
1341 .any(|w| w == ["-s", "sid"])
1342 );
1343 assert!(
1344 plan_for(AgentKind::Antigravity, &seat, true)
1345 .argv
1346 .windows(2)
1347 .any(|w| w == ["--conversation", "sid"])
1348 );
1349 }
1350
1351 #[test]
1352 fn sessions_disabled_never_resumes() {
1353 let mut seat = SeatState::new("impl-A", "a", 7);
1354 seat.turns = 3;
1355 seat.captured_session = Some("sid".to_owned());
1356 for kind in [
1357 AgentKind::Claude,
1358 AgentKind::Opencode,
1359 AgentKind::Antigravity,
1360 ] {
1361 assert!(!has_session(kind, &seat, false));
1362 }
1363 }
1364
1365 #[test]
1366 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1367 let seat = SeatState::new("judge-1", "a", 7);
1368 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1369 let p = plan_for(kind, &seat, false);
1370 assert!(
1371 p.argv.iter().all(|a| a != "do the thing"),
1372 "{kind:?} put the prompt on the command line"
1373 );
1374 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1375 }
1376 let p = plan_for(AgentKind::Antigravity, &seat, false);
1378 let at = p.argv.iter().position(|a| a == "-p").unwrap();
1379 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1380 assert!(p.stdin.is_none());
1381 }
1382
1383 #[test]
1384 fn agy_print_timeout_tracks_the_node_budget() {
1385 let seat = SeatState::new("impl-A", "a", 7);
1386 let p = build_command(
1387 &spec(AgentKind::Antigravity, None),
1388 &seat,
1389 &Invocation {
1390 cwd: Path::new("."),
1391 prompt: "p",
1392 timeout: Duration::from_secs(3600),
1393 allow_write: true,
1394 sessions: true,
1395 artifacts: Path::new("/art"),
1396 stem: "t",
1397 run: "test-run",
1398 node: "test",
1399 cache_dir: None,
1400 attachments: &[],
1401 },
1402 Path::new("/art/p.md"),
1403 )
1404 .unwrap();
1405 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1406 }
1407
1408 #[test]
1415 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
1416 let mut s = spec(AgentKind::Antigravity, None);
1417 s.prompt_delivery = Some(Delivery::Argv);
1418 let seat = SeatState::new("talk", "a", 7);
1419 let atts = [PathBuf::from("/art/attachments/abc.png")];
1420
1421 let without = build_command(
1422 &s,
1423 &seat,
1424 &Invocation {
1425 attachments: &[],
1426 ..inv(Path::new("."), Path::new("/art"), true)
1427 },
1428 Path::new("/art/p.md"),
1429 )
1430 .unwrap();
1431 assert!(
1432 !without.argv.iter().any(|a| a == "--add-dir"),
1433 "no attachment, no reason to widen the sandbox: {without:?}"
1434 );
1435
1436 let with = build_command(
1437 &s,
1438 &seat,
1439 &Invocation {
1440 attachments: &atts,
1441 ..inv(Path::new("."), Path::new("/art"), true)
1442 },
1443 Path::new("/art/p.md"),
1444 )
1445 .unwrap();
1446 assert!(
1447 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1448 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
1449 );
1450 }
1451
1452 #[test]
1458 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
1459 let seat = SeatState::new("plan", "a", 7);
1460 let atts = [
1461 PathBuf::from("/art/attachments/own.png"),
1462 PathBuf::from("/other-chat/attachments/inherited.png"),
1463 ];
1464
1465 let p = build_command(
1466 &spec(AgentKind::Antigravity, None),
1467 &seat,
1468 &Invocation {
1469 attachments: &atts,
1470 ..inv(Path::new("."), Path::new("/art"), true)
1471 },
1472 Path::new("/art/p.md"),
1473 )
1474 .unwrap();
1475
1476 assert!(
1477 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1478 "this conversation's own artifacts dir must still be granted: {p:?}"
1479 );
1480 assert!(
1481 p.argv
1482 .windows(2)
1483 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
1484 "the inherited attachment's own directory must be granted too: {p:?}"
1485 );
1486 }
1487
1488 #[test]
1489 fn command_agents_get_placeholders_substituted() {
1490 let seat = SeatState::new("impl-A", "a", 7);
1491 let p = plan_for(AgentKind::Command, &seat, true);
1492 assert_eq!(p.argv[0], "echo");
1493 assert_eq!(p.argv[1], "impl-A");
1494 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1495 }
1496
1497 #[test]
1498 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1499 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1501 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1502 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1503 let out = extract(AgentKind::Claude, stdout);
1504 let quota = out.quota.as_ref().expect("rate limit must be detected");
1505 assert_eq!(
1506 quota.reset.as_deref(),
1507 Some("4:50am (Asia/Tokyo)"),
1508 "reset time read from the body"
1509 );
1510 }
1511
1512 #[test]
1513 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1514 let out = extract(
1515 AgentKind::Claude,
1516 r#"{"is_error":true,"result":"session limit reached"}"#,
1517 );
1518 let quota = out.quota.expect("rate limit detected without a reset");
1519 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1520 }
1521
1522 #[test]
1523 fn ordinary_failures_are_never_quota() {
1524 let claude_fail = extract(
1526 AgentKind::Claude,
1527 r#"{"is_error":true,"result":"account does not exist"}"#,
1528 );
1529 assert!(claude_fail.quota.is_none());
1530
1531 let cmd_fail = extract(AgentKind::Command, "boom");
1533 assert!(cmd_fail.quota.is_none());
1534
1535 let success = extract(
1537 AgentKind::Command,
1538 r#"{"is_error":false,"result":"session limit is fine"}"#,
1539 );
1540 assert!(success.quota.is_none());
1541 }
1542
1543 #[test]
1544 fn command_agent_can_carry_the_claude_quota_shape() {
1545 let out = extract(
1546 AgentKind::Command,
1547 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1548 );
1549 assert!(
1550 out.quota.is_some(),
1551 "a wrapper emitting the claude shape counts as quota"
1552 );
1553 }
1554
1555 #[test]
1556 fn claude_json_result_is_extracted() {
1557 let out = extract(
1558 AgentKind::Claude,
1559 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1560 );
1561 assert_eq!(out.text, "all done");
1562 assert_eq!(out.session.as_deref(), Some("abc"));
1563 assert_eq!(out.status.as_deref(), Some("success"));
1564 }
1565
1566 #[test]
1567 fn opencode_event_stream_is_concatenated() {
1568 let stream = concat!(
1569 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
1570 "\n",
1571 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
1572 "\n",
1573 "garbage line\n",
1574 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
1575 "\n"
1576 );
1577 let out = extract(AgentKind::Opencode, stream);
1578 assert_eq!(out.text, "first\nsecond");
1579 assert_eq!(out.session.as_deref(), Some("ses_1"));
1580 }
1581
1582 #[test]
1583 fn agy_json_survives_a_leading_warning_line() {
1584 let stdout = concat!(
1585 "warning: --mode plan has no effect while slash commands are disabled.\n",
1586 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1587 "\n"
1588 );
1589 let out = extract(AgentKind::Antigravity, stdout);
1590 assert_eq!(out.text, "persimmon");
1591 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1592 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1593 }
1594
1595 const AGY_DROPPED: &str = concat!(
1602 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
1603 r#""response":"","error":"the connection to the agent was interrupted before "#,
1604 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
1605 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
1606 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
1607 r#""total_tokens":274380}}"#
1608 );
1609
1610 #[test]
1611 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
1612 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
1613 let dropped = out.dropped.expect("recognised as undelivered work");
1614 assert_eq!(dropped.output_tokens, 14267);
1615 assert!(
1616 dropped.why.contains("subscriber fell behind"),
1617 "the CLI's own words are kept for the record: {}",
1618 dropped.why
1619 );
1620 assert_eq!(
1623 out.session.as_deref(),
1624 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
1625 );
1626 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
1627 }
1628
1629 #[test]
1630 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
1631 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
1635 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
1636
1637 let answered = concat!(
1640 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
1641 r#""usage":{"output_tokens":10}}"#
1642 );
1643 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
1644
1645 let ok = concat!(
1647 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
1648 r#""usage":{"output_tokens":10}}"#
1649 );
1650 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
1651 }
1652
1653 #[test]
1654 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
1655 let out = AgentOutput {
1656 text: String::new(),
1657 exit_code: Some(1),
1658 timed_out: false,
1659 duration_ms: 431_194,
1660 artifacts: Vec::new(),
1661 quota: None,
1662 dropped: Some(Dropped {
1663 why: "subscriber fell behind updates".to_owned(),
1664 output_tokens: 14267,
1665 }),
1666 };
1667 assert!(!out.usable());
1668 assert!(out.work_undelivered());
1669 assert!(!out.quota_exhausted());
1672 }
1673
1674 #[test]
1675 fn non_json_stdout_falls_back_to_raw_text() {
1676 let out = extract(AgentKind::Antigravity, "plain answer\n");
1677 assert_eq!(out.text, "plain answer");
1678 assert!(out.session.is_none());
1679 }
1680
1681 #[tokio::test]
1682 async fn command_agent_round_trip_writes_artifacts() {
1683 let dir = tempfile::tempdir().unwrap();
1684 let art = dir.path().join("artifacts");
1685 let mut seat = SeatState::new("impl-A", "a", 7);
1686 let s = command_helper("reply");
1687 let out = invoke(
1688 &s,
1689 &mut seat,
1690 &Invocation {
1691 cwd: dir.path(),
1692 prompt: "unused",
1693 timeout: Duration::from_secs(30),
1694 allow_write: true,
1695 sessions: true,
1696 artifacts: &art,
1697 stem: "impl-A",
1698 run: "test-run",
1699 node: "test",
1700 cache_dir: None,
1701 attachments: &[],
1702 },
1703 )
1704 .await
1705 .unwrap();
1706 assert!(out.usable(), "{out:?}");
1707 assert!(out.text.contains("hello impl-A"), "{}", out.text);
1708 assert_eq!(seat.turns, 1);
1709 assert!(art.join("impl-A.prompt.md").is_file());
1710 assert!(art.join("impl-A.out").is_file());
1711 }
1712
1713 #[tokio::test]
1714 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
1715 let dir = tempfile::tempdir().unwrap();
1719 let cache = dir.path().join("magi-cache");
1720 let mut seat = SeatState::new("impl-A", "a", 7);
1721 let s = command_helper("cache");
1722 let out = invoke(
1723 &s,
1724 &mut seat,
1725 &Invocation {
1726 cwd: dir.path(),
1727 prompt: "unused",
1728 timeout: Duration::from_secs(30),
1729 allow_write: true,
1730 sessions: true,
1731 artifacts: &dir.path().join("artifacts"),
1732 stem: "cache",
1733 run: "test-run",
1734 node: "test",
1735 cache_dir: Some(&cache),
1736 attachments: &[],
1737 },
1738 )
1739 .await
1740 .unwrap();
1741 assert!(out.usable(), "{out:?}");
1742 assert!(
1743 out.text.contains(cache.to_string_lossy().as_ref()),
1744 "the seat must see CARGO_TARGET_DIR = the shared cache"
1745 );
1746 }
1747
1748 #[tokio::test]
1749 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1750 let dir = tempfile::tempdir().unwrap();
1751 let mut seat = SeatState::new("impl-A", "a", 7);
1752 let s = command_helper("ignore-stdin");
1755 let big = "x".repeat(1_000_000);
1756 let out = invoke(
1757 &s,
1758 &mut seat,
1759 &Invocation {
1760 cwd: dir.path(),
1761 prompt: &big,
1762 timeout: Duration::from_secs(60),
1763 allow_write: true,
1764 sessions: true,
1765 artifacts: &dir.path().join("artifacts"),
1766 stem: "big",
1767 run: "test-run",
1768 node: "test",
1769 cache_dir: None,
1770 attachments: &[],
1771 },
1772 )
1773 .await
1774 .unwrap();
1775 assert!(out.usable(), "{out:?}");
1776 assert!(out.text.contains("done"), "{}", out.text);
1777 }
1778
1779 #[tokio::test]
1780 async fn timeout_is_reported_not_hung() {
1781 let dir = tempfile::tempdir().unwrap();
1782 let mut seat = SeatState::new("impl-A", "a", 7);
1783 let s = command_helper("sleep");
1784 let out = invoke(
1785 &s,
1786 &mut seat,
1787 &Invocation {
1788 cwd: dir.path(),
1789 prompt: "unused",
1790 timeout: Duration::from_millis(300),
1791 allow_write: true,
1792 sessions: true,
1793 artifacts: &dir.path().join("artifacts"),
1794 stem: "slow",
1795 run: "test-run",
1796 node: "test",
1797 cache_dir: None,
1798 attachments: &[],
1799 },
1800 )
1801 .await
1802 .unwrap();
1803 assert!(out.timed_out);
1804 assert!(!out.usable());
1805 }
1806
1807 #[tokio::test]
1808 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
1809 let dir = tempfile::tempdir().unwrap();
1815 let artifacts = dir.path().join("artifacts");
1816 let mut seat = SeatState::new("impl-A", "a", 7);
1817 let s = command_helper("chatty-sleep");
1818 let out = invoke(
1819 &s,
1820 &mut seat,
1821 &Invocation {
1822 cwd: dir.path(),
1823 prompt: "unused",
1824 timeout: Duration::from_secs(10),
1829 allow_write: true,
1830 sessions: true,
1831 artifacts: &artifacts,
1832 stem: "chatty",
1833 run: "test-run",
1834 node: "test",
1835 cache_dir: None,
1836 attachments: &[],
1837 },
1838 )
1839 .await
1840 .unwrap();
1841
1842 assert!(out.timed_out, "{out:?}");
1843 assert!(!out.usable(), "a cut-off answer is still not an answer");
1844 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
1845 assert!(
1846 recorded.contains("i-said-something"),
1847 "the artifact must keep what arrived before the kill, got {recorded:?}"
1848 );
1849 assert!(
1850 out.text.contains("i-said-something"),
1851 "and the graph must be able to see it too, got {:?}",
1852 out.text
1853 );
1854 }
1855
1856 #[test]
1857 fn missing_programs_reports_command_binaries() {
1858 let mut s = spec(AgentKind::Command, None);
1859 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
1860 assert_eq!(
1861 missing_programs(&[s]),
1862 ["definitely-not-a-real-binary-xyz".to_owned()]
1863 );
1864 }
1865
1866 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
1867 AgentSpec {
1868 id: id.to_owned(),
1869 kind,
1870 model: None,
1871 command: Vec::new(),
1872 extra_args: Vec::new(),
1873 env: BTreeMap::new(),
1874 prompt_delivery: None,
1875 }
1876 }
1877
1878 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
1881 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
1882 }
1883
1884 #[test]
1885 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
1886 let agents = [
1887 pick_spec("oc", AgentKind::Opencode),
1888 pick_spec("opus", AgentKind::Claude),
1889 pick_spec("agy", AgentKind::Antigravity),
1890 ];
1891 let got = pick(&agents, None, &without(&[])).expect("a pick");
1892 assert_eq!(got.id, "opus");
1893 }
1894
1895 #[test]
1896 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
1897 let agents = [
1898 pick_spec("opus", AgentKind::Claude),
1899 pick_spec("oc", AgentKind::Opencode),
1900 pick_spec("agy", AgentKind::Antigravity),
1901 ];
1902 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
1903 assert_eq!(got.id, "agy");
1904 }
1905
1906 #[test]
1907 fn pick_on_an_empty_roster_says_what_to_install() {
1908 let msg = pick(&[], None, &without(&[]))
1909 .expect_err("nobody to ask")
1910 .to_string();
1911 assert!(msg.contains("roster is empty"), "{msg}");
1912 assert!(msg.contains("claude"), "{msg}");
1913 assert!(msg.contains("magi.toml"), "{msg}");
1914 }
1915
1916 #[test]
1917 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
1918 let agents = [
1919 pick_spec("opus", AgentKind::Claude),
1920 pick_spec("oc", AgentKind::Opencode),
1921 ];
1922 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
1923 let msg = format!("{err:#}");
1924 assert!(msg.contains("claude"), "{msg}");
1925 assert!(msg.contains("opencode"), "{msg}");
1926 }
1927
1928 #[test]
1929 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
1930 let agents = [
1931 pick_spec("opus", AgentKind::Claude),
1932 pick_spec("oc", AgentKind::Opencode),
1933 ];
1934 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
1935 assert_eq!(got.id, "oc");
1936 }
1937
1938 #[test]
1939 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
1940 let agents = [
1941 pick_spec("opus", AgentKind::Claude),
1942 pick_spec("oc", AgentKind::Opencode),
1943 ];
1944 let msg = pick(&agents, Some("gemini"), &without(&[]))
1945 .expect_err("no such agent")
1946 .to_string();
1947 assert!(msg.contains("gemini"), "{msg}");
1948 assert!(msg.contains("opus, oc"), "{msg}");
1949 }
1950
1951 #[test]
1952 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
1953 let agents = [
1954 pick_spec("opus", AgentKind::Claude),
1955 pick_spec("oc", AgentKind::Opencode),
1956 ];
1957 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
1958 .expect_err("must not silently substitute another model")
1959 .to_string();
1960 assert!(msg.contains("opencode"), "{msg}");
1961 assert!(msg.contains("--agent"), "{msg}");
1962 }
1963}