1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::time::{Duration, Instant};
36
37use anyhow::{Context as _, Result, bail};
38use serde::{Deserialize, Serialize};
39use std::sync::{Arc, Mutex};
40
41use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
42use tokio::process::Command;
43
44use crate::config::{AgentKind, AgentSpec, Delivery};
45use crate::proc::Quiet as _;
46use crate::rng::SplitMix64;
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SeatState {
52 pub key: String,
54 pub agent: String,
56 pub turns: usize,
58 pub claude_session: Option<String>,
61 pub captured_session: Option<String>,
63}
64
65impl SeatState {
66 pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
68 let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
69 Self {
70 key: key.to_owned(),
71 agent: agent.to_owned(),
72 turns: 0,
73 claude_session: Some(rng.uuid_v4()),
74 captured_session: None,
75 }
76 }
77}
78
79pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
81 if !sessions_enabled || seat.turns == 0 {
82 return false;
83 }
84 match kind {
85 AgentKind::Claude => seat.claude_session.is_some(),
86 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
87 seat.captured_session.is_some()
88 }
89 AgentKind::Command => true,
90 }
91}
92
93#[derive(Debug)]
95pub struct Invocation<'a> {
96 pub cwd: &'a Path,
99 pub prompt: &'a str,
101 pub timeout: Duration,
103 pub allow_write: bool,
105 pub sessions: bool,
107 pub artifacts: &'a Path,
109 pub stem: &'a str,
111 pub run: &'a str,
115 pub node: &'a str,
119 pub cache_dir: Option<&'a Path>,
125 pub attachments: &'a [PathBuf],
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141pub struct Quota {
142 #[serde(default)]
144 pub reset: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
154pub struct Dropped {
155 pub why: String,
157 pub output_tokens: u64,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct CommandEvidence {
177 pub id: String,
179 pub description: String,
181 pub exit_code: Option<i32>,
183 pub result_summary: String,
185 pub source: String,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AgentOutput {
192 pub text: String,
194 pub exit_code: Option<i32>,
196 pub timed_out: bool,
198 pub duration_ms: u64,
200 pub artifacts: Vec<String>,
202 #[serde(default)]
206 pub quota: Option<Quota>,
207 #[serde(default)]
211 pub dropped: Option<Dropped>,
212 #[serde(default)]
216 pub commands: Vec<CommandEvidence>,
217}
218
219impl AgentOutput {
220 pub fn usable(&self) -> bool {
222 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
223 }
224
225 pub fn quota_exhausted(&self) -> bool {
227 self.quota.is_some()
228 }
229
230 pub fn work_undelivered(&self) -> bool {
235 self.dropped.is_some()
236 }
237}
238
239const PIPE_GRACE: Duration = Duration::from_secs(3);
244
245type Captured = Arc<Mutex<Vec<u8>>>;
247
248fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
258where
259 R: tokio::io::AsyncRead + Unpin + Send + 'static,
260{
261 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
262 let Some(mut pipe) = pipe else {
263 return (buf, None);
264 };
265 let sink = Arc::clone(&buf);
266 let handle = tokio::spawn(async move {
267 let mut chunk = [0u8; 8192];
268 loop {
269 match pipe.read(&mut chunk).await {
270 Ok(0) | Err(_) => break,
271 Ok(n) => {
272 if let Ok(mut guard) = sink.lock() {
273 guard.extend_from_slice(&chunk[..n]);
274 }
275 }
276 }
277 }
278 });
279 (buf, Some(handle))
280}
281
282async fn collect(
287 buf: &Captured,
288 handle: Option<tokio::task::JoinHandle<()>>,
289 grace: Duration,
290) -> String {
291 if let Some(handle) = handle {
292 if tokio::time::timeout(grace, handle).await.is_err() {
293 tracing::debug!("a pipe is still held open after the child exited");
294 }
295 }
296 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
297 String::from_utf8_lossy(&bytes).into_owned()
298}
299
300pub async fn invoke(
302 spec: &AgentSpec,
303 seat: &mut SeatState,
304 inv: &Invocation<'_>,
305) -> Result<AgentOutput> {
306 tokio::fs::create_dir_all(inv.artifacts)
307 .await
308 .with_context(|| format!("create {}", inv.artifacts.display()))?;
309 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
310 tokio::fs::write(&prompt_path, inv.prompt)
311 .await
312 .with_context(|| format!("write {}", prompt_path.display()))?;
313
314 let plan = build_command(spec, seat, inv, &prompt_path)?;
315 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
316
317 let started = Instant::now();
318 let child_path = spec
321 .env
322 .iter()
323 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
324 .map(|(_, v)| std::ffi::OsString::from(v))
325 .or_else(|| std::env::var_os("PATH"))
326 .unwrap_or_default();
327 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
328 || plan.argv[0].clone().into(),
329 std::path::PathBuf::into_os_string,
330 );
331 let mut cmd = Command::new(program);
332 cmd.args(&plan.argv[1..])
333 .current_dir(inv.cwd)
334 .envs(&spec.env)
335 .env("MAGI_SEAT", &seat.key)
336 .env("MAGI_TURN", seat.turns.to_string())
337 .env("MAGI_RUN", inv.run)
338 .env("MAGI_NODE", inv.node)
339 .env("MAGI_PROMPT_FILE", &prompt_path)
340 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
341 .env("GIT_TERMINAL_PROMPT", "0")
342 .stdin(if plan.stdin.is_some() {
343 Stdio::piped()
344 } else {
345 Stdio::null()
346 })
347 .stdout(Stdio::piped())
348 .stderr(Stdio::piped())
349 .kill_on_drop(true)
350 .quiet();
353 if let Some(cache) = inv.cache_dir {
354 cmd.env("CARGO_TARGET_DIR", cache);
357 } else {
358 cmd.env_remove("CARGO_TARGET_DIR");
366 }
367
368 let mut child = cmd
369 .spawn()
370 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
371 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
375 tokio::spawn(async move {
376 sink.write_all(body.as_bytes()).await.ok();
377 sink.shutdown().await.ok();
378 });
379 }
380
381 let (out_buf, out_reader) = drain(child.stdout.take());
398 let (err_buf, err_reader) = drain(child.stderr.take());
399
400 let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
401 Ok(res) => {
402 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
403 (status.code(), false)
404 }
405 Err(_) => {
406 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
407 child.start_kill().ok();
409 (None, true)
410 }
411 };
412
413 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
418 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
419
420 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
421 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
422 tokio::fs::write(&out_path, &stdout).await.ok();
423 tokio::fs::write(&err_path, &stderr).await.ok();
424
425 let mut extracted = extract(spec.kind, &stdout);
426 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
427 extracted.quota = agy_quota(&stdout, &stderr);
428 }
429 if let Some(quota) = &extracted.quota {
430 extracted.dropped = None;
433 tracing::warn!(
434 seat = %seat.key,
435 agent = %spec.id,
436 reset = ?quota.reset,
437 "agent is out of quota"
438 );
439 }
440 if let Some(session) = extracted.session {
441 match spec.kind {
442 AgentKind::Claude => seat.claude_session = Some(session),
443 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
444 seat.captured_session = Some(session);
445 }
446 AgentKind::Command => {}
447 }
448 }
449 if let Some(status) = &extracted.status
450 && !status.eq_ignore_ascii_case("success")
451 {
452 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
453 }
454 let text = if extracted.text.trim().is_empty() {
455 if stdout.trim().is_empty() {
457 stderr.trim().to_owned()
458 } else {
459 stdout.trim().to_owned()
460 }
461 } else {
462 extracted.text
463 };
464 seat.turns += 1;
465
466 Ok(AgentOutput {
467 text,
468 exit_code: code,
469 timed_out,
470 duration_ms: started.elapsed().as_millis() as u64,
471 artifacts: vec![
472 file_name(&prompt_path),
473 file_name(&out_path),
474 file_name(&err_path),
475 ],
476 quota: extracted.quota,
477 dropped: extracted.dropped,
478 commands: extracted.commands,
479 })
480}
481
482fn file_name(p: &Path) -> String {
483 p.file_name()
484 .unwrap_or_default()
485 .to_string_lossy()
486 .into_owned()
487}
488
489#[derive(Debug)]
491struct Plan {
492 argv: Vec<String>,
493 stdin: Option<String>,
494}
495
496fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
507 if matches!(kind, AgentKind::Antigravity) {
508 return format!("@{}", prompt_path.display());
509 }
510 format!(
511 "Read the file at {} and follow every instruction in it exactly. That \
512 file is your complete task description; this message contains nothing \
513 else.",
514 prompt_path.display()
515 )
516}
517
518fn build_command(
519 spec: &AgentSpec,
520 seat: &SeatState,
521 inv: &Invocation<'_>,
522 prompt_path: &Path,
523) -> Result<Plan> {
524 let mut argv: Vec<String> = Vec::new();
525 let mut stdin: Option<String> = None;
526 let delivery = spec.delivery();
527 let resuming = has_session(spec.kind, seat, inv.sessions);
528
529 match spec.kind {
530 AgentKind::Claude => {
531 argv.push("claude".to_owned());
536 argv.push("-p".to_owned());
537 argv.push("--output-format".to_owned());
538 argv.push("json".to_owned());
539 if let Some(m) = &spec.model {
540 argv.push("--model".to_owned());
541 argv.push(m.clone());
542 }
543 if inv.sessions {
544 let uuid = seat
545 .claude_session
546 .as_deref()
547 .context("claude seat is missing its session uuid")?;
548 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
549 argv.push(uuid.to_owned());
550 }
551 argv.push("--permission-mode".to_owned());
552 argv.push("bypassPermissions".to_owned());
553 if !inv.allow_write {
554 argv.push("--disallowed-tools".to_owned());
555 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
556 }
557 }
558 AgentKind::Opencode => {
559 argv.push("opencode".to_owned());
563 argv.push("run".to_owned());
564 argv.push("--format".to_owned());
565 argv.push("json".to_owned());
566 argv.push("--dir".to_owned());
567 argv.push(inv.cwd.to_string_lossy().into_owned());
568 argv.push("--auto".to_owned());
577 if let Some(m) = &spec.model {
578 argv.push("-m".to_owned());
579 argv.push(m.clone());
580 }
581 if resuming {
582 argv.push("-s".to_owned());
583 argv.push(
584 seat.captured_session
585 .clone()
586 .expect("has_session checked the id is present"),
587 );
588 }
589 }
590 AgentKind::Antigravity => {
591 argv.push("agy".to_owned());
592 argv.push("--output-format".to_owned());
593 argv.push("json".to_owned());
594 argv.push("--print-timeout".to_owned());
597 argv.push(format!("{}s", inv.timeout.as_secs()));
598 argv.push("--mode".to_owned());
599 argv.push(
600 if inv.allow_write {
601 "accept-edits"
602 } else {
603 "plan"
604 }
605 .to_owned(),
606 );
607 if inv.allow_write {
608 argv.push("--dangerously-skip-permissions".to_owned());
609 }
610 if let Some(m) = &spec.model {
611 argv.push("--model".to_owned());
612 argv.push(m.clone());
613 }
614 if resuming {
615 argv.push("--conversation".to_owned());
616 argv.push(
617 seat.captured_session
618 .clone()
619 .expect("has_session checked the id is present"),
620 );
621 }
622 let mut add_dirs: Vec<String> = Vec::new();
632 if delivery == Delivery::File || !inv.attachments.is_empty() {
633 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
634 }
635 for path in inv.attachments {
636 let Some(parent) = path.parent() else {
637 continue;
638 };
639 if parent.starts_with(inv.artifacts) {
640 continue;
641 }
642 let dir = parent.to_string_lossy().into_owned();
643 if !add_dirs.contains(&dir) {
644 add_dirs.push(dir);
645 }
646 }
647 for dir in add_dirs {
648 argv.push("--add-dir".to_owned());
649 argv.push(dir);
650 }
651 }
652 AgentKind::Codex => {
653 argv.push("codex".to_owned());
659 argv.push("exec".to_owned());
660 argv.push("--json".to_owned());
661 argv.push("--skip-git-repo-check".to_owned());
664 argv.push("-C".to_owned());
665 argv.push(inv.cwd.to_string_lossy().into_owned());
666 argv.push("--sandbox".to_owned());
671 argv.push(
672 if inv.allow_write {
673 "workspace-write"
674 } else {
675 "read-only"
676 }
677 .to_owned(),
678 );
679 argv.push("-c".to_owned());
682 argv.push("approval_policy=\"never\"".to_owned());
683 if let Some(m) = &spec.model {
684 argv.push("-m".to_owned());
685 argv.push(m.clone());
686 }
687 if resuming {
693 argv.push("resume".to_owned());
694 argv.push(
695 seat.captured_session
696 .clone()
697 .expect("has_session checked the id is present"),
698 );
699 }
700 }
701 AgentKind::Omp => {
702 argv.push("omp".to_owned());
706 argv.push("-p".to_owned());
707 argv.push("--mode=json".to_owned());
708 argv.push("--auto-approve".to_owned());
718 if let Some(m) = &spec.model {
719 argv.push("--model".to_owned());
720 argv.push(m.clone());
721 }
722 if resuming {
728 argv.push("--resume".to_owned());
729 argv.push(
730 seat.captured_session
731 .clone()
732 .expect("has_session checked the id is present"),
733 );
734 }
735 }
736 AgentKind::Command => {
737 if spec.command.is_empty() {
742 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
743 }
744 let vars: BTreeMap<&str, String> = BTreeMap::from([
745 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
746 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
747 ("{label}", seat.key.clone()),
748 ("{session}", seat.claude_session.clone().unwrap_or_default()),
749 ]);
750 for raw in &spec.command {
751 let mut arg = raw.clone();
752 for (k, v) in &vars {
753 if arg.contains(k) {
754 arg = arg.replace(k, v);
755 }
756 }
757 argv.push(arg);
758 }
759 }
760 }
761
762 argv.extend(spec.extra_args.iter().cloned());
763
764 if spec.kind == AgentKind::Antigravity {
767 argv.push("-p".to_owned());
768 }
769 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
772 argv.push("-".to_owned());
773 }
774 match delivery {
775 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
776 argv.push(pointer(spec.kind, prompt_path));
778 }
779 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
780 Delivery::Argv => argv.push(inv.prompt.to_owned()),
781 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
782 }
783
784 Ok(Plan { argv, stdin })
785}
786
787#[derive(Debug, Default)]
789struct Extracted {
790 text: String,
791 session: Option<String>,
792 status: Option<String>,
793 quota: Option<Quota>,
794 dropped: Option<Dropped>,
795 commands: Vec<CommandEvidence>,
796}
797
798fn extract(kind: AgentKind, stdout: &str) -> Extracted {
800 match kind {
801 AgentKind::Claude => {
802 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
803 return Extracted {
804 text: stdout.trim().to_owned(),
805 ..Extracted::default()
806 };
807 };
808 Extracted {
809 text: v
810 .get("result")
811 .and_then(|r| r.as_str())
812 .unwrap_or_default()
813 .to_owned(),
814 session: v
815 .get("session_id")
816 .and_then(|s| s.as_str())
817 .map(str::to_owned),
818 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
819 if e {
820 "error".to_owned()
821 } else {
822 "success".to_owned()
823 }
824 }),
825 quota: claude_quota(&v),
826 dropped: None,
829 commands: Vec::new(),
830 }
831 }
832 AgentKind::Opencode => {
833 let mut text = String::new();
835 let mut session = None;
836 for line in stdout.lines() {
837 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
838 continue;
839 };
840 if session.is_none() {
841 session = v
842 .get("sessionID")
843 .and_then(|s| s.as_str())
844 .map(str::to_owned);
845 }
846 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
847 if part.get("type").and_then(|t| t.as_str()) == Some("text")
848 && let Some(t) = part.get("text").and_then(|t| t.as_str())
849 {
850 if !text.is_empty() {
851 text.push('\n');
852 }
853 text.push_str(t);
854 }
855 }
856 Extracted {
857 text,
858 session,
859 status: None,
860 quota: None,
861 dropped: None,
862 commands: Vec::new(),
863 }
864 }
865 AgentKind::Antigravity => {
866 let obj = stdout
869 .lines()
870 .rev()
871 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
872 let Some(v) = obj else {
873 return Extracted {
874 text: stdout.trim().to_owned(),
875 ..Extracted::default()
876 };
877 };
878 Extracted {
879 text: v
880 .get("response")
881 .and_then(|r| r.as_str())
882 .unwrap_or_default()
883 .trim()
884 .to_owned(),
885 session: v
886 .get("conversation_id")
887 .and_then(|s| s.as_str())
888 .map(str::to_owned),
889 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
890 quota: None,
891 dropped: dropped_stream(&v),
892 commands: Vec::new(),
893 }
894 }
895 AgentKind::Codex => {
896 let mut text = String::new();
918 let mut session = None;
919 let mut status = None;
920 let mut commands = Vec::new();
921 for line in stdout.lines() {
922 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
923 continue;
924 };
925 match v.get("type").and_then(|t| t.as_str()) {
926 Some("thread.started") => {
927 session = v
928 .get("thread_id")
929 .and_then(|s| s.as_str())
930 .map(str::to_owned);
931 }
932 Some("item.completed") => {
933 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
934 match item.get("type").and_then(|t| t.as_str()) {
935 Some("agent_message") => {
936 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
937 text = t.trim().to_owned();
938 }
939 }
940 Some("command_execution") => {
941 commands.push(command_evidence(item));
942 }
943 _ => {}
944 }
945 }
946 Some("turn.completed") => status = Some("success".to_owned()),
947 Some("turn.failed") => status = Some("error".to_owned()),
948 _ => {}
949 }
950 }
951 Extracted {
952 text,
953 session,
954 status,
955 quota: None,
956 dropped: None,
957 commands,
958 }
959 }
960 AgentKind::Omp => {
961 let mut text = String::new();
982 let mut session = None;
983 for line in stdout.lines() {
984 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
985 continue;
986 };
987 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
988 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
989 continue;
990 }
991 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
995 {
996 Some("agent_end") => v
997 .get("messages")
998 .and_then(|m| m.as_array())
999 .map(|m| m.iter().collect())
1000 .unwrap_or_default(),
1001 Some("turn_end") | Some("message_end") => {
1002 v.get("message").into_iter().collect()
1003 }
1004 _ => continue,
1005 };
1006 for message in messages {
1007 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1008 continue;
1009 }
1010 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1011 continue;
1012 };
1013 for part in parts {
1014 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1015 continue;
1016 }
1017 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1018 && !t.trim().is_empty()
1019 {
1020 text = t.trim().to_owned();
1021 }
1022 }
1023 }
1024 }
1025 Extracted {
1026 text,
1027 session,
1028 status: None,
1029 quota: None,
1030 dropped: None,
1031 commands: Vec::new(),
1032 }
1033 }
1034 AgentKind::Command => {
1035 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1040 let quota = parsed.as_ref().and_then(claude_quota);
1041 let dropped = parsed.as_ref().and_then(dropped_stream);
1044 Extracted {
1045 text: stdout.trim().to_owned(),
1046 session: None,
1047 status: None,
1048 quota,
1049 dropped,
1050 commands: Vec::new(),
1051 }
1052 }
1053 }
1054}
1055
1056fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1063 let description = match item.get("command") {
1064 Some(serde_json::Value::String(s)) => s.clone(),
1065 Some(serde_json::Value::Array(parts)) => parts
1066 .iter()
1067 .filter_map(|p| p.as_str())
1068 .collect::<Vec<_>>()
1069 .join(" "),
1070 _ => String::new(),
1071 };
1072 let result_summary = item
1073 .get("aggregated_output")
1074 .and_then(|o| o.as_str())
1075 .map(|s| tail_chars(s.trim(), 400))
1076 .unwrap_or_default();
1077 CommandEvidence {
1078 id: item
1079 .get("id")
1080 .and_then(|s| s.as_str())
1081 .unwrap_or_default()
1082 .to_owned(),
1083 description,
1084 exit_code: item
1085 .get("exit_code")
1086 .and_then(serde_json::Value::as_i64)
1087 .map(|e| e as i32),
1088 result_summary,
1089 source: "codex".to_owned(),
1090 }
1091}
1092
1093fn tail_chars(s: &str, max: usize) -> String {
1095 let count = s.chars().count();
1096 if count <= max {
1097 return s.to_owned();
1098 }
1099 s.chars().skip(count - max).collect()
1100}
1101
1102fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1109 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1110 if !is_err {
1111 return None;
1112 }
1113 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1114 if !result.to_lowercase().contains("session limit") {
1115 return None;
1116 }
1117 let reset = result
1120 .split("resets ")
1121 .nth(1)
1122 .map(str::trim)
1123 .filter(|s| !s.is_empty())
1124 .map(str::to_owned);
1125 Some(Quota { reset })
1126}
1127
1128fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1143 let from_stdout = stdout
1144 .lines()
1145 .rev()
1146 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1147 .and_then(|v| {
1148 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1149 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1150 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1151 .then(|| error.to_owned())
1152 });
1153 let from_stderr = || {
1154 stderr.lines().find_map(|l| {
1155 let v: serde_json::Value =
1156 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1157 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1158 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1159 (exhausted && code).then(|| {
1160 v.get("short_error")
1161 .and_then(|e| e.as_str())
1162 .unwrap_or_default()
1163 .to_owned()
1164 })
1165 })
1166 };
1167 let text = from_stdout.or_else(from_stderr)?;
1168 let reset = text
1169 .split_once("Resets ")
1170 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1171 .filter(|s| !s.is_empty())
1172 .map(str::to_owned);
1173 Some(Quota { reset })
1174}
1175
1176fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1207 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1208 if !status.eq_ignore_ascii_case("error") {
1209 return None;
1210 }
1211 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1212 if !response.trim().is_empty() {
1213 return None;
1215 }
1216 let produced = v
1217 .get("usage")
1218 .and_then(|u| u.get("output_tokens"))
1219 .and_then(serde_json::Value::as_u64)
1220 .unwrap_or(0);
1221 if produced == 0 {
1222 return None;
1224 }
1225 Some(Dropped {
1226 why: v
1227 .get("error")
1228 .and_then(|e| e.as_str())
1229 .unwrap_or("the CLI ended the stream without delivering its answer")
1230 .trim()
1231 .to_owned(),
1232 output_tokens: produced,
1233 })
1234}
1235
1236pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1238 let mut missing = Vec::new();
1239 for s in specs {
1240 let program = match s.kind {
1241 AgentKind::Command => s.command.first().map(String::as_str),
1242 other => other.program(),
1243 };
1244 if let Some(p) = program
1245 && !crate::config::which(p)
1246 && !Path::new(p).is_file()
1247 && !missing.iter().any(|m: &String| m == p)
1248 {
1249 missing.push(p.to_owned());
1250 }
1251 }
1252 missing
1253}
1254
1255pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1257 run_dir.join("artifacts")
1258}
1259
1260pub fn installed(spec: &AgentSpec) -> bool {
1262 spec.kind.program().is_none_or(crate::config::which)
1265}
1266
1267pub fn pick(
1289 agents: &[AgentSpec],
1290 want: Option<&str>,
1291 available: &dyn Fn(&AgentSpec) -> bool,
1292) -> Result<AgentSpec> {
1293 if let Some(id) = want {
1294 let spec = agents
1295 .iter()
1296 .find(|a| a.id == id)
1297 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1298 if !available(spec) {
1299 bail!(
1300 "agent `{}` needs `{}` on PATH; install it or pass a different \
1301 --agent",
1302 spec.id,
1303 spec.kind.program().unwrap_or("its command")
1304 );
1305 }
1306 return Ok(spec.clone());
1307 }
1308
1309 if agents.is_empty() {
1310 bail!(
1311 "the agent roster is empty, so there is nobody to ask: install one \
1312 of claude, opencode or agy - magi derives a roster from what is on \
1313 PATH - or add an [[agents]] entry to magi.toml."
1314 );
1315 }
1316
1317 if let Some(spec) = agents
1318 .iter()
1319 .find(|a| a.kind == AgentKind::Claude && available(a))
1320 {
1321 return Ok(spec.clone());
1322 }
1323
1324 agents
1325 .iter()
1326 .find(|a| available(a))
1327 .cloned()
1328 .with_context(|| {
1329 let missing = agents
1330 .iter()
1331 .filter_map(|a| a.kind.program())
1332 .collect::<Vec<_>>()
1333 .join(", ");
1334 format!(
1335 "no agent in the roster can be run here: install one of \
1336 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1337 you do have"
1338 )
1339 })
1340}
1341
1342fn ids(agents: &[AgentSpec]) -> String {
1343 if agents.is_empty() {
1344 return "no agents at all".to_owned();
1345 }
1346 agents
1347 .iter()
1348 .map(|a| a.id.clone())
1349 .collect::<Vec<_>>()
1350 .join(", ")
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355 use super::*;
1356
1357 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1358
1359 fn command_helper(mode: &str) -> AgentSpec {
1362 AgentSpec {
1363 id: "helper".to_owned(),
1364 kind: AgentKind::Command,
1365 model: None,
1366 command: vec![
1367 std::env::current_exe()
1368 .expect("locate test helper")
1369 .to_string_lossy()
1370 .into_owned(),
1371 "--exact".to_owned(),
1372 "agent::tests::command_agent_test_helper".to_owned(),
1373 "--nocapture".to_owned(),
1374 ],
1375 extra_args: Vec::new(),
1376 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1377 prompt_delivery: None,
1378 }
1379 }
1380
1381 #[test]
1382 fn command_agent_test_helper() {
1383 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1384 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1385 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1386 Ok("no-cache") => println!(
1387 "{}",
1388 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1389 ),
1390 Ok("ignore-stdin") => println!("done"),
1391 Ok("chatty-sleep") => {
1392 println!("i-said-something");
1393 std::thread::sleep(Duration::from_secs(30));
1394 }
1395 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1396 Ok(other) => panic!("unknown command helper mode {other}"),
1397 Err(_) => {}
1398 }
1399 }
1400
1401 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1402 AgentSpec {
1403 id: "a".to_owned(),
1404 kind,
1405 model: model.map(str::to_owned),
1406 command: vec!["echo".to_owned(), "{label}".to_owned()],
1407 extra_args: Vec::new(),
1408 env: BTreeMap::new(),
1409 prompt_delivery: None,
1410 }
1411 }
1412
1413 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1414 Invocation {
1415 cwd,
1416 prompt: "do the thing",
1417 timeout: Duration::from_secs(900),
1418 allow_write,
1419 sessions: true,
1420 artifacts: art,
1421 stem: "t",
1422 run: "test-run",
1423 node: "test",
1424 cache_dir: None,
1425 attachments: &[],
1426 }
1427 }
1428
1429 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1430 build_command(
1431 &spec(kind, None),
1432 seat,
1433 &inv(Path::new("."), Path::new("/art"), allow_write),
1434 Path::new("/art/p.md"),
1435 )
1436 .unwrap()
1437 }
1438
1439 #[test]
1440 fn claude_mints_then_resumes_the_same_uuid() {
1441 let mut seat = SeatState::new("judge-1", "a", 7);
1442 let uuid = seat.claude_session.clone().unwrap();
1443 let first = plan_for(AgentKind::Claude, &seat, true);
1444 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1445 assert!(!first.argv.iter().any(|a| a == "--resume"));
1446
1447 seat.turns = 1;
1448 let second = plan_for(AgentKind::Claude, &seat, true);
1449 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1450 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1451 }
1452
1453 #[test]
1454 fn read_only_seats_cannot_edit() {
1455 let seat = SeatState::new("judge-1", "a", 7);
1456 let claude = plan_for(AgentKind::Claude, &seat, false);
1457 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1458 assert!(
1459 !plan_for(AgentKind::Claude, &seat, true)
1460 .argv
1461 .iter()
1462 .any(|a| a == "--disallowed-tools")
1463 );
1464
1465 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1466 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1467 assert!(
1468 !agy.argv
1469 .iter()
1470 .any(|a| a == "--dangerously-skip-permissions")
1471 );
1472 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1473 assert!(
1474 agy_rw
1475 .argv
1476 .windows(2)
1477 .any(|w| w == ["--mode", "accept-edits"])
1478 );
1479 assert!(
1480 agy_rw
1481 .argv
1482 .iter()
1483 .any(|a| a == "--dangerously-skip-permissions")
1484 );
1485 let agy_prompt = agy_rw
1490 .argv
1491 .iter()
1492 .position(|a| a == "-p")
1493 .map(|i| agy_rw.argv[i + 1].clone())
1494 .expect("agy takes its prompt with -p");
1495 assert!(
1496 agy_prompt.starts_with('@'),
1497 "agy must get a file reference, got {agy_prompt:?}"
1498 );
1499 assert!(
1500 !agy_prompt.contains("Read the file at"),
1501 "the prose pointer is for CLIs with no file syntax"
1502 );
1503
1504 for allow_write in [false, true] {
1509 assert!(
1510 plan_for(AgentKind::Opencode, &seat, allow_write)
1511 .argv
1512 .iter()
1513 .any(|a| a == "--auto"),
1514 "opencode needs --auto even to read (allow_write = {allow_write})"
1515 );
1516 }
1517 }
1518
1519 #[test]
1522 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1523 let mut seat = SeatState::new("judge-1", "a", 7);
1524
1525 let ro = plan_for(AgentKind::Codex, &seat, false);
1529 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1530 let rw = plan_for(AgentKind::Codex, &seat, true);
1531 assert!(
1532 rw.argv
1533 .windows(2)
1534 .any(|w| w == ["--sandbox", "workspace-write"])
1535 );
1536 for p in [&ro, &rw] {
1537 assert!(
1538 !p.argv
1539 .iter()
1540 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1541 "the bypass defeats the only enforced read-only mode we have"
1542 );
1543 assert!(
1545 p.argv
1546 .windows(2)
1547 .any(|w| w == ["-c", "approval_policy=\"never\""]),
1548 "an unattended seat that asks for approval blocks until timeout"
1549 );
1550 }
1551
1552 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1554 assert_eq!(
1555 ro.argv.last().map(String::as_str),
1556 Some("-"),
1557 "without the `-` argument codex waits for a prompt it never gets"
1558 );
1559
1560 seat.turns = 1;
1564 assert!(!has_session(AgentKind::Codex, &seat, true));
1565 assert!(
1566 !plan_for(AgentKind::Codex, &seat, true)
1567 .argv
1568 .iter()
1569 .any(|a| a == "resume")
1570 );
1571 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1572 let resumed = plan_for(AgentKind::Codex, &seat, true);
1573 let at = resumed
1574 .argv
1575 .iter()
1576 .position(|a| a == "resume")
1577 .expect("resumes by subcommand");
1578 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1579 assert!(
1580 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1581 "every option precedes the subcommand"
1582 );
1583 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1584 }
1585
1586 #[test]
1589 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
1590 let mut seat = SeatState::new("review-1", "a", 7);
1591
1592 let first = plan_for(AgentKind::Omp, &seat, false);
1596 assert!(first.argv.iter().any(|a| a == "-p"));
1597 assert!(first.argv.iter().any(|a| a == "--mode=json"));
1598 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
1599 assert!(
1600 !first.argv.iter().any(|a| a == "do the thing"),
1601 "the prompt reached argv, where Windows caps it"
1602 );
1603
1604 for allow_write in [false, true] {
1610 let p = plan_for(AgentKind::Omp, &seat, allow_write);
1611 assert!(
1612 p.argv.iter().any(|a| a == "--auto-approve"),
1613 "omp needs --auto-approve even to read (allow_write = {allow_write})"
1614 );
1615 assert!(
1616 !p.argv
1617 .iter()
1618 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1619 "nothing ever asks for the bypass"
1620 );
1621 }
1622
1623 seat.turns = 1;
1626 assert!(!has_session(AgentKind::Omp, &seat, true));
1627 assert!(
1628 !plan_for(AgentKind::Omp, &seat, true)
1629 .argv
1630 .iter()
1631 .any(|a| a == "--resume")
1632 );
1633 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
1634 let resumed = plan_for(AgentKind::Omp, &seat, true);
1635 assert!(
1636 resumed
1637 .argv
1638 .windows(2)
1639 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
1640 "a captured id is what makes the next turn a resume"
1641 );
1642 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
1645 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
1647 }
1648
1649 #[test]
1654 fn omp_takes_the_answer_without_an_agent_end_line() {
1655 let stream = concat!(
1656 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
1657 "\n",
1658 r#"{"type":"agent_start"}"#,
1659 "\n",
1660 r#"{"type":"turn_start"}"#,
1661 "\n",
1662 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
1663 "\n",
1664 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
1665 "\n",
1666 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
1667 "\n",
1668 );
1669 let out = extract(AgentKind::Omp, stream);
1670 assert_eq!(
1671 out.text, "{\"vote\":\"approve\"}",
1672 "the last assistant text block is the answer even with no agent_end"
1673 );
1674 assert_eq!(
1675 out.session.as_deref(),
1676 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
1677 );
1678 }
1679
1680 #[test]
1684 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
1685 let stream = concat!(
1686 r#"{"type":"session","version":3,"id":"s1"}"#,
1687 "\n",
1688 "{\"type\":\"agent_end\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"review this\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Looking at the diff…\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"…\"},{\"type\":\"text\",\"text\":\"## 判定\\n\\n問題ありません。\"}]}]}",
1689 "\n",
1690 );
1691 let out = extract(AgentKind::Omp, stream);
1692 assert_eq!(
1693 out.text, "## 判定\n\n問題ありません。",
1694 "the narration is not the answer, and non-ASCII survives intact"
1695 );
1696 assert_eq!(out.session.as_deref(), Some("s1"));
1697 }
1698
1699 #[test]
1702 fn omp_skips_non_json_lines() {
1703 let stream = concat!(
1704 "Warning: some omp notice\n",
1705 r#"{"type":"session","version":3,"id":"s2"}"#,
1706 "\n",
1707 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
1708 "\n",
1709 "trailing junk",
1710 "\n",
1711 );
1712 let out = extract(AgentKind::Omp, stream);
1713 assert_eq!(out.text, "the answer");
1714 assert_eq!(out.session.as_deref(), Some("s2"));
1715 }
1716
1717 #[test]
1719 fn codex_takes_the_last_agent_message_and_the_thread_id() {
1720 let stream = concat!(
1721 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1722 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1723 "\n",
1724 r#"{"type":"turn.started"}"#,
1725 "\n",
1726 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1727 "\n",
1728 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1729 "\n",
1730 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1731 "\n",
1732 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1733 "\n",
1734 );
1735 let out = extract(AgentKind::Codex, stream);
1736 assert_eq!(
1737 out.text, "{\"verdict\": \"ok\"}",
1738 "the last agent message is the answer; earlier ones narrate"
1739 );
1740 assert_eq!(
1741 out.session.as_deref(),
1742 Some("01a07440-4545-7492-85c1-024e3259a90a")
1743 );
1744 assert_eq!(out.status.as_deref(), Some("success"));
1745
1746 let failed = concat!(
1747 r#"{"type":"thread.started","thread_id":"t1"}"#,
1748 "\n",
1749 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1750 "\n",
1751 );
1752 assert_eq!(
1753 extract(AgentKind::Codex, failed).status.as_deref(),
1754 Some("error")
1755 );
1756 }
1757
1758 #[test]
1759 fn captured_sessions_resume_only_once_reported() {
1760 let mut seat = SeatState::new("impl-A", "a", 7);
1761 seat.turns = 1;
1762 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1763 assert!(!has_session(kind, &seat, true));
1764 let p = plan_for(kind, &seat, true);
1765 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1766 }
1767
1768 seat.captured_session = Some("sid".to_owned());
1769 assert!(has_session(AgentKind::Opencode, &seat, true));
1770 assert!(
1771 plan_for(AgentKind::Opencode, &seat, true)
1772 .argv
1773 .windows(2)
1774 .any(|w| w == ["-s", "sid"])
1775 );
1776 assert!(
1777 plan_for(AgentKind::Antigravity, &seat, true)
1778 .argv
1779 .windows(2)
1780 .any(|w| w == ["--conversation", "sid"])
1781 );
1782 }
1783
1784 #[test]
1785 fn sessions_disabled_never_resumes() {
1786 let mut seat = SeatState::new("impl-A", "a", 7);
1787 seat.turns = 3;
1788 seat.captured_session = Some("sid".to_owned());
1789 for kind in [
1790 AgentKind::Claude,
1791 AgentKind::Opencode,
1792 AgentKind::Antigravity,
1793 ] {
1794 assert!(!has_session(kind, &seat, false));
1795 }
1796 }
1797
1798 #[test]
1799 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1800 let seat = SeatState::new("judge-1", "a", 7);
1801 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1802 let p = plan_for(kind, &seat, false);
1803 assert!(
1804 p.argv.iter().all(|a| a != "do the thing"),
1805 "{kind:?} put the prompt on the command line"
1806 );
1807 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1808 }
1809 let p = plan_for(AgentKind::Antigravity, &seat, false);
1811 let at = p.argv.iter().position(|a| a == "-p").unwrap();
1812 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1813 assert!(p.stdin.is_none());
1814 }
1815
1816 #[test]
1817 fn agy_print_timeout_tracks_the_node_budget() {
1818 let seat = SeatState::new("impl-A", "a", 7);
1819 let p = build_command(
1820 &spec(AgentKind::Antigravity, None),
1821 &seat,
1822 &Invocation {
1823 cwd: Path::new("."),
1824 prompt: "p",
1825 timeout: Duration::from_secs(3600),
1826 allow_write: true,
1827 sessions: true,
1828 artifacts: Path::new("/art"),
1829 stem: "t",
1830 run: "test-run",
1831 node: "test",
1832 cache_dir: None,
1833 attachments: &[],
1834 },
1835 Path::new("/art/p.md"),
1836 )
1837 .unwrap();
1838 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1839 }
1840
1841 #[test]
1848 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
1849 let mut s = spec(AgentKind::Antigravity, None);
1850 s.prompt_delivery = Some(Delivery::Argv);
1851 let seat = SeatState::new("talk", "a", 7);
1852 let atts = [PathBuf::from("/art/attachments/abc.png")];
1853
1854 let without = build_command(
1855 &s,
1856 &seat,
1857 &Invocation {
1858 attachments: &[],
1859 ..inv(Path::new("."), Path::new("/art"), true)
1860 },
1861 Path::new("/art/p.md"),
1862 )
1863 .unwrap();
1864 assert!(
1865 !without.argv.iter().any(|a| a == "--add-dir"),
1866 "no attachment, no reason to widen the sandbox: {without:?}"
1867 );
1868
1869 let with = build_command(
1870 &s,
1871 &seat,
1872 &Invocation {
1873 attachments: &atts,
1874 ..inv(Path::new("."), Path::new("/art"), true)
1875 },
1876 Path::new("/art/p.md"),
1877 )
1878 .unwrap();
1879 assert!(
1880 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1881 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
1882 );
1883 }
1884
1885 #[test]
1891 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
1892 let seat = SeatState::new("plan", "a", 7);
1893 let atts = [
1894 PathBuf::from("/art/attachments/own.png"),
1895 PathBuf::from("/other-chat/attachments/inherited.png"),
1896 ];
1897
1898 let p = build_command(
1899 &spec(AgentKind::Antigravity, None),
1900 &seat,
1901 &Invocation {
1902 attachments: &atts,
1903 ..inv(Path::new("."), Path::new("/art"), true)
1904 },
1905 Path::new("/art/p.md"),
1906 )
1907 .unwrap();
1908
1909 assert!(
1910 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1911 "this conversation's own artifacts dir must still be granted: {p:?}"
1912 );
1913 assert!(
1914 p.argv
1915 .windows(2)
1916 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
1917 "the inherited attachment's own directory must be granted too: {p:?}"
1918 );
1919 }
1920
1921 #[test]
1922 fn command_agents_get_placeholders_substituted() {
1923 let seat = SeatState::new("impl-A", "a", 7);
1924 let p = plan_for(AgentKind::Command, &seat, true);
1925 assert_eq!(p.argv[0], "echo");
1926 assert_eq!(p.argv[1], "impl-A");
1927 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1928 }
1929
1930 #[test]
1931 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1932 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1934 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1935 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1936 let out = extract(AgentKind::Claude, stdout);
1937 let quota = out.quota.as_ref().expect("rate limit must be detected");
1938 assert_eq!(
1939 quota.reset.as_deref(),
1940 Some("4:50am (Asia/Tokyo)"),
1941 "reset time read from the body"
1942 );
1943 }
1944
1945 #[test]
1946 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1947 let out = extract(
1948 AgentKind::Claude,
1949 r#"{"is_error":true,"result":"session limit reached"}"#,
1950 );
1951 let quota = out.quota.expect("rate limit detected without a reset");
1952 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1953 }
1954
1955 #[test]
1956 fn ordinary_failures_are_never_quota() {
1957 let claude_fail = extract(
1959 AgentKind::Claude,
1960 r#"{"is_error":true,"result":"account does not exist"}"#,
1961 );
1962 assert!(claude_fail.quota.is_none());
1963
1964 let cmd_fail = extract(AgentKind::Command, "boom");
1966 assert!(cmd_fail.quota.is_none());
1967
1968 let success = extract(
1970 AgentKind::Command,
1971 r#"{"is_error":false,"result":"session limit is fine"}"#,
1972 );
1973 assert!(success.quota.is_none());
1974 }
1975
1976 #[test]
1984 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
1985 let stream = concat!(
1986 r#"{"type":"thread.started","thread_id":"t1"}"#,
1987 "\n",
1988 r#"{"type":"item.completed","item":{"id":"item49","type":"command_execution","command":["bash","-lc","cargo test --test graph_cached_gate"],"exit_code":1,"aggregated_output":"test result: 1 passed; 1 failed"}}"#,
1989 "\n",
1990 r#"{"type":"item.completed","item":{"id":"item52","type":"command_execution","command":["bash","-lc","cargo test --test graph_cached_gate a_single_test"],"exit_code":0,"aggregated_output":"test result: 1 passed; 0 failed"}}"#,
1991 "\n",
1992 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
1993 "\n",
1994 r#"{"type":"turn.completed"}"#,
1995 "\n",
1996 );
1997 let out = extract(AgentKind::Codex, stream);
1998 assert_eq!(out.text, "Both tests in the target pass.");
1999 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2000
2001 let paired = &out.commands[0];
2002 assert_eq!(paired.id, "item49");
2003 assert_eq!(paired.exit_code, Some(1));
2004 assert!(paired.description.contains("graph_cached_gate"));
2005 assert!(paired.result_summary.contains("1 failed"));
2006
2007 let solo = &out.commands[1];
2008 assert_eq!(solo.exit_code, Some(0));
2009
2010 assert!(
2014 out.commands
2015 .iter()
2016 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2017 "a failed run of the actual target must still be visible: {:?}",
2018 out.commands
2019 );
2020 }
2021
2022 #[test]
2023 fn command_agent_can_carry_the_claude_quota_shape() {
2024 let out = extract(
2025 AgentKind::Command,
2026 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2027 );
2028 assert!(
2029 out.quota.is_some(),
2030 "a wrapper emitting the claude shape counts as quota"
2031 );
2032 }
2033
2034 #[test]
2035 fn claude_json_result_is_extracted() {
2036 let out = extract(
2037 AgentKind::Claude,
2038 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2039 );
2040 assert_eq!(out.text, "all done");
2041 assert_eq!(out.session.as_deref(), Some("abc"));
2042 assert_eq!(out.status.as_deref(), Some("success"));
2043 }
2044
2045 #[test]
2060 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2061 let stdout = r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","stop_reason":"end_turn","result":"I'll pause here until the `cargo make check` background run reports back.","session_id":"11111111-1111-1111-1111-111111111111"}"#;
2062 let out = extract(AgentKind::Claude, stdout);
2063 assert_eq!(out.status.as_deref(), Some("success"));
2064 assert!(out.quota.is_none());
2065 assert!(!out.text.trim().is_empty());
2066
2067 let agent_out = AgentOutput {
2068 text: out.text.clone(),
2069 exit_code: Some(0),
2070 timed_out: false,
2071 duration_ms: 500,
2072 artifacts: Vec::new(),
2073 quota: out.quota,
2074 dropped: out.dropped,
2075 commands: out.commands,
2076 };
2077 assert!(
2078 agent_out.usable(),
2079 "the CLI turn itself ended cleanly and must read as usable"
2080 );
2081 assert!(
2082 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2083 "a clean CLI turn is not proof the node's own report ever arrived"
2084 );
2085 }
2086
2087 #[test]
2088 fn opencode_event_stream_is_concatenated() {
2089 let stream = concat!(
2090 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2091 "\n",
2092 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2093 "\n",
2094 "garbage line\n",
2095 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2096 "\n"
2097 );
2098 let out = extract(AgentKind::Opencode, stream);
2099 assert_eq!(out.text, "first\nsecond");
2100 assert_eq!(out.session.as_deref(), Some("ses_1"));
2101 }
2102
2103 #[test]
2104 fn agy_json_survives_a_leading_warning_line() {
2105 let stdout = concat!(
2106 "warning: --mode plan has no effect while slash commands are disabled.\n",
2107 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2108 "\n"
2109 );
2110 let out = extract(AgentKind::Antigravity, stdout);
2111 assert_eq!(out.text, "persimmon");
2112 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2113 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2114 }
2115
2116 const AGY_DROPPED: &str = concat!(
2123 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2124 r#""response":"","error":"the connection to the agent was interrupted before "#,
2125 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2126 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2127 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2128 r#""total_tokens":274380}}"#
2129 );
2130
2131 const AGY_QUOTA_OUT: &str = concat!(
2133 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2134 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2135 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2136 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2137 );
2138 const AGY_QUOTA_ERR: &str = concat!(
2139 "error: Individual quota reached. Resets in 1h2m49s.\n",
2140 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
2141 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
2142 r#""retryable":true}"#,
2143 "\n"
2144 );
2145
2146 #[test]
2147 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
2148 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
2149 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
2150 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
2151 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
2152 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
2154 assert!(stderr_only.reset.is_none());
2155 }
2156
2157 #[test]
2158 fn ordinary_agy_failures_are_not_a_quota() {
2159 assert!(agy_quota(AGY_DROPPED, "").is_none());
2160 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
2161 assert!(
2162 agy_quota(
2163 "",
2164 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
2165 )
2166 .is_none()
2167 );
2168 assert!(
2169 agy_quota(
2170 "",
2171 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
2172 )
2173 .is_none()
2174 );
2175 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
2176 }
2177
2178 #[test]
2179 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2180 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2181 let dropped = out.dropped.expect("recognised as undelivered work");
2182 assert_eq!(dropped.output_tokens, 14267);
2183 assert!(
2184 dropped.why.contains("subscriber fell behind"),
2185 "the CLI's own words are kept for the record: {}",
2186 dropped.why
2187 );
2188 assert_eq!(
2191 out.session.as_deref(),
2192 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
2193 );
2194 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
2195 }
2196
2197 #[test]
2198 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
2199 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
2203 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
2204
2205 let answered = concat!(
2208 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
2209 r#""usage":{"output_tokens":10}}"#
2210 );
2211 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
2212
2213 let ok = concat!(
2215 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
2216 r#""usage":{"output_tokens":10}}"#
2217 );
2218 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
2219 }
2220
2221 #[test]
2222 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
2223 let out = AgentOutput {
2224 text: String::new(),
2225 exit_code: Some(1),
2226 timed_out: false,
2227 duration_ms: 431_194,
2228 artifacts: Vec::new(),
2229 quota: None,
2230 dropped: Some(Dropped {
2231 why: "subscriber fell behind updates".to_owned(),
2232 output_tokens: 14267,
2233 }),
2234 commands: Vec::new(),
2235 };
2236 assert!(!out.usable());
2237 assert!(out.work_undelivered());
2238 assert!(!out.quota_exhausted());
2241 }
2242
2243 #[test]
2244 fn non_json_stdout_falls_back_to_raw_text() {
2245 let out = extract(AgentKind::Antigravity, "plain answer\n");
2246 assert_eq!(out.text, "plain answer");
2247 assert!(out.session.is_none());
2248 }
2249
2250 #[tokio::test]
2251 async fn command_agent_round_trip_writes_artifacts() {
2252 let dir = tempfile::tempdir().unwrap();
2253 let art = dir.path().join("artifacts");
2254 let mut seat = SeatState::new("impl-A", "a", 7);
2255 let s = command_helper("reply");
2256 let out = invoke(
2257 &s,
2258 &mut seat,
2259 &Invocation {
2260 cwd: dir.path(),
2261 prompt: "unused",
2262 timeout: Duration::from_secs(30),
2263 allow_write: true,
2264 sessions: true,
2265 artifacts: &art,
2266 stem: "impl-A",
2267 run: "test-run",
2268 node: "test",
2269 cache_dir: None,
2270 attachments: &[],
2271 },
2272 )
2273 .await
2274 .unwrap();
2275 assert!(out.usable(), "{out:?}");
2276 assert!(out.text.contains("hello impl-A"), "{}", out.text);
2277 assert_eq!(seat.turns, 1);
2278 assert!(art.join("impl-A.prompt.md").is_file());
2279 assert!(art.join("impl-A.out").is_file());
2280 }
2281
2282 #[tokio::test]
2283 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
2284 let dir = tempfile::tempdir().unwrap();
2288 let cache = dir.path().join("magi-cache");
2289 let mut seat = SeatState::new("impl-A", "a", 7);
2290 let s = command_helper("cache");
2291 let out = invoke(
2292 &s,
2293 &mut seat,
2294 &Invocation {
2295 cwd: dir.path(),
2296 prompt: "unused",
2297 timeout: Duration::from_secs(30),
2298 allow_write: true,
2299 sessions: true,
2300 artifacts: &dir.path().join("artifacts"),
2301 stem: "cache",
2302 run: "test-run",
2303 node: "test",
2304 cache_dir: Some(&cache),
2305 attachments: &[],
2306 },
2307 )
2308 .await
2309 .unwrap();
2310 assert!(out.usable(), "{out:?}");
2311 assert!(
2312 out.text.contains(cache.to_string_lossy().as_ref()),
2313 "the seat must see CARGO_TARGET_DIR = the shared cache"
2314 );
2315 }
2316
2317 #[tokio::test]
2318 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
2319 let previous = std::env::var("CARGO_TARGET_DIR").ok();
2327 unsafe {
2332 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
2333 }
2334 let dir = tempfile::tempdir().unwrap();
2335 let mut seat = SeatState::new("review-1", "a", 7);
2336 let s = command_helper("no-cache");
2337 let result = invoke(
2338 &s,
2339 &mut seat,
2340 &Invocation {
2341 cwd: dir.path(),
2342 prompt: "unused",
2343 timeout: Duration::from_secs(30),
2344 allow_write: false,
2345 sessions: true,
2346 artifacts: &dir.path().join("artifacts"),
2347 stem: "no-cache",
2348 run: "test-run",
2349 node: "test",
2350 cache_dir: None,
2351 attachments: &[],
2352 },
2353 )
2354 .await;
2355 unsafe {
2360 match &previous {
2361 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
2362 None => std::env::remove_var("CARGO_TARGET_DIR"),
2363 }
2364 }
2365 let out = result.unwrap();
2366 assert!(out.usable(), "{out:?}");
2367 assert!(
2368 out.text.contains("ABSENT"),
2369 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
2370 out.text
2371 );
2372 }
2373
2374 #[tokio::test]
2375 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
2376 let dir = tempfile::tempdir().unwrap();
2377 let mut seat = SeatState::new("impl-A", "a", 7);
2378 let s = command_helper("ignore-stdin");
2381 let big = "x".repeat(1_000_000);
2382 let out = invoke(
2383 &s,
2384 &mut seat,
2385 &Invocation {
2386 cwd: dir.path(),
2387 prompt: &big,
2388 timeout: Duration::from_secs(60),
2389 allow_write: true,
2390 sessions: true,
2391 artifacts: &dir.path().join("artifacts"),
2392 stem: "big",
2393 run: "test-run",
2394 node: "test",
2395 cache_dir: None,
2396 attachments: &[],
2397 },
2398 )
2399 .await
2400 .unwrap();
2401 assert!(out.usable(), "{out:?}");
2402 assert!(out.text.contains("done"), "{}", out.text);
2403 }
2404
2405 #[tokio::test]
2406 async fn timeout_is_reported_not_hung() {
2407 let dir = tempfile::tempdir().unwrap();
2408 let mut seat = SeatState::new("impl-A", "a", 7);
2409 let s = command_helper("sleep");
2410 let out = invoke(
2411 &s,
2412 &mut seat,
2413 &Invocation {
2414 cwd: dir.path(),
2415 prompt: "unused",
2416 timeout: Duration::from_millis(300),
2417 allow_write: true,
2418 sessions: true,
2419 artifacts: &dir.path().join("artifacts"),
2420 stem: "slow",
2421 run: "test-run",
2422 node: "test",
2423 cache_dir: None,
2424 attachments: &[],
2425 },
2426 )
2427 .await
2428 .unwrap();
2429 assert!(out.timed_out);
2430 assert!(!out.usable());
2431 }
2432
2433 #[tokio::test]
2434 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
2435 let dir = tempfile::tempdir().unwrap();
2441 let artifacts = dir.path().join("artifacts");
2442 let mut seat = SeatState::new("impl-A", "a", 7);
2443 let s = command_helper("chatty-sleep");
2444 let out = invoke(
2445 &s,
2446 &mut seat,
2447 &Invocation {
2448 cwd: dir.path(),
2449 prompt: "unused",
2450 timeout: Duration::from_secs(10),
2455 allow_write: true,
2456 sessions: true,
2457 artifacts: &artifacts,
2458 stem: "chatty",
2459 run: "test-run",
2460 node: "test",
2461 cache_dir: None,
2462 attachments: &[],
2463 },
2464 )
2465 .await
2466 .unwrap();
2467
2468 assert!(out.timed_out, "{out:?}");
2469 assert!(!out.usable(), "a cut-off answer is still not an answer");
2470 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
2471 assert!(
2472 recorded.contains("i-said-something"),
2473 "the artifact must keep what arrived before the kill, got {recorded:?}"
2474 );
2475 assert!(
2476 out.text.contains("i-said-something"),
2477 "and the graph must be able to see it too, got {:?}",
2478 out.text
2479 );
2480 }
2481
2482 #[test]
2483 fn missing_programs_reports_command_binaries() {
2484 let mut s = spec(AgentKind::Command, None);
2485 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
2486 assert_eq!(
2487 missing_programs(&[s]),
2488 ["definitely-not-a-real-binary-xyz".to_owned()]
2489 );
2490 }
2491
2492 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
2493 AgentSpec {
2494 id: id.to_owned(),
2495 kind,
2496 model: None,
2497 command: Vec::new(),
2498 extra_args: Vec::new(),
2499 env: BTreeMap::new(),
2500 prompt_delivery: None,
2501 }
2502 }
2503
2504 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
2507 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
2508 }
2509
2510 #[test]
2511 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
2512 let agents = [
2513 pick_spec("oc", AgentKind::Opencode),
2514 pick_spec("opus", AgentKind::Claude),
2515 pick_spec("agy", AgentKind::Antigravity),
2516 ];
2517 let got = pick(&agents, None, &without(&[])).expect("a pick");
2518 assert_eq!(got.id, "opus");
2519 }
2520
2521 #[test]
2522 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
2523 let agents = [
2524 pick_spec("opus", AgentKind::Claude),
2525 pick_spec("oc", AgentKind::Opencode),
2526 pick_spec("agy", AgentKind::Antigravity),
2527 ];
2528 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
2529 assert_eq!(got.id, "agy");
2530 }
2531
2532 #[test]
2533 fn pick_on_an_empty_roster_says_what_to_install() {
2534 let msg = pick(&[], None, &without(&[]))
2535 .expect_err("nobody to ask")
2536 .to_string();
2537 assert!(msg.contains("roster is empty"), "{msg}");
2538 assert!(msg.contains("claude"), "{msg}");
2539 assert!(msg.contains("magi.toml"), "{msg}");
2540 }
2541
2542 #[test]
2543 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
2544 let agents = [
2545 pick_spec("opus", AgentKind::Claude),
2546 pick_spec("oc", AgentKind::Opencode),
2547 ];
2548 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
2549 let msg = format!("{err:#}");
2550 assert!(msg.contains("claude"), "{msg}");
2551 assert!(msg.contains("opencode"), "{msg}");
2552 }
2553
2554 #[test]
2555 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
2556 let agents = [
2557 pick_spec("opus", AgentKind::Claude),
2558 pick_spec("oc", AgentKind::Opencode),
2559 ];
2560 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
2561 assert_eq!(got.id, "oc");
2562 }
2563
2564 #[test]
2565 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
2566 let agents = [
2567 pick_spec("opus", AgentKind::Claude),
2568 pick_spec("oc", AgentKind::Opencode),
2569 ];
2570 let msg = pick(&agents, Some("gemini"), &without(&[]))
2571 .expect_err("no such agent")
2572 .to_string();
2573 assert!(msg.contains("gemini"), "{msg}");
2574 assert!(msg.contains("opus, oc"), "{msg}");
2575 }
2576
2577 #[test]
2578 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
2579 let agents = [
2580 pick_spec("opus", AgentKind::Claude),
2581 pick_spec("oc", AgentKind::Opencode),
2582 ];
2583 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
2584 .expect_err("must not silently substitute another model")
2585 .to_string();
2586 assert!(msg.contains("opencode"), "{msg}");
2587 assert!(msg.contains("--agent"), "{msg}");
2588 }
2589}