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::{AgentChoice, AgentKind, AgentSpec, Delivery};
45use crate::proc::Quiet as _;
46use crate::rng::SplitMix64;
47
48#[derive(Debug, Clone, PartialEq, Eq, 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 unsandboxed: bool,
111 pub sessions: bool,
113 pub artifacts: &'a Path,
115 pub stem: &'a str,
117 pub run: &'a str,
121 pub node: &'a str,
125 pub cache_dir: Option<&'a Path>,
131 pub attachments: &'a [PathBuf],
138 pub writable: &'a [PathBuf],
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct Quota {
153 #[serde(default)]
155 pub reset: Option<String>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct Dropped {
166 pub why: String,
168 pub output_tokens: u64,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct CommandEvidence {
188 pub id: String,
190 pub description: String,
192 pub exit_code: Option<i32>,
194 pub result_summary: String,
196 pub source: String,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct AgentOutput {
203 pub text: String,
205 pub exit_code: Option<i32>,
207 pub timed_out: bool,
209 pub duration_ms: u64,
211 pub artifacts: Vec<String>,
213 #[serde(default)]
217 pub quota: Option<Quota>,
218 #[serde(default)]
222 pub dropped: Option<Dropped>,
223 #[serde(default)]
227 pub commands: Vec<CommandEvidence>,
228 #[serde(default)]
233 pub context_tokens: Option<u64>,
234}
235
236impl AgentOutput {
237 pub fn usable(&self) -> bool {
239 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
240 }
241
242 pub fn quota_exhausted(&self) -> bool {
244 self.quota.is_some()
245 }
246
247 pub fn work_undelivered(&self) -> bool {
252 self.dropped.is_some()
253 }
254}
255
256const PIPE_GRACE: Duration = Duration::from_secs(3);
261
262type Captured = Arc<Mutex<Vec<u8>>>;
264
265fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
275where
276 R: tokio::io::AsyncRead + Unpin + Send + 'static,
277{
278 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
279 let Some(mut pipe) = pipe else {
280 return (buf, None);
281 };
282 let sink = Arc::clone(&buf);
283 let handle = tokio::spawn(async move {
284 let mut chunk = [0u8; 8192];
285 loop {
286 match pipe.read(&mut chunk).await {
287 Ok(0) | Err(_) => break,
288 Ok(n) => {
289 if let Ok(mut guard) = sink.lock() {
290 guard.extend_from_slice(&chunk[..n]);
291 }
292 }
293 }
294 }
295 });
296 (buf, Some(handle))
297}
298
299async fn collect(
304 buf: &Captured,
305 handle: Option<tokio::task::JoinHandle<()>>,
306 grace: Duration,
307) -> String {
308 if let Some(handle) = handle {
309 if tokio::time::timeout(grace, handle).await.is_err() {
310 tracing::debug!("a pipe is still held open after the child exited");
311 }
312 }
313 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
314 String::from_utf8_lossy(&bytes).into_owned()
315}
316
317pub async fn invoke(
319 spec: &AgentSpec,
320 seat: &mut SeatState,
321 inv: &Invocation<'_>,
322) -> Result<AgentOutput> {
323 tokio::fs::create_dir_all(inv.artifacts)
324 .await
325 .with_context(|| format!("create {}", inv.artifacts.display()))?;
326 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
327 tokio::fs::write(&prompt_path, inv.prompt)
328 .await
329 .with_context(|| format!("write {}", prompt_path.display()))?;
330
331 let plan = build_command(spec, seat, inv, &prompt_path)?;
332 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
333
334 let started = Instant::now();
335 let child_path = spec
338 .env
339 .iter()
340 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
341 .map(|(_, v)| std::ffi::OsString::from(v))
342 .or_else(|| std::env::var_os("PATH"))
343 .unwrap_or_default();
344 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
345 || plan.argv[0].clone().into(),
346 std::path::PathBuf::into_os_string,
347 );
348 let mut cmd = Command::new(program);
349 cmd.args(&plan.argv[1..])
350 .current_dir(inv.cwd)
351 .envs(&spec.env)
352 .env("MAGI_SEAT", &seat.key)
353 .env("MAGI_TURN", seat.turns.to_string())
354 .env("MAGI_RUN", inv.run)
355 .env("MAGI_NODE", inv.node)
356 .env("MAGI_PROMPT_FILE", &prompt_path)
357 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
358 .env("GIT_TERMINAL_PROMPT", "0")
359 .stdin(if plan.stdin.is_some() {
360 Stdio::piped()
361 } else {
362 Stdio::null()
363 })
364 .stdout(Stdio::piped())
365 .stderr(Stdio::piped())
366 .kill_on_drop(true)
367 .quiet();
370 if let Some(cache) = inv.cache_dir {
371 cmd.env("CARGO_TARGET_DIR", cache);
374 } else {
375 cmd.env_remove("CARGO_TARGET_DIR");
383 }
384
385 let mut child = cmd
386 .spawn()
387 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
388 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
392 tokio::spawn(async move {
393 sink.write_all(body.as_bytes()).await.ok();
394 sink.shutdown().await.ok();
395 });
396 }
397
398 let (out_buf, out_reader) = drain(child.stdout.take());
415 let (err_buf, err_reader) = drain(child.stderr.take());
416
417 let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
418 Ok(res) => {
419 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
420 (status.code(), false)
421 }
422 Err(_) => {
423 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
424 child.start_kill().ok();
426 (None, true)
427 }
428 };
429
430 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
435 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
436
437 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
438 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
439 tokio::fs::write(&out_path, &stdout).await.ok();
440 tokio::fs::write(&err_path, &stderr).await.ok();
441
442 let mut extracted = extract(spec.kind, &stdout);
443 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
444 extracted.quota = agy_quota(&stdout, &stderr);
445 }
446 if let Some(quota) = &extracted.quota {
447 extracted.dropped = None;
450 tracing::warn!(
451 seat = %seat.key,
452 agent = %spec.id,
453 reset = ?quota.reset,
454 "agent is out of quota"
455 );
456 }
457 if let Some(session) = extracted.session {
458 match spec.kind {
459 AgentKind::Claude => seat.claude_session = Some(session),
460 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
461 seat.captured_session = Some(session);
462 }
463 AgentKind::Command => {}
464 }
465 }
466 if let Some(status) = &extracted.status
467 && !status.eq_ignore_ascii_case("success")
468 {
469 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
470 }
471 let text = if extracted.text.trim().is_empty() {
472 if stdout.trim().is_empty() {
474 stderr.trim().to_owned()
475 } else {
476 stdout.trim().to_owned()
477 }
478 } else {
479 extracted.text
480 };
481 seat.turns += 1;
482
483 Ok(AgentOutput {
484 text,
485 exit_code: code,
486 timed_out,
487 duration_ms: started.elapsed().as_millis() as u64,
488 artifacts: vec![
489 file_name(&prompt_path),
490 file_name(&out_path),
491 file_name(&err_path),
492 ],
493 quota: extracted.quota,
494 dropped: extracted.dropped,
495 commands: extracted.commands,
496 context_tokens: extracted.context_tokens,
497 })
498}
499
500fn file_name(p: &Path) -> String {
501 p.file_name()
502 .unwrap_or_default()
503 .to_string_lossy()
504 .into_owned()
505}
506
507#[derive(Debug)]
509struct Plan {
510 argv: Vec<String>,
511 stdin: Option<String>,
512}
513
514fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
525 if matches!(kind, AgentKind::Antigravity) {
526 return format!("@{}", prompt_path.display());
527 }
528 format!(
529 "Read the file at {} and follow every instruction in it exactly. That \
530 file is your complete task description; this message contains nothing \
531 else.",
532 prompt_path.display()
533 )
534}
535
536fn build_command(
537 spec: &AgentSpec,
538 seat: &SeatState,
539 inv: &Invocation<'_>,
540 prompt_path: &Path,
541) -> Result<Plan> {
542 let mut argv: Vec<String> = Vec::new();
543 let mut stdin: Option<String> = None;
544 let delivery = spec.delivery();
545 let resuming = has_session(spec.kind, seat, inv.sessions);
546
547 match spec.kind {
548 AgentKind::Claude => {
549 argv.push("claude".to_owned());
554 argv.push("-p".to_owned());
555 argv.push("--output-format".to_owned());
556 argv.push("json".to_owned());
557 if let Some(m) = &spec.model {
558 argv.push("--model".to_owned());
559 argv.push(m.clone());
560 }
561 if inv.sessions {
562 let uuid = seat
563 .claude_session
564 .as_deref()
565 .context("claude seat is missing its session uuid")?;
566 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
567 argv.push(uuid.to_owned());
568 }
569 argv.push("--permission-mode".to_owned());
570 argv.push("bypassPermissions".to_owned());
571 if !inv.allow_write {
572 argv.push("--disallowed-tools".to_owned());
573 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
574 }
575 }
576 AgentKind::Opencode => {
577 argv.push("opencode".to_owned());
581 argv.push("run".to_owned());
582 argv.push("--format".to_owned());
583 argv.push("json".to_owned());
584 argv.push("--dir".to_owned());
585 argv.push(inv.cwd.to_string_lossy().into_owned());
586 argv.push("--auto".to_owned());
595 if let Some(m) = &spec.model {
596 argv.push("-m".to_owned());
597 argv.push(m.clone());
598 }
599 if resuming {
600 argv.push("-s".to_owned());
601 argv.push(
602 seat.captured_session
603 .clone()
604 .expect("has_session checked the id is present"),
605 );
606 }
607 }
608 AgentKind::Antigravity => {
609 argv.push("agy".to_owned());
610 argv.push("--output-format".to_owned());
611 argv.push("json".to_owned());
612 argv.push("--print-timeout".to_owned());
615 argv.push(format!("{}s", inv.timeout.as_secs()));
616 argv.push("--mode".to_owned());
617 argv.push(
618 if inv.allow_write {
619 "accept-edits"
620 } else {
621 "plan"
622 }
623 .to_owned(),
624 );
625 if inv.allow_write {
626 argv.push("--dangerously-skip-permissions".to_owned());
627 }
628 if let Some(m) = &spec.model {
629 argv.push("--model".to_owned());
630 argv.push(m.clone());
631 }
632 if resuming {
633 argv.push("--conversation".to_owned());
634 argv.push(
635 seat.captured_session
636 .clone()
637 .expect("has_session checked the id is present"),
638 );
639 }
640 let mut add_dirs: Vec<String> = Vec::new();
650 if delivery == Delivery::File || !inv.attachments.is_empty() {
651 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
652 }
653 for path in inv.attachments {
654 let Some(parent) = path.parent() else {
655 continue;
656 };
657 if parent.starts_with(inv.artifacts) {
658 continue;
659 }
660 let dir = parent.to_string_lossy().into_owned();
661 if !add_dirs.contains(&dir) {
662 add_dirs.push(dir);
663 }
664 }
665 for dir in add_dirs {
666 argv.push("--add-dir".to_owned());
667 argv.push(dir);
668 }
669 }
670 AgentKind::Codex => {
671 argv.push("codex".to_owned());
677 argv.push("exec".to_owned());
678 argv.push("--json".to_owned());
679 argv.push("--skip-git-repo-check".to_owned());
682 argv.push("-C".to_owned());
683 argv.push(inv.cwd.to_string_lossy().into_owned());
684 if inv.allow_write && inv.unsandboxed {
693 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
694 } else {
695 argv.push("--sandbox".to_owned());
696 argv.push(
697 if inv.allow_write {
698 "workspace-write"
699 } else {
700 "read-only"
701 }
702 .to_owned(),
703 );
704 argv.push("-c".to_owned());
707 argv.push("approval_policy=\"never\"".to_owned());
708 if inv.allow_write && !inv.writable.is_empty() {
709 let roots: Vec<String> = inv
710 .writable
711 .iter()
712 .map(|p| p.to_string_lossy().into_owned())
713 .collect();
714 argv.push("-c".to_owned());
715 argv.push(format!(
716 "sandbox_workspace_write.writable_roots={}",
717 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
718 ));
719 }
720 }
721 if let Some(m) = &spec.model {
722 argv.push("-m".to_owned());
723 argv.push(m.clone());
724 }
725 if resuming {
731 argv.push("resume".to_owned());
732 argv.push(
733 seat.captured_session
734 .clone()
735 .expect("has_session checked the id is present"),
736 );
737 }
738 }
739 AgentKind::Omp => {
740 argv.push("omp".to_owned());
744 argv.push("-p".to_owned());
745 argv.push("--mode=json".to_owned());
746 argv.push("--auto-approve".to_owned());
756 if let Some(m) = &spec.model {
757 argv.push("--model".to_owned());
758 argv.push(m.clone());
759 }
760 if resuming {
766 argv.push("--resume".to_owned());
767 argv.push(
768 seat.captured_session
769 .clone()
770 .expect("has_session checked the id is present"),
771 );
772 }
773 }
774 AgentKind::Command => {
775 if spec.command.is_empty() {
780 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
781 }
782 let vars: BTreeMap<&str, String> = BTreeMap::from([
783 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
784 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
785 ("{label}", seat.key.clone()),
786 ("{session}", seat.claude_session.clone().unwrap_or_default()),
787 ]);
788 for raw in &spec.command {
789 let mut arg = raw.clone();
790 for (k, v) in &vars {
791 if arg.contains(k) {
792 arg = arg.replace(k, v);
793 }
794 }
795 argv.push(arg);
796 }
797 }
798 }
799
800 argv.extend(spec.extra_args.iter().cloned());
801
802 if spec.kind == AgentKind::Antigravity {
805 argv.push("-p".to_owned());
806 }
807 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
810 argv.push("-".to_owned());
811 }
812 match delivery {
813 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
814 argv.push(pointer(spec.kind, prompt_path));
816 }
817 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
818 Delivery::Argv => argv.push(inv.prompt.to_owned()),
819 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
820 }
821
822 Ok(Plan { argv, stdin })
823}
824
825#[derive(Debug, Default)]
827struct Extracted {
828 text: String,
829 session: Option<String>,
830 status: Option<String>,
831 quota: Option<Quota>,
832 dropped: Option<Dropped>,
833 commands: Vec<CommandEvidence>,
834 context_tokens: Option<u64>,
835}
836
837fn extract(kind: AgentKind, stdout: &str) -> Extracted {
839 let mut extracted = extract_answer(kind, stdout);
840 extracted.context_tokens = context_tokens(kind, stdout);
841 extracted
842}
843
844fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
846 v.get(key).and_then(serde_json::Value::as_u64)
847}
848
849fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
854 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
855 let base = first(input)?;
856 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
857 Some(base.saturating_add(cached))
858}
859
860fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
881 use serde_json::Value;
882 let lines = || {
883 stdout
884 .lines()
885 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
886 };
887 match kind {
888 AgentKind::Claude => None,
889 AgentKind::Opencode => lines()
890 .filter(|v| {
891 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
893 || v.get("part")
894 .and_then(|p| p.get("type"))
895 .and_then(|t| t.as_str())
896 == Some("step-finish")
897 })
898 .filter_map(|v| {
899 let tokens = v.get("part")?.get("tokens")?;
900 let base = uint(tokens, "input")?;
901 let cache = tokens.get("cache");
902 let cached = ["read", "write"]
903 .iter()
904 .filter_map(|k| cache.and_then(|c| uint(c, k)))
905 .sum::<u64>();
906 Some(base.saturating_add(cached))
907 })
908 .next_back(),
909 AgentKind::Antigravity => None,
910 AgentKind::Codex => None,
911 AgentKind::Omp => lines()
912 .flat_map(|v| {
913 match v.get("type").and_then(|t| t.as_str()) {
916 Some("agent_end") => v
917 .get("messages")
918 .and_then(|m| m.as_array())
919 .cloned()
920 .unwrap_or_default(),
921 Some("turn_end") | Some("message_end") => {
922 v.get("message").cloned().into_iter().collect()
923 }
924 _ => Vec::new(),
925 }
926 })
927 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
928 .filter_map(|m| {
929 input_side(
930 m.get("usage")?,
931 &["input", "input_tokens"],
932 &[
933 "cacheRead",
934 "cacheWrite",
935 "cache_read_tokens",
936 "cache_write_tokens",
937 ],
938 )
939 })
940 .next_back(),
941 AgentKind::Command => None,
942 }
943}
944
945pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
948 extract_answer(kind, stdout).text
949}
950
951fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
952 match kind {
953 AgentKind::Claude => {
954 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
955 return Extracted {
956 text: stdout.trim().to_owned(),
957 ..Extracted::default()
958 };
959 };
960 Extracted {
961 text: v
962 .get("result")
963 .and_then(|r| r.as_str())
964 .unwrap_or_default()
965 .to_owned(),
966 session: v
967 .get("session_id")
968 .and_then(|s| s.as_str())
969 .map(str::to_owned),
970 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
971 if e {
972 "error".to_owned()
973 } else {
974 "success".to_owned()
975 }
976 }),
977 quota: claude_quota(&v),
978 dropped: None,
981 commands: Vec::new(),
982 context_tokens: None,
983 }
984 }
985 AgentKind::Opencode => {
986 let mut text = String::new();
988 let mut session = None;
989 for line in stdout.lines() {
990 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
991 continue;
992 };
993 if session.is_none() {
994 session = v
995 .get("sessionID")
996 .and_then(|s| s.as_str())
997 .map(str::to_owned);
998 }
999 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1000 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1001 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1002 {
1003 if !text.is_empty() {
1004 text.push('\n');
1005 }
1006 text.push_str(t);
1007 }
1008 }
1009 Extracted {
1010 text,
1011 session,
1012 status: None,
1013 quota: None,
1014 dropped: None,
1015 commands: Vec::new(),
1016 context_tokens: None,
1017 }
1018 }
1019 AgentKind::Antigravity => {
1020 let obj = stdout
1023 .lines()
1024 .rev()
1025 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1026 let Some(v) = obj else {
1027 return Extracted {
1028 text: stdout.trim().to_owned(),
1029 ..Extracted::default()
1030 };
1031 };
1032 Extracted {
1033 text: v
1034 .get("response")
1035 .and_then(|r| r.as_str())
1036 .unwrap_or_default()
1037 .trim()
1038 .to_owned(),
1039 session: v
1040 .get("conversation_id")
1041 .and_then(|s| s.as_str())
1042 .map(str::to_owned),
1043 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1044 quota: None,
1045 dropped: dropped_stream(&v),
1046 commands: Vec::new(),
1047 context_tokens: None,
1048 }
1049 }
1050 AgentKind::Codex => {
1051 let mut text = String::new();
1073 let mut session = None;
1074 let mut status = None;
1075 let mut commands = Vec::new();
1076 for line in stdout.lines() {
1077 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1078 continue;
1079 };
1080 match v.get("type").and_then(|t| t.as_str()) {
1081 Some("thread.started") => {
1082 session = v
1083 .get("thread_id")
1084 .and_then(|s| s.as_str())
1085 .map(str::to_owned);
1086 }
1087 Some("item.completed") => {
1088 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1089 match item.get("type").and_then(|t| t.as_str()) {
1090 Some("agent_message") => {
1091 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1092 text = t.trim().to_owned();
1093 }
1094 }
1095 Some("command_execution") => {
1096 commands.push(command_evidence(item));
1097 }
1098 _ => {}
1099 }
1100 }
1101 Some("turn.completed") => status = Some("success".to_owned()),
1102 Some("turn.failed") => status = Some("error".to_owned()),
1103 _ => {}
1104 }
1105 }
1106 Extracted {
1107 text,
1108 session,
1109 status,
1110 quota: None,
1111 dropped: None,
1112 commands,
1113 context_tokens: None,
1114 }
1115 }
1116 AgentKind::Omp => {
1117 let mut text = String::new();
1138 let mut session = None;
1139 for line in stdout.lines() {
1140 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1141 continue;
1142 };
1143 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1144 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1145 continue;
1146 }
1147 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1151 {
1152 Some("agent_end") => v
1153 .get("messages")
1154 .and_then(|m| m.as_array())
1155 .map(|m| m.iter().collect())
1156 .unwrap_or_default(),
1157 Some("turn_end") | Some("message_end") => {
1158 v.get("message").into_iter().collect()
1159 }
1160 _ => continue,
1161 };
1162 for message in messages {
1163 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1164 continue;
1165 }
1166 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1167 continue;
1168 };
1169 for part in parts {
1170 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1171 continue;
1172 }
1173 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1174 && !t.trim().is_empty()
1175 {
1176 text = t.trim().to_owned();
1177 }
1178 }
1179 }
1180 }
1181 Extracted {
1182 text,
1183 session,
1184 status: None,
1185 quota: None,
1186 dropped: None,
1187 commands: Vec::new(),
1188 context_tokens: None,
1189 }
1190 }
1191 AgentKind::Command => {
1192 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1197 let quota = parsed.as_ref().and_then(claude_quota);
1198 let dropped = parsed.as_ref().and_then(dropped_stream);
1201 Extracted {
1202 text: stdout.trim().to_owned(),
1203 session: None,
1204 status: None,
1205 quota,
1206 dropped,
1207 commands: Vec::new(),
1208 context_tokens: None,
1209 }
1210 }
1211 }
1212}
1213
1214fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1221 let description = match item.get("command") {
1222 Some(serde_json::Value::String(s)) => s.clone(),
1223 Some(serde_json::Value::Array(parts)) => parts
1224 .iter()
1225 .filter_map(|p| p.as_str())
1226 .collect::<Vec<_>>()
1227 .join(" "),
1228 _ => String::new(),
1229 };
1230 let result_summary = item
1231 .get("aggregated_output")
1232 .and_then(|o| o.as_str())
1233 .map(|s| tail_chars(s.trim(), 400))
1234 .unwrap_or_default();
1235 CommandEvidence {
1236 id: item
1237 .get("id")
1238 .and_then(|s| s.as_str())
1239 .unwrap_or_default()
1240 .to_owned(),
1241 description,
1242 exit_code: item
1243 .get("exit_code")
1244 .and_then(serde_json::Value::as_i64)
1245 .map(|e| e as i32),
1246 result_summary,
1247 source: "codex".to_owned(),
1248 }
1249}
1250
1251fn tail_chars(s: &str, max: usize) -> String {
1253 let count = s.chars().count();
1254 if count <= max {
1255 return s.to_owned();
1256 }
1257 s.chars().skip(count - max).collect()
1258}
1259
1260fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1267 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1268 if !is_err {
1269 return None;
1270 }
1271 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1272 if !result.to_lowercase().contains("session limit") {
1273 return None;
1274 }
1275 let reset = result
1278 .split("resets ")
1279 .nth(1)
1280 .map(str::trim)
1281 .filter(|s| !s.is_empty())
1282 .map(str::to_owned);
1283 Some(Quota { reset })
1284}
1285
1286fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1301 let from_stdout = stdout
1302 .lines()
1303 .rev()
1304 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1305 .and_then(|v| {
1306 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1307 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1308 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1309 .then(|| error.to_owned())
1310 });
1311 let from_stderr = || {
1312 stderr.lines().find_map(|l| {
1313 let v: serde_json::Value =
1314 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1315 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1316 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1317 (exhausted && code).then(|| {
1318 v.get("short_error")
1319 .and_then(|e| e.as_str())
1320 .unwrap_or_default()
1321 .to_owned()
1322 })
1323 })
1324 };
1325 let text = from_stdout.or_else(from_stderr)?;
1326 let reset = text
1327 .split_once("Resets ")
1328 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1329 .filter(|s| !s.is_empty())
1330 .map(str::to_owned);
1331 Some(Quota { reset })
1332}
1333
1334fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1365 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1366 if !status.eq_ignore_ascii_case("error") {
1367 return None;
1368 }
1369 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1370 if !response.trim().is_empty() {
1371 return None;
1373 }
1374 let produced = v
1375 .get("usage")
1376 .and_then(|u| u.get("output_tokens"))
1377 .and_then(serde_json::Value::as_u64)
1378 .unwrap_or(0);
1379 if produced == 0 {
1380 return None;
1382 }
1383 Some(Dropped {
1384 why: v
1385 .get("error")
1386 .and_then(|e| e.as_str())
1387 .unwrap_or("the CLI ended the stream without delivering its answer")
1388 .trim()
1389 .to_owned(),
1390 output_tokens: produced,
1391 })
1392}
1393
1394pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1396 let mut missing = Vec::new();
1397 for s in specs {
1398 let program = match s.kind {
1399 AgentKind::Command => s.command.first().map(String::as_str),
1400 other => other.program(),
1401 };
1402 if let Some(p) = program
1403 && !crate::config::which(p)
1404 && !Path::new(p).is_file()
1405 && !missing.iter().any(|m: &String| m == p)
1406 {
1407 missing.push(p.to_owned());
1408 }
1409 }
1410 missing
1411}
1412
1413pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1415 run_dir.join("artifacts")
1416}
1417
1418pub fn installed(spec: &AgentSpec) -> bool {
1420 spec.kind.program().is_none_or(crate::config::which)
1423}
1424
1425pub fn pick(
1447 agents: &[AgentSpec],
1448 want: Option<&str>,
1449 available: &dyn Fn(&AgentSpec) -> bool,
1450) -> Result<AgentSpec> {
1451 if let Some(id) = want {
1452 let spec = agents
1453 .iter()
1454 .find(|a| a.id == id)
1455 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1456 if !available(spec) {
1457 bail!(
1458 "agent `{}` needs `{}` on PATH; install it or pass a different \
1459 --agent",
1460 spec.id,
1461 spec.kind.program().unwrap_or("its command")
1462 );
1463 }
1464 return Ok(spec.clone());
1465 }
1466
1467 if agents.is_empty() {
1468 bail!(
1469 "the agent roster is empty, so there is nobody to ask: install one \
1470 of claude, opencode or agy - magi derives a roster from what is on \
1471 PATH - or add an [[agents]] entry to magi.toml."
1472 );
1473 }
1474
1475 if let Some(spec) = agents
1476 .iter()
1477 .find(|a| a.kind == AgentKind::Claude && available(a))
1478 {
1479 return Ok(spec.clone());
1480 }
1481
1482 agents
1483 .iter()
1484 .find(|a| available(a))
1485 .cloned()
1486 .with_context(|| {
1487 let missing = agents
1488 .iter()
1489 .filter_map(|a| a.kind.program())
1490 .collect::<Vec<_>>()
1491 .join(", ");
1492 format!(
1493 "no agent in the roster can be run here: install one of \
1494 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1495 you do have"
1496 )
1497 })
1498}
1499
1500pub fn pick_chain(
1508 agents: &[AgentSpec],
1509 choice: Option<&AgentChoice>,
1510 available: &dyn Fn(&AgentSpec) -> bool,
1511 role: &str,
1512) -> Result<Vec<AgentSpec>> {
1513 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1514 if wanted.is_empty() {
1515 return pick(agents, None, available)
1516 .map(|s| vec![s])
1517 .with_context(|| format!("choose an agent for the {role} role"));
1518 }
1519 let mut chain: Vec<AgentSpec> = Vec::new();
1520 for id in wanted {
1521 if chain.iter().any(|s| s.id == id) {
1522 continue;
1523 }
1524 match pick(agents, Some(id), available) {
1525 Ok(spec) => chain.push(spec),
1526 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1527 }
1528 }
1529 if chain.is_empty() {
1530 bail!(
1531 "[roles] {role} names no agent that can run here; the roster has {}",
1532 ids(agents)
1533 );
1534 }
1535 Ok(chain)
1536}
1537
1538pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1544 match outcome {
1545 Err(_) => true,
1546 Ok(out) => output_advances(out),
1547 }
1548}
1549
1550pub fn output_advances(out: &AgentOutput) -> bool {
1554 out.quota_exhausted() || !out.usable()
1555}
1556
1557fn ids(agents: &[AgentSpec]) -> String {
1558 if agents.is_empty() {
1559 return "no agents at all".to_owned();
1560 }
1561 agents
1562 .iter()
1563 .map(|a| a.id.clone())
1564 .collect::<Vec<_>>()
1565 .join(", ")
1566}
1567
1568#[cfg(test)]
1569mod tests {
1570 use super::*;
1571
1572 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1573
1574 fn command_helper(mode: &str) -> AgentSpec {
1577 AgentSpec {
1578 id: "helper".to_owned(),
1579 kind: AgentKind::Command,
1580 model: None,
1581 command: vec![
1582 std::env::current_exe()
1583 .expect("locate test helper")
1584 .to_string_lossy()
1585 .into_owned(),
1586 "--exact".to_owned(),
1587 "agent::tests::command_agent_test_helper".to_owned(),
1588 "--nocapture".to_owned(),
1589 ],
1590 extra_args: Vec::new(),
1591 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1592 prompt_delivery: None,
1593 }
1594 }
1595
1596 #[test]
1597 fn command_agent_test_helper() {
1598 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1599 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1600 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1601 Ok("no-cache") => println!(
1602 "{}",
1603 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1604 ),
1605 Ok("ignore-stdin") => println!("done"),
1606 Ok("chatty-sleep") => {
1607 println!("i-said-something");
1608 std::thread::sleep(Duration::from_secs(30));
1609 }
1610 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1611 Ok(other) => panic!("unknown command helper mode {other}"),
1612 Err(_) => {}
1613 }
1614 }
1615
1616 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1617 AgentSpec {
1618 id: "a".to_owned(),
1619 kind,
1620 model: model.map(str::to_owned),
1621 command: vec!["echo".to_owned(), "{label}".to_owned()],
1622 extra_args: Vec::new(),
1623 env: BTreeMap::new(),
1624 prompt_delivery: None,
1625 }
1626 }
1627
1628 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1629 Invocation {
1630 cwd,
1631 prompt: "do the thing",
1632 timeout: Duration::from_secs(900),
1633 allow_write,
1634 unsandboxed: false,
1635 sessions: true,
1636 artifacts: art,
1637 stem: "t",
1638 run: "test-run",
1639 node: "test",
1640 cache_dir: None,
1641 attachments: &[],
1642 writable: &[],
1643 }
1644 }
1645
1646 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1647 build_command(
1648 &spec(kind, None),
1649 seat,
1650 &inv(Path::new("."), Path::new("/art"), allow_write),
1651 Path::new("/art/p.md"),
1652 )
1653 .unwrap()
1654 }
1655
1656 #[test]
1657 fn claude_mints_then_resumes_the_same_uuid() {
1658 let mut seat = SeatState::new("judge-1", "a", 7);
1659 let uuid = seat.claude_session.clone().unwrap();
1660 let first = plan_for(AgentKind::Claude, &seat, true);
1661 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1662 assert!(!first.argv.iter().any(|a| a == "--resume"));
1663
1664 seat.turns = 1;
1665 let second = plan_for(AgentKind::Claude, &seat, true);
1666 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1667 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1668 }
1669
1670 #[test]
1671 fn read_only_seats_cannot_edit() {
1672 let seat = SeatState::new("judge-1", "a", 7);
1673 let claude = plan_for(AgentKind::Claude, &seat, false);
1674 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1675 assert!(
1676 !plan_for(AgentKind::Claude, &seat, true)
1677 .argv
1678 .iter()
1679 .any(|a| a == "--disallowed-tools")
1680 );
1681
1682 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1683 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1684 assert!(
1685 !agy.argv
1686 .iter()
1687 .any(|a| a == "--dangerously-skip-permissions")
1688 );
1689 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1690 assert!(
1691 agy_rw
1692 .argv
1693 .windows(2)
1694 .any(|w| w == ["--mode", "accept-edits"])
1695 );
1696 assert!(
1697 agy_rw
1698 .argv
1699 .iter()
1700 .any(|a| a == "--dangerously-skip-permissions")
1701 );
1702 let agy_prompt = agy_rw
1707 .argv
1708 .iter()
1709 .position(|a| a == "-p")
1710 .map(|i| agy_rw.argv[i + 1].clone())
1711 .expect("agy takes its prompt with -p");
1712 assert!(
1713 agy_prompt.starts_with('@'),
1714 "agy must get a file reference, got {agy_prompt:?}"
1715 );
1716 assert!(
1717 !agy_prompt.contains("Read the file at"),
1718 "the prose pointer is for CLIs with no file syntax"
1719 );
1720
1721 for allow_write in [false, true] {
1726 assert!(
1727 plan_for(AgentKind::Opencode, &seat, allow_write)
1728 .argv
1729 .iter()
1730 .any(|a| a == "--auto"),
1731 "opencode needs --auto even to read (allow_write = {allow_write})"
1732 );
1733 }
1734 }
1735
1736 #[test]
1739 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
1740 let seat = SeatState::new("deputy-x", "a", 7);
1741 let roots = [PathBuf::from("/data/questions")];
1742 let mk = |allow_write: bool| {
1743 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1744 i.writable = &roots;
1745 build_command(
1746 &spec(AgentKind::Codex, None),
1747 &seat,
1748 &i,
1749 Path::new("/art/p.md"),
1750 )
1751 .unwrap()
1752 };
1753 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
1754 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
1755 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
1756 }
1757
1758 #[test]
1759 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
1760 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
1761 let mut seat = SeatState::new("talk", "a", 7);
1762 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
1763 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1764 i.unsandboxed = unsandboxed;
1765 build_command(
1766 &spec(AgentKind::Codex, None),
1767 seat,
1768 &i,
1769 Path::new("/art/p.md"),
1770 )
1771 .unwrap()
1772 };
1773
1774 let first = build(&seat, true, true);
1776 assert!(first.argv.iter().any(|a| a == BYPASS));
1777 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
1778 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
1779
1780 seat.captured_session = Some("thread-1".to_owned());
1782 seat.turns = 1;
1783 let resumed = build(&seat, true, true);
1784 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
1785 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
1786
1787 let ro = build(&seat, false, true);
1789 assert!(!ro.argv.iter().any(|a| a == BYPASS));
1790 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1791 }
1792
1793 #[test]
1794 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1795 let mut seat = SeatState::new("judge-1", "a", 7);
1796
1797 let ro = plan_for(AgentKind::Codex, &seat, false);
1801 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1802 let rw = plan_for(AgentKind::Codex, &seat, true);
1803 assert!(
1804 rw.argv
1805 .windows(2)
1806 .any(|w| w == ["--sandbox", "workspace-write"])
1807 );
1808 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
1811 ro_flagged.unsandboxed = true;
1812 let ro_flagged = build_command(
1813 &spec(AgentKind::Codex, None),
1814 &seat,
1815 &ro_flagged,
1816 Path::new("/art/p.md"),
1817 )
1818 .unwrap();
1819 assert!(
1820 ro_flagged
1821 .argv
1822 .windows(2)
1823 .any(|w| w == ["--sandbox", "read-only"])
1824 );
1825 for p in [&ro, &rw, &ro_flagged] {
1826 assert!(
1827 !p.argv
1828 .iter()
1829 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1830 "the bypass defeats the only enforced read-only mode we have"
1831 );
1832 assert!(
1834 p.argv
1835 .windows(2)
1836 .any(|w| w == ["-c", "approval_policy=\"never\""]),
1837 "an unattended seat that asks for approval blocks until timeout"
1838 );
1839 }
1840
1841 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1843 assert_eq!(
1844 ro.argv.last().map(String::as_str),
1845 Some("-"),
1846 "without the `-` argument codex waits for a prompt it never gets"
1847 );
1848
1849 seat.turns = 1;
1853 assert!(!has_session(AgentKind::Codex, &seat, true));
1854 assert!(
1855 !plan_for(AgentKind::Codex, &seat, true)
1856 .argv
1857 .iter()
1858 .any(|a| a == "resume")
1859 );
1860 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1861 let resumed = plan_for(AgentKind::Codex, &seat, true);
1862 let at = resumed
1863 .argv
1864 .iter()
1865 .position(|a| a == "resume")
1866 .expect("resumes by subcommand");
1867 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1868 assert!(
1869 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1870 "every option precedes the subcommand"
1871 );
1872 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1873 }
1874
1875 #[test]
1878 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
1879 let mut seat = SeatState::new("review-1", "a", 7);
1880
1881 let first = plan_for(AgentKind::Omp, &seat, false);
1885 assert!(first.argv.iter().any(|a| a == "-p"));
1886 assert!(first.argv.iter().any(|a| a == "--mode=json"));
1887 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
1888 assert!(
1889 !first.argv.iter().any(|a| a == "do the thing"),
1890 "the prompt reached argv, where Windows caps it"
1891 );
1892
1893 for allow_write in [false, true] {
1899 let p = plan_for(AgentKind::Omp, &seat, allow_write);
1900 assert!(
1901 p.argv.iter().any(|a| a == "--auto-approve"),
1902 "omp needs --auto-approve even to read (allow_write = {allow_write})"
1903 );
1904 assert!(
1905 !p.argv
1906 .iter()
1907 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1908 "nothing ever asks for the bypass"
1909 );
1910 }
1911
1912 seat.turns = 1;
1915 assert!(!has_session(AgentKind::Omp, &seat, true));
1916 assert!(
1917 !plan_for(AgentKind::Omp, &seat, true)
1918 .argv
1919 .iter()
1920 .any(|a| a == "--resume")
1921 );
1922 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
1923 let resumed = plan_for(AgentKind::Omp, &seat, true);
1924 assert!(
1925 resumed
1926 .argv
1927 .windows(2)
1928 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
1929 "a captured id is what makes the next turn a resume"
1930 );
1931 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
1934 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
1936 }
1937
1938 #[test]
1943 fn omp_takes_the_answer_without_an_agent_end_line() {
1944 let stream = concat!(
1945 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
1946 "\n",
1947 r#"{"type":"agent_start"}"#,
1948 "\n",
1949 r#"{"type":"turn_start"}"#,
1950 "\n",
1951 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
1952 "\n",
1953 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
1954 "\n",
1955 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
1956 "\n",
1957 );
1958 let out = extract(AgentKind::Omp, stream);
1959 assert_eq!(
1960 out.text, "{\"vote\":\"approve\"}",
1961 "the last assistant text block is the answer even with no agent_end"
1962 );
1963 assert_eq!(
1964 out.session.as_deref(),
1965 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
1966 );
1967 }
1968
1969 #[test]
1973 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
1974 let stream = concat!(
1975 r#"{"type":"session","version":3,"id":"s1"}"#,
1976 "\n",
1977 "{\"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問題ありません。\"}]}]}",
1978 "\n",
1979 );
1980 let out = extract(AgentKind::Omp, stream);
1981 assert_eq!(
1982 out.text, "## 判定\n\n問題ありません。",
1983 "the narration is not the answer, and non-ASCII survives intact"
1984 );
1985 assert_eq!(out.session.as_deref(), Some("s1"));
1986 }
1987
1988 #[test]
1991 fn omp_skips_non_json_lines() {
1992 let stream = concat!(
1993 "Warning: some omp notice\n",
1994 r#"{"type":"session","version":3,"id":"s2"}"#,
1995 "\n",
1996 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
1997 "\n",
1998 "trailing junk",
1999 "\n",
2000 );
2001 let out = extract(AgentKind::Omp, stream);
2002 assert_eq!(out.text, "the answer");
2003 assert_eq!(out.session.as_deref(), Some("s2"));
2004 }
2005
2006 #[test]
2008 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2009 let stream = concat!(
2010 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2011 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2012 "\n",
2013 r#"{"type":"turn.started"}"#,
2014 "\n",
2015 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2016 "\n",
2017 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2018 "\n",
2019 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2020 "\n",
2021 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2022 "\n",
2023 );
2024 let out = extract(AgentKind::Codex, stream);
2025 assert_eq!(
2026 out.text, "{\"verdict\": \"ok\"}",
2027 "the last agent message is the answer; earlier ones narrate"
2028 );
2029 assert_eq!(
2030 out.session.as_deref(),
2031 Some("01a07440-4545-7492-85c1-024e3259a90a")
2032 );
2033 assert_eq!(out.status.as_deref(), Some("success"));
2034
2035 let failed = concat!(
2036 r#"{"type":"thread.started","thread_id":"t1"}"#,
2037 "\n",
2038 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2039 "\n",
2040 );
2041 assert_eq!(
2042 extract(AgentKind::Codex, failed).status.as_deref(),
2043 Some("error")
2044 );
2045 }
2046
2047 #[test]
2048 fn captured_sessions_resume_only_once_reported() {
2049 let mut seat = SeatState::new("impl-A", "a", 7);
2050 seat.turns = 1;
2051 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2052 assert!(!has_session(kind, &seat, true));
2053 let p = plan_for(kind, &seat, true);
2054 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2055 }
2056
2057 seat.captured_session = Some("sid".to_owned());
2058 assert!(has_session(AgentKind::Opencode, &seat, true));
2059 assert!(
2060 plan_for(AgentKind::Opencode, &seat, true)
2061 .argv
2062 .windows(2)
2063 .any(|w| w == ["-s", "sid"])
2064 );
2065 assert!(
2066 plan_for(AgentKind::Antigravity, &seat, true)
2067 .argv
2068 .windows(2)
2069 .any(|w| w == ["--conversation", "sid"])
2070 );
2071 }
2072
2073 #[test]
2074 fn sessions_disabled_never_resumes() {
2075 let mut seat = SeatState::new("impl-A", "a", 7);
2076 seat.turns = 3;
2077 seat.captured_session = Some("sid".to_owned());
2078 for kind in [
2079 AgentKind::Claude,
2080 AgentKind::Opencode,
2081 AgentKind::Antigravity,
2082 ] {
2083 assert!(!has_session(kind, &seat, false));
2084 }
2085 }
2086
2087 #[test]
2088 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2089 let seat = SeatState::new("judge-1", "a", 7);
2090 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2091 let p = plan_for(kind, &seat, false);
2092 assert!(
2093 p.argv.iter().all(|a| a != "do the thing"),
2094 "{kind:?} put the prompt on the command line"
2095 );
2096 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2097 }
2098 let p = plan_for(AgentKind::Antigravity, &seat, false);
2100 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2101 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2102 assert!(p.stdin.is_none());
2103 }
2104
2105 #[test]
2106 fn agy_print_timeout_tracks_the_node_budget() {
2107 let seat = SeatState::new("impl-A", "a", 7);
2108 let p = build_command(
2109 &spec(AgentKind::Antigravity, None),
2110 &seat,
2111 &Invocation {
2112 cwd: Path::new("."),
2113 prompt: "p",
2114 timeout: Duration::from_secs(3600),
2115 allow_write: true,
2116 unsandboxed: false,
2117 sessions: true,
2118 artifacts: Path::new("/art"),
2119 stem: "t",
2120 run: "test-run",
2121 node: "test",
2122 cache_dir: None,
2123 attachments: &[],
2124 writable: &[],
2125 },
2126 Path::new("/art/p.md"),
2127 )
2128 .unwrap();
2129 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2130 }
2131
2132 #[test]
2139 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2140 let mut s = spec(AgentKind::Antigravity, None);
2141 s.prompt_delivery = Some(Delivery::Argv);
2142 let seat = SeatState::new("talk", "a", 7);
2143 let atts = [PathBuf::from("/art/attachments/abc.png")];
2144
2145 let without = build_command(
2146 &s,
2147 &seat,
2148 &Invocation {
2149 attachments: &[],
2150 writable: &[],
2151 ..inv(Path::new("."), Path::new("/art"), true)
2152 },
2153 Path::new("/art/p.md"),
2154 )
2155 .unwrap();
2156 assert!(
2157 !without.argv.iter().any(|a| a == "--add-dir"),
2158 "no attachment, no reason to widen the sandbox: {without:?}"
2159 );
2160
2161 let with = build_command(
2162 &s,
2163 &seat,
2164 &Invocation {
2165 attachments: &atts,
2166 writable: &[],
2167 ..inv(Path::new("."), Path::new("/art"), true)
2168 },
2169 Path::new("/art/p.md"),
2170 )
2171 .unwrap();
2172 assert!(
2173 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2174 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2175 );
2176 }
2177
2178 #[test]
2184 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2185 let seat = SeatState::new("plan", "a", 7);
2186 let atts = [
2187 PathBuf::from("/art/attachments/own.png"),
2188 PathBuf::from("/other-chat/attachments/inherited.png"),
2189 ];
2190
2191 let p = build_command(
2192 &spec(AgentKind::Antigravity, None),
2193 &seat,
2194 &Invocation {
2195 attachments: &atts,
2196 writable: &[],
2197 ..inv(Path::new("."), Path::new("/art"), true)
2198 },
2199 Path::new("/art/p.md"),
2200 )
2201 .unwrap();
2202
2203 assert!(
2204 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2205 "this conversation's own artifacts dir must still be granted: {p:?}"
2206 );
2207 assert!(
2208 p.argv
2209 .windows(2)
2210 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2211 "the inherited attachment's own directory must be granted too: {p:?}"
2212 );
2213 }
2214
2215 #[test]
2216 fn command_agents_get_placeholders_substituted() {
2217 let seat = SeatState::new("impl-A", "a", 7);
2218 let p = plan_for(AgentKind::Command, &seat, true);
2219 assert_eq!(p.argv[0], "echo");
2220 assert_eq!(p.argv[1], "impl-A");
2221 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2222 }
2223
2224 #[test]
2225 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2226 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2228 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2229 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2230 let out = extract(AgentKind::Claude, stdout);
2231 let quota = out.quota.as_ref().expect("rate limit must be detected");
2232 assert_eq!(
2233 quota.reset.as_deref(),
2234 Some("4:50am (Asia/Tokyo)"),
2235 "reset time read from the body"
2236 );
2237 }
2238
2239 #[test]
2240 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2241 let out = extract(
2242 AgentKind::Claude,
2243 r#"{"is_error":true,"result":"session limit reached"}"#,
2244 );
2245 let quota = out.quota.expect("rate limit detected without a reset");
2246 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2247 }
2248
2249 #[test]
2250 fn ordinary_failures_are_never_quota() {
2251 let claude_fail = extract(
2253 AgentKind::Claude,
2254 r#"{"is_error":true,"result":"account does not exist"}"#,
2255 );
2256 assert!(claude_fail.quota.is_none());
2257
2258 let cmd_fail = extract(AgentKind::Command, "boom");
2260 assert!(cmd_fail.quota.is_none());
2261
2262 let success = extract(
2264 AgentKind::Command,
2265 r#"{"is_error":false,"result":"session limit is fine"}"#,
2266 );
2267 assert!(success.quota.is_none());
2268 }
2269
2270 #[test]
2278 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2279 let stream = concat!(
2280 r#"{"type":"thread.started","thread_id":"t1"}"#,
2281 "\n",
2282 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"}}"#,
2283 "\n",
2284 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"}}"#,
2285 "\n",
2286 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2287 "\n",
2288 r#"{"type":"turn.completed"}"#,
2289 "\n",
2290 );
2291 let out = extract(AgentKind::Codex, stream);
2292 assert_eq!(out.text, "Both tests in the target pass.");
2293 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2294
2295 let paired = &out.commands[0];
2296 assert_eq!(paired.id, "item49");
2297 assert_eq!(paired.exit_code, Some(1));
2298 assert!(paired.description.contains("graph_cached_gate"));
2299 assert!(paired.result_summary.contains("1 failed"));
2300
2301 let solo = &out.commands[1];
2302 assert_eq!(solo.exit_code, Some(0));
2303
2304 assert!(
2308 out.commands
2309 .iter()
2310 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2311 "a failed run of the actual target must still be visible: {:?}",
2312 out.commands
2313 );
2314 }
2315
2316 #[test]
2317 fn command_agent_can_carry_the_claude_quota_shape() {
2318 let out = extract(
2319 AgentKind::Command,
2320 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2321 );
2322 assert!(
2323 out.quota.is_some(),
2324 "a wrapper emitting the claude shape counts as quota"
2325 );
2326 }
2327
2328 #[test]
2329 fn claude_json_result_is_extracted() {
2330 let out = extract(
2331 AgentKind::Claude,
2332 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2333 );
2334 assert_eq!(out.text, "all done");
2335 assert_eq!(out.session.as_deref(), Some("abc"));
2336 assert_eq!(out.status.as_deref(), Some("success"));
2337 }
2338
2339 #[test]
2354 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2355 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"}"#;
2356 let out = extract(AgentKind::Claude, stdout);
2357 assert_eq!(out.status.as_deref(), Some("success"));
2358 assert!(out.quota.is_none());
2359 assert!(!out.text.trim().is_empty());
2360
2361 let agent_out = AgentOutput {
2362 text: out.text.clone(),
2363 exit_code: Some(0),
2364 timed_out: false,
2365 duration_ms: 500,
2366 artifacts: Vec::new(),
2367 quota: out.quota,
2368 dropped: out.dropped,
2369 commands: out.commands,
2370 context_tokens: None,
2371 };
2372 assert!(
2373 agent_out.usable(),
2374 "the CLI turn itself ended cleanly and must read as usable"
2375 );
2376 assert!(
2377 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2378 "a clean CLI turn is not proof the node's own report ever arrived"
2379 );
2380 }
2381
2382 #[test]
2383 fn opencode_event_stream_is_concatenated() {
2384 let stream = concat!(
2385 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2386 "\n",
2387 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2388 "\n",
2389 "garbage line\n",
2390 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2391 "\n"
2392 );
2393 let out = extract(AgentKind::Opencode, stream);
2394 assert_eq!(out.text, "first\nsecond");
2395 assert_eq!(out.session.as_deref(), Some("ses_1"));
2396 }
2397
2398 #[test]
2399 fn agy_json_survives_a_leading_warning_line() {
2400 let stdout = concat!(
2401 "warning: --mode plan has no effect while slash commands are disabled.\n",
2402 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2403 "\n"
2404 );
2405 let out = extract(AgentKind::Antigravity, stdout);
2406 assert_eq!(out.text, "persimmon");
2407 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2408 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2409 }
2410
2411 const AGY_DROPPED: &str = concat!(
2418 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2419 r#""response":"","error":"the connection to the agent was interrupted before "#,
2420 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2421 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2422 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2423 r#""total_tokens":274380}}"#
2424 );
2425
2426 const AGY_QUOTA_OUT: &str = concat!(
2428 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2429 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2430 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2431 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2432 );
2433 const AGY_QUOTA_ERR: &str = concat!(
2434 "error: Individual quota reached. Resets in 1h2m49s.\n",
2435 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
2436 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
2437 r#""retryable":true}"#,
2438 "\n"
2439 );
2440
2441 #[test]
2442 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
2443 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
2444 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
2445 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
2446 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
2447 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
2449 assert!(stderr_only.reset.is_none());
2450 }
2451
2452 #[test]
2453 fn ordinary_agy_failures_are_not_a_quota() {
2454 assert!(agy_quota(AGY_DROPPED, "").is_none());
2455 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
2456 assert!(
2457 agy_quota(
2458 "",
2459 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
2460 )
2461 .is_none()
2462 );
2463 assert!(
2464 agy_quota(
2465 "",
2466 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
2467 )
2468 .is_none()
2469 );
2470 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
2471 }
2472
2473 #[test]
2474 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2475 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2476 let dropped = out.dropped.expect("recognised as undelivered work");
2477 assert_eq!(dropped.output_tokens, 14267);
2478 assert!(
2479 dropped.why.contains("subscriber fell behind"),
2480 "the CLI's own words are kept for the record: {}",
2481 dropped.why
2482 );
2483 assert_eq!(
2486 out.session.as_deref(),
2487 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
2488 );
2489 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
2490 }
2491
2492 #[test]
2493 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
2494 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
2498 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
2499
2500 let answered = concat!(
2503 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
2504 r#""usage":{"output_tokens":10}}"#
2505 );
2506 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
2507
2508 let ok = concat!(
2510 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
2511 r#""usage":{"output_tokens":10}}"#
2512 );
2513 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
2514 }
2515
2516 #[test]
2517 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
2518 let out = AgentOutput {
2519 text: String::new(),
2520 exit_code: Some(1),
2521 timed_out: false,
2522 duration_ms: 431_194,
2523 artifacts: Vec::new(),
2524 quota: None,
2525 dropped: Some(Dropped {
2526 why: "subscriber fell behind updates".to_owned(),
2527 output_tokens: 14267,
2528 }),
2529 commands: Vec::new(),
2530 context_tokens: None,
2531 };
2532 assert!(!out.usable());
2533 assert!(out.work_undelivered());
2534 assert!(!out.quota_exhausted());
2537 }
2538
2539 #[test]
2540 fn non_json_stdout_falls_back_to_raw_text() {
2541 let out = extract(AgentKind::Antigravity, "plain answer\n");
2542 assert_eq!(out.text, "plain answer");
2543 assert!(out.session.is_none());
2544 }
2545
2546 #[tokio::test]
2547 async fn command_agent_round_trip_writes_artifacts() {
2548 let dir = tempfile::tempdir().unwrap();
2549 let art = dir.path().join("artifacts");
2550 let mut seat = SeatState::new("impl-A", "a", 7);
2551 let s = command_helper("reply");
2552 let out = invoke(
2553 &s,
2554 &mut seat,
2555 &Invocation {
2556 cwd: dir.path(),
2557 prompt: "unused",
2558 timeout: Duration::from_secs(30),
2559 allow_write: true,
2560 unsandboxed: false,
2561 sessions: true,
2562 artifacts: &art,
2563 stem: "impl-A",
2564 run: "test-run",
2565 node: "test",
2566 cache_dir: None,
2567 attachments: &[],
2568 writable: &[],
2569 },
2570 )
2571 .await
2572 .unwrap();
2573 assert!(out.usable(), "{out:?}");
2574 assert!(out.text.contains("hello impl-A"), "{}", out.text);
2575 assert_eq!(seat.turns, 1);
2576 assert!(art.join("impl-A.prompt.md").is_file());
2577 assert!(art.join("impl-A.out").is_file());
2578 }
2579
2580 #[tokio::test]
2581 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
2582 let dir = tempfile::tempdir().unwrap();
2586 let cache = dir.path().join("magi-cache");
2587 let mut seat = SeatState::new("impl-A", "a", 7);
2588 let s = command_helper("cache");
2589 let out = invoke(
2590 &s,
2591 &mut seat,
2592 &Invocation {
2593 cwd: dir.path(),
2594 prompt: "unused",
2595 timeout: Duration::from_secs(30),
2596 allow_write: true,
2597 unsandboxed: false,
2598 sessions: true,
2599 artifacts: &dir.path().join("artifacts"),
2600 stem: "cache",
2601 run: "test-run",
2602 node: "test",
2603 cache_dir: Some(&cache),
2604 attachments: &[],
2605 writable: &[],
2606 },
2607 )
2608 .await
2609 .unwrap();
2610 assert!(out.usable(), "{out:?}");
2611 assert!(
2612 out.text.contains(cache.to_string_lossy().as_ref()),
2613 "the seat must see CARGO_TARGET_DIR = the shared cache"
2614 );
2615 }
2616
2617 #[tokio::test]
2618 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
2619 let previous = std::env::var("CARGO_TARGET_DIR").ok();
2627 unsafe {
2632 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
2633 }
2634 let dir = tempfile::tempdir().unwrap();
2635 let mut seat = SeatState::new("review-1", "a", 7);
2636 let s = command_helper("no-cache");
2637 let result = invoke(
2638 &s,
2639 &mut seat,
2640 &Invocation {
2641 cwd: dir.path(),
2642 prompt: "unused",
2643 timeout: Duration::from_secs(30),
2644 allow_write: false,
2645 unsandboxed: false,
2646 sessions: true,
2647 artifacts: &dir.path().join("artifacts"),
2648 stem: "no-cache",
2649 run: "test-run",
2650 node: "test",
2651 cache_dir: None,
2652 attachments: &[],
2653 writable: &[],
2654 },
2655 )
2656 .await;
2657 unsafe {
2662 match &previous {
2663 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
2664 None => std::env::remove_var("CARGO_TARGET_DIR"),
2665 }
2666 }
2667 let out = result.unwrap();
2668 assert!(out.usable(), "{out:?}");
2669 assert!(
2670 out.text.contains("ABSENT"),
2671 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
2672 out.text
2673 );
2674 }
2675
2676 #[tokio::test]
2677 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
2678 let dir = tempfile::tempdir().unwrap();
2679 let mut seat = SeatState::new("impl-A", "a", 7);
2680 let s = command_helper("ignore-stdin");
2683 let big = "x".repeat(1_000_000);
2684 let out = invoke(
2685 &s,
2686 &mut seat,
2687 &Invocation {
2688 cwd: dir.path(),
2689 prompt: &big,
2690 timeout: Duration::from_secs(60),
2691 allow_write: true,
2692 unsandboxed: false,
2693 sessions: true,
2694 artifacts: &dir.path().join("artifacts"),
2695 stem: "big",
2696 run: "test-run",
2697 node: "test",
2698 cache_dir: None,
2699 attachments: &[],
2700 writable: &[],
2701 },
2702 )
2703 .await
2704 .unwrap();
2705 assert!(out.usable(), "{out:?}");
2706 assert!(out.text.contains("done"), "{}", out.text);
2707 }
2708
2709 #[tokio::test]
2710 async fn timeout_is_reported_not_hung() {
2711 let dir = tempfile::tempdir().unwrap();
2712 let mut seat = SeatState::new("impl-A", "a", 7);
2713 let s = command_helper("sleep");
2714 let out = invoke(
2715 &s,
2716 &mut seat,
2717 &Invocation {
2718 cwd: dir.path(),
2719 prompt: "unused",
2720 timeout: Duration::from_millis(300),
2721 allow_write: true,
2722 unsandboxed: false,
2723 sessions: true,
2724 artifacts: &dir.path().join("artifacts"),
2725 stem: "slow",
2726 run: "test-run",
2727 node: "test",
2728 cache_dir: None,
2729 attachments: &[],
2730 writable: &[],
2731 },
2732 )
2733 .await
2734 .unwrap();
2735 assert!(out.timed_out);
2736 assert!(!out.usable());
2737 }
2738
2739 #[tokio::test]
2740 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
2741 let dir = tempfile::tempdir().unwrap();
2747 let artifacts = dir.path().join("artifacts");
2748 let mut seat = SeatState::new("impl-A", "a", 7);
2749 let s = command_helper("chatty-sleep");
2750 let out = invoke(
2751 &s,
2752 &mut seat,
2753 &Invocation {
2754 cwd: dir.path(),
2755 prompt: "unused",
2756 timeout: Duration::from_secs(10),
2761 allow_write: true,
2762 unsandboxed: false,
2763 sessions: true,
2764 artifacts: &artifacts,
2765 stem: "chatty",
2766 run: "test-run",
2767 node: "test",
2768 cache_dir: None,
2769 attachments: &[],
2770 writable: &[],
2771 },
2772 )
2773 .await
2774 .unwrap();
2775
2776 assert!(out.timed_out, "{out:?}");
2777 assert!(!out.usable(), "a cut-off answer is still not an answer");
2778 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
2779 assert!(
2780 recorded.contains("i-said-something"),
2781 "the artifact must keep what arrived before the kill, got {recorded:?}"
2782 );
2783 assert!(
2784 out.text.contains("i-said-something"),
2785 "and the graph must be able to see it too, got {:?}",
2786 out.text
2787 );
2788 }
2789
2790 #[test]
2791 fn missing_programs_reports_command_binaries() {
2792 let mut s = spec(AgentKind::Command, None);
2793 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
2794 assert_eq!(
2795 missing_programs(&[s]),
2796 ["definitely-not-a-real-binary-xyz".to_owned()]
2797 );
2798 }
2799
2800 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
2801 AgentSpec {
2802 id: id.to_owned(),
2803 kind,
2804 model: None,
2805 command: Vec::new(),
2806 extra_args: Vec::new(),
2807 env: BTreeMap::new(),
2808 prompt_delivery: None,
2809 }
2810 }
2811
2812 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
2815 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
2816 }
2817
2818 #[test]
2819 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
2820 let agents = [
2821 pick_spec("oc", AgentKind::Opencode),
2822 pick_spec("opus", AgentKind::Claude),
2823 pick_spec("agy", AgentKind::Antigravity),
2824 ];
2825 let got = pick(&agents, None, &without(&[])).expect("a pick");
2826 assert_eq!(got.id, "opus");
2827 }
2828
2829 #[test]
2830 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
2831 let agents = [
2832 pick_spec("opus", AgentKind::Claude),
2833 pick_spec("oc", AgentKind::Opencode),
2834 pick_spec("agy", AgentKind::Antigravity),
2835 ];
2836 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
2837 assert_eq!(got.id, "agy");
2838 }
2839
2840 #[test]
2841 fn pick_on_an_empty_roster_says_what_to_install() {
2842 let msg = pick(&[], None, &without(&[]))
2843 .expect_err("nobody to ask")
2844 .to_string();
2845 assert!(msg.contains("roster is empty"), "{msg}");
2846 assert!(msg.contains("claude"), "{msg}");
2847 assert!(msg.contains("magi.toml"), "{msg}");
2848 }
2849
2850 #[test]
2851 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
2852 let agents = [
2853 pick_spec("opus", AgentKind::Claude),
2854 pick_spec("oc", AgentKind::Opencode),
2855 ];
2856 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
2857 let msg = format!("{err:#}");
2858 assert!(msg.contains("claude"), "{msg}");
2859 assert!(msg.contains("opencode"), "{msg}");
2860 }
2861
2862 #[test]
2863 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
2864 let agents = [
2865 pick_spec("opus", AgentKind::Claude),
2866 pick_spec("oc", AgentKind::Opencode),
2867 ];
2868 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
2869 assert_eq!(got.id, "oc");
2870 }
2871
2872 #[test]
2873 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
2874 let agents = [
2875 pick_spec("opus", AgentKind::Claude),
2876 pick_spec("oc", AgentKind::Opencode),
2877 ];
2878 let msg = pick(&agents, Some("gemini"), &without(&[]))
2879 .expect_err("no such agent")
2880 .to_string();
2881 assert!(msg.contains("gemini"), "{msg}");
2882 assert!(msg.contains("opus, oc"), "{msg}");
2883 }
2884
2885 #[test]
2886 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
2887 let agents = [
2888 pick_spec("opus", AgentKind::Claude),
2889 pick_spec("oc", AgentKind::Opencode),
2890 ];
2891 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
2892 .expect_err("must not silently substitute another model")
2893 .to_string();
2894 assert!(msg.contains("opencode"), "{msg}");
2895 assert!(msg.contains("--agent"), "{msg}");
2896 }
2897
2898 fn named(id: &str) -> AgentSpec {
2899 AgentSpec {
2900 id: id.to_owned(),
2901 ..spec(AgentKind::Command, None)
2902 }
2903 }
2904
2905 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
2906 AgentOutput {
2907 text: text.to_owned(),
2908 exit_code: Some(exit),
2909 timed_out: false,
2910 duration_ms: 0,
2911 artifacts: Vec::new(),
2912 quota: quota.then_some(Quota { reset: None }),
2913 dropped: None,
2914 commands: Vec::new(),
2915 context_tokens: None,
2916 }
2917 }
2918
2919 #[test]
2920 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
2921 let agents = [named("a"), named("b"), named("c")];
2922 let all = |_: &AgentSpec| true;
2923 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
2924 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
2925 assert_eq!(
2926 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
2927 ["c", "a"]
2928 );
2929
2930 let one = AgentChoice::from("b");
2931 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
2932 assert_eq!(got.len(), 1);
2933 assert_eq!(got[0].id, "b");
2934
2935 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
2936 assert_eq!(got.len(), 1, "unset keeps pick's default");
2937 assert_eq!(got[0].id, "a");
2938 let empty = AgentChoice::Chain(Vec::new());
2939 assert_eq!(
2940 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
2941 "a"
2942 );
2943 }
2944
2945 #[test]
2946 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
2947 let agents = [named("a"), named("b")];
2948 let not_a = |s: &AgentSpec| s.id != "a";
2949 let chain = AgentChoice::Chain(
2950 ["a", "ghost", "b", "b"]
2951 .iter()
2952 .map(|s| (*s).to_owned())
2953 .collect(),
2954 );
2955 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
2956 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
2957
2958 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
2959 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
2960 assert_eq!(
2961 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
2962 ["b", "a"]
2963 );
2964 }
2965
2966 #[test]
2967 fn a_chain_with_nothing_runnable_names_the_role() {
2968 let agents = [named("a")];
2969 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
2970 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
2971 .unwrap_err()
2972 .to_string();
2973 assert!(err.contains("conductor"), "{err}");
2974 }
2975
2976 #[test]
2977 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
2978 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
2979 assert!(chain_advances(&Ok(output("limit", 0, true))));
2980 assert!(chain_advances(&Ok(output("", 0, false))));
2981 assert!(chain_advances(&Ok(output("x", 1, false))));
2982 assert!(!chain_advances(&Ok(output("answer", 0, false))));
2983 }
2984
2985 #[test]
2986 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
2987 for out in [
2988 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
2989 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
2990 r#"{"result":"ok"}"#,
2991 "not json",
2992 ] {
2993 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
2994 }
2995 }
2996
2997 #[test]
2998 fn opencode_context_tokens_take_the_last_step_finish() {
2999 let out = concat!(
3000 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3001 "\n",
3002 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3003 "\n",
3004 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3005 "\n"
3006 );
3007 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3008 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3009 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3010 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3011 }
3012
3013 #[test]
3014 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3015 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3016 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3017 }
3018
3019 #[test]
3020 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3021 let out = concat!(
3022 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3023 "\n"
3024 );
3025 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3026 }
3027
3028 #[test]
3029 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3030 let out = concat!(
3031 r#"{"type":"session","id":"s"}"#,
3032 "\n",
3033 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3034 "\n",
3035 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3036 "\n"
3037 );
3038 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3039 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3040 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3041 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3042 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3043 }
3044
3045 #[test]
3046 fn command_agents_report_no_context_tokens() {
3047 let out = r#"{"usage":{"input_tokens":5}}"#;
3048 assert_eq!(context_tokens(AgentKind::Command, out), None);
3049 }
3050}