1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::time::{Duration, Instant};
36
37use anyhow::{Context as _, Result, bail};
38use serde::{Deserialize, Serialize};
39use std::sync::{Arc, Mutex};
40
41use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
42use tokio::process::Command;
43
44use crate::config::{AgentKind, AgentSpec, Delivery};
45use crate::proc::Quiet as _;
46use crate::rng::SplitMix64;
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SeatState {
52 pub key: String,
54 pub agent: String,
56 pub turns: usize,
58 pub claude_session: Option<String>,
61 pub captured_session: Option<String>,
63}
64
65impl SeatState {
66 pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
68 let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
69 Self {
70 key: key.to_owned(),
71 agent: agent.to_owned(),
72 turns: 0,
73 claude_session: Some(rng.uuid_v4()),
74 captured_session: None,
75 }
76 }
77}
78
79pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
81 if !sessions_enabled || seat.turns == 0 {
82 return false;
83 }
84 match kind {
85 AgentKind::Claude => seat.claude_session.is_some(),
86 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
87 seat.captured_session.is_some()
88 }
89 AgentKind::Command => true,
90 }
91}
92
93#[derive(Debug)]
95pub struct Invocation<'a> {
96 pub cwd: &'a Path,
99 pub prompt: &'a str,
101 pub timeout: Duration,
103 pub allow_write: bool,
105 pub sessions: bool,
107 pub artifacts: &'a Path,
109 pub stem: &'a str,
111 pub run: &'a str,
115 pub node: &'a str,
119 pub cache_dir: Option<&'a Path>,
125 pub attachments: &'a [PathBuf],
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141pub struct Quota {
142 #[serde(default)]
144 pub reset: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
154pub struct Dropped {
155 pub why: String,
157 pub output_tokens: u64,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct AgentOutput {
165 pub text: String,
167 pub exit_code: Option<i32>,
169 pub timed_out: bool,
171 pub duration_ms: u64,
173 pub artifacts: Vec<String>,
175 #[serde(default)]
179 pub quota: Option<Quota>,
180 #[serde(default)]
184 pub dropped: Option<Dropped>,
185}
186
187impl AgentOutput {
188 pub fn usable(&self) -> bool {
190 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
191 }
192
193 pub fn quota_exhausted(&self) -> bool {
195 self.quota.is_some()
196 }
197
198 pub fn work_undelivered(&self) -> bool {
203 self.dropped.is_some()
204 }
205}
206
207const PIPE_GRACE: Duration = Duration::from_secs(3);
212
213type Captured = Arc<Mutex<Vec<u8>>>;
215
216fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
226where
227 R: tokio::io::AsyncRead + Unpin + Send + 'static,
228{
229 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
230 let Some(mut pipe) = pipe else {
231 return (buf, None);
232 };
233 let sink = Arc::clone(&buf);
234 let handle = tokio::spawn(async move {
235 let mut chunk = [0u8; 8192];
236 loop {
237 match pipe.read(&mut chunk).await {
238 Ok(0) | Err(_) => break,
239 Ok(n) => {
240 if let Ok(mut guard) = sink.lock() {
241 guard.extend_from_slice(&chunk[..n]);
242 }
243 }
244 }
245 }
246 });
247 (buf, Some(handle))
248}
249
250async fn collect(
255 buf: &Captured,
256 handle: Option<tokio::task::JoinHandle<()>>,
257 grace: Duration,
258) -> String {
259 if let Some(handle) = handle {
260 if tokio::time::timeout(grace, handle).await.is_err() {
261 tracing::debug!("a pipe is still held open after the child exited");
262 }
263 }
264 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
265 String::from_utf8_lossy(&bytes).into_owned()
266}
267
268pub async fn invoke(
270 spec: &AgentSpec,
271 seat: &mut SeatState,
272 inv: &Invocation<'_>,
273) -> Result<AgentOutput> {
274 tokio::fs::create_dir_all(inv.artifacts)
275 .await
276 .with_context(|| format!("create {}", inv.artifacts.display()))?;
277 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
278 tokio::fs::write(&prompt_path, inv.prompt)
279 .await
280 .with_context(|| format!("write {}", prompt_path.display()))?;
281
282 let plan = build_command(spec, seat, inv, &prompt_path)?;
283 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
284
285 let started = Instant::now();
286 let mut cmd = Command::new(&plan.argv[0]);
287 cmd.args(&plan.argv[1..])
288 .current_dir(inv.cwd)
289 .envs(&spec.env)
290 .env("MAGI_SEAT", &seat.key)
291 .env("MAGI_TURN", seat.turns.to_string())
292 .env("MAGI_RUN", inv.run)
293 .env("MAGI_NODE", inv.node)
294 .env("MAGI_PROMPT_FILE", &prompt_path)
295 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
296 .env("GIT_TERMINAL_PROMPT", "0")
297 .stdin(if plan.stdin.is_some() {
298 Stdio::piped()
299 } else {
300 Stdio::null()
301 })
302 .stdout(Stdio::piped())
303 .stderr(Stdio::piped())
304 .kill_on_drop(true)
305 .quiet();
308 if let Some(cache) = inv.cache_dir {
309 cmd.env("CARGO_TARGET_DIR", cache);
312 }
313
314 let mut child = cmd
315 .spawn()
316 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
317 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), child.stdin.take()) {
321 tokio::spawn(async move {
322 sink.write_all(body.as_bytes()).await.ok();
323 sink.shutdown().await.ok();
324 });
325 }
326
327 let (out_buf, out_reader) = drain(child.stdout.take());
344 let (err_buf, err_reader) = drain(child.stderr.take());
345
346 let (code, timed_out) = match tokio::time::timeout(inv.timeout, child.wait()).await {
347 Ok(res) => {
348 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
349 (status.code(), false)
350 }
351 Err(_) => {
352 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
353 child.start_kill().ok();
355 (None, true)
356 }
357 };
358
359 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
364 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
365
366 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
367 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
368 tokio::fs::write(&out_path, &stdout).await.ok();
369 tokio::fs::write(&err_path, &stderr).await.ok();
370
371 let extracted = extract(spec.kind, &stdout);
372 if let Some(session) = extracted.session {
373 match spec.kind {
374 AgentKind::Claude => seat.claude_session = Some(session),
375 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
376 seat.captured_session = Some(session);
377 }
378 AgentKind::Command => {}
379 }
380 }
381 if let Some(status) = &extracted.status
382 && !status.eq_ignore_ascii_case("success")
383 {
384 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
385 }
386 let text = if extracted.text.trim().is_empty() {
387 if stdout.trim().is_empty() {
389 stderr.trim().to_owned()
390 } else {
391 stdout.trim().to_owned()
392 }
393 } else {
394 extracted.text
395 };
396 seat.turns += 1;
397
398 Ok(AgentOutput {
399 text,
400 exit_code: code,
401 timed_out,
402 duration_ms: started.elapsed().as_millis() as u64,
403 artifacts: vec![
404 file_name(&prompt_path),
405 file_name(&out_path),
406 file_name(&err_path),
407 ],
408 quota: extracted.quota,
409 dropped: extracted.dropped,
410 })
411}
412
413fn file_name(p: &Path) -> String {
414 p.file_name()
415 .unwrap_or_default()
416 .to_string_lossy()
417 .into_owned()
418}
419
420#[derive(Debug)]
422struct Plan {
423 argv: Vec<String>,
424 stdin: Option<String>,
425}
426
427fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
438 if matches!(kind, AgentKind::Antigravity) {
439 return format!("@{}", prompt_path.display());
440 }
441 format!(
442 "Read the file at {} and follow every instruction in it exactly. That \
443 file is your complete task description; this message contains nothing \
444 else.",
445 prompt_path.display()
446 )
447}
448
449fn build_command(
450 spec: &AgentSpec,
451 seat: &SeatState,
452 inv: &Invocation<'_>,
453 prompt_path: &Path,
454) -> Result<Plan> {
455 let mut argv: Vec<String> = Vec::new();
456 let mut stdin: Option<String> = None;
457 let delivery = spec.delivery();
458 let resuming = has_session(spec.kind, seat, inv.sessions);
459
460 match spec.kind {
461 AgentKind::Claude => {
462 argv.push("claude".to_owned());
467 argv.push("-p".to_owned());
468 argv.push("--output-format".to_owned());
469 argv.push("json".to_owned());
470 if let Some(m) = &spec.model {
471 argv.push("--model".to_owned());
472 argv.push(m.clone());
473 }
474 if inv.sessions {
475 let uuid = seat
476 .claude_session
477 .as_deref()
478 .context("claude seat is missing its session uuid")?;
479 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
480 argv.push(uuid.to_owned());
481 }
482 argv.push("--permission-mode".to_owned());
483 argv.push("bypassPermissions".to_owned());
484 if !inv.allow_write {
485 argv.push("--disallowed-tools".to_owned());
486 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
487 }
488 }
489 AgentKind::Opencode => {
490 argv.push("opencode".to_owned());
494 argv.push("run".to_owned());
495 argv.push("--format".to_owned());
496 argv.push("json".to_owned());
497 argv.push("--dir".to_owned());
498 argv.push(inv.cwd.to_string_lossy().into_owned());
499 argv.push("--auto".to_owned());
508 if let Some(m) = &spec.model {
509 argv.push("-m".to_owned());
510 argv.push(m.clone());
511 }
512 if resuming {
513 argv.push("-s".to_owned());
514 argv.push(
515 seat.captured_session
516 .clone()
517 .expect("has_session checked the id is present"),
518 );
519 }
520 }
521 AgentKind::Antigravity => {
522 argv.push("agy".to_owned());
523 argv.push("--output-format".to_owned());
524 argv.push("json".to_owned());
525 argv.push("--print-timeout".to_owned());
528 argv.push(format!("{}s", inv.timeout.as_secs()));
529 argv.push("--mode".to_owned());
530 argv.push(
531 if inv.allow_write {
532 "accept-edits"
533 } else {
534 "plan"
535 }
536 .to_owned(),
537 );
538 if inv.allow_write {
539 argv.push("--dangerously-skip-permissions".to_owned());
540 }
541 if let Some(m) = &spec.model {
542 argv.push("--model".to_owned());
543 argv.push(m.clone());
544 }
545 if resuming {
546 argv.push("--conversation".to_owned());
547 argv.push(
548 seat.captured_session
549 .clone()
550 .expect("has_session checked the id is present"),
551 );
552 }
553 let mut add_dirs: Vec<String> = Vec::new();
563 if delivery == Delivery::File || !inv.attachments.is_empty() {
564 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
565 }
566 for path in inv.attachments {
567 let Some(parent) = path.parent() else {
568 continue;
569 };
570 if parent.starts_with(inv.artifacts) {
571 continue;
572 }
573 let dir = parent.to_string_lossy().into_owned();
574 if !add_dirs.contains(&dir) {
575 add_dirs.push(dir);
576 }
577 }
578 for dir in add_dirs {
579 argv.push("--add-dir".to_owned());
580 argv.push(dir);
581 }
582 }
583 AgentKind::Codex => {
584 argv.push("codex".to_owned());
590 argv.push("exec".to_owned());
591 argv.push("--json".to_owned());
592 argv.push("--skip-git-repo-check".to_owned());
595 argv.push("-C".to_owned());
596 argv.push(inv.cwd.to_string_lossy().into_owned());
597 argv.push("--sandbox".to_owned());
602 argv.push(
603 if inv.allow_write {
604 "workspace-write"
605 } else {
606 "read-only"
607 }
608 .to_owned(),
609 );
610 argv.push("-c".to_owned());
613 argv.push("approval_policy=\"never\"".to_owned());
614 if let Some(m) = &spec.model {
615 argv.push("-m".to_owned());
616 argv.push(m.clone());
617 }
618 if resuming {
624 argv.push("resume".to_owned());
625 argv.push(
626 seat.captured_session
627 .clone()
628 .expect("has_session checked the id is present"),
629 );
630 }
631 }
632 AgentKind::Omp => {
633 argv.push("omp".to_owned());
637 argv.push("-p".to_owned());
638 argv.push("--mode=json".to_owned());
639 argv.push("--auto-approve".to_owned());
649 if let Some(m) = &spec.model {
650 argv.push("--model".to_owned());
651 argv.push(m.clone());
652 }
653 if resuming {
659 argv.push("--resume".to_owned());
660 argv.push(
661 seat.captured_session
662 .clone()
663 .expect("has_session checked the id is present"),
664 );
665 }
666 }
667 AgentKind::Command => {
668 if spec.command.is_empty() {
673 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
674 }
675 let vars: BTreeMap<&str, String> = BTreeMap::from([
676 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
677 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
678 ("{label}", seat.key.clone()),
679 ("{session}", seat.claude_session.clone().unwrap_or_default()),
680 ]);
681 for raw in &spec.command {
682 let mut arg = raw.clone();
683 for (k, v) in &vars {
684 if arg.contains(k) {
685 arg = arg.replace(k, v);
686 }
687 }
688 argv.push(arg);
689 }
690 }
691 }
692
693 argv.extend(spec.extra_args.iter().cloned());
694
695 if spec.kind == AgentKind::Antigravity {
698 argv.push("-p".to_owned());
699 }
700 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
703 argv.push("-".to_owned());
704 }
705 match delivery {
706 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
707 argv.push(pointer(spec.kind, prompt_path));
709 }
710 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
711 Delivery::Argv => argv.push(inv.prompt.to_owned()),
712 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
713 }
714
715 Ok(Plan { argv, stdin })
716}
717
718#[derive(Debug, Default)]
720struct Extracted {
721 text: String,
722 session: Option<String>,
723 status: Option<String>,
724 quota: Option<Quota>,
725 dropped: Option<Dropped>,
726}
727
728fn extract(kind: AgentKind, stdout: &str) -> Extracted {
730 match kind {
731 AgentKind::Claude => {
732 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
733 return Extracted {
734 text: stdout.trim().to_owned(),
735 ..Extracted::default()
736 };
737 };
738 Extracted {
739 text: v
740 .get("result")
741 .and_then(|r| r.as_str())
742 .unwrap_or_default()
743 .to_owned(),
744 session: v
745 .get("session_id")
746 .and_then(|s| s.as_str())
747 .map(str::to_owned),
748 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
749 if e {
750 "error".to_owned()
751 } else {
752 "success".to_owned()
753 }
754 }),
755 quota: claude_quota(&v),
756 dropped: None,
759 }
760 }
761 AgentKind::Opencode => {
762 let mut text = String::new();
764 let mut session = None;
765 for line in stdout.lines() {
766 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
767 continue;
768 };
769 if session.is_none() {
770 session = v
771 .get("sessionID")
772 .and_then(|s| s.as_str())
773 .map(str::to_owned);
774 }
775 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
776 if part.get("type").and_then(|t| t.as_str()) == Some("text")
777 && let Some(t) = part.get("text").and_then(|t| t.as_str())
778 {
779 if !text.is_empty() {
780 text.push('\n');
781 }
782 text.push_str(t);
783 }
784 }
785 Extracted {
786 text,
787 session,
788 status: None,
789 quota: None,
790 dropped: None,
791 }
792 }
793 AgentKind::Antigravity => {
794 let obj = stdout
797 .lines()
798 .rev()
799 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
800 let Some(v) = obj else {
801 return Extracted {
802 text: stdout.trim().to_owned(),
803 ..Extracted::default()
804 };
805 };
806 Extracted {
807 text: v
808 .get("response")
809 .and_then(|r| r.as_str())
810 .unwrap_or_default()
811 .trim()
812 .to_owned(),
813 session: v
814 .get("conversation_id")
815 .and_then(|s| s.as_str())
816 .map(str::to_owned),
817 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
818 quota: None,
819 dropped: dropped_stream(&v),
820 }
821 }
822 AgentKind::Codex => {
823 let mut text = String::new();
834 let mut session = None;
835 let mut status = None;
836 for line in stdout.lines() {
837 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
838 continue;
839 };
840 match v.get("type").and_then(|t| t.as_str()) {
841 Some("thread.started") => {
842 session = v
843 .get("thread_id")
844 .and_then(|s| s.as_str())
845 .map(str::to_owned);
846 }
847 Some("item.completed") => {
848 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
849 if item.get("type").and_then(|t| t.as_str()) == Some("agent_message")
850 && let Some(t) = item.get("text").and_then(|t| t.as_str())
851 {
852 text = t.trim().to_owned();
853 }
854 }
855 Some("turn.completed") => status = Some("success".to_owned()),
856 Some("turn.failed") => status = Some("error".to_owned()),
857 _ => {}
858 }
859 }
860 Extracted {
861 text,
862 session,
863 status,
864 quota: None,
865 dropped: None,
866 }
867 }
868 AgentKind::Omp => {
869 let mut text = String::new();
890 let mut session = None;
891 for line in stdout.lines() {
892 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
893 continue;
894 };
895 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
896 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
897 continue;
898 }
899 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
903 {
904 Some("agent_end") => v
905 .get("messages")
906 .and_then(|m| m.as_array())
907 .map(|m| m.iter().collect())
908 .unwrap_or_default(),
909 Some("turn_end") | Some("message_end") => {
910 v.get("message").into_iter().collect()
911 }
912 _ => continue,
913 };
914 for message in messages {
915 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
916 continue;
917 }
918 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
919 continue;
920 };
921 for part in parts {
922 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
923 continue;
924 }
925 if let Some(t) = part.get("text").and_then(|t| t.as_str())
926 && !t.trim().is_empty()
927 {
928 text = t.trim().to_owned();
929 }
930 }
931 }
932 }
933 Extracted {
934 text,
935 session,
936 status: None,
937 quota: None,
938 dropped: None,
939 }
940 }
941 AgentKind::Command => {
942 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
947 let quota = parsed.as_ref().and_then(claude_quota);
948 let dropped = parsed.as_ref().and_then(dropped_stream);
951 Extracted {
952 text: stdout.trim().to_owned(),
953 session: None,
954 status: None,
955 quota,
956 dropped,
957 }
958 }
959 }
960}
961
962fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
969 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
970 if !is_err {
971 return None;
972 }
973 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
974 if !result.to_lowercase().contains("session limit") {
975 return None;
976 }
977 let reset = result
980 .split("resets ")
981 .nth(1)
982 .map(str::trim)
983 .filter(|s| !s.is_empty())
984 .map(str::to_owned);
985 Some(Quota { reset })
986}
987
988fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1019 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1020 if !status.eq_ignore_ascii_case("error") {
1021 return None;
1022 }
1023 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1024 if !response.trim().is_empty() {
1025 return None;
1027 }
1028 let produced = v
1029 .get("usage")
1030 .and_then(|u| u.get("output_tokens"))
1031 .and_then(serde_json::Value::as_u64)
1032 .unwrap_or(0);
1033 if produced == 0 {
1034 return None;
1036 }
1037 Some(Dropped {
1038 why: v
1039 .get("error")
1040 .and_then(|e| e.as_str())
1041 .unwrap_or("the CLI ended the stream without delivering its answer")
1042 .trim()
1043 .to_owned(),
1044 output_tokens: produced,
1045 })
1046}
1047
1048pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1050 let mut missing = Vec::new();
1051 for s in specs {
1052 let program = match s.kind {
1053 AgentKind::Command => s.command.first().map(String::as_str),
1054 other => other.program(),
1055 };
1056 if let Some(p) = program
1057 && !crate::config::which(p)
1058 && !Path::new(p).is_file()
1059 && !missing.iter().any(|m: &String| m == p)
1060 {
1061 missing.push(p.to_owned());
1062 }
1063 }
1064 missing
1065}
1066
1067pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1069 run_dir.join("artifacts")
1070}
1071
1072pub fn installed(spec: &AgentSpec) -> bool {
1074 spec.kind.program().is_none_or(crate::config::which)
1077}
1078
1079pub fn pick(
1101 agents: &[AgentSpec],
1102 want: Option<&str>,
1103 available: &dyn Fn(&AgentSpec) -> bool,
1104) -> Result<AgentSpec> {
1105 if let Some(id) = want {
1106 let spec = agents
1107 .iter()
1108 .find(|a| a.id == id)
1109 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1110 if !available(spec) {
1111 bail!(
1112 "agent `{}` needs `{}` on PATH; install it or pass a different \
1113 --agent",
1114 spec.id,
1115 spec.kind.program().unwrap_or("its command")
1116 );
1117 }
1118 return Ok(spec.clone());
1119 }
1120
1121 if agents.is_empty() {
1122 bail!(
1123 "the agent roster is empty, so there is nobody to ask: install one \
1124 of claude, opencode or agy - magi derives a roster from what is on \
1125 PATH - or add an [[agents]] entry to magi.toml."
1126 );
1127 }
1128
1129 if let Some(spec) = agents
1130 .iter()
1131 .find(|a| a.kind == AgentKind::Claude && available(a))
1132 {
1133 return Ok(spec.clone());
1134 }
1135
1136 agents
1137 .iter()
1138 .find(|a| available(a))
1139 .cloned()
1140 .with_context(|| {
1141 let missing = agents
1142 .iter()
1143 .filter_map(|a| a.kind.program())
1144 .collect::<Vec<_>>()
1145 .join(", ");
1146 format!(
1147 "no agent in the roster can be run here: install one of \
1148 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1149 you do have"
1150 )
1151 })
1152}
1153
1154fn ids(agents: &[AgentSpec]) -> String {
1155 if agents.is_empty() {
1156 return "no agents at all".to_owned();
1157 }
1158 agents
1159 .iter()
1160 .map(|a| a.id.clone())
1161 .collect::<Vec<_>>()
1162 .join(", ")
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168
1169 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1170
1171 fn command_helper(mode: &str) -> AgentSpec {
1174 AgentSpec {
1175 id: "helper".to_owned(),
1176 kind: AgentKind::Command,
1177 model: None,
1178 command: vec![
1179 std::env::current_exe()
1180 .expect("locate test helper")
1181 .to_string_lossy()
1182 .into_owned(),
1183 "--exact".to_owned(),
1184 "agent::tests::command_agent_test_helper".to_owned(),
1185 "--nocapture".to_owned(),
1186 ],
1187 extra_args: Vec::new(),
1188 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1189 prompt_delivery: None,
1190 }
1191 }
1192
1193 #[test]
1194 fn command_agent_test_helper() {
1195 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1196 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1197 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1198 Ok("ignore-stdin") => println!("done"),
1199 Ok("chatty-sleep") => {
1200 println!("i-said-something");
1201 std::thread::sleep(Duration::from_secs(30));
1202 }
1203 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1204 Ok(other) => panic!("unknown command helper mode {other}"),
1205 Err(_) => {}
1206 }
1207 }
1208
1209 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1210 AgentSpec {
1211 id: "a".to_owned(),
1212 kind,
1213 model: model.map(str::to_owned),
1214 command: vec!["echo".to_owned(), "{label}".to_owned()],
1215 extra_args: Vec::new(),
1216 env: BTreeMap::new(),
1217 prompt_delivery: None,
1218 }
1219 }
1220
1221 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1222 Invocation {
1223 cwd,
1224 prompt: "do the thing",
1225 timeout: Duration::from_secs(900),
1226 allow_write,
1227 sessions: true,
1228 artifacts: art,
1229 stem: "t",
1230 run: "test-run",
1231 node: "test",
1232 cache_dir: None,
1233 attachments: &[],
1234 }
1235 }
1236
1237 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1238 build_command(
1239 &spec(kind, None),
1240 seat,
1241 &inv(Path::new("."), Path::new("/art"), allow_write),
1242 Path::new("/art/p.md"),
1243 )
1244 .unwrap()
1245 }
1246
1247 #[test]
1248 fn claude_mints_then_resumes_the_same_uuid() {
1249 let mut seat = SeatState::new("judge-1", "a", 7);
1250 let uuid = seat.claude_session.clone().unwrap();
1251 let first = plan_for(AgentKind::Claude, &seat, true);
1252 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1253 assert!(!first.argv.iter().any(|a| a == "--resume"));
1254
1255 seat.turns = 1;
1256 let second = plan_for(AgentKind::Claude, &seat, true);
1257 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1258 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1259 }
1260
1261 #[test]
1262 fn read_only_seats_cannot_edit() {
1263 let seat = SeatState::new("judge-1", "a", 7);
1264 let claude = plan_for(AgentKind::Claude, &seat, false);
1265 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1266 assert!(
1267 !plan_for(AgentKind::Claude, &seat, true)
1268 .argv
1269 .iter()
1270 .any(|a| a == "--disallowed-tools")
1271 );
1272
1273 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1274 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1275 assert!(
1276 !agy.argv
1277 .iter()
1278 .any(|a| a == "--dangerously-skip-permissions")
1279 );
1280 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1281 assert!(
1282 agy_rw
1283 .argv
1284 .windows(2)
1285 .any(|w| w == ["--mode", "accept-edits"])
1286 );
1287 assert!(
1288 agy_rw
1289 .argv
1290 .iter()
1291 .any(|a| a == "--dangerously-skip-permissions")
1292 );
1293 let agy_prompt = agy_rw
1298 .argv
1299 .iter()
1300 .position(|a| a == "-p")
1301 .map(|i| agy_rw.argv[i + 1].clone())
1302 .expect("agy takes its prompt with -p");
1303 assert!(
1304 agy_prompt.starts_with('@'),
1305 "agy must get a file reference, got {agy_prompt:?}"
1306 );
1307 assert!(
1308 !agy_prompt.contains("Read the file at"),
1309 "the prose pointer is for CLIs with no file syntax"
1310 );
1311
1312 for allow_write in [false, true] {
1317 assert!(
1318 plan_for(AgentKind::Opencode, &seat, allow_write)
1319 .argv
1320 .iter()
1321 .any(|a| a == "--auto"),
1322 "opencode needs --auto even to read (allow_write = {allow_write})"
1323 );
1324 }
1325 }
1326
1327 #[test]
1330 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1331 let mut seat = SeatState::new("judge-1", "a", 7);
1332
1333 let ro = plan_for(AgentKind::Codex, &seat, false);
1337 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1338 let rw = plan_for(AgentKind::Codex, &seat, true);
1339 assert!(
1340 rw.argv
1341 .windows(2)
1342 .any(|w| w == ["--sandbox", "workspace-write"])
1343 );
1344 for p in [&ro, &rw] {
1345 assert!(
1346 !p.argv
1347 .iter()
1348 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1349 "the bypass defeats the only enforced read-only mode we have"
1350 );
1351 assert!(
1353 p.argv
1354 .windows(2)
1355 .any(|w| w == ["-c", "approval_policy=\"never\""]),
1356 "an unattended seat that asks for approval blocks until timeout"
1357 );
1358 }
1359
1360 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
1362 assert_eq!(
1363 ro.argv.last().map(String::as_str),
1364 Some("-"),
1365 "without the `-` argument codex waits for a prompt it never gets"
1366 );
1367
1368 seat.turns = 1;
1372 assert!(!has_session(AgentKind::Codex, &seat, true));
1373 assert!(
1374 !plan_for(AgentKind::Codex, &seat, true)
1375 .argv
1376 .iter()
1377 .any(|a| a == "resume")
1378 );
1379 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
1380 let resumed = plan_for(AgentKind::Codex, &seat, true);
1381 let at = resumed
1382 .argv
1383 .iter()
1384 .position(|a| a == "resume")
1385 .expect("resumes by subcommand");
1386 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
1387 assert!(
1388 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
1389 "every option precedes the subcommand"
1390 );
1391 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
1392 }
1393
1394 #[test]
1397 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
1398 let mut seat = SeatState::new("review-1", "a", 7);
1399
1400 let first = plan_for(AgentKind::Omp, &seat, false);
1404 assert!(first.argv.iter().any(|a| a == "-p"));
1405 assert!(first.argv.iter().any(|a| a == "--mode=json"));
1406 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
1407 assert!(
1408 !first.argv.iter().any(|a| a == "do the thing"),
1409 "the prompt reached argv, where Windows caps it"
1410 );
1411
1412 for allow_write in [false, true] {
1418 let p = plan_for(AgentKind::Omp, &seat, allow_write);
1419 assert!(
1420 p.argv.iter().any(|a| a == "--auto-approve"),
1421 "omp needs --auto-approve even to read (allow_write = {allow_write})"
1422 );
1423 assert!(
1424 !p.argv
1425 .iter()
1426 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
1427 "nothing ever asks for the bypass"
1428 );
1429 }
1430
1431 seat.turns = 1;
1434 assert!(!has_session(AgentKind::Omp, &seat, true));
1435 assert!(
1436 !plan_for(AgentKind::Omp, &seat, true)
1437 .argv
1438 .iter()
1439 .any(|a| a == "--resume")
1440 );
1441 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
1442 let resumed = plan_for(AgentKind::Omp, &seat, true);
1443 assert!(
1444 resumed
1445 .argv
1446 .windows(2)
1447 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
1448 "a captured id is what makes the next turn a resume"
1449 );
1450 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
1453 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
1455 }
1456
1457 #[test]
1462 fn omp_takes_the_answer_without_an_agent_end_line() {
1463 let stream = concat!(
1464 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
1465 "\n",
1466 r#"{"type":"agent_start"}"#,
1467 "\n",
1468 r#"{"type":"turn_start"}"#,
1469 "\n",
1470 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
1471 "\n",
1472 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
1473 "\n",
1474 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
1475 "\n",
1476 );
1477 let out = extract(AgentKind::Omp, stream);
1478 assert_eq!(
1479 out.text, "{\"vote\":\"approve\"}",
1480 "the last assistant text block is the answer even with no agent_end"
1481 );
1482 assert_eq!(
1483 out.session.as_deref(),
1484 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
1485 );
1486 }
1487
1488 #[test]
1492 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
1493 let stream = concat!(
1494 r#"{"type":"session","version":3,"id":"s1"}"#,
1495 "\n",
1496 "{\"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問題ありません。\"}]}]}",
1497 "\n",
1498 );
1499 let out = extract(AgentKind::Omp, stream);
1500 assert_eq!(
1501 out.text, "## 判定\n\n問題ありません。",
1502 "the narration is not the answer, and non-ASCII survives intact"
1503 );
1504 assert_eq!(out.session.as_deref(), Some("s1"));
1505 }
1506
1507 #[test]
1510 fn omp_skips_non_json_lines() {
1511 let stream = concat!(
1512 "Warning: some omp notice\n",
1513 r#"{"type":"session","version":3,"id":"s2"}"#,
1514 "\n",
1515 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
1516 "\n",
1517 "trailing junk",
1518 "\n",
1519 );
1520 let out = extract(AgentKind::Omp, stream);
1521 assert_eq!(out.text, "the answer");
1522 assert_eq!(out.session.as_deref(), Some("s2"));
1523 }
1524
1525 #[test]
1527 fn codex_takes_the_last_agent_message_and_the_thread_id() {
1528 let stream = concat!(
1529 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
1530 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
1531 "\n",
1532 r#"{"type":"turn.started"}"#,
1533 "\n",
1534 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
1535 "\n",
1536 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
1537 "\n",
1538 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
1539 "\n",
1540 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
1541 "\n",
1542 );
1543 let out = extract(AgentKind::Codex, stream);
1544 assert_eq!(
1545 out.text, "{\"verdict\": \"ok\"}",
1546 "the last agent message is the answer; earlier ones narrate"
1547 );
1548 assert_eq!(
1549 out.session.as_deref(),
1550 Some("01a07440-4545-7492-85c1-024e3259a90a")
1551 );
1552 assert_eq!(out.status.as_deref(), Some("success"));
1553
1554 let failed = concat!(
1555 r#"{"type":"thread.started","thread_id":"t1"}"#,
1556 "\n",
1557 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
1558 "\n",
1559 );
1560 assert_eq!(
1561 extract(AgentKind::Codex, failed).status.as_deref(),
1562 Some("error")
1563 );
1564 }
1565
1566 #[test]
1567 fn captured_sessions_resume_only_once_reported() {
1568 let mut seat = SeatState::new("impl-A", "a", 7);
1569 seat.turns = 1;
1570 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1571 assert!(!has_session(kind, &seat, true));
1572 let p = plan_for(kind, &seat, true);
1573 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
1574 }
1575
1576 seat.captured_session = Some("sid".to_owned());
1577 assert!(has_session(AgentKind::Opencode, &seat, true));
1578 assert!(
1579 plan_for(AgentKind::Opencode, &seat, true)
1580 .argv
1581 .windows(2)
1582 .any(|w| w == ["-s", "sid"])
1583 );
1584 assert!(
1585 plan_for(AgentKind::Antigravity, &seat, true)
1586 .argv
1587 .windows(2)
1588 .any(|w| w == ["--conversation", "sid"])
1589 );
1590 }
1591
1592 #[test]
1593 fn sessions_disabled_never_resumes() {
1594 let mut seat = SeatState::new("impl-A", "a", 7);
1595 seat.turns = 3;
1596 seat.captured_session = Some("sid".to_owned());
1597 for kind in [
1598 AgentKind::Claude,
1599 AgentKind::Opencode,
1600 AgentKind::Antigravity,
1601 ] {
1602 assert!(!has_session(kind, &seat, false));
1603 }
1604 }
1605
1606 #[test]
1607 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
1608 let seat = SeatState::new("judge-1", "a", 7);
1609 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
1610 let p = plan_for(kind, &seat, false);
1611 assert!(
1612 p.argv.iter().all(|a| a != "do the thing"),
1613 "{kind:?} put the prompt on the command line"
1614 );
1615 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
1616 }
1617 let p = plan_for(AgentKind::Antigravity, &seat, false);
1619 let at = p.argv.iter().position(|a| a == "-p").unwrap();
1620 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
1621 assert!(p.stdin.is_none());
1622 }
1623
1624 #[test]
1625 fn agy_print_timeout_tracks_the_node_budget() {
1626 let seat = SeatState::new("impl-A", "a", 7);
1627 let p = build_command(
1628 &spec(AgentKind::Antigravity, None),
1629 &seat,
1630 &Invocation {
1631 cwd: Path::new("."),
1632 prompt: "p",
1633 timeout: Duration::from_secs(3600),
1634 allow_write: true,
1635 sessions: true,
1636 artifacts: Path::new("/art"),
1637 stem: "t",
1638 run: "test-run",
1639 node: "test",
1640 cache_dir: None,
1641 attachments: &[],
1642 },
1643 Path::new("/art/p.md"),
1644 )
1645 .unwrap();
1646 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
1647 }
1648
1649 #[test]
1656 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
1657 let mut s = spec(AgentKind::Antigravity, None);
1658 s.prompt_delivery = Some(Delivery::Argv);
1659 let seat = SeatState::new("talk", "a", 7);
1660 let atts = [PathBuf::from("/art/attachments/abc.png")];
1661
1662 let without = build_command(
1663 &s,
1664 &seat,
1665 &Invocation {
1666 attachments: &[],
1667 ..inv(Path::new("."), Path::new("/art"), true)
1668 },
1669 Path::new("/art/p.md"),
1670 )
1671 .unwrap();
1672 assert!(
1673 !without.argv.iter().any(|a| a == "--add-dir"),
1674 "no attachment, no reason to widen the sandbox: {without:?}"
1675 );
1676
1677 let with = build_command(
1678 &s,
1679 &seat,
1680 &Invocation {
1681 attachments: &atts,
1682 ..inv(Path::new("."), Path::new("/art"), true)
1683 },
1684 Path::new("/art/p.md"),
1685 )
1686 .unwrap();
1687 assert!(
1688 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1689 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
1690 );
1691 }
1692
1693 #[test]
1699 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
1700 let seat = SeatState::new("plan", "a", 7);
1701 let atts = [
1702 PathBuf::from("/art/attachments/own.png"),
1703 PathBuf::from("/other-chat/attachments/inherited.png"),
1704 ];
1705
1706 let p = build_command(
1707 &spec(AgentKind::Antigravity, None),
1708 &seat,
1709 &Invocation {
1710 attachments: &atts,
1711 ..inv(Path::new("."), Path::new("/art"), true)
1712 },
1713 Path::new("/art/p.md"),
1714 )
1715 .unwrap();
1716
1717 assert!(
1718 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
1719 "this conversation's own artifacts dir must still be granted: {p:?}"
1720 );
1721 assert!(
1722 p.argv
1723 .windows(2)
1724 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
1725 "the inherited attachment's own directory must be granted too: {p:?}"
1726 );
1727 }
1728
1729 #[test]
1730 fn command_agents_get_placeholders_substituted() {
1731 let seat = SeatState::new("impl-A", "a", 7);
1732 let p = plan_for(AgentKind::Command, &seat, true);
1733 assert_eq!(p.argv[0], "echo");
1734 assert_eq!(p.argv[1], "impl-A");
1735 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
1736 }
1737
1738 #[test]
1739 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
1740 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
1742 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
1743 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
1744 let out = extract(AgentKind::Claude, stdout);
1745 let quota = out.quota.as_ref().expect("rate limit must be detected");
1746 assert_eq!(
1747 quota.reset.as_deref(),
1748 Some("4:50am (Asia/Tokyo)"),
1749 "reset time read from the body"
1750 );
1751 }
1752
1753 #[test]
1754 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
1755 let out = extract(
1756 AgentKind::Claude,
1757 r#"{"is_error":true,"result":"session limit reached"}"#,
1758 );
1759 let quota = out.quota.expect("rate limit detected without a reset");
1760 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
1761 }
1762
1763 #[test]
1764 fn ordinary_failures_are_never_quota() {
1765 let claude_fail = extract(
1767 AgentKind::Claude,
1768 r#"{"is_error":true,"result":"account does not exist"}"#,
1769 );
1770 assert!(claude_fail.quota.is_none());
1771
1772 let cmd_fail = extract(AgentKind::Command, "boom");
1774 assert!(cmd_fail.quota.is_none());
1775
1776 let success = extract(
1778 AgentKind::Command,
1779 r#"{"is_error":false,"result":"session limit is fine"}"#,
1780 );
1781 assert!(success.quota.is_none());
1782 }
1783
1784 #[test]
1785 fn command_agent_can_carry_the_claude_quota_shape() {
1786 let out = extract(
1787 AgentKind::Command,
1788 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
1789 );
1790 assert!(
1791 out.quota.is_some(),
1792 "a wrapper emitting the claude shape counts as quota"
1793 );
1794 }
1795
1796 #[test]
1797 fn claude_json_result_is_extracted() {
1798 let out = extract(
1799 AgentKind::Claude,
1800 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
1801 );
1802 assert_eq!(out.text, "all done");
1803 assert_eq!(out.session.as_deref(), Some("abc"));
1804 assert_eq!(out.status.as_deref(), Some("success"));
1805 }
1806
1807 #[test]
1808 fn opencode_event_stream_is_concatenated() {
1809 let stream = concat!(
1810 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
1811 "\n",
1812 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
1813 "\n",
1814 "garbage line\n",
1815 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
1816 "\n"
1817 );
1818 let out = extract(AgentKind::Opencode, stream);
1819 assert_eq!(out.text, "first\nsecond");
1820 assert_eq!(out.session.as_deref(), Some("ses_1"));
1821 }
1822
1823 #[test]
1824 fn agy_json_survives_a_leading_warning_line() {
1825 let stdout = concat!(
1826 "warning: --mode plan has no effect while slash commands are disabled.\n",
1827 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
1828 "\n"
1829 );
1830 let out = extract(AgentKind::Antigravity, stdout);
1831 assert_eq!(out.text, "persimmon");
1832 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
1833 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
1834 }
1835
1836 const AGY_DROPPED: &str = concat!(
1843 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
1844 r#""response":"","error":"the connection to the agent was interrupted before "#,
1845 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
1846 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
1847 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
1848 r#""total_tokens":274380}}"#
1849 );
1850
1851 #[test]
1852 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
1853 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
1854 let dropped = out.dropped.expect("recognised as undelivered work");
1855 assert_eq!(dropped.output_tokens, 14267);
1856 assert!(
1857 dropped.why.contains("subscriber fell behind"),
1858 "the CLI's own words are kept for the record: {}",
1859 dropped.why
1860 );
1861 assert_eq!(
1864 out.session.as_deref(),
1865 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
1866 );
1867 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
1868 }
1869
1870 #[test]
1871 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
1872 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
1876 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
1877
1878 let answered = concat!(
1881 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
1882 r#""usage":{"output_tokens":10}}"#
1883 );
1884 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
1885
1886 let ok = concat!(
1888 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
1889 r#""usage":{"output_tokens":10}}"#
1890 );
1891 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
1892 }
1893
1894 #[test]
1895 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
1896 let out = AgentOutput {
1897 text: String::new(),
1898 exit_code: Some(1),
1899 timed_out: false,
1900 duration_ms: 431_194,
1901 artifacts: Vec::new(),
1902 quota: None,
1903 dropped: Some(Dropped {
1904 why: "subscriber fell behind updates".to_owned(),
1905 output_tokens: 14267,
1906 }),
1907 };
1908 assert!(!out.usable());
1909 assert!(out.work_undelivered());
1910 assert!(!out.quota_exhausted());
1913 }
1914
1915 #[test]
1916 fn non_json_stdout_falls_back_to_raw_text() {
1917 let out = extract(AgentKind::Antigravity, "plain answer\n");
1918 assert_eq!(out.text, "plain answer");
1919 assert!(out.session.is_none());
1920 }
1921
1922 #[tokio::test]
1923 async fn command_agent_round_trip_writes_artifacts() {
1924 let dir = tempfile::tempdir().unwrap();
1925 let art = dir.path().join("artifacts");
1926 let mut seat = SeatState::new("impl-A", "a", 7);
1927 let s = command_helper("reply");
1928 let out = invoke(
1929 &s,
1930 &mut seat,
1931 &Invocation {
1932 cwd: dir.path(),
1933 prompt: "unused",
1934 timeout: Duration::from_secs(30),
1935 allow_write: true,
1936 sessions: true,
1937 artifacts: &art,
1938 stem: "impl-A",
1939 run: "test-run",
1940 node: "test",
1941 cache_dir: None,
1942 attachments: &[],
1943 },
1944 )
1945 .await
1946 .unwrap();
1947 assert!(out.usable(), "{out:?}");
1948 assert!(out.text.contains("hello impl-A"), "{}", out.text);
1949 assert_eq!(seat.turns, 1);
1950 assert!(art.join("impl-A.prompt.md").is_file());
1951 assert!(art.join("impl-A.out").is_file());
1952 }
1953
1954 #[tokio::test]
1955 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
1956 let dir = tempfile::tempdir().unwrap();
1960 let cache = dir.path().join("magi-cache");
1961 let mut seat = SeatState::new("impl-A", "a", 7);
1962 let s = command_helper("cache");
1963 let out = invoke(
1964 &s,
1965 &mut seat,
1966 &Invocation {
1967 cwd: dir.path(),
1968 prompt: "unused",
1969 timeout: Duration::from_secs(30),
1970 allow_write: true,
1971 sessions: true,
1972 artifacts: &dir.path().join("artifacts"),
1973 stem: "cache",
1974 run: "test-run",
1975 node: "test",
1976 cache_dir: Some(&cache),
1977 attachments: &[],
1978 },
1979 )
1980 .await
1981 .unwrap();
1982 assert!(out.usable(), "{out:?}");
1983 assert!(
1984 out.text.contains(cache.to_string_lossy().as_ref()),
1985 "the seat must see CARGO_TARGET_DIR = the shared cache"
1986 );
1987 }
1988
1989 #[tokio::test]
1990 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
1991 let dir = tempfile::tempdir().unwrap();
1992 let mut seat = SeatState::new("impl-A", "a", 7);
1993 let s = command_helper("ignore-stdin");
1996 let big = "x".repeat(1_000_000);
1997 let out = invoke(
1998 &s,
1999 &mut seat,
2000 &Invocation {
2001 cwd: dir.path(),
2002 prompt: &big,
2003 timeout: Duration::from_secs(60),
2004 allow_write: true,
2005 sessions: true,
2006 artifacts: &dir.path().join("artifacts"),
2007 stem: "big",
2008 run: "test-run",
2009 node: "test",
2010 cache_dir: None,
2011 attachments: &[],
2012 },
2013 )
2014 .await
2015 .unwrap();
2016 assert!(out.usable(), "{out:?}");
2017 assert!(out.text.contains("done"), "{}", out.text);
2018 }
2019
2020 #[tokio::test]
2021 async fn timeout_is_reported_not_hung() {
2022 let dir = tempfile::tempdir().unwrap();
2023 let mut seat = SeatState::new("impl-A", "a", 7);
2024 let s = command_helper("sleep");
2025 let out = invoke(
2026 &s,
2027 &mut seat,
2028 &Invocation {
2029 cwd: dir.path(),
2030 prompt: "unused",
2031 timeout: Duration::from_millis(300),
2032 allow_write: true,
2033 sessions: true,
2034 artifacts: &dir.path().join("artifacts"),
2035 stem: "slow",
2036 run: "test-run",
2037 node: "test",
2038 cache_dir: None,
2039 attachments: &[],
2040 },
2041 )
2042 .await
2043 .unwrap();
2044 assert!(out.timed_out);
2045 assert!(!out.usable());
2046 }
2047
2048 #[tokio::test]
2049 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
2050 let dir = tempfile::tempdir().unwrap();
2056 let artifacts = dir.path().join("artifacts");
2057 let mut seat = SeatState::new("impl-A", "a", 7);
2058 let s = command_helper("chatty-sleep");
2059 let out = invoke(
2060 &s,
2061 &mut seat,
2062 &Invocation {
2063 cwd: dir.path(),
2064 prompt: "unused",
2065 timeout: Duration::from_secs(10),
2070 allow_write: true,
2071 sessions: true,
2072 artifacts: &artifacts,
2073 stem: "chatty",
2074 run: "test-run",
2075 node: "test",
2076 cache_dir: None,
2077 attachments: &[],
2078 },
2079 )
2080 .await
2081 .unwrap();
2082
2083 assert!(out.timed_out, "{out:?}");
2084 assert!(!out.usable(), "a cut-off answer is still not an answer");
2085 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
2086 assert!(
2087 recorded.contains("i-said-something"),
2088 "the artifact must keep what arrived before the kill, got {recorded:?}"
2089 );
2090 assert!(
2091 out.text.contains("i-said-something"),
2092 "and the graph must be able to see it too, got {:?}",
2093 out.text
2094 );
2095 }
2096
2097 #[test]
2098 fn missing_programs_reports_command_binaries() {
2099 let mut s = spec(AgentKind::Command, None);
2100 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
2101 assert_eq!(
2102 missing_programs(&[s]),
2103 ["definitely-not-a-real-binary-xyz".to_owned()]
2104 );
2105 }
2106
2107 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
2108 AgentSpec {
2109 id: id.to_owned(),
2110 kind,
2111 model: None,
2112 command: Vec::new(),
2113 extra_args: Vec::new(),
2114 env: BTreeMap::new(),
2115 prompt_delivery: None,
2116 }
2117 }
2118
2119 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
2122 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
2123 }
2124
2125 #[test]
2126 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
2127 let agents = [
2128 pick_spec("oc", AgentKind::Opencode),
2129 pick_spec("opus", AgentKind::Claude),
2130 pick_spec("agy", AgentKind::Antigravity),
2131 ];
2132 let got = pick(&agents, None, &without(&[])).expect("a pick");
2133 assert_eq!(got.id, "opus");
2134 }
2135
2136 #[test]
2137 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
2138 let agents = [
2139 pick_spec("opus", AgentKind::Claude),
2140 pick_spec("oc", AgentKind::Opencode),
2141 pick_spec("agy", AgentKind::Antigravity),
2142 ];
2143 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
2144 assert_eq!(got.id, "agy");
2145 }
2146
2147 #[test]
2148 fn pick_on_an_empty_roster_says_what_to_install() {
2149 let msg = pick(&[], None, &without(&[]))
2150 .expect_err("nobody to ask")
2151 .to_string();
2152 assert!(msg.contains("roster is empty"), "{msg}");
2153 assert!(msg.contains("claude"), "{msg}");
2154 assert!(msg.contains("magi.toml"), "{msg}");
2155 }
2156
2157 #[test]
2158 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
2159 let agents = [
2160 pick_spec("opus", AgentKind::Claude),
2161 pick_spec("oc", AgentKind::Opencode),
2162 ];
2163 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
2164 let msg = format!("{err:#}");
2165 assert!(msg.contains("claude"), "{msg}");
2166 assert!(msg.contains("opencode"), "{msg}");
2167 }
2168
2169 #[test]
2170 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
2171 let agents = [
2172 pick_spec("opus", AgentKind::Claude),
2173 pick_spec("oc", AgentKind::Opencode),
2174 ];
2175 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
2176 assert_eq!(got.id, "oc");
2177 }
2178
2179 #[test]
2180 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
2181 let agents = [
2182 pick_spec("opus", AgentKind::Claude),
2183 pick_spec("oc", AgentKind::Opencode),
2184 ];
2185 let msg = pick(&agents, Some("gemini"), &without(&[]))
2186 .expect_err("no such agent")
2187 .to_string();
2188 assert!(msg.contains("gemini"), "{msg}");
2189 assert!(msg.contains("opus, oc"), "{msg}");
2190 }
2191
2192 #[test]
2193 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
2194 let agents = [
2195 pick_spec("opus", AgentKind::Claude),
2196 pick_spec("oc", AgentKind::Opencode),
2197 ];
2198 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
2199 .expect_err("must not silently substitute another model")
2200 .to_string();
2201 assert!(msg.contains("opencode"), "{msg}");
2202 assert!(msg.contains("--agent"), "{msg}");
2203 }
2204}