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 extracted = extract(spec.kind, &stdout);
426 if let Some(session) = extracted.session {
427 match spec.kind {
428 AgentKind::Claude => seat.claude_session = Some(session),
429 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
430 seat.captured_session = Some(session);
431 }
432 AgentKind::Command => {}
433 }
434 }
435 if let Some(status) = &extracted.status
436 && !status.eq_ignore_ascii_case("success")
437 {
438 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
439 }
440 let text = if extracted.text.trim().is_empty() {
441 if stdout.trim().is_empty() {
443 stderr.trim().to_owned()
444 } else {
445 stdout.trim().to_owned()
446 }
447 } else {
448 extracted.text
449 };
450 seat.turns += 1;
451
452 Ok(AgentOutput {
453 text,
454 exit_code: code,
455 timed_out,
456 duration_ms: started.elapsed().as_millis() as u64,
457 artifacts: vec![
458 file_name(&prompt_path),
459 file_name(&out_path),
460 file_name(&err_path),
461 ],
462 quota: extracted.quota,
463 dropped: extracted.dropped,
464 commands: extracted.commands,
465 })
466}
467
468fn file_name(p: &Path) -> String {
469 p.file_name()
470 .unwrap_or_default()
471 .to_string_lossy()
472 .into_owned()
473}
474
475#[derive(Debug)]
477struct Plan {
478 argv: Vec<String>,
479 stdin: Option<String>,
480}
481
482fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
493 if matches!(kind, AgentKind::Antigravity) {
494 return format!("@{}", prompt_path.display());
495 }
496 format!(
497 "Read the file at {} and follow every instruction in it exactly. That \
498 file is your complete task description; this message contains nothing \
499 else.",
500 prompt_path.display()
501 )
502}
503
504fn build_command(
505 spec: &AgentSpec,
506 seat: &SeatState,
507 inv: &Invocation<'_>,
508 prompt_path: &Path,
509) -> Result<Plan> {
510 let mut argv: Vec<String> = Vec::new();
511 let mut stdin: Option<String> = None;
512 let delivery = spec.delivery();
513 let resuming = has_session(spec.kind, seat, inv.sessions);
514
515 match spec.kind {
516 AgentKind::Claude => {
517 argv.push("claude".to_owned());
522 argv.push("-p".to_owned());
523 argv.push("--output-format".to_owned());
524 argv.push("json".to_owned());
525 if let Some(m) = &spec.model {
526 argv.push("--model".to_owned());
527 argv.push(m.clone());
528 }
529 if inv.sessions {
530 let uuid = seat
531 .claude_session
532 .as_deref()
533 .context("claude seat is missing its session uuid")?;
534 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
535 argv.push(uuid.to_owned());
536 }
537 argv.push("--permission-mode".to_owned());
538 argv.push("bypassPermissions".to_owned());
539 if !inv.allow_write {
540 argv.push("--disallowed-tools".to_owned());
541 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
542 }
543 }
544 AgentKind::Opencode => {
545 argv.push("opencode".to_owned());
549 argv.push("run".to_owned());
550 argv.push("--format".to_owned());
551 argv.push("json".to_owned());
552 argv.push("--dir".to_owned());
553 argv.push(inv.cwd.to_string_lossy().into_owned());
554 argv.push("--auto".to_owned());
563 if let Some(m) = &spec.model {
564 argv.push("-m".to_owned());
565 argv.push(m.clone());
566 }
567 if resuming {
568 argv.push("-s".to_owned());
569 argv.push(
570 seat.captured_session
571 .clone()
572 .expect("has_session checked the id is present"),
573 );
574 }
575 }
576 AgentKind::Antigravity => {
577 argv.push("agy".to_owned());
578 argv.push("--output-format".to_owned());
579 argv.push("json".to_owned());
580 argv.push("--print-timeout".to_owned());
583 argv.push(format!("{}s", inv.timeout.as_secs()));
584 argv.push("--mode".to_owned());
585 argv.push(
586 if inv.allow_write {
587 "accept-edits"
588 } else {
589 "plan"
590 }
591 .to_owned(),
592 );
593 if inv.allow_write {
594 argv.push("--dangerously-skip-permissions".to_owned());
595 }
596 if let Some(m) = &spec.model {
597 argv.push("--model".to_owned());
598 argv.push(m.clone());
599 }
600 if resuming {
601 argv.push("--conversation".to_owned());
602 argv.push(
603 seat.captured_session
604 .clone()
605 .expect("has_session checked the id is present"),
606 );
607 }
608 let mut add_dirs: Vec<String> = Vec::new();
618 if delivery == Delivery::File || !inv.attachments.is_empty() {
619 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
620 }
621 for path in inv.attachments {
622 let Some(parent) = path.parent() else {
623 continue;
624 };
625 if parent.starts_with(inv.artifacts) {
626 continue;
627 }
628 let dir = parent.to_string_lossy().into_owned();
629 if !add_dirs.contains(&dir) {
630 add_dirs.push(dir);
631 }
632 }
633 for dir in add_dirs {
634 argv.push("--add-dir".to_owned());
635 argv.push(dir);
636 }
637 }
638 AgentKind::Codex => {
639 argv.push("codex".to_owned());
645 argv.push("exec".to_owned());
646 argv.push("--json".to_owned());
647 argv.push("--skip-git-repo-check".to_owned());
650 argv.push("-C".to_owned());
651 argv.push(inv.cwd.to_string_lossy().into_owned());
652 argv.push("--sandbox".to_owned());
657 argv.push(
658 if inv.allow_write {
659 "workspace-write"
660 } else {
661 "read-only"
662 }
663 .to_owned(),
664 );
665 argv.push("-c".to_owned());
668 argv.push("approval_policy=\"never\"".to_owned());
669 if let Some(m) = &spec.model {
670 argv.push("-m".to_owned());
671 argv.push(m.clone());
672 }
673 if resuming {
679 argv.push("resume".to_owned());
680 argv.push(
681 seat.captured_session
682 .clone()
683 .expect("has_session checked the id is present"),
684 );
685 }
686 }
687 AgentKind::Omp => {
688 argv.push("omp".to_owned());
692 argv.push("-p".to_owned());
693 argv.push("--mode=json".to_owned());
694 argv.push("--auto-approve".to_owned());
704 if let Some(m) = &spec.model {
705 argv.push("--model".to_owned());
706 argv.push(m.clone());
707 }
708 if resuming {
714 argv.push("--resume".to_owned());
715 argv.push(
716 seat.captured_session
717 .clone()
718 .expect("has_session checked the id is present"),
719 );
720 }
721 }
722 AgentKind::Command => {
723 if spec.command.is_empty() {
728 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
729 }
730 let vars: BTreeMap<&str, String> = BTreeMap::from([
731 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
732 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
733 ("{label}", seat.key.clone()),
734 ("{session}", seat.claude_session.clone().unwrap_or_default()),
735 ]);
736 for raw in &spec.command {
737 let mut arg = raw.clone();
738 for (k, v) in &vars {
739 if arg.contains(k) {
740 arg = arg.replace(k, v);
741 }
742 }
743 argv.push(arg);
744 }
745 }
746 }
747
748 argv.extend(spec.extra_args.iter().cloned());
749
750 if spec.kind == AgentKind::Antigravity {
753 argv.push("-p".to_owned());
754 }
755 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
758 argv.push("-".to_owned());
759 }
760 match delivery {
761 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
762 argv.push(pointer(spec.kind, prompt_path));
764 }
765 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
766 Delivery::Argv => argv.push(inv.prompt.to_owned()),
767 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
768 }
769
770 Ok(Plan { argv, stdin })
771}
772
773#[derive(Debug, Default)]
775struct Extracted {
776 text: String,
777 session: Option<String>,
778 status: Option<String>,
779 quota: Option<Quota>,
780 dropped: Option<Dropped>,
781 commands: Vec<CommandEvidence>,
782}
783
784fn extract(kind: AgentKind, stdout: &str) -> Extracted {
786 match kind {
787 AgentKind::Claude => {
788 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
789 return Extracted {
790 text: stdout.trim().to_owned(),
791 ..Extracted::default()
792 };
793 };
794 Extracted {
795 text: v
796 .get("result")
797 .and_then(|r| r.as_str())
798 .unwrap_or_default()
799 .to_owned(),
800 session: v
801 .get("session_id")
802 .and_then(|s| s.as_str())
803 .map(str::to_owned),
804 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
805 if e {
806 "error".to_owned()
807 } else {
808 "success".to_owned()
809 }
810 }),
811 quota: claude_quota(&v),
812 dropped: None,
815 commands: Vec::new(),
816 }
817 }
818 AgentKind::Opencode => {
819 let mut text = String::new();
821 let mut session = None;
822 for line in stdout.lines() {
823 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
824 continue;
825 };
826 if session.is_none() {
827 session = v
828 .get("sessionID")
829 .and_then(|s| s.as_str())
830 .map(str::to_owned);
831 }
832 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
833 if part.get("type").and_then(|t| t.as_str()) == Some("text")
834 && let Some(t) = part.get("text").and_then(|t| t.as_str())
835 {
836 if !text.is_empty() {
837 text.push('\n');
838 }
839 text.push_str(t);
840 }
841 }
842 Extracted {
843 text,
844 session,
845 status: None,
846 quota: None,
847 dropped: None,
848 commands: Vec::new(),
849 }
850 }
851 AgentKind::Antigravity => {
852 let obj = stdout
855 .lines()
856 .rev()
857 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
858 let Some(v) = obj else {
859 return Extracted {
860 text: stdout.trim().to_owned(),
861 ..Extracted::default()
862 };
863 };
864 Extracted {
865 text: v
866 .get("response")
867 .and_then(|r| r.as_str())
868 .unwrap_or_default()
869 .trim()
870 .to_owned(),
871 session: v
872 .get("conversation_id")
873 .and_then(|s| s.as_str())
874 .map(str::to_owned),
875 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
876 quota: None,
877 dropped: dropped_stream(&v),
878 commands: Vec::new(),
879 }
880 }
881 AgentKind::Codex => {
882 let mut text = String::new();
904 let mut session = None;
905 let mut status = None;
906 let mut commands = Vec::new();
907 for line in stdout.lines() {
908 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
909 continue;
910 };
911 match v.get("type").and_then(|t| t.as_str()) {
912 Some("thread.started") => {
913 session = v
914 .get("thread_id")
915 .and_then(|s| s.as_str())
916 .map(str::to_owned);
917 }
918 Some("item.completed") => {
919 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
920 match item.get("type").and_then(|t| t.as_str()) {
921 Some("agent_message") => {
922 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
923 text = t.trim().to_owned();
924 }
925 }
926 Some("command_execution") => {
927 commands.push(command_evidence(item));
928 }
929 _ => {}
930 }
931 }
932 Some("turn.completed") => status = Some("success".to_owned()),
933 Some("turn.failed") => status = Some("error".to_owned()),
934 _ => {}
935 }
936 }
937 Extracted {
938 text,
939 session,
940 status,
941 quota: None,
942 dropped: None,
943 commands,
944 }
945 }
946 AgentKind::Omp => {
947 let mut text = String::new();
968 let mut session = None;
969 for line in stdout.lines() {
970 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
971 continue;
972 };
973 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
974 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
975 continue;
976 }
977 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
981 {
982 Some("agent_end") => v
983 .get("messages")
984 .and_then(|m| m.as_array())
985 .map(|m| m.iter().collect())
986 .unwrap_or_default(),
987 Some("turn_end") | Some("message_end") => {
988 v.get("message").into_iter().collect()
989 }
990 _ => continue,
991 };
992 for message in messages {
993 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
994 continue;
995 }
996 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
997 continue;
998 };
999 for part in parts {
1000 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1001 continue;
1002 }
1003 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1004 && !t.trim().is_empty()
1005 {
1006 text = t.trim().to_owned();
1007 }
1008 }
1009 }
1010 }
1011 Extracted {
1012 text,
1013 session,
1014 status: None,
1015 quota: None,
1016 dropped: None,
1017 commands: Vec::new(),
1018 }
1019 }
1020 AgentKind::Command => {
1021 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1026 let quota = parsed.as_ref().and_then(claude_quota);
1027 let dropped = parsed.as_ref().and_then(dropped_stream);
1030 Extracted {
1031 text: stdout.trim().to_owned(),
1032 session: None,
1033 status: None,
1034 quota,
1035 dropped,
1036 commands: Vec::new(),
1037 }
1038 }
1039 }
1040}
1041
1042fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1049 let description = match item.get("command") {
1050 Some(serde_json::Value::String(s)) => s.clone(),
1051 Some(serde_json::Value::Array(parts)) => parts
1052 .iter()
1053 .filter_map(|p| p.as_str())
1054 .collect::<Vec<_>>()
1055 .join(" "),
1056 _ => String::new(),
1057 };
1058 let result_summary = item
1059 .get("aggregated_output")
1060 .and_then(|o| o.as_str())
1061 .map(|s| tail_chars(s.trim(), 400))
1062 .unwrap_or_default();
1063 CommandEvidence {
1064 id: item
1065 .get("id")
1066 .and_then(|s| s.as_str())
1067 .unwrap_or_default()
1068 .to_owned(),
1069 description,
1070 exit_code: item
1071 .get("exit_code")
1072 .and_then(serde_json::Value::as_i64)
1073 .map(|e| e as i32),
1074 result_summary,
1075 source: "codex".to_owned(),
1076 }
1077}
1078
1079fn tail_chars(s: &str, max: usize) -> String {
1081 let count = s.chars().count();
1082 if count <= max {
1083 return s.to_owned();
1084 }
1085 s.chars().skip(count - max).collect()
1086}
1087
1088fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1095 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1096 if !is_err {
1097 return None;
1098 }
1099 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1100 if !result.to_lowercase().contains("session limit") {
1101 return None;
1102 }
1103 let reset = result
1106 .split("resets ")
1107 .nth(1)
1108 .map(str::trim)
1109 .filter(|s| !s.is_empty())
1110 .map(str::to_owned);
1111 Some(Quota { reset })
1112}
1113
1114fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1145 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1146 if !status.eq_ignore_ascii_case("error") {
1147 return None;
1148 }
1149 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1150 if !response.trim().is_empty() {
1151 return None;
1153 }
1154 let produced = v
1155 .get("usage")
1156 .and_then(|u| u.get("output_tokens"))
1157 .and_then(serde_json::Value::as_u64)
1158 .unwrap_or(0);
1159 if produced == 0 {
1160 return None;
1162 }
1163 Some(Dropped {
1164 why: v
1165 .get("error")
1166 .and_then(|e| e.as_str())
1167 .unwrap_or("the CLI ended the stream without delivering its answer")
1168 .trim()
1169 .to_owned(),
1170 output_tokens: produced,
1171 })
1172}
1173
1174pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1176 let mut missing = Vec::new();
1177 for s in specs {
1178 let program = match s.kind {
1179 AgentKind::Command => s.command.first().map(String::as_str),
1180 other => other.program(),
1181 };
1182 if let Some(p) = program
1183 && !crate::config::which(p)
1184 && !Path::new(p).is_file()
1185 && !missing.iter().any(|m: &String| m == p)
1186 {
1187 missing.push(p.to_owned());
1188 }
1189 }
1190 missing
1191}
1192
1193pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1195 run_dir.join("artifacts")
1196}
1197
1198pub fn installed(spec: &AgentSpec) -> bool {
1200 spec.kind.program().is_none_or(crate::config::which)
1203}
1204
1205pub fn pick(
1227 agents: &[AgentSpec],
1228 want: Option<&str>,
1229 available: &dyn Fn(&AgentSpec) -> bool,
1230) -> Result<AgentSpec> {
1231 if let Some(id) = want {
1232 let spec = agents
1233 .iter()
1234 .find(|a| a.id == id)
1235 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1236 if !available(spec) {
1237 bail!(
1238 "agent `{}` needs `{}` on PATH; install it or pass a different \
1239 --agent",
1240 spec.id,
1241 spec.kind.program().unwrap_or("its command")
1242 );
1243 }
1244 return Ok(spec.clone());
1245 }
1246
1247 if agents.is_empty() {
1248 bail!(
1249 "the agent roster is empty, so there is nobody to ask: install one \
1250 of claude, opencode or agy - magi derives a roster from what is on \
1251 PATH - or add an [[agents]] entry to magi.toml."
1252 );
1253 }
1254
1255 if let Some(spec) = agents
1256 .iter()
1257 .find(|a| a.kind == AgentKind::Claude && available(a))
1258 {
1259 return Ok(spec.clone());
1260 }
1261
1262 agents
1263 .iter()
1264 .find(|a| available(a))
1265 .cloned()
1266 .with_context(|| {
1267 let missing = agents
1268 .iter()
1269 .filter_map(|a| a.kind.program())
1270 .collect::<Vec<_>>()
1271 .join(", ");
1272 format!(
1273 "no agent in the roster can be run here: install one of \
1274 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1275 you do have"
1276 )
1277 })
1278}
1279
1280fn ids(agents: &[AgentSpec]) -> String {
1281 if agents.is_empty() {
1282 return "no agents at all".to_owned();
1283 }
1284 agents
1285 .iter()
1286 .map(|a| a.id.clone())
1287 .collect::<Vec<_>>()
1288 .join(", ")
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294
1295 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1296
1297 fn command_helper(mode: &str) -> AgentSpec {
1300 AgentSpec {
1301 id: "helper".to_owned(),
1302 kind: AgentKind::Command,
1303 model: None,
1304 command: vec![
1305 std::env::current_exe()
1306 .expect("locate test helper")
1307 .to_string_lossy()
1308 .into_owned(),
1309 "--exact".to_owned(),
1310 "agent::tests::command_agent_test_helper".to_owned(),
1311 "--nocapture".to_owned(),
1312 ],
1313 extra_args: Vec::new(),
1314 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1315 prompt_delivery: None,
1316 }
1317 }
1318
1319 #[test]
1320 fn command_agent_test_helper() {
1321 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1322 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1323 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1324 Ok("no-cache") => println!(
1325 "{}",
1326 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1327 ),
1328 Ok("ignore-stdin") => println!("done"),
1329 Ok("chatty-sleep") => {
1330 println!("i-said-something");
1331 std::thread::sleep(Duration::from_secs(30));
1332 }
1333 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1334 Ok(other) => panic!("unknown command helper mode {other}"),
1335 Err(_) => {}
1336 }
1337 }
1338
1339 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1340 AgentSpec {
1341 id: "a".to_owned(),
1342 kind,
1343 model: model.map(str::to_owned),
1344 command: vec!["echo".to_owned(), "{label}".to_owned()],
1345 extra_args: Vec::new(),
1346 env: BTreeMap::new(),
1347 prompt_delivery: None,
1348 }
1349 }
1350
1351 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1352 Invocation {
1353 cwd,
1354 prompt: "do the thing",
1355 timeout: Duration::from_secs(900),
1356 allow_write,
1357 sessions: true,
1358 artifacts: art,
1359 stem: "t",
1360 run: "test-run",
1361 node: "test",
1362 cache_dir: None,
1363 attachments: &[],
1364 }
1365 }
1366
1367 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1368 build_command(
1369 &spec(kind, None),
1370 seat,
1371 &inv(Path::new("."), Path::new("/art"), allow_write),
1372 Path::new("/art/p.md"),
1373 )
1374 .unwrap()
1375 }
1376
1377 #[test]
1378 fn claude_mints_then_resumes_the_same_uuid() {
1379 let mut seat = SeatState::new("judge-1", "a", 7);
1380 let uuid = seat.claude_session.clone().unwrap();
1381 let first = plan_for(AgentKind::Claude, &seat, true);
1382 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1383 assert!(!first.argv.iter().any(|a| a == "--resume"));
1384
1385 seat.turns = 1;
1386 let second = plan_for(AgentKind::Claude, &seat, true);
1387 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1388 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1389 }
1390
1391 #[test]
1392 fn read_only_seats_cannot_edit() {
1393 let seat = SeatState::new("judge-1", "a", 7);
1394 let claude = plan_for(AgentKind::Claude, &seat, false);
1395 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1396 assert!(
1397 !plan_for(AgentKind::Claude, &seat, true)
1398 .argv
1399 .iter()
1400 .any(|a| a == "--disallowed-tools")
1401 );
1402
1403 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1404 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1405 assert!(
1406 !agy.argv
1407 .iter()
1408 .any(|a| a == "--dangerously-skip-permissions")
1409 );
1410 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1411 assert!(
1412 agy_rw
1413 .argv
1414 .windows(2)
1415 .any(|w| w == ["--mode", "accept-edits"])
1416 );
1417 assert!(
1418 agy_rw
1419 .argv
1420 .iter()
1421 .any(|a| a == "--dangerously-skip-permissions")
1422 );
1423 let agy_prompt = agy_rw
1428 .argv
1429 .iter()
1430 .position(|a| a == "-p")
1431 .map(|i| agy_rw.argv[i + 1].clone())
1432 .expect("agy takes its prompt with -p");
1433 assert!(
1434 agy_prompt.starts_with('@'),
1435 "agy must get a file reference, got {agy_prompt:?}"
1436 );
1437 assert!(
1438 !agy_prompt.contains("Read the file at"),
1439 "the prose pointer is for CLIs with no file syntax"
1440 );
1441
1442 for allow_write in [false, true] {
1447 assert!(
1448 plan_for(AgentKind::Opencode, &seat, allow_write)
1449 .argv
1450 .iter()
1451 .any(|a| a == "--auto"),
1452 "opencode needs --auto even to read (allow_write = {allow_write})"
1453 );
1454 }
1455 }
1456
1457 #[test]
1460 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1461 let mut seat = SeatState::new("judge-1", "a", 7);
1462
1463 let ro = plan_for(AgentKind::Codex, &seat, false);
1467 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1468 let rw = plan_for(AgentKind::Codex, &seat, true);
1469 assert!(
1470 rw.argv
1471 .windows(2)
1472 .any(|w| w == ["--sandbox", "workspace-write"])
1473 );
1474 for p in [&ro, &rw] {
1475 assert!(
1476 !p.argv
1477 .iter()
1478 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1479 "the bypass defeats the only enforced read-only mode we have"
1480 );
1481 assert!(
1483 p.argv
1484 .windows(2)
1485 .any(|w| w == ["-c", "approval_policy=\"never\""]),
1486 "an unattended seat that asks for approval blocks until timeout"
1487 );
1488 }
1489
1490 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1492 assert_eq!(
1493 ro.argv.last().map(String::as_str),
1494 Some("-"),
1495 "without the `-` argument codex waits for a prompt it never gets"
1496 );
1497
1498 seat.turns = 1;
1502 assert!(!has_session(AgentKind::Codex, &seat, true));
1503 assert!(
1504 !plan_for(AgentKind::Codex, &seat, true)
1505 .argv
1506 .iter()
1507 .any(|a| a == "resume")
1508 );
1509 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1510 let resumed = plan_for(AgentKind::Codex, &seat, true);
1511 let at = resumed
1512 .argv
1513 .iter()
1514 .position(|a| a == "resume")
1515 .expect("resumes by subcommand");
1516 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1517 assert!(
1518 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1519 "every option precedes the subcommand"
1520 );
1521 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1522 }
1523
1524 #[test]
1527 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
1528 let mut seat = SeatState::new("review-1", "a", 7);
1529
1530 let first = plan_for(AgentKind::Omp, &seat, false);
1534 assert!(first.argv.iter().any(|a| a == "-p"));
1535 assert!(first.argv.iter().any(|a| a == "--mode=json"));
1536 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
1537 assert!(
1538 !first.argv.iter().any(|a| a == "do the thing"),
1539 "the prompt reached argv, where Windows caps it"
1540 );
1541
1542 for allow_write in [false, true] {
1548 let p = plan_for(AgentKind::Omp, &seat, allow_write);
1549 assert!(
1550 p.argv.iter().any(|a| a == "--auto-approve"),
1551 "omp needs --auto-approve even to read (allow_write = {allow_write})"
1552 );
1553 assert!(
1554 !p.argv
1555 .iter()
1556 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1557 "nothing ever asks for the bypass"
1558 );
1559 }
1560
1561 seat.turns = 1;
1564 assert!(!has_session(AgentKind::Omp, &seat, true));
1565 assert!(
1566 !plan_for(AgentKind::Omp, &seat, true)
1567 .argv
1568 .iter()
1569 .any(|a| a == "--resume")
1570 );
1571 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
1572 let resumed = plan_for(AgentKind::Omp, &seat, true);
1573 assert!(
1574 resumed
1575 .argv
1576 .windows(2)
1577 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
1578 "a captured id is what makes the next turn a resume"
1579 );
1580 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
1583 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
1585 }
1586
1587 #[test]
1592 fn omp_takes_the_answer_without_an_agent_end_line() {
1593 let stream = concat!(
1594 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
1595 "\n",
1596 r#"{"type":"agent_start"}"#,
1597 "\n",
1598 r#"{"type":"turn_start"}"#,
1599 "\n",
1600 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
1601 "\n",
1602 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
1603 "\n",
1604 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
1605 "\n",
1606 );
1607 let out = extract(AgentKind::Omp, stream);
1608 assert_eq!(
1609 out.text, "{\"vote\":\"approve\"}",
1610 "the last assistant text block is the answer even with no agent_end"
1611 );
1612 assert_eq!(
1613 out.session.as_deref(),
1614 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
1615 );
1616 }
1617
1618 #[test]
1622 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
1623 let stream = concat!(
1624 r#"{"type":"session","version":3,"id":"s1"}"#,
1625 "\n",
1626 "{\"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問題ありません。\"}]}]}",
1627 "\n",
1628 );
1629 let out = extract(AgentKind::Omp, stream);
1630 assert_eq!(
1631 out.text, "## 判定\n\n問題ありません。",
1632 "the narration is not the answer, and non-ASCII survives intact"
1633 );
1634 assert_eq!(out.session.as_deref(), Some("s1"));
1635 }
1636
1637 #[test]
1640 fn omp_skips_non_json_lines() {
1641 let stream = concat!(
1642 "Warning: some omp notice\n",
1643 r#"{"type":"session","version":3,"id":"s2"}"#,
1644 "\n",
1645 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
1646 "\n",
1647 "trailing junk",
1648 "\n",
1649 );
1650 let out = extract(AgentKind::Omp, stream);
1651 assert_eq!(out.text, "the answer");
1652 assert_eq!(out.session.as_deref(), Some("s2"));
1653 }
1654
1655 #[test]
1657 fn codex_takes_the_last_agent_message_and_the_thread_id() {
1658 let stream = concat!(
1659 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1660 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1661 "\n",
1662 r#"{"type":"turn.started"}"#,
1663 "\n",
1664 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1665 "\n",
1666 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1667 "\n",
1668 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1669 "\n",
1670 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1671 "\n",
1672 );
1673 let out = extract(AgentKind::Codex, stream);
1674 assert_eq!(
1675 out.text, "{\"verdict\": \"ok\"}",
1676 "the last agent message is the answer; earlier ones narrate"
1677 );
1678 assert_eq!(
1679 out.session.as_deref(),
1680 Some("01a07440-4545-7492-85c1-024e3259a90a")
1681 );
1682 assert_eq!(out.status.as_deref(), Some("success"));
1683
1684 let failed = concat!(
1685 r#"{"type":"thread.started","thread_id":"t1"}"#,
1686 "\n",
1687 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1688 "\n",
1689 );
1690 assert_eq!(
1691 extract(AgentKind::Codex, failed).status.as_deref(),
1692 Some("error")
1693 );
1694 }
1695
1696 #[test]
1697 fn captured_sessions_resume_only_once_reported() {
1698 let mut seat = SeatState::new("impl-A", "a", 7);
1699 seat.turns = 1;
1700 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1701 assert!(!has_session(kind, &seat, true));
1702 let p = plan_for(kind, &seat, true);
1703 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1704 }
1705
1706 seat.captured_session = Some("sid".to_owned());
1707 assert!(has_session(AgentKind::Opencode, &seat, true));
1708 assert!(
1709 plan_for(AgentKind::Opencode, &seat, true)
1710 .argv
1711 .windows(2)
1712 .any(|w| w == ["-s", "sid"])
1713 );
1714 assert!(
1715 plan_for(AgentKind::Antigravity, &seat, true)
1716 .argv
1717 .windows(2)
1718 .any(|w| w == ["--conversation", "sid"])
1719 );
1720 }
1721
1722 #[test]
1723 fn sessions_disabled_never_resumes() {
1724 let mut seat = SeatState::new("impl-A", "a", 7);
1725 seat.turns = 3;
1726 seat.captured_session = Some("sid".to_owned());
1727 for kind in [
1728 AgentKind::Claude,
1729 AgentKind::Opencode,
1730 AgentKind::Antigravity,
1731 ] {
1732 assert!(!has_session(kind, &seat, false));
1733 }
1734 }
1735
1736 #[test]
1737 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1738 let seat = SeatState::new("judge-1", "a", 7);
1739 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1740 let p = plan_for(kind, &seat, false);
1741 assert!(
1742 p.argv.iter().all(|a| a != "do the thing"),
1743 "{kind:?} put the prompt on the command line"
1744 );
1745 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1746 }
1747 let p = plan_for(AgentKind::Antigravity, &seat, false);
1749 let at = p.argv.iter().position(|a| a == "-p").unwrap();
1750 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1751 assert!(p.stdin.is_none());
1752 }
1753
1754 #[test]
1755 fn agy_print_timeout_tracks_the_node_budget() {
1756 let seat = SeatState::new("impl-A", "a", 7);
1757 let p = build_command(
1758 &spec(AgentKind::Antigravity, None),
1759 &seat,
1760 &Invocation {
1761 cwd: Path::new("."),
1762 prompt: "p",
1763 timeout: Duration::from_secs(3600),
1764 allow_write: true,
1765 sessions: true,
1766 artifacts: Path::new("/art"),
1767 stem: "t",
1768 run: "test-run",
1769 node: "test",
1770 cache_dir: None,
1771 attachments: &[],
1772 },
1773 Path::new("/art/p.md"),
1774 )
1775 .unwrap();
1776 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1777 }
1778
1779 #[test]
1786 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
1787 let mut s = spec(AgentKind::Antigravity, None);
1788 s.prompt_delivery = Some(Delivery::Argv);
1789 let seat = SeatState::new("talk", "a", 7);
1790 let atts = [PathBuf::from("/art/attachments/abc.png")];
1791
1792 let without = build_command(
1793 &s,
1794 &seat,
1795 &Invocation {
1796 attachments: &[],
1797 ..inv(Path::new("."), Path::new("/art"), true)
1798 },
1799 Path::new("/art/p.md"),
1800 )
1801 .unwrap();
1802 assert!(
1803 !without.argv.iter().any(|a| a == "--add-dir"),
1804 "no attachment, no reason to widen the sandbox: {without:?}"
1805 );
1806
1807 let with = build_command(
1808 &s,
1809 &seat,
1810 &Invocation {
1811 attachments: &atts,
1812 ..inv(Path::new("."), Path::new("/art"), true)
1813 },
1814 Path::new("/art/p.md"),
1815 )
1816 .unwrap();
1817 assert!(
1818 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1819 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
1820 );
1821 }
1822
1823 #[test]
1829 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
1830 let seat = SeatState::new("plan", "a", 7);
1831 let atts = [
1832 PathBuf::from("/art/attachments/own.png"),
1833 PathBuf::from("/other-chat/attachments/inherited.png"),
1834 ];
1835
1836 let p = build_command(
1837 &spec(AgentKind::Antigravity, None),
1838 &seat,
1839 &Invocation {
1840 attachments: &atts,
1841 ..inv(Path::new("."), Path::new("/art"), true)
1842 },
1843 Path::new("/art/p.md"),
1844 )
1845 .unwrap();
1846
1847 assert!(
1848 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1849 "this conversation's own artifacts dir must still be granted: {p:?}"
1850 );
1851 assert!(
1852 p.argv
1853 .windows(2)
1854 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
1855 "the inherited attachment's own directory must be granted too: {p:?}"
1856 );
1857 }
1858
1859 #[test]
1860 fn command_agents_get_placeholders_substituted() {
1861 let seat = SeatState::new("impl-A", "a", 7);
1862 let p = plan_for(AgentKind::Command, &seat, true);
1863 assert_eq!(p.argv[0], "echo");
1864 assert_eq!(p.argv[1], "impl-A");
1865 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1866 }
1867
1868 #[test]
1869 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1870 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1872 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1873 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1874 let out = extract(AgentKind::Claude, stdout);
1875 let quota = out.quota.as_ref().expect("rate limit must be detected");
1876 assert_eq!(
1877 quota.reset.as_deref(),
1878 Some("4:50am (Asia/Tokyo)"),
1879 "reset time read from the body"
1880 );
1881 }
1882
1883 #[test]
1884 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1885 let out = extract(
1886 AgentKind::Claude,
1887 r#"{"is_error":true,"result":"session limit reached"}"#,
1888 );
1889 let quota = out.quota.expect("rate limit detected without a reset");
1890 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1891 }
1892
1893 #[test]
1894 fn ordinary_failures_are_never_quota() {
1895 let claude_fail = extract(
1897 AgentKind::Claude,
1898 r#"{"is_error":true,"result":"account does not exist"}"#,
1899 );
1900 assert!(claude_fail.quota.is_none());
1901
1902 let cmd_fail = extract(AgentKind::Command, "boom");
1904 assert!(cmd_fail.quota.is_none());
1905
1906 let success = extract(
1908 AgentKind::Command,
1909 r#"{"is_error":false,"result":"session limit is fine"}"#,
1910 );
1911 assert!(success.quota.is_none());
1912 }
1913
1914 #[test]
1922 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
1923 let stream = concat!(
1924 r#"{"type":"thread.started","thread_id":"t1"}"#,
1925 "\n",
1926 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"}}"#,
1927 "\n",
1928 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"}}"#,
1929 "\n",
1930 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
1931 "\n",
1932 r#"{"type":"turn.completed"}"#,
1933 "\n",
1934 );
1935 let out = extract(AgentKind::Codex, stream);
1936 assert_eq!(out.text, "Both tests in the target pass.");
1937 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
1938
1939 let paired = &out.commands[0];
1940 assert_eq!(paired.id, "item49");
1941 assert_eq!(paired.exit_code, Some(1));
1942 assert!(paired.description.contains("graph_cached_gate"));
1943 assert!(paired.result_summary.contains("1 failed"));
1944
1945 let solo = &out.commands[1];
1946 assert_eq!(solo.exit_code, Some(0));
1947
1948 assert!(
1952 out.commands
1953 .iter()
1954 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
1955 "a failed run of the actual target must still be visible: {:?}",
1956 out.commands
1957 );
1958 }
1959
1960 #[test]
1961 fn command_agent_can_carry_the_claude_quota_shape() {
1962 let out = extract(
1963 AgentKind::Command,
1964 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1965 );
1966 assert!(
1967 out.quota.is_some(),
1968 "a wrapper emitting the claude shape counts as quota"
1969 );
1970 }
1971
1972 #[test]
1973 fn claude_json_result_is_extracted() {
1974 let out = extract(
1975 AgentKind::Claude,
1976 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1977 );
1978 assert_eq!(out.text, "all done");
1979 assert_eq!(out.session.as_deref(), Some("abc"));
1980 assert_eq!(out.status.as_deref(), Some("success"));
1981 }
1982
1983 #[test]
1998 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
1999 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"}"#;
2000 let out = extract(AgentKind::Claude, stdout);
2001 assert_eq!(out.status.as_deref(), Some("success"));
2002 assert!(out.quota.is_none());
2003 assert!(!out.text.trim().is_empty());
2004
2005 let agent_out = AgentOutput {
2006 text: out.text.clone(),
2007 exit_code: Some(0),
2008 timed_out: false,
2009 duration_ms: 500,
2010 artifacts: Vec::new(),
2011 quota: out.quota,
2012 dropped: out.dropped,
2013 commands: out.commands,
2014 };
2015 assert!(
2016 agent_out.usable(),
2017 "the CLI turn itself ended cleanly and must read as usable"
2018 );
2019 assert!(
2020 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2021 "a clean CLI turn is not proof the node's own report ever arrived"
2022 );
2023 }
2024
2025 #[test]
2026 fn opencode_event_stream_is_concatenated() {
2027 let stream = concat!(
2028 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2029 "\n",
2030 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2031 "\n",
2032 "garbage line\n",
2033 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2034 "\n"
2035 );
2036 let out = extract(AgentKind::Opencode, stream);
2037 assert_eq!(out.text, "first\nsecond");
2038 assert_eq!(out.session.as_deref(), Some("ses_1"));
2039 }
2040
2041 #[test]
2042 fn agy_json_survives_a_leading_warning_line() {
2043 let stdout = concat!(
2044 "warning: --mode plan has no effect while slash commands are disabled.\n",
2045 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2046 "\n"
2047 );
2048 let out = extract(AgentKind::Antigravity, stdout);
2049 assert_eq!(out.text, "persimmon");
2050 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2051 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2052 }
2053
2054 const AGY_DROPPED: &str = concat!(
2061 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2062 r#""response":"","error":"the connection to the agent was interrupted before "#,
2063 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2064 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2065 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2066 r#""total_tokens":274380}}"#
2067 );
2068
2069 #[test]
2070 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2071 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2072 let dropped = out.dropped.expect("recognised as undelivered work");
2073 assert_eq!(dropped.output_tokens, 14267);
2074 assert!(
2075 dropped.why.contains("subscriber fell behind"),
2076 "the CLI's own words are kept for the record: {}",
2077 dropped.why
2078 );
2079 assert_eq!(
2082 out.session.as_deref(),
2083 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
2084 );
2085 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
2086 }
2087
2088 #[test]
2089 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
2090 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
2094 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
2095
2096 let answered = concat!(
2099 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
2100 r#""usage":{"output_tokens":10}}"#
2101 );
2102 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
2103
2104 let ok = concat!(
2106 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
2107 r#""usage":{"output_tokens":10}}"#
2108 );
2109 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
2110 }
2111
2112 #[test]
2113 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
2114 let out = AgentOutput {
2115 text: String::new(),
2116 exit_code: Some(1),
2117 timed_out: false,
2118 duration_ms: 431_194,
2119 artifacts: Vec::new(),
2120 quota: None,
2121 dropped: Some(Dropped {
2122 why: "subscriber fell behind updates".to_owned(),
2123 output_tokens: 14267,
2124 }),
2125 commands: Vec::new(),
2126 };
2127 assert!(!out.usable());
2128 assert!(out.work_undelivered());
2129 assert!(!out.quota_exhausted());
2132 }
2133
2134 #[test]
2135 fn non_json_stdout_falls_back_to_raw_text() {
2136 let out = extract(AgentKind::Antigravity, "plain answer\n");
2137 assert_eq!(out.text, "plain answer");
2138 assert!(out.session.is_none());
2139 }
2140
2141 #[tokio::test]
2142 async fn command_agent_round_trip_writes_artifacts() {
2143 let dir = tempfile::tempdir().unwrap();
2144 let art = dir.path().join("artifacts");
2145 let mut seat = SeatState::new("impl-A", "a", 7);
2146 let s = command_helper("reply");
2147 let out = invoke(
2148 &s,
2149 &mut seat,
2150 &Invocation {
2151 cwd: dir.path(),
2152 prompt: "unused",
2153 timeout: Duration::from_secs(30),
2154 allow_write: true,
2155 sessions: true,
2156 artifacts: &art,
2157 stem: "impl-A",
2158 run: "test-run",
2159 node: "test",
2160 cache_dir: None,
2161 attachments: &[],
2162 },
2163 )
2164 .await
2165 .unwrap();
2166 assert!(out.usable(), "{out:?}");
2167 assert!(out.text.contains("hello impl-A"), "{}", out.text);
2168 assert_eq!(seat.turns, 1);
2169 assert!(art.join("impl-A.prompt.md").is_file());
2170 assert!(art.join("impl-A.out").is_file());
2171 }
2172
2173 #[tokio::test]
2174 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
2175 let dir = tempfile::tempdir().unwrap();
2179 let cache = dir.path().join("magi-cache");
2180 let mut seat = SeatState::new("impl-A", "a", 7);
2181 let s = command_helper("cache");
2182 let out = invoke(
2183 &s,
2184 &mut seat,
2185 &Invocation {
2186 cwd: dir.path(),
2187 prompt: "unused",
2188 timeout: Duration::from_secs(30),
2189 allow_write: true,
2190 sessions: true,
2191 artifacts: &dir.path().join("artifacts"),
2192 stem: "cache",
2193 run: "test-run",
2194 node: "test",
2195 cache_dir: Some(&cache),
2196 attachments: &[],
2197 },
2198 )
2199 .await
2200 .unwrap();
2201 assert!(out.usable(), "{out:?}");
2202 assert!(
2203 out.text.contains(cache.to_string_lossy().as_ref()),
2204 "the seat must see CARGO_TARGET_DIR = the shared cache"
2205 );
2206 }
2207
2208 #[tokio::test]
2209 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
2210 let previous = std::env::var("CARGO_TARGET_DIR").ok();
2218 unsafe {
2223 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
2224 }
2225 let dir = tempfile::tempdir().unwrap();
2226 let mut seat = SeatState::new("review-1", "a", 7);
2227 let s = command_helper("no-cache");
2228 let result = invoke(
2229 &s,
2230 &mut seat,
2231 &Invocation {
2232 cwd: dir.path(),
2233 prompt: "unused",
2234 timeout: Duration::from_secs(30),
2235 allow_write: false,
2236 sessions: true,
2237 artifacts: &dir.path().join("artifacts"),
2238 stem: "no-cache",
2239 run: "test-run",
2240 node: "test",
2241 cache_dir: None,
2242 attachments: &[],
2243 },
2244 )
2245 .await;
2246 unsafe {
2251 match &previous {
2252 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
2253 None => std::env::remove_var("CARGO_TARGET_DIR"),
2254 }
2255 }
2256 let out = result.unwrap();
2257 assert!(out.usable(), "{out:?}");
2258 assert!(
2259 out.text.contains("ABSENT"),
2260 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
2261 out.text
2262 );
2263 }
2264
2265 #[tokio::test]
2266 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
2267 let dir = tempfile::tempdir().unwrap();
2268 let mut seat = SeatState::new("impl-A", "a", 7);
2269 let s = command_helper("ignore-stdin");
2272 let big = "x".repeat(1_000_000);
2273 let out = invoke(
2274 &s,
2275 &mut seat,
2276 &Invocation {
2277 cwd: dir.path(),
2278 prompt: &big,
2279 timeout: Duration::from_secs(60),
2280 allow_write: true,
2281 sessions: true,
2282 artifacts: &dir.path().join("artifacts"),
2283 stem: "big",
2284 run: "test-run",
2285 node: "test",
2286 cache_dir: None,
2287 attachments: &[],
2288 },
2289 )
2290 .await
2291 .unwrap();
2292 assert!(out.usable(), "{out:?}");
2293 assert!(out.text.contains("done"), "{}", out.text);
2294 }
2295
2296 #[tokio::test]
2297 async fn timeout_is_reported_not_hung() {
2298 let dir = tempfile::tempdir().unwrap();
2299 let mut seat = SeatState::new("impl-A", "a", 7);
2300 let s = command_helper("sleep");
2301 let out = invoke(
2302 &s,
2303 &mut seat,
2304 &Invocation {
2305 cwd: dir.path(),
2306 prompt: "unused",
2307 timeout: Duration::from_millis(300),
2308 allow_write: true,
2309 sessions: true,
2310 artifacts: &dir.path().join("artifacts"),
2311 stem: "slow",
2312 run: "test-run",
2313 node: "test",
2314 cache_dir: None,
2315 attachments: &[],
2316 },
2317 )
2318 .await
2319 .unwrap();
2320 assert!(out.timed_out);
2321 assert!(!out.usable());
2322 }
2323
2324 #[tokio::test]
2325 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
2326 let dir = tempfile::tempdir().unwrap();
2332 let artifacts = dir.path().join("artifacts");
2333 let mut seat = SeatState::new("impl-A", "a", 7);
2334 let s = command_helper("chatty-sleep");
2335 let out = invoke(
2336 &s,
2337 &mut seat,
2338 &Invocation {
2339 cwd: dir.path(),
2340 prompt: "unused",
2341 timeout: Duration::from_secs(10),
2346 allow_write: true,
2347 sessions: true,
2348 artifacts: &artifacts,
2349 stem: "chatty",
2350 run: "test-run",
2351 node: "test",
2352 cache_dir: None,
2353 attachments: &[],
2354 },
2355 )
2356 .await
2357 .unwrap();
2358
2359 assert!(out.timed_out, "{out:?}");
2360 assert!(!out.usable(), "a cut-off answer is still not an answer");
2361 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
2362 assert!(
2363 recorded.contains("i-said-something"),
2364 "the artifact must keep what arrived before the kill, got {recorded:?}"
2365 );
2366 assert!(
2367 out.text.contains("i-said-something"),
2368 "and the graph must be able to see it too, got {:?}",
2369 out.text
2370 );
2371 }
2372
2373 #[test]
2374 fn missing_programs_reports_command_binaries() {
2375 let mut s = spec(AgentKind::Command, None);
2376 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
2377 assert_eq!(
2378 missing_programs(&[s]),
2379 ["definitely-not-a-real-binary-xyz".to_owned()]
2380 );
2381 }
2382
2383 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
2384 AgentSpec {
2385 id: id.to_owned(),
2386 kind,
2387 model: None,
2388 command: Vec::new(),
2389 extra_args: Vec::new(),
2390 env: BTreeMap::new(),
2391 prompt_delivery: None,
2392 }
2393 }
2394
2395 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
2398 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
2399 }
2400
2401 #[test]
2402 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
2403 let agents = [
2404 pick_spec("oc", AgentKind::Opencode),
2405 pick_spec("opus", AgentKind::Claude),
2406 pick_spec("agy", AgentKind::Antigravity),
2407 ];
2408 let got = pick(&agents, None, &without(&[])).expect("a pick");
2409 assert_eq!(got.id, "opus");
2410 }
2411
2412 #[test]
2413 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
2414 let agents = [
2415 pick_spec("opus", AgentKind::Claude),
2416 pick_spec("oc", AgentKind::Opencode),
2417 pick_spec("agy", AgentKind::Antigravity),
2418 ];
2419 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
2420 assert_eq!(got.id, "agy");
2421 }
2422
2423 #[test]
2424 fn pick_on_an_empty_roster_says_what_to_install() {
2425 let msg = pick(&[], None, &without(&[]))
2426 .expect_err("nobody to ask")
2427 .to_string();
2428 assert!(msg.contains("roster is empty"), "{msg}");
2429 assert!(msg.contains("claude"), "{msg}");
2430 assert!(msg.contains("magi.toml"), "{msg}");
2431 }
2432
2433 #[test]
2434 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
2435 let agents = [
2436 pick_spec("opus", AgentKind::Claude),
2437 pick_spec("oc", AgentKind::Opencode),
2438 ];
2439 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
2440 let msg = format!("{err:#}");
2441 assert!(msg.contains("claude"), "{msg}");
2442 assert!(msg.contains("opencode"), "{msg}");
2443 }
2444
2445 #[test]
2446 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
2447 let agents = [
2448 pick_spec("opus", AgentKind::Claude),
2449 pick_spec("oc", AgentKind::Opencode),
2450 ];
2451 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
2452 assert_eq!(got.id, "oc");
2453 }
2454
2455 #[test]
2456 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
2457 let agents = [
2458 pick_spec("opus", AgentKind::Claude),
2459 pick_spec("oc", AgentKind::Opencode),
2460 ];
2461 let msg = pick(&agents, Some("gemini"), &without(&[]))
2462 .expect_err("no such agent")
2463 .to_string();
2464 assert!(msg.contains("gemini"), "{msg}");
2465 assert!(msg.contains("opus, oc"), "{msg}");
2466 }
2467
2468 #[test]
2469 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
2470 let agents = [
2471 pick_spec("opus", AgentKind::Claude),
2472 pick_spec("oc", AgentKind::Opencode),
2473 ];
2474 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
2475 .expect_err("must not silently substitute another model")
2476 .to_string();
2477 assert!(msg.contains("opencode"), "{msg}");
2478 assert!(msg.contains("--agent"), "{msg}");
2479 }
2480}