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 let status = v.get("api_error_status");
1273 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1274 || status.and_then(serde_json::Value::as_str) == Some("429"))
1275 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1276 if !usage_limit && !result.to_lowercase().contains("session limit") {
1277 return None;
1278 }
1279 let reset = result
1282 .split("resets ")
1283 .nth(1)
1284 .map(str::trim)
1285 .filter(|s| !s.is_empty())
1286 .map(str::to_owned);
1287 Some(Quota { reset })
1288}
1289
1290fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1305 let from_stdout = stdout
1306 .lines()
1307 .rev()
1308 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1309 .and_then(|v| {
1310 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1311 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1312 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1313 .then(|| error.to_owned())
1314 });
1315 let from_stderr = || {
1316 stderr.lines().find_map(|l| {
1317 let v: serde_json::Value =
1318 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1319 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1320 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1321 (exhausted && code).then(|| {
1322 v.get("short_error")
1323 .and_then(|e| e.as_str())
1324 .unwrap_or_default()
1325 .to_owned()
1326 })
1327 })
1328 };
1329 let text = from_stdout.or_else(from_stderr)?;
1330 let reset = text
1331 .split_once("Resets ")
1332 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1333 .filter(|s| !s.is_empty())
1334 .map(str::to_owned);
1335 Some(Quota { reset })
1336}
1337
1338fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1369 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1370 if !status.eq_ignore_ascii_case("error") {
1371 return None;
1372 }
1373 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1374 if !response.trim().is_empty() {
1375 return None;
1377 }
1378 let produced = v
1379 .get("usage")
1380 .and_then(|u| u.get("output_tokens"))
1381 .and_then(serde_json::Value::as_u64)
1382 .unwrap_or(0);
1383 if produced == 0 {
1384 return None;
1386 }
1387 Some(Dropped {
1388 why: v
1389 .get("error")
1390 .and_then(|e| e.as_str())
1391 .unwrap_or("the CLI ended the stream without delivering its answer")
1392 .trim()
1393 .to_owned(),
1394 output_tokens: produced,
1395 })
1396}
1397
1398pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1400 let mut missing = Vec::new();
1401 for s in specs {
1402 let program = match s.kind {
1403 AgentKind::Command => s.command.first().map(String::as_str),
1404 other => other.program(),
1405 };
1406 if let Some(p) = program
1407 && !crate::config::which(p)
1408 && !Path::new(p).is_file()
1409 && !missing.iter().any(|m: &String| m == p)
1410 {
1411 missing.push(p.to_owned());
1412 }
1413 }
1414 missing
1415}
1416
1417pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1419 run_dir.join("artifacts")
1420}
1421
1422pub fn installed(spec: &AgentSpec) -> bool {
1424 spec.kind.program().is_none_or(crate::config::which)
1427}
1428
1429pub fn pick(
1451 agents: &[AgentSpec],
1452 want: Option<&str>,
1453 available: &dyn Fn(&AgentSpec) -> bool,
1454) -> Result<AgentSpec> {
1455 if let Some(id) = want {
1456 let spec = agents
1457 .iter()
1458 .find(|a| a.id == id)
1459 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1460 if !available(spec) {
1461 bail!(
1462 "agent `{}` needs `{}` on PATH; install it or pass a different \
1463 --agent",
1464 spec.id,
1465 spec.kind.program().unwrap_or("its command")
1466 );
1467 }
1468 return Ok(spec.clone());
1469 }
1470
1471 if agents.is_empty() {
1472 bail!(
1473 "the agent roster is empty, so there is nobody to ask: install one \
1474 of claude, opencode or agy - magi derives a roster from what is on \
1475 PATH - or add an [[agents]] entry to magi.toml."
1476 );
1477 }
1478
1479 if let Some(spec) = agents
1480 .iter()
1481 .find(|a| a.kind == AgentKind::Claude && available(a))
1482 {
1483 return Ok(spec.clone());
1484 }
1485
1486 agents
1487 .iter()
1488 .find(|a| available(a))
1489 .cloned()
1490 .with_context(|| {
1491 let missing = agents
1492 .iter()
1493 .filter_map(|a| a.kind.program())
1494 .collect::<Vec<_>>()
1495 .join(", ");
1496 format!(
1497 "no agent in the roster can be run here: install one of \
1498 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1499 you do have"
1500 )
1501 })
1502}
1503
1504pub fn pick_chain(
1512 agents: &[AgentSpec],
1513 choice: Option<&AgentChoice>,
1514 available: &dyn Fn(&AgentSpec) -> bool,
1515 role: &str,
1516) -> Result<Vec<AgentSpec>> {
1517 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1518 if wanted.is_empty() {
1519 return pick(agents, None, available)
1520 .map(|s| vec![s])
1521 .with_context(|| format!("choose an agent for the {role} role"));
1522 }
1523 let mut chain: Vec<AgentSpec> = Vec::new();
1524 for id in wanted {
1525 if chain.iter().any(|s| s.id == id) {
1526 continue;
1527 }
1528 match pick(agents, Some(id), available) {
1529 Ok(spec) => chain.push(spec),
1530 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1531 }
1532 }
1533 if chain.is_empty() {
1534 bail!(
1535 "[roles] {role} names no agent that can run here; the roster has {}",
1536 ids(agents)
1537 );
1538 }
1539 Ok(chain)
1540}
1541
1542pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1548 match outcome {
1549 Err(_) => true,
1550 Ok(out) => output_advances(out),
1551 }
1552}
1553
1554pub fn output_advances(out: &AgentOutput) -> bool {
1558 out.quota_exhausted() || !out.usable()
1559}
1560
1561fn ids(agents: &[AgentSpec]) -> String {
1562 if agents.is_empty() {
1563 return "no agents at all".to_owned();
1564 }
1565 agents
1566 .iter()
1567 .map(|a| a.id.clone())
1568 .collect::<Vec<_>>()
1569 .join(", ")
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574 use super::*;
1575
1576 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1577
1578 fn command_helper(mode: &str) -> AgentSpec {
1581 AgentSpec {
1582 id: "helper".to_owned(),
1583 kind: AgentKind::Command,
1584 model: None,
1585 command: vec![
1586 std::env::current_exe()
1587 .expect("locate test helper")
1588 .to_string_lossy()
1589 .into_owned(),
1590 "--exact".to_owned(),
1591 "agent::tests::command_agent_test_helper".to_owned(),
1592 "--nocapture".to_owned(),
1593 ],
1594 extra_args: Vec::new(),
1595 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1596 prompt_delivery: None,
1597 }
1598 }
1599
1600 #[test]
1601 fn command_agent_test_helper() {
1602 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1603 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1604 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1605 Ok("no-cache") => println!(
1606 "{}",
1607 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1608 ),
1609 Ok("ignore-stdin") => println!("done"),
1610 Ok("chatty-sleep") => {
1611 println!("i-said-something");
1612 std::thread::sleep(Duration::from_secs(30));
1613 }
1614 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1615 Ok(other) => panic!("unknown command helper mode {other}"),
1616 Err(_) => {}
1617 }
1618 }
1619
1620 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1621 AgentSpec {
1622 id: "a".to_owned(),
1623 kind,
1624 model: model.map(str::to_owned),
1625 command: vec!["echo".to_owned(), "{label}".to_owned()],
1626 extra_args: Vec::new(),
1627 env: BTreeMap::new(),
1628 prompt_delivery: None,
1629 }
1630 }
1631
1632 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1633 Invocation {
1634 cwd,
1635 prompt: "do the thing",
1636 timeout: Duration::from_secs(900),
1637 allow_write,
1638 unsandboxed: false,
1639 sessions: true,
1640 artifacts: art,
1641 stem: "t",
1642 run: "test-run",
1643 node: "test",
1644 cache_dir: None,
1645 attachments: &[],
1646 writable: &[],
1647 }
1648 }
1649
1650 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1651 build_command(
1652 &spec(kind, None),
1653 seat,
1654 &inv(Path::new("."), Path::new("/art"), allow_write),
1655 Path::new("/art/p.md"),
1656 )
1657 .unwrap()
1658 }
1659
1660 #[test]
1661 fn claude_mints_then_resumes_the_same_uuid() {
1662 let mut seat = SeatState::new("judge-1", "a", 7);
1663 let uuid = seat.claude_session.clone().unwrap();
1664 let first = plan_for(AgentKind::Claude, &seat, true);
1665 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1666 assert!(!first.argv.iter().any(|a| a == "--resume"));
1667
1668 seat.turns = 1;
1669 let second = plan_for(AgentKind::Claude, &seat, true);
1670 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1671 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1672 }
1673
1674 #[test]
1675 fn read_only_seats_cannot_edit() {
1676 let seat = SeatState::new("judge-1", "a", 7);
1677 let claude = plan_for(AgentKind::Claude, &seat, false);
1678 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1679 assert!(
1680 !plan_for(AgentKind::Claude, &seat, true)
1681 .argv
1682 .iter()
1683 .any(|a| a == "--disallowed-tools")
1684 );
1685
1686 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1687 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1688 assert!(
1689 !agy.argv
1690 .iter()
1691 .any(|a| a == "--dangerously-skip-permissions")
1692 );
1693 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1694 assert!(
1695 agy_rw
1696 .argv
1697 .windows(2)
1698 .any(|w| w == ["--mode", "accept-edits"])
1699 );
1700 assert!(
1701 agy_rw
1702 .argv
1703 .iter()
1704 .any(|a| a == "--dangerously-skip-permissions")
1705 );
1706 let agy_prompt = agy_rw
1711 .argv
1712 .iter()
1713 .position(|a| a == "-p")
1714 .map(|i| agy_rw.argv[i + 1].clone())
1715 .expect("agy takes its prompt with -p");
1716 assert!(
1717 agy_prompt.starts_with('@'),
1718 "agy must get a file reference, got {agy_prompt:?}"
1719 );
1720 assert!(
1721 !agy_prompt.contains("Read the file at"),
1722 "the prose pointer is for CLIs with no file syntax"
1723 );
1724
1725 for allow_write in [false, true] {
1730 assert!(
1731 plan_for(AgentKind::Opencode, &seat, allow_write)
1732 .argv
1733 .iter()
1734 .any(|a| a == "--auto"),
1735 "opencode needs --auto even to read (allow_write = {allow_write})"
1736 );
1737 }
1738 }
1739
1740 #[test]
1743 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
1744 let seat = SeatState::new("deputy-x", "a", 7);
1745 let roots = [PathBuf::from("/data/questions")];
1746 let mk = |allow_write: bool| {
1747 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1748 i.writable = &roots;
1749 build_command(
1750 &spec(AgentKind::Codex, None),
1751 &seat,
1752 &i,
1753 Path::new("/art/p.md"),
1754 )
1755 .unwrap()
1756 };
1757 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
1758 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
1759 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
1760 }
1761
1762 #[test]
1763 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
1764 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
1765 let mut seat = SeatState::new("talk", "a", 7);
1766 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
1767 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1768 i.unsandboxed = unsandboxed;
1769 build_command(
1770 &spec(AgentKind::Codex, None),
1771 seat,
1772 &i,
1773 Path::new("/art/p.md"),
1774 )
1775 .unwrap()
1776 };
1777
1778 let first = build(&seat, true, true);
1780 assert!(first.argv.iter().any(|a| a == BYPASS));
1781 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
1782 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
1783
1784 seat.captured_session = Some("thread-1".to_owned());
1786 seat.turns = 1;
1787 let resumed = build(&seat, true, true);
1788 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
1789 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
1790
1791 let ro = build(&seat, false, true);
1793 assert!(!ro.argv.iter().any(|a| a == BYPASS));
1794 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1795 }
1796
1797 #[test]
1798 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1799 let mut seat = SeatState::new("judge-1", "a", 7);
1800
1801 let ro = plan_for(AgentKind::Codex, &seat, false);
1805 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1806 let rw = plan_for(AgentKind::Codex, &seat, true);
1807 assert!(
1808 rw.argv
1809 .windows(2)
1810 .any(|w| w == ["--sandbox", "workspace-write"])
1811 );
1812 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
1815 ro_flagged.unsandboxed = true;
1816 let ro_flagged = build_command(
1817 &spec(AgentKind::Codex, None),
1818 &seat,
1819 &ro_flagged,
1820 Path::new("/art/p.md"),
1821 )
1822 .unwrap();
1823 assert!(
1824 ro_flagged
1825 .argv
1826 .windows(2)
1827 .any(|w| w == ["--sandbox", "read-only"])
1828 );
1829 for p in [&ro, &rw, &ro_flagged] {
1830 assert!(
1831 !p.argv
1832 .iter()
1833 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1834 "the bypass defeats the only enforced read-only mode we have"
1835 );
1836 assert!(
1838 p.argv
1839 .windows(2)
1840 .any(|w| w == ["-c", "approval_policy=\"never\""]),
1841 "an unattended seat that asks for approval blocks until timeout"
1842 );
1843 }
1844
1845 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1847 assert_eq!(
1848 ro.argv.last().map(String::as_str),
1849 Some("-"),
1850 "without the `-` argument codex waits for a prompt it never gets"
1851 );
1852
1853 seat.turns = 1;
1857 assert!(!has_session(AgentKind::Codex, &seat, true));
1858 assert!(
1859 !plan_for(AgentKind::Codex, &seat, true)
1860 .argv
1861 .iter()
1862 .any(|a| a == "resume")
1863 );
1864 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1865 let resumed = plan_for(AgentKind::Codex, &seat, true);
1866 let at = resumed
1867 .argv
1868 .iter()
1869 .position(|a| a == "resume")
1870 .expect("resumes by subcommand");
1871 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1872 assert!(
1873 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1874 "every option precedes the subcommand"
1875 );
1876 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1877 }
1878
1879 #[test]
1882 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
1883 let mut seat = SeatState::new("review-1", "a", 7);
1884
1885 let first = plan_for(AgentKind::Omp, &seat, false);
1889 assert!(first.argv.iter().any(|a| a == "-p"));
1890 assert!(first.argv.iter().any(|a| a == "--mode=json"));
1891 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
1892 assert!(
1893 !first.argv.iter().any(|a| a == "do the thing"),
1894 "the prompt reached argv, where Windows caps it"
1895 );
1896
1897 for allow_write in [false, true] {
1903 let p = plan_for(AgentKind::Omp, &seat, allow_write);
1904 assert!(
1905 p.argv.iter().any(|a| a == "--auto-approve"),
1906 "omp needs --auto-approve even to read (allow_write = {allow_write})"
1907 );
1908 assert!(
1909 !p.argv
1910 .iter()
1911 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1912 "nothing ever asks for the bypass"
1913 );
1914 }
1915
1916 seat.turns = 1;
1919 assert!(!has_session(AgentKind::Omp, &seat, true));
1920 assert!(
1921 !plan_for(AgentKind::Omp, &seat, true)
1922 .argv
1923 .iter()
1924 .any(|a| a == "--resume")
1925 );
1926 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
1927 let resumed = plan_for(AgentKind::Omp, &seat, true);
1928 assert!(
1929 resumed
1930 .argv
1931 .windows(2)
1932 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
1933 "a captured id is what makes the next turn a resume"
1934 );
1935 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
1938 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
1940 }
1941
1942 #[test]
1947 fn omp_takes_the_answer_without_an_agent_end_line() {
1948 let stream = concat!(
1949 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
1950 "\n",
1951 r#"{"type":"agent_start"}"#,
1952 "\n",
1953 r#"{"type":"turn_start"}"#,
1954 "\n",
1955 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
1956 "\n",
1957 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
1958 "\n",
1959 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
1960 "\n",
1961 );
1962 let out = extract(AgentKind::Omp, stream);
1963 assert_eq!(
1964 out.text, "{\"vote\":\"approve\"}",
1965 "the last assistant text block is the answer even with no agent_end"
1966 );
1967 assert_eq!(
1968 out.session.as_deref(),
1969 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
1970 );
1971 }
1972
1973 #[test]
1977 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
1978 let stream = concat!(
1979 r#"{"type":"session","version":3,"id":"s1"}"#,
1980 "\n",
1981 "{\"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問題ありません。\"}]}]}",
1982 "\n",
1983 );
1984 let out = extract(AgentKind::Omp, stream);
1985 assert_eq!(
1986 out.text, "## 判定\n\n問題ありません。",
1987 "the narration is not the answer, and non-ASCII survives intact"
1988 );
1989 assert_eq!(out.session.as_deref(), Some("s1"));
1990 }
1991
1992 #[test]
1995 fn omp_skips_non_json_lines() {
1996 let stream = concat!(
1997 "Warning: some omp notice\n",
1998 r#"{"type":"session","version":3,"id":"s2"}"#,
1999 "\n",
2000 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2001 "\n",
2002 "trailing junk",
2003 "\n",
2004 );
2005 let out = extract(AgentKind::Omp, stream);
2006 assert_eq!(out.text, "the answer");
2007 assert_eq!(out.session.as_deref(), Some("s2"));
2008 }
2009
2010 #[test]
2012 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2013 let stream = concat!(
2014 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2015 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2016 "\n",
2017 r#"{"type":"turn.started"}"#,
2018 "\n",
2019 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2020 "\n",
2021 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2022 "\n",
2023 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2024 "\n",
2025 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2026 "\n",
2027 );
2028 let out = extract(AgentKind::Codex, stream);
2029 assert_eq!(
2030 out.text, "{\"verdict\": \"ok\"}",
2031 "the last agent message is the answer; earlier ones narrate"
2032 );
2033 assert_eq!(
2034 out.session.as_deref(),
2035 Some("01a07440-4545-7492-85c1-024e3259a90a")
2036 );
2037 assert_eq!(out.status.as_deref(), Some("success"));
2038
2039 let failed = concat!(
2040 r#"{"type":"thread.started","thread_id":"t1"}"#,
2041 "\n",
2042 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2043 "\n",
2044 );
2045 assert_eq!(
2046 extract(AgentKind::Codex, failed).status.as_deref(),
2047 Some("error")
2048 );
2049 }
2050
2051 #[test]
2052 fn captured_sessions_resume_only_once_reported() {
2053 let mut seat = SeatState::new("impl-A", "a", 7);
2054 seat.turns = 1;
2055 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2056 assert!(!has_session(kind, &seat, true));
2057 let p = plan_for(kind, &seat, true);
2058 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2059 }
2060
2061 seat.captured_session = Some("sid".to_owned());
2062 assert!(has_session(AgentKind::Opencode, &seat, true));
2063 assert!(
2064 plan_for(AgentKind::Opencode, &seat, true)
2065 .argv
2066 .windows(2)
2067 .any(|w| w == ["-s", "sid"])
2068 );
2069 assert!(
2070 plan_for(AgentKind::Antigravity, &seat, true)
2071 .argv
2072 .windows(2)
2073 .any(|w| w == ["--conversation", "sid"])
2074 );
2075 }
2076
2077 #[test]
2078 fn sessions_disabled_never_resumes() {
2079 let mut seat = SeatState::new("impl-A", "a", 7);
2080 seat.turns = 3;
2081 seat.captured_session = Some("sid".to_owned());
2082 for kind in [
2083 AgentKind::Claude,
2084 AgentKind::Opencode,
2085 AgentKind::Antigravity,
2086 ] {
2087 assert!(!has_session(kind, &seat, false));
2088 }
2089 }
2090
2091 #[test]
2092 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2093 let seat = SeatState::new("judge-1", "a", 7);
2094 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2095 let p = plan_for(kind, &seat, false);
2096 assert!(
2097 p.argv.iter().all(|a| a != "do the thing"),
2098 "{kind:?} put the prompt on the command line"
2099 );
2100 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2101 }
2102 let p = plan_for(AgentKind::Antigravity, &seat, false);
2104 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2105 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2106 assert!(p.stdin.is_none());
2107 }
2108
2109 #[test]
2110 fn agy_print_timeout_tracks_the_node_budget() {
2111 let seat = SeatState::new("impl-A", "a", 7);
2112 let p = build_command(
2113 &spec(AgentKind::Antigravity, None),
2114 &seat,
2115 &Invocation {
2116 cwd: Path::new("."),
2117 prompt: "p",
2118 timeout: Duration::from_secs(3600),
2119 allow_write: true,
2120 unsandboxed: false,
2121 sessions: true,
2122 artifacts: Path::new("/art"),
2123 stem: "t",
2124 run: "test-run",
2125 node: "test",
2126 cache_dir: None,
2127 attachments: &[],
2128 writable: &[],
2129 },
2130 Path::new("/art/p.md"),
2131 )
2132 .unwrap();
2133 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2134 }
2135
2136 #[test]
2143 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2144 let mut s = spec(AgentKind::Antigravity, None);
2145 s.prompt_delivery = Some(Delivery::Argv);
2146 let seat = SeatState::new("talk", "a", 7);
2147 let atts = [PathBuf::from("/art/attachments/abc.png")];
2148
2149 let without = build_command(
2150 &s,
2151 &seat,
2152 &Invocation {
2153 attachments: &[],
2154 writable: &[],
2155 ..inv(Path::new("."), Path::new("/art"), true)
2156 },
2157 Path::new("/art/p.md"),
2158 )
2159 .unwrap();
2160 assert!(
2161 !without.argv.iter().any(|a| a == "--add-dir"),
2162 "no attachment, no reason to widen the sandbox: {without:?}"
2163 );
2164
2165 let with = build_command(
2166 &s,
2167 &seat,
2168 &Invocation {
2169 attachments: &atts,
2170 writable: &[],
2171 ..inv(Path::new("."), Path::new("/art"), true)
2172 },
2173 Path::new("/art/p.md"),
2174 )
2175 .unwrap();
2176 assert!(
2177 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2178 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2179 );
2180 }
2181
2182 #[test]
2188 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2189 let seat = SeatState::new("plan", "a", 7);
2190 let atts = [
2191 PathBuf::from("/art/attachments/own.png"),
2192 PathBuf::from("/other-chat/attachments/inherited.png"),
2193 ];
2194
2195 let p = build_command(
2196 &spec(AgentKind::Antigravity, None),
2197 &seat,
2198 &Invocation {
2199 attachments: &atts,
2200 writable: &[],
2201 ..inv(Path::new("."), Path::new("/art"), true)
2202 },
2203 Path::new("/art/p.md"),
2204 )
2205 .unwrap();
2206
2207 assert!(
2208 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2209 "this conversation's own artifacts dir must still be granted: {p:?}"
2210 );
2211 assert!(
2212 p.argv
2213 .windows(2)
2214 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2215 "the inherited attachment's own directory must be granted too: {p:?}"
2216 );
2217 }
2218
2219 #[test]
2220 fn command_agents_get_placeholders_substituted() {
2221 let seat = SeatState::new("impl-A", "a", 7);
2222 let p = plan_for(AgentKind::Command, &seat, true);
2223 assert_eq!(p.argv[0], "echo");
2224 assert_eq!(p.argv[1], "impl-A");
2225 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2226 }
2227
2228 #[test]
2229 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2230 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2232 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2233 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2234 let out = extract(AgentKind::Claude, stdout);
2235 let quota = out.quota.as_ref().expect("rate limit must be detected");
2236 assert_eq!(
2237 quota.reset.as_deref(),
2238 Some("4:50am (Asia/Tokyo)"),
2239 "reset time read from the body"
2240 );
2241 }
2242
2243 #[test]
2244 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2245 let out = extract(
2246 AgentKind::Claude,
2247 r#"{"is_error":true,"result":"session limit reached"}"#,
2248 );
2249 let quota = out.quota.expect("rate limit detected without a reset");
2250 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2251 }
2252
2253 #[test]
2254 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2255 for kind in [AgentKind::Claude, AgentKind::Command] {
2256 for reply in [
2257 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2258 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2259 ] {
2260 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2261 }
2262 for reply in [
2263 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2264 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2265 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2266 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2267 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2268 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2269 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2270 ] {
2271 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2272 }
2273 }
2274 }
2275
2276 #[test]
2277 fn ordinary_failures_are_never_quota() {
2278 let claude_fail = extract(
2280 AgentKind::Claude,
2281 r#"{"is_error":true,"result":"account does not exist"}"#,
2282 );
2283 assert!(claude_fail.quota.is_none());
2284
2285 let cmd_fail = extract(AgentKind::Command, "boom");
2287 assert!(cmd_fail.quota.is_none());
2288
2289 let success = extract(
2291 AgentKind::Command,
2292 r#"{"is_error":false,"result":"session limit is fine"}"#,
2293 );
2294 assert!(success.quota.is_none());
2295 }
2296
2297 #[test]
2305 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2306 let stream = concat!(
2307 r#"{"type":"thread.started","thread_id":"t1"}"#,
2308 "\n",
2309 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"}}"#,
2310 "\n",
2311 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"}}"#,
2312 "\n",
2313 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2314 "\n",
2315 r#"{"type":"turn.completed"}"#,
2316 "\n",
2317 );
2318 let out = extract(AgentKind::Codex, stream);
2319 assert_eq!(out.text, "Both tests in the target pass.");
2320 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2321
2322 let paired = &out.commands[0];
2323 assert_eq!(paired.id, "item49");
2324 assert_eq!(paired.exit_code, Some(1));
2325 assert!(paired.description.contains("graph_cached_gate"));
2326 assert!(paired.result_summary.contains("1 failed"));
2327
2328 let solo = &out.commands[1];
2329 assert_eq!(solo.exit_code, Some(0));
2330
2331 assert!(
2335 out.commands
2336 .iter()
2337 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2338 "a failed run of the actual target must still be visible: {:?}",
2339 out.commands
2340 );
2341 }
2342
2343 #[test]
2344 fn command_agent_can_carry_the_claude_quota_shape() {
2345 let out = extract(
2346 AgentKind::Command,
2347 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2348 );
2349 assert!(
2350 out.quota.is_some(),
2351 "a wrapper emitting the claude shape counts as quota"
2352 );
2353 }
2354
2355 #[test]
2356 fn claude_json_result_is_extracted() {
2357 let out = extract(
2358 AgentKind::Claude,
2359 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2360 );
2361 assert_eq!(out.text, "all done");
2362 assert_eq!(out.session.as_deref(), Some("abc"));
2363 assert_eq!(out.status.as_deref(), Some("success"));
2364 }
2365
2366 #[test]
2381 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2382 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"}"#;
2383 let out = extract(AgentKind::Claude, stdout);
2384 assert_eq!(out.status.as_deref(), Some("success"));
2385 assert!(out.quota.is_none());
2386 assert!(!out.text.trim().is_empty());
2387
2388 let agent_out = AgentOutput {
2389 text: out.text.clone(),
2390 exit_code: Some(0),
2391 timed_out: false,
2392 duration_ms: 500,
2393 artifacts: Vec::new(),
2394 quota: out.quota,
2395 dropped: out.dropped,
2396 commands: out.commands,
2397 context_tokens: None,
2398 };
2399 assert!(
2400 agent_out.usable(),
2401 "the CLI turn itself ended cleanly and must read as usable"
2402 );
2403 assert!(
2404 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2405 "a clean CLI turn is not proof the node's own report ever arrived"
2406 );
2407 }
2408
2409 #[test]
2410 fn opencode_event_stream_is_concatenated() {
2411 let stream = concat!(
2412 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2413 "\n",
2414 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2415 "\n",
2416 "garbage line\n",
2417 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2418 "\n"
2419 );
2420 let out = extract(AgentKind::Opencode, stream);
2421 assert_eq!(out.text, "first\nsecond");
2422 assert_eq!(out.session.as_deref(), Some("ses_1"));
2423 }
2424
2425 #[test]
2426 fn agy_json_survives_a_leading_warning_line() {
2427 let stdout = concat!(
2428 "warning: --mode plan has no effect while slash commands are disabled.\n",
2429 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2430 "\n"
2431 );
2432 let out = extract(AgentKind::Antigravity, stdout);
2433 assert_eq!(out.text, "persimmon");
2434 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2435 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2436 }
2437
2438 const AGY_DROPPED: &str = concat!(
2445 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2446 r#""response":"","error":"the connection to the agent was interrupted before "#,
2447 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2448 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2449 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2450 r#""total_tokens":274380}}"#
2451 );
2452
2453 const AGY_QUOTA_OUT: &str = concat!(
2455 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2456 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2457 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2458 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2459 );
2460 const AGY_QUOTA_ERR: &str = concat!(
2461 "error: Individual quota reached. Resets in 1h2m49s.\n",
2462 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
2463 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
2464 r#""retryable":true}"#,
2465 "\n"
2466 );
2467
2468 #[test]
2469 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
2470 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
2471 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
2472 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
2473 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
2474 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
2476 assert!(stderr_only.reset.is_none());
2477 }
2478
2479 #[test]
2480 fn ordinary_agy_failures_are_not_a_quota() {
2481 assert!(agy_quota(AGY_DROPPED, "").is_none());
2482 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
2483 assert!(
2484 agy_quota(
2485 "",
2486 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
2487 )
2488 .is_none()
2489 );
2490 assert!(
2491 agy_quota(
2492 "",
2493 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
2494 )
2495 .is_none()
2496 );
2497 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
2498 }
2499
2500 #[test]
2501 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2502 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2503 let dropped = out.dropped.expect("recognised as undelivered work");
2504 assert_eq!(dropped.output_tokens, 14267);
2505 assert!(
2506 dropped.why.contains("subscriber fell behind"),
2507 "the CLI's own words are kept for the record: {}",
2508 dropped.why
2509 );
2510 assert_eq!(
2513 out.session.as_deref(),
2514 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
2515 );
2516 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
2517 }
2518
2519 #[test]
2520 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
2521 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
2525 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
2526
2527 let answered = concat!(
2530 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
2531 r#""usage":{"output_tokens":10}}"#
2532 );
2533 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
2534
2535 let ok = concat!(
2537 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
2538 r#""usage":{"output_tokens":10}}"#
2539 );
2540 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
2541 }
2542
2543 #[test]
2544 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
2545 let out = AgentOutput {
2546 text: String::new(),
2547 exit_code: Some(1),
2548 timed_out: false,
2549 duration_ms: 431_194,
2550 artifacts: Vec::new(),
2551 quota: None,
2552 dropped: Some(Dropped {
2553 why: "subscriber fell behind updates".to_owned(),
2554 output_tokens: 14267,
2555 }),
2556 commands: Vec::new(),
2557 context_tokens: None,
2558 };
2559 assert!(!out.usable());
2560 assert!(out.work_undelivered());
2561 assert!(!out.quota_exhausted());
2564 }
2565
2566 #[test]
2567 fn non_json_stdout_falls_back_to_raw_text() {
2568 let out = extract(AgentKind::Antigravity, "plain answer\n");
2569 assert_eq!(out.text, "plain answer");
2570 assert!(out.session.is_none());
2571 }
2572
2573 #[tokio::test]
2574 async fn command_agent_round_trip_writes_artifacts() {
2575 let dir = tempfile::tempdir().unwrap();
2576 let art = dir.path().join("artifacts");
2577 let mut seat = SeatState::new("impl-A", "a", 7);
2578 let s = command_helper("reply");
2579 let out = invoke(
2580 &s,
2581 &mut seat,
2582 &Invocation {
2583 cwd: dir.path(),
2584 prompt: "unused",
2585 timeout: Duration::from_secs(30),
2586 allow_write: true,
2587 unsandboxed: false,
2588 sessions: true,
2589 artifacts: &art,
2590 stem: "impl-A",
2591 run: "test-run",
2592 node: "test",
2593 cache_dir: None,
2594 attachments: &[],
2595 writable: &[],
2596 },
2597 )
2598 .await
2599 .unwrap();
2600 assert!(out.usable(), "{out:?}");
2601 assert!(out.text.contains("hello impl-A"), "{}", out.text);
2602 assert_eq!(seat.turns, 1);
2603 assert!(art.join("impl-A.prompt.md").is_file());
2604 assert!(art.join("impl-A.out").is_file());
2605 }
2606
2607 #[tokio::test]
2608 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
2609 let dir = tempfile::tempdir().unwrap();
2613 let cache = dir.path().join("magi-cache");
2614 let mut seat = SeatState::new("impl-A", "a", 7);
2615 let s = command_helper("cache");
2616 let out = invoke(
2617 &s,
2618 &mut seat,
2619 &Invocation {
2620 cwd: dir.path(),
2621 prompt: "unused",
2622 timeout: Duration::from_secs(30),
2623 allow_write: true,
2624 unsandboxed: false,
2625 sessions: true,
2626 artifacts: &dir.path().join("artifacts"),
2627 stem: "cache",
2628 run: "test-run",
2629 node: "test",
2630 cache_dir: Some(&cache),
2631 attachments: &[],
2632 writable: &[],
2633 },
2634 )
2635 .await
2636 .unwrap();
2637 assert!(out.usable(), "{out:?}");
2638 assert!(
2639 out.text.contains(cache.to_string_lossy().as_ref()),
2640 "the seat must see CARGO_TARGET_DIR = the shared cache"
2641 );
2642 }
2643
2644 #[tokio::test]
2645 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
2646 let previous = std::env::var("CARGO_TARGET_DIR").ok();
2654 unsafe {
2659 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
2660 }
2661 let dir = tempfile::tempdir().unwrap();
2662 let mut seat = SeatState::new("review-1", "a", 7);
2663 let s = command_helper("no-cache");
2664 let result = invoke(
2665 &s,
2666 &mut seat,
2667 &Invocation {
2668 cwd: dir.path(),
2669 prompt: "unused",
2670 timeout: Duration::from_secs(30),
2671 allow_write: false,
2672 unsandboxed: false,
2673 sessions: true,
2674 artifacts: &dir.path().join("artifacts"),
2675 stem: "no-cache",
2676 run: "test-run",
2677 node: "test",
2678 cache_dir: None,
2679 attachments: &[],
2680 writable: &[],
2681 },
2682 )
2683 .await;
2684 unsafe {
2689 match &previous {
2690 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
2691 None => std::env::remove_var("CARGO_TARGET_DIR"),
2692 }
2693 }
2694 let out = result.unwrap();
2695 assert!(out.usable(), "{out:?}");
2696 assert!(
2697 out.text.contains("ABSENT"),
2698 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
2699 out.text
2700 );
2701 }
2702
2703 #[tokio::test]
2704 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
2705 let dir = tempfile::tempdir().unwrap();
2706 let mut seat = SeatState::new("impl-A", "a", 7);
2707 let s = command_helper("ignore-stdin");
2710 let big = "x".repeat(1_000_000);
2711 let out = invoke(
2712 &s,
2713 &mut seat,
2714 &Invocation {
2715 cwd: dir.path(),
2716 prompt: &big,
2717 timeout: Duration::from_secs(60),
2718 allow_write: true,
2719 unsandboxed: false,
2720 sessions: true,
2721 artifacts: &dir.path().join("artifacts"),
2722 stem: "big",
2723 run: "test-run",
2724 node: "test",
2725 cache_dir: None,
2726 attachments: &[],
2727 writable: &[],
2728 },
2729 )
2730 .await
2731 .unwrap();
2732 assert!(out.usable(), "{out:?}");
2733 assert!(out.text.contains("done"), "{}", out.text);
2734 }
2735
2736 #[tokio::test]
2737 async fn timeout_is_reported_not_hung() {
2738 let dir = tempfile::tempdir().unwrap();
2739 let mut seat = SeatState::new("impl-A", "a", 7);
2740 let s = command_helper("sleep");
2741 let out = invoke(
2742 &s,
2743 &mut seat,
2744 &Invocation {
2745 cwd: dir.path(),
2746 prompt: "unused",
2747 timeout: Duration::from_millis(300),
2748 allow_write: true,
2749 unsandboxed: false,
2750 sessions: true,
2751 artifacts: &dir.path().join("artifacts"),
2752 stem: "slow",
2753 run: "test-run",
2754 node: "test",
2755 cache_dir: None,
2756 attachments: &[],
2757 writable: &[],
2758 },
2759 )
2760 .await
2761 .unwrap();
2762 assert!(out.timed_out);
2763 assert!(!out.usable());
2764 }
2765
2766 #[tokio::test]
2767 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
2768 let dir = tempfile::tempdir().unwrap();
2774 let artifacts = dir.path().join("artifacts");
2775 let mut seat = SeatState::new("impl-A", "a", 7);
2776 let s = command_helper("chatty-sleep");
2777 let out = invoke(
2778 &s,
2779 &mut seat,
2780 &Invocation {
2781 cwd: dir.path(),
2782 prompt: "unused",
2783 timeout: Duration::from_secs(10),
2788 allow_write: true,
2789 unsandboxed: false,
2790 sessions: true,
2791 artifacts: &artifacts,
2792 stem: "chatty",
2793 run: "test-run",
2794 node: "test",
2795 cache_dir: None,
2796 attachments: &[],
2797 writable: &[],
2798 },
2799 )
2800 .await
2801 .unwrap();
2802
2803 assert!(out.timed_out, "{out:?}");
2804 assert!(!out.usable(), "a cut-off answer is still not an answer");
2805 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
2806 assert!(
2807 recorded.contains("i-said-something"),
2808 "the artifact must keep what arrived before the kill, got {recorded:?}"
2809 );
2810 assert!(
2811 out.text.contains("i-said-something"),
2812 "and the graph must be able to see it too, got {:?}",
2813 out.text
2814 );
2815 }
2816
2817 #[test]
2818 fn missing_programs_reports_command_binaries() {
2819 let mut s = spec(AgentKind::Command, None);
2820 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
2821 assert_eq!(
2822 missing_programs(&[s]),
2823 ["definitely-not-a-real-binary-xyz".to_owned()]
2824 );
2825 }
2826
2827 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
2828 AgentSpec {
2829 id: id.to_owned(),
2830 kind,
2831 model: None,
2832 command: Vec::new(),
2833 extra_args: Vec::new(),
2834 env: BTreeMap::new(),
2835 prompt_delivery: None,
2836 }
2837 }
2838
2839 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
2842 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
2843 }
2844
2845 #[test]
2846 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
2847 let agents = [
2848 pick_spec("oc", AgentKind::Opencode),
2849 pick_spec("opus", AgentKind::Claude),
2850 pick_spec("agy", AgentKind::Antigravity),
2851 ];
2852 let got = pick(&agents, None, &without(&[])).expect("a pick");
2853 assert_eq!(got.id, "opus");
2854 }
2855
2856 #[test]
2857 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
2858 let agents = [
2859 pick_spec("opus", AgentKind::Claude),
2860 pick_spec("oc", AgentKind::Opencode),
2861 pick_spec("agy", AgentKind::Antigravity),
2862 ];
2863 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
2864 assert_eq!(got.id, "agy");
2865 }
2866
2867 #[test]
2868 fn pick_on_an_empty_roster_says_what_to_install() {
2869 let msg = pick(&[], None, &without(&[]))
2870 .expect_err("nobody to ask")
2871 .to_string();
2872 assert!(msg.contains("roster is empty"), "{msg}");
2873 assert!(msg.contains("claude"), "{msg}");
2874 assert!(msg.contains("magi.toml"), "{msg}");
2875 }
2876
2877 #[test]
2878 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
2879 let agents = [
2880 pick_spec("opus", AgentKind::Claude),
2881 pick_spec("oc", AgentKind::Opencode),
2882 ];
2883 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
2884 let msg = format!("{err:#}");
2885 assert!(msg.contains("claude"), "{msg}");
2886 assert!(msg.contains("opencode"), "{msg}");
2887 }
2888
2889 #[test]
2890 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
2891 let agents = [
2892 pick_spec("opus", AgentKind::Claude),
2893 pick_spec("oc", AgentKind::Opencode),
2894 ];
2895 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
2896 assert_eq!(got.id, "oc");
2897 }
2898
2899 #[test]
2900 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
2901 let agents = [
2902 pick_spec("opus", AgentKind::Claude),
2903 pick_spec("oc", AgentKind::Opencode),
2904 ];
2905 let msg = pick(&agents, Some("gemini"), &without(&[]))
2906 .expect_err("no such agent")
2907 .to_string();
2908 assert!(msg.contains("gemini"), "{msg}");
2909 assert!(msg.contains("opus, oc"), "{msg}");
2910 }
2911
2912 #[test]
2913 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
2914 let agents = [
2915 pick_spec("opus", AgentKind::Claude),
2916 pick_spec("oc", AgentKind::Opencode),
2917 ];
2918 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
2919 .expect_err("must not silently substitute another model")
2920 .to_string();
2921 assert!(msg.contains("opencode"), "{msg}");
2922 assert!(msg.contains("--agent"), "{msg}");
2923 }
2924
2925 fn named(id: &str) -> AgentSpec {
2926 AgentSpec {
2927 id: id.to_owned(),
2928 ..spec(AgentKind::Command, None)
2929 }
2930 }
2931
2932 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
2933 AgentOutput {
2934 text: text.to_owned(),
2935 exit_code: Some(exit),
2936 timed_out: false,
2937 duration_ms: 0,
2938 artifacts: Vec::new(),
2939 quota: quota.then_some(Quota { reset: None }),
2940 dropped: None,
2941 commands: Vec::new(),
2942 context_tokens: None,
2943 }
2944 }
2945
2946 #[test]
2947 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
2948 let agents = [named("a"), named("b"), named("c")];
2949 let all = |_: &AgentSpec| true;
2950 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
2951 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
2952 assert_eq!(
2953 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
2954 ["c", "a"]
2955 );
2956
2957 let one = AgentChoice::from("b");
2958 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
2959 assert_eq!(got.len(), 1);
2960 assert_eq!(got[0].id, "b");
2961
2962 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
2963 assert_eq!(got.len(), 1, "unset keeps pick's default");
2964 assert_eq!(got[0].id, "a");
2965 let empty = AgentChoice::Chain(Vec::new());
2966 assert_eq!(
2967 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
2968 "a"
2969 );
2970 }
2971
2972 #[test]
2973 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
2974 let agents = [named("a"), named("b")];
2975 let not_a = |s: &AgentSpec| s.id != "a";
2976 let chain = AgentChoice::Chain(
2977 ["a", "ghost", "b", "b"]
2978 .iter()
2979 .map(|s| (*s).to_owned())
2980 .collect(),
2981 );
2982 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
2983 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
2984
2985 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
2986 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
2987 assert_eq!(
2988 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
2989 ["b", "a"]
2990 );
2991 }
2992
2993 #[test]
2994 fn a_chain_with_nothing_runnable_names_the_role() {
2995 let agents = [named("a")];
2996 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
2997 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
2998 .unwrap_err()
2999 .to_string();
3000 assert!(err.contains("conductor"), "{err}");
3001 }
3002
3003 #[test]
3004 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3005 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3006 assert!(chain_advances(&Ok(output("limit", 0, true))));
3007 assert!(chain_advances(&Ok(output("", 0, false))));
3008 assert!(chain_advances(&Ok(output("x", 1, false))));
3009 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3010 }
3011
3012 #[test]
3013 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3014 for out in [
3015 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3016 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3017 r#"{"result":"ok"}"#,
3018 "not json",
3019 ] {
3020 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3021 }
3022 }
3023
3024 #[test]
3025 fn opencode_context_tokens_take_the_last_step_finish() {
3026 let out = concat!(
3027 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3028 "\n",
3029 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3030 "\n",
3031 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3032 "\n"
3033 );
3034 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3035 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3036 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3037 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3038 }
3039
3040 #[test]
3041 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3042 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3043 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3044 }
3045
3046 #[test]
3047 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3048 let out = concat!(
3049 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3050 "\n"
3051 );
3052 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3053 }
3054
3055 #[test]
3056 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3057 let out = concat!(
3058 r#"{"type":"session","id":"s"}"#,
3059 "\n",
3060 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3061 "\n",
3062 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3063 "\n"
3064 );
3065 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3066 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3067 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3068 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3069 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3070 }
3071
3072 #[test]
3073 fn command_agents_report_no_context_tokens() {
3074 let out = r#"{"usage":{"input_tokens":5}}"#;
3075 assert_eq!(context_tokens(AgentKind::Command, out), None);
3076 }
3077}