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, Clone)]
95pub struct Invocation<'a> {
96 pub coordination: Option<&'a crate::config::Coordination>,
98 pub fetched_base: Option<&'a crate::run::FetchedBase>,
100 pub cwd: &'a Path,
103 pub prompt: &'a str,
105 pub timeout: Duration,
107 pub allow_write: bool,
112 pub sessions: bool,
114 pub artifacts: &'a Path,
116 pub stem: &'a str,
118 pub run: &'a str,
122 pub node: &'a str,
126 pub cache_dir: Option<&'a Path>,
132 pub attachments: &'a [PathBuf],
139 pub writable: &'a [PathBuf],
146}
147
148fn repository_writer(inv: &Invocation<'_>) -> bool {
157 inv.allow_write
158 && (matches!(
159 inv.node,
160 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
161 ) || inv.node == crate::queue::CHAT_NODE)
162}
163
164fn repository_seat(inv: &Invocation<'_>) -> bool {
165 !matches!(
171 inv.node,
172 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
173 )
174}
175
176pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
183 let mut roots = Vec::new();
184 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
185 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
186 if !common.starts_with(&cwd) {
187 roots.push(common);
188 }
189 }
190 for name in ["questions", "notifications"] {
194 let dir = home.join(name);
195 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
196 roots.push(std::fs::canonicalize(dir)?);
197 }
198 Ok(roots)
199}
200
201fn editing_access_note(base: Option<&crate::run::FetchedBase>) -> String {
203 let mut note = String::from(
204 "Launcher repository access contract (applies to this turn and overrides earlier fetch instructions):\n",
205 );
206 if let Some(base) = base {
207 note.push_str(&format!(
208 "The launcher successfully fetched `{}/{}` at {} and pinned the run base to `{}`. Use this exact local commit as the verified fetched base; this is a snapshot, not a claim that the remote has not moved since. Do not fetch again as a prerequisite, move the base, or refresh it on resume. Base updates belong to the launcher's existing base-sync path.\n",
209 base.remote, base.branch, base.fetched_at, base.commit,
210 ));
211 } else {
212 note.push_str("No successful launcher fetch evidence is supplied for this invocation. Do not describe local tracking refs as freshly fetched. If fresh remote evidence is essential, report an infrastructure blocker requiring a launcher fetch and a new prepared turn.\n");
213 }
214 note
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224pub struct Quota {
225 #[serde(default)]
227 pub reset: Option<String>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub struct Dropped {
238 pub why: String,
240 pub output_tokens: u64,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct CommandEvidence {
260 pub id: String,
262 pub description: String,
264 pub exit_code: Option<i32>,
266 pub result_summary: String,
268 pub source: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct AgentOutput {
275 pub text: String,
277 pub exit_code: Option<i32>,
279 pub timed_out: bool,
281 pub duration_ms: u64,
283 pub artifacts: Vec<String>,
285 #[serde(default)]
289 pub quota: Option<Quota>,
290 #[serde(default)]
294 pub dropped: Option<Dropped>,
295 #[serde(default)]
299 pub commands: Vec<CommandEvidence>,
300 #[serde(default)]
305 pub context_tokens: Option<u64>,
306}
307
308impl AgentOutput {
309 pub fn usable(&self) -> bool {
311 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
312 }
313
314 pub fn quota_exhausted(&self) -> bool {
316 self.quota.is_some()
317 }
318
319 pub fn work_undelivered(&self) -> bool {
324 self.dropped.is_some()
325 }
326}
327
328#[derive(Debug)]
331pub(crate) struct ExecutionInterrupted(pub String);
332
333impl std::fmt::Display for ExecutionInterrupted {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 write!(f, "execution interrupted: {}", self.0)
336 }
337}
338
339impl std::error::Error for ExecutionInterrupted {}
340
341const PIPE_GRACE: Duration = Duration::from_secs(3);
346
347pub(crate) type Captured = Arc<Mutex<Vec<u8>>>;
349
350pub(crate) fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
360where
361 R: tokio::io::AsyncRead + Unpin + Send + 'static,
362{
363 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
364 let Some(mut pipe) = pipe else {
365 return (buf, None);
366 };
367 let sink = Arc::clone(&buf);
368 let handle = tokio::spawn(async move {
369 let mut chunk = [0u8; 8192];
370 loop {
371 match pipe.read(&mut chunk).await {
372 Ok(0) | Err(_) => break,
373 Ok(n) => {
374 if let Ok(mut guard) = sink.lock() {
375 guard.extend_from_slice(&chunk[..n]);
376 }
377 }
378 }
379 }
380 });
381 (buf, Some(handle))
382}
383
384pub(crate) async fn collect(
389 buf: &Captured,
390 handle: Option<tokio::task::JoinHandle<()>>,
391 grace: Duration,
392) -> String {
393 if let Some(handle) = handle {
394 if tokio::time::timeout(grace, handle).await.is_err() {
395 tracing::debug!("a pipe is still held open after the child exited");
396 }
397 }
398 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
399 String::from_utf8_lossy(&bytes).into_owned()
400}
401
402pub async fn invoke(
404 spec: &AgentSpec,
405 seat: &mut SeatState,
406 inv: &Invocation<'_>,
407) -> Result<AgentOutput> {
408 let lease = if repository_seat(inv) {
413 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
414 .await?
415 } else {
416 None
417 };
418 let inv = &Invocation {
419 timeout: spec.turn_timeout(inv.timeout),
420 ..inv.clone()
421 };
422 let mut outputs = Vec::new();
423 if repository_seat(inv) {
424 if let Some(guard) = crate::retention::OutputLease::borrow(inv.cwd).map_err(|e| {
425 crate::repo_lock::ResourceBlocked(format!("worktree output ownership: {e:#}"))
426 })? {
427 outputs.push(guard);
428 }
429 if let Some(cache) = inv.cache_dir {
430 outputs.push(crate::retention::OutputLease::cache(
431 &crate::run::home(),
432 cache,
433 false,
434 )?);
435 }
436 }
437
438 let mut prompt = inv.prompt.to_owned();
439 if inv.node == crate::queue::CHAT_NODE {
440 prompt.push_str("\n\n");
441 prompt.push_str(crate::prompt::CHAT_FOLLOW_THROUGH);
442 }
443 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
444 prompt.push_str(&format!("\n\nRepository coordination: the launcher holds the exclusive repository lock at {} for this invocation. Do not acquire a second lock or release/delete it. Repository access remains read-only.", lease.path.display()));
445 }
446 let home = repository_writer(inv).then(crate::run::home);
449 let mut writable = inv.writable.to_vec();
450 if let Some(home) = &home {
451 for root in worktree_writable_roots(inv.cwd, home).await? {
452 if !writable.contains(&root) {
453 writable.push(root);
454 }
455 }
456 }
457 if repository_writer(inv) {
458 prompt.push_str("\n\n");
459 prompt.push_str(&editing_access_note(inv.fetched_base));
460 }
461 let inv = &Invocation {
462 writable: &writable,
463 prompt: &prompt,
464 ..inv.clone()
465 };
466 tokio::fs::create_dir_all(inv.artifacts)
467 .await
468 .with_context(|| format!("create {}", inv.artifacts.display()))?;
469 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
470 tokio::fs::write(&prompt_path, inv.prompt)
471 .await
472 .with_context(|| format!("write {}", prompt_path.display()))?;
473
474 let plan = build_command(spec, seat, inv, &prompt_path)?;
475 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.logged_argv(), "spawning agent");
476 tokio::fs::write(
478 inv.artifacts.join(format!("{}.access.json", inv.stem)),
479 serde_json::to_vec_pretty(&serde_json::json!({
480 "launcher_version": env!("CARGO_PKG_VERSION"),
481 "kind": spec.kind,
482 "allow_write": inv.allow_write,
483 "unsandboxed": repository_writer(inv),
484 "writable_roots": inv.writable,
485 "fetched_base": inv.fetched_base,
486 }))?,
487 )
488 .await
489 .context("write seat access evidence")?;
490
491 let started = Instant::now();
492 let child_path = spec
495 .env
496 .iter()
497 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
498 .map(|(_, v)| std::ffi::OsString::from(v))
499 .or_else(|| std::env::var_os("PATH"))
500 .unwrap_or_default();
501 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
502 || plan.argv[0].clone().into(),
503 std::path::PathBuf::into_os_string,
504 );
505 let mut cmd = Command::new(program);
506 cmd.args(&plan.argv[1..])
507 .current_dir(inv.cwd)
508 .envs(&spec.env)
509 .env("MAGI_SEAT", &seat.key)
510 .env("MAGI_TURN", seat.turns.to_string())
511 .env("MAGI_RUN", inv.run)
512 .env("MAGI_NODE", inv.node)
513 .env("MAGI_PROMPT_FILE", &prompt_path)
514 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
515 .env("GIT_TERMINAL_PROMPT", "0")
516 .stdin(if plan.stdin.is_some() {
517 Stdio::piped()
518 } else {
519 Stdio::null()
520 })
521 .stdout(Stdio::piped())
522 .stderr(Stdio::piped())
523 .kill_on_drop(true)
524 .quiet();
527 if let Some(home) = home {
528 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
531 }
532 if let Some(cache) = inv.cache_dir {
533 cmd.env("CARGO_TARGET_DIR", cache);
536 } else {
537 cmd.env_remove("CARGO_TARGET_DIR");
545 }
546
547 #[cfg(any(unix, windows))]
548 let _chat_controls = {
549 use crate::chat_control::bridge::{self, Bridge};
550 bridge::clear_environment(&mut cmd);
551 let bridge = if inv.node == crate::queue::CHAT_NODE {
552 crate::run::try_home().and_then(|home| {
553 match Bridge::start(&home, inv.run, inv.artifacts) {
554 Ok(bridge) => Some(bridge),
555 Err(error) => {
556 tracing::warn!(%error, "chat controls unavailable; continuing conversation");
557 None
558 }
559 }
560 })
561 } else {
562 None
563 };
564 if let Some(bridge) = &bridge {
565 cmd.env("MAGI_HOME", std::fs::canonicalize(crate::run::home())?);
569 bridge.attach(&mut cmd);
570 }
571 bridge
572 };
573
574 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
575 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
576 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
580 tokio::spawn(async move {
581 sink.write_all(body.as_bytes()).await.ok();
582 sink.shutdown().await.ok();
583 });
584 }
585
586 let (out_buf, out_reader) = drain(running.child().stdout.take());
603 let (err_buf, err_reader) = drain(running.child().stderr.take());
604
605 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
606 .ok()
607 .map_or(0, |state| state.config.disk.min_free_bytes);
608 let mut paths = vec![inv.cwd.to_owned()];
609 if let Some(cache) = inv.cache_dir {
610 paths.push(cache.to_owned());
611 }
612 let mut pressure = None;
613 let waited = tokio::select! {
614 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
615 reason = crate::retention::emergency(paths, min_free) => {
616 pressure = Some(reason);
617 None
618 }
619 };
620 let (code, timed_out) = match waited {
621 Some(Ok(res)) => {
622 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
623 (status.code(), false)
624 }
625 Some(Err(_)) | None => {
626 if let Some(reason) = &pressure {
627 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
628 } else {
629 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
630 }
631 if !running.coordinated() {
633 running
634 .child()
635 .kill()
636 .await
637 .context("stop timed-out seat")?;
638 }
639 (None, true)
640 }
641 };
642 running.finish().await;
643
644 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
649 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
650
651 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
652 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
653 tokio::fs::write(&out_path, &stdout).await.ok();
654 tokio::fs::write(&err_path, &stderr).await.ok();
655 if let Some(reason) = pressure {
656 return Err(
657 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
658 .context(ExecutionInterrupted(reason)),
659 );
660 }
661
662 let mut extracted = extract(spec.kind, &stdout);
663 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
664 extracted.quota = agy_quota(&stdout, &stderr);
665 }
666 if let Some(quota) = &extracted.quota {
667 extracted.dropped = None;
670 tracing::warn!(
671 seat = %seat.key,
672 agent = %spec.id,
673 reset = ?quota.reset,
674 "agent is out of quota"
675 );
676 }
677 if let Some(session) = extracted.session {
678 match spec.kind {
679 AgentKind::Claude => seat.claude_session = Some(session),
680 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
681 seat.captured_session = Some(session);
682 }
683 AgentKind::Command => {}
684 }
685 }
686 if let Some(status) = &extracted.status
687 && !status.eq_ignore_ascii_case("success")
688 {
689 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
690 }
691 let text = if extracted.text.trim().is_empty() {
692 if stdout.trim().is_empty() {
694 stderr.trim().to_owned()
695 } else {
696 stdout.trim().to_owned()
697 }
698 } else {
699 extracted.text
700 };
701 seat.turns += 1;
702
703 Ok(AgentOutput {
704 text,
705 exit_code: code,
706 timed_out,
707 duration_ms: started.elapsed().as_millis() as u64,
708 artifacts: vec![
709 file_name(&prompt_path),
710 file_name(&out_path),
711 file_name(&err_path),
712 ],
713 quota: extracted.quota,
714 dropped: extracted.dropped,
715 commands: extracted.commands,
716 context_tokens: extracted.context_tokens,
717 })
718}
719
720fn file_name(p: &Path) -> String {
721 p.file_name()
722 .unwrap_or_default()
723 .to_string_lossy()
724 .into_owned()
725}
726
727#[derive(Debug)]
729struct Plan {
730 argv: Vec<String>,
731 stdin: Option<String>,
732}
733
734impl Plan {
735 fn logged_argv(&self) -> Vec<&str> {
738 let mut settings_value = false;
739 self.argv
740 .iter()
741 .map(|arg| {
742 if settings_value {
743 settings_value = false;
744 "[redacted]"
745 } else if arg == "--settings" {
746 settings_value = true;
747 arg.as_str()
748 } else if arg.starts_with("--settings=") {
749 "--settings=[redacted]"
750 } else {
751 arg.as_str()
752 }
753 })
754 .collect()
755 }
756}
757
758fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
769 if matches!(kind, AgentKind::Antigravity) {
770 return format!("@{}", prompt_path.display());
771 }
772 format!(
773 "Read the file at {} and follow every instruction in it exactly. That \
774 file is your complete task description; this message contains nothing \
775 else.",
776 prompt_path.display()
777 )
778}
779
780fn build_command(
781 spec: &AgentSpec,
782 seat: &SeatState,
783 inv: &Invocation<'_>,
784 prompt_path: &Path,
785) -> Result<Plan> {
786 let mut argv: Vec<String> = Vec::new();
787 let mut stdin: Option<String> = None;
788 let delivery = spec.delivery();
789 let resuming = has_session(spec.kind, seat, inv.sessions);
790
791 match spec.kind {
792 AgentKind::Claude => {
793 argv.push("claude".to_owned());
798 argv.push("-p".to_owned());
799 argv.push("--output-format".to_owned());
800 argv.push("json".to_owned());
801 if let Some(m) = &spec.model {
802 argv.push("--model".to_owned());
803 argv.push(m.clone());
804 }
805 if inv.sessions {
806 let uuid = seat
807 .claude_session
808 .as_deref()
809 .context("claude seat is missing its session uuid")?;
810 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
811 argv.push(uuid.to_owned());
812 }
813 argv.push("--permission-mode".to_owned());
814 argv.push("bypassPermissions".to_owned());
815 if !inv.allow_write {
816 argv.push("--disallowed-tools".to_owned());
817 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
818 }
819 }
820 AgentKind::Opencode => {
821 argv.push("opencode".to_owned());
825 argv.push("run".to_owned());
826 argv.push("--format".to_owned());
827 argv.push("json".to_owned());
828 argv.push("--dir".to_owned());
829 argv.push(inv.cwd.to_string_lossy().into_owned());
830 argv.push("--auto".to_owned());
839 if let Some(m) = &spec.model {
840 argv.push("-m".to_owned());
841 argv.push(m.clone());
842 }
843 if resuming {
844 argv.push("-s".to_owned());
845 argv.push(
846 seat.captured_session
847 .clone()
848 .expect("has_session checked the id is present"),
849 );
850 }
851 }
852 AgentKind::Antigravity => {
853 argv.push("agy".to_owned());
854 argv.push("--output-format".to_owned());
855 argv.push("json".to_owned());
856 argv.push("--print-timeout".to_owned());
859 argv.push(format!("{}s", inv.timeout.as_secs()));
860 argv.push("--mode".to_owned());
861 argv.push(
862 if inv.allow_write {
863 "accept-edits"
864 } else {
865 "plan"
866 }
867 .to_owned(),
868 );
869 if inv.allow_write {
870 argv.push("--dangerously-skip-permissions".to_owned());
871 }
872 if let Some(m) = &spec.model {
873 argv.push("--model".to_owned());
874 argv.push(m.clone());
875 }
876 if resuming {
877 argv.push("--conversation".to_owned());
878 argv.push(
879 seat.captured_session
880 .clone()
881 .expect("has_session checked the id is present"),
882 );
883 }
884 let mut add_dirs: Vec<String> = Vec::new();
894 if delivery == Delivery::File || !inv.attachments.is_empty() {
895 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
896 }
897 for path in inv.attachments {
898 let Some(parent) = path.parent() else {
899 continue;
900 };
901 if parent.starts_with(inv.artifacts) {
902 continue;
903 }
904 let dir = parent.to_string_lossy().into_owned();
905 if !add_dirs.contains(&dir) {
906 add_dirs.push(dir);
907 }
908 }
909 if inv.allow_write {
910 for path in inv.writable {
911 let dir = path.to_string_lossy().into_owned();
912 if !add_dirs.contains(&dir) {
913 add_dirs.push(dir);
914 }
915 }
916 }
917 for dir in add_dirs {
918 argv.push("--add-dir".to_owned());
919 argv.push(dir);
920 }
921 }
922 AgentKind::Codex => {
923 argv.push("codex".to_owned());
929 argv.push("exec".to_owned());
930 argv.push("--json".to_owned());
931 argv.push("--skip-git-repo-check".to_owned());
934 argv.push("-C".to_owned());
935 argv.push(inv.cwd.to_string_lossy().into_owned());
936 if repository_writer(inv) {
946 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
947 } else {
948 argv.push("--sandbox".to_owned());
949 argv.push(
950 if inv.allow_write {
951 "workspace-write"
952 } else {
953 "read-only"
954 }
955 .to_owned(),
956 );
957 argv.push("-c".to_owned());
960 argv.push("approval_policy=\"never\"".to_owned());
961 if inv.allow_write && !inv.writable.is_empty() {
962 let roots: Vec<String> = inv
963 .writable
964 .iter()
965 .map(|p| p.to_string_lossy().into_owned())
966 .collect();
967 argv.push("-c".to_owned());
968 argv.push(format!(
969 "sandbox_workspace_write.writable_roots={}",
970 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
971 ));
972 }
973 }
974 if let Some(m) = &spec.model {
975 argv.push("-m".to_owned());
976 argv.push(m.clone());
977 }
978 if resuming {
984 argv.push("resume".to_owned());
985 argv.push(
986 seat.captured_session
987 .clone()
988 .expect("has_session checked the id is present"),
989 );
990 }
991 }
992 AgentKind::Omp => {
993 argv.push("omp".to_owned());
997 argv.push("-p".to_owned());
998 argv.push("--mode=json".to_owned());
999 argv.push("--auto-approve".to_owned());
1009 if let Some(m) = &spec.model {
1010 argv.push("--model".to_owned());
1011 argv.push(m.clone());
1012 }
1013 if resuming {
1019 argv.push("--resume".to_owned());
1020 argv.push(
1021 seat.captured_session
1022 .clone()
1023 .expect("has_session checked the id is present"),
1024 );
1025 }
1026 }
1027 AgentKind::Command => {
1028 if spec.command.is_empty() {
1033 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1034 }
1035 let vars: BTreeMap<&str, String> = BTreeMap::from([
1036 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1037 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1038 ("{label}", seat.key.clone()),
1039 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1040 ]);
1041 for raw in &spec.command {
1042 let mut arg = raw.clone();
1043 for (k, v) in &vars {
1044 if arg.contains(k) {
1045 arg = arg.replace(k, v);
1046 }
1047 }
1048 argv.push(arg);
1049 }
1050 }
1051 }
1052
1053 argv.extend(spec.extra_args.iter().cloned());
1054
1055 if spec.kind == AgentKind::Antigravity {
1058 argv.push("-p".to_owned());
1059 }
1060 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1063 argv.push("-".to_owned());
1064 }
1065 match delivery {
1066 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1067 argv.push(pointer(spec.kind, prompt_path));
1069 }
1070 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1071 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1072 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1073 }
1074
1075 Ok(Plan { argv, stdin })
1076}
1077
1078#[derive(Debug, Default)]
1080struct Extracted {
1081 text: String,
1082 session: Option<String>,
1083 status: Option<String>,
1084 quota: Option<Quota>,
1085 dropped: Option<Dropped>,
1086 commands: Vec<CommandEvidence>,
1087 context_tokens: Option<u64>,
1088}
1089
1090fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1092 let mut extracted = extract_answer(kind, stdout);
1093 extracted.context_tokens = context_tokens(kind, stdout);
1094 extracted
1095}
1096
1097fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1099 v.get(key).and_then(serde_json::Value::as_u64)
1100}
1101
1102fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1107 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1108 let base = first(input)?;
1109 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1110 Some(base.saturating_add(cached))
1111}
1112
1113fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1134 use serde_json::Value;
1135 let lines = || {
1136 stdout
1137 .lines()
1138 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1139 };
1140 match kind {
1141 AgentKind::Claude => None,
1142 AgentKind::Opencode => lines()
1143 .filter(|v| {
1144 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1146 || v.get("part")
1147 .and_then(|p| p.get("type"))
1148 .and_then(|t| t.as_str())
1149 == Some("step-finish")
1150 })
1151 .filter_map(|v| {
1152 let tokens = v.get("part")?.get("tokens")?;
1153 let base = uint(tokens, "input")?;
1154 let cache = tokens.get("cache");
1155 let cached = ["read", "write"]
1156 .iter()
1157 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1158 .sum::<u64>();
1159 Some(base.saturating_add(cached))
1160 })
1161 .next_back(),
1162 AgentKind::Antigravity => None,
1163 AgentKind::Codex => None,
1164 AgentKind::Omp => lines()
1165 .flat_map(|v| {
1166 match v.get("type").and_then(|t| t.as_str()) {
1169 Some("agent_end") => v
1170 .get("messages")
1171 .and_then(|m| m.as_array())
1172 .cloned()
1173 .unwrap_or_default(),
1174 Some("turn_end") | Some("message_end") => {
1175 v.get("message").cloned().into_iter().collect()
1176 }
1177 _ => Vec::new(),
1178 }
1179 })
1180 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1181 .filter_map(|m| {
1182 input_side(
1183 m.get("usage")?,
1184 &["input", "input_tokens"],
1185 &[
1186 "cacheRead",
1187 "cacheWrite",
1188 "cache_read_tokens",
1189 "cache_write_tokens",
1190 ],
1191 )
1192 })
1193 .next_back(),
1194 AgentKind::Command => None,
1195 }
1196}
1197
1198pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1201 extract_answer(kind, stdout).text
1202}
1203
1204fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1205 match kind {
1206 AgentKind::Claude => {
1207 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1208 return Extracted {
1209 text: stdout.trim().to_owned(),
1210 ..Extracted::default()
1211 };
1212 };
1213 Extracted {
1214 text: v
1215 .get("result")
1216 .and_then(|r| r.as_str())
1217 .unwrap_or_default()
1218 .to_owned(),
1219 session: v
1220 .get("session_id")
1221 .and_then(|s| s.as_str())
1222 .map(str::to_owned),
1223 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1224 if e {
1225 "error".to_owned()
1226 } else {
1227 "success".to_owned()
1228 }
1229 }),
1230 quota: claude_quota(&v),
1231 dropped: None,
1234 commands: Vec::new(),
1235 context_tokens: None,
1236 }
1237 }
1238 AgentKind::Opencode => {
1239 let mut text = String::new();
1241 let mut session = None;
1242 for line in stdout.lines() {
1243 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1244 continue;
1245 };
1246 if session.is_none() {
1247 session = v
1248 .get("sessionID")
1249 .and_then(|s| s.as_str())
1250 .map(str::to_owned);
1251 }
1252 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1253 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1254 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1255 {
1256 if !text.is_empty() {
1257 text.push('\n');
1258 }
1259 text.push_str(t);
1260 }
1261 }
1262 Extracted {
1263 text,
1264 session,
1265 status: None,
1266 quota: None,
1267 dropped: None,
1268 commands: Vec::new(),
1269 context_tokens: None,
1270 }
1271 }
1272 AgentKind::Antigravity => {
1273 let obj = stdout
1276 .lines()
1277 .rev()
1278 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1279 let Some(v) = obj else {
1280 return Extracted {
1281 text: stdout.trim().to_owned(),
1282 ..Extracted::default()
1283 };
1284 };
1285 Extracted {
1286 text: v
1287 .get("response")
1288 .and_then(|r| r.as_str())
1289 .unwrap_or_default()
1290 .trim()
1291 .to_owned(),
1292 session: v
1293 .get("conversation_id")
1294 .and_then(|s| s.as_str())
1295 .map(str::to_owned),
1296 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1297 quota: None,
1298 dropped: dropped_stream(&v),
1299 commands: Vec::new(),
1300 context_tokens: None,
1301 }
1302 }
1303 AgentKind::Codex => {
1304 let mut text = String::new();
1326 let mut session = None;
1327 let mut status = None;
1328 let mut commands = Vec::new();
1329 for line in stdout.lines() {
1330 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1331 continue;
1332 };
1333 match v.get("type").and_then(|t| t.as_str()) {
1334 Some("thread.started") => {
1335 session = v
1336 .get("thread_id")
1337 .and_then(|s| s.as_str())
1338 .map(str::to_owned);
1339 }
1340 Some("item.completed") => {
1341 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1342 match item.get("type").and_then(|t| t.as_str()) {
1343 Some("agent_message") => {
1344 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1345 text = t.trim().to_owned();
1346 }
1347 }
1348 Some("command_execution") => {
1349 commands.push(command_evidence(item));
1350 }
1351 _ => {}
1352 }
1353 }
1354 Some("turn.completed") => status = Some("success".to_owned()),
1355 Some("turn.failed") => status = Some("error".to_owned()),
1356 _ => {}
1357 }
1358 }
1359 Extracted {
1360 text,
1361 session,
1362 status,
1363 quota: None,
1364 dropped: None,
1365 commands,
1366 context_tokens: None,
1367 }
1368 }
1369 AgentKind::Omp => {
1370 let mut text = String::new();
1391 let mut session = None;
1392 for line in stdout.lines() {
1393 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1394 continue;
1395 };
1396 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1397 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1398 continue;
1399 }
1400 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1404 {
1405 Some("agent_end") => v
1406 .get("messages")
1407 .and_then(|m| m.as_array())
1408 .map(|m| m.iter().collect())
1409 .unwrap_or_default(),
1410 Some("turn_end") | Some("message_end") => {
1411 v.get("message").into_iter().collect()
1412 }
1413 _ => continue,
1414 };
1415 for message in messages {
1416 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1417 continue;
1418 }
1419 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1420 continue;
1421 };
1422 for part in parts {
1423 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1424 continue;
1425 }
1426 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1427 && !t.trim().is_empty()
1428 {
1429 text = t.trim().to_owned();
1430 }
1431 }
1432 }
1433 }
1434 Extracted {
1435 text,
1436 session,
1437 status: None,
1438 quota: None,
1439 dropped: None,
1440 commands: Vec::new(),
1441 context_tokens: None,
1442 }
1443 }
1444 AgentKind::Command => {
1445 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1450 let quota = parsed.as_ref().and_then(claude_quota);
1451 let dropped = parsed.as_ref().and_then(dropped_stream);
1454 Extracted {
1455 text: stdout.trim().to_owned(),
1456 session: None,
1457 status: None,
1458 quota,
1459 dropped,
1460 commands: Vec::new(),
1461 context_tokens: None,
1462 }
1463 }
1464 }
1465}
1466
1467fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1474 let description = match item.get("command") {
1475 Some(serde_json::Value::String(s)) => s.clone(),
1476 Some(serde_json::Value::Array(parts)) => parts
1477 .iter()
1478 .filter_map(|p| p.as_str())
1479 .collect::<Vec<_>>()
1480 .join(" "),
1481 _ => String::new(),
1482 };
1483 let result_summary = item
1484 .get("aggregated_output")
1485 .and_then(|o| o.as_str())
1486 .map(|s| tail_chars(s.trim(), 400))
1487 .unwrap_or_default();
1488 CommandEvidence {
1489 id: item
1490 .get("id")
1491 .and_then(|s| s.as_str())
1492 .unwrap_or_default()
1493 .to_owned(),
1494 description,
1495 exit_code: item
1496 .get("exit_code")
1497 .and_then(serde_json::Value::as_i64)
1498 .map(|e| e as i32),
1499 result_summary,
1500 source: "codex".to_owned(),
1501 }
1502}
1503
1504fn tail_chars(s: &str, max: usize) -> String {
1506 let count = s.chars().count();
1507 if count <= max {
1508 return s.to_owned();
1509 }
1510 s.chars().skip(count - max).collect()
1511}
1512
1513fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1520 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1521 if !is_err {
1522 return None;
1523 }
1524 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1525 let status = v.get("api_error_status");
1526 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1527 || status.and_then(serde_json::Value::as_str) == Some("429"))
1528 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1529 if !usage_limit && !result.to_lowercase().contains("session limit") {
1530 return None;
1531 }
1532 let reset = result
1535 .split("resets ")
1536 .nth(1)
1537 .map(str::trim)
1538 .filter(|s| !s.is_empty())
1539 .map(str::to_owned);
1540 Some(Quota { reset })
1541}
1542
1543fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1558 let from_stdout = stdout
1559 .lines()
1560 .rev()
1561 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1562 .and_then(|v| {
1563 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1564 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1565 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1566 .then(|| error.to_owned())
1567 });
1568 let from_stderr = || {
1569 stderr.lines().find_map(|l| {
1570 let v: serde_json::Value =
1571 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1572 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1573 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1574 (exhausted && code).then(|| {
1575 v.get("short_error")
1576 .and_then(|e| e.as_str())
1577 .unwrap_or_default()
1578 .to_owned()
1579 })
1580 })
1581 };
1582 let text = from_stdout.or_else(from_stderr)?;
1583 let reset = text
1584 .split_once("Resets ")
1585 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1586 .filter(|s| !s.is_empty())
1587 .map(str::to_owned);
1588 Some(Quota { reset })
1589}
1590
1591fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1622 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1623 if !status.eq_ignore_ascii_case("error") {
1624 return None;
1625 }
1626 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1627 if !response.trim().is_empty() {
1628 return None;
1630 }
1631 let produced = v
1632 .get("usage")
1633 .and_then(|u| u.get("output_tokens"))
1634 .and_then(serde_json::Value::as_u64)
1635 .unwrap_or(0);
1636 if produced == 0 {
1637 return None;
1639 }
1640 Some(Dropped {
1641 why: v
1642 .get("error")
1643 .and_then(|e| e.as_str())
1644 .unwrap_or("the CLI ended the stream without delivering its answer")
1645 .trim()
1646 .to_owned(),
1647 output_tokens: produced,
1648 })
1649}
1650
1651pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1653 let mut missing = Vec::new();
1654 for s in specs {
1655 let program = match s.kind {
1656 AgentKind::Command => s.command.first().map(String::as_str),
1657 other => other.program(),
1658 };
1659 if let Some(p) = program
1660 && !crate::config::which(p)
1661 && !Path::new(p).is_file()
1662 && !missing.iter().any(|m: &String| m == p)
1663 {
1664 missing.push(p.to_owned());
1665 }
1666 }
1667 missing
1668}
1669
1670pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1672 run_dir.join("artifacts")
1673}
1674
1675pub fn installed(spec: &AgentSpec) -> bool {
1677 spec.kind.program().is_none_or(crate::config::which)
1680}
1681
1682pub fn pick(
1704 agents: &[AgentSpec],
1705 want: Option<&str>,
1706 available: &dyn Fn(&AgentSpec) -> bool,
1707) -> Result<AgentSpec> {
1708 if let Some(id) = want {
1709 let spec = agents
1710 .iter()
1711 .find(|a| a.id == id)
1712 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1713 if !available(spec) {
1714 bail!(
1715 "agent `{}` needs `{}` on PATH; install it or pass a different \
1716 --agent",
1717 spec.id,
1718 spec.kind.program().unwrap_or("its command")
1719 );
1720 }
1721 return Ok(spec.clone());
1722 }
1723
1724 if agents.is_empty() {
1725 bail!(
1726 "the agent roster is empty, so there is nobody to ask: install one \
1727 of claude, opencode or agy - magi derives a roster from what is on \
1728 PATH - or add an [[agents]] entry to magi.toml."
1729 );
1730 }
1731
1732 if let Some(spec) = agents
1733 .iter()
1734 .find(|a| a.kind == AgentKind::Claude && available(a))
1735 {
1736 return Ok(spec.clone());
1737 }
1738
1739 agents
1740 .iter()
1741 .find(|a| available(a))
1742 .cloned()
1743 .with_context(|| {
1744 let missing = agents
1745 .iter()
1746 .filter_map(|a| a.kind.program())
1747 .collect::<Vec<_>>()
1748 .join(", ");
1749 format!(
1750 "no agent in the roster can be run here: install one of \
1751 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1752 you do have"
1753 )
1754 })
1755}
1756
1757pub fn for_github_text(spec: &AgentSpec, github_language: &str, cwd: &Path) -> Result<AgentSpec> {
1770 let mut spec = spec.clone();
1771 if spec.kind == AgentKind::Claude {
1772 let mut existing = None;
1775 let mut args = spec.extra_args.iter().enumerate();
1776 while let Some((index, arg)) = args.next() {
1777 if arg == "--settings" {
1778 let (value_index, value) = args.next().context("--settings requires a value")?;
1779 existing = Some((value_index, value.as_str(), false));
1780 } else if let Some(value) = arg.strip_prefix("--settings=") {
1781 existing = Some((index, value, true));
1782 }
1783 }
1784 let mut settings = if let Some((_, value, _)) = existing {
1785 let json = if value.trim_start().starts_with('{') {
1786 value.to_owned()
1787 } else {
1788 std::fs::read_to_string(cwd.join(value))
1789 .context("cannot read existing seat --settings file")?
1790 };
1791 serde_json::from_str::<serde_json::Value>(&json)
1792 .context("cannot parse existing seat --settings JSON")?
1793 } else {
1794 serde_json::json!({})
1795 };
1796 settings
1797 .as_object_mut()
1798 .context("seat --settings must be a JSON object")?
1799 .insert(
1800 "language".to_owned(),
1801 serde_json::json!(crate::lang::name(github_language)),
1802 );
1803 let settings = settings.to_string();
1804 if let Some((index, _, equals)) = existing {
1805 spec.extra_args[index] = if equals {
1806 format!("--settings={settings}")
1807 } else {
1808 settings
1809 };
1810 } else {
1811 spec.extra_args.extend(["--settings".to_owned(), settings]);
1812 }
1813 }
1814 Ok(spec)
1815}
1816
1817pub fn pick_chain(
1825 agents: &[AgentSpec],
1826 choice: Option<&AgentChoice>,
1827 available: &dyn Fn(&AgentSpec) -> bool,
1828 role: &str,
1829) -> Result<Vec<AgentSpec>> {
1830 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1831 if wanted.is_empty() {
1832 return pick(agents, None, available)
1833 .map(|s| vec![s])
1834 .with_context(|| format!("choose an agent for the {role} role"));
1835 }
1836 let mut chain: Vec<AgentSpec> = Vec::new();
1837 for id in wanted {
1838 if chain.iter().any(|s| s.id == id) {
1839 continue;
1840 }
1841 match pick(agents, Some(id), available) {
1842 Ok(spec) => chain.push(spec),
1843 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1844 }
1845 }
1846 if chain.is_empty() {
1847 bail!(
1848 "[roles] {role} names no agent that can run here; the roster has {}",
1849 ids(agents)
1850 );
1851 }
1852 Ok(chain)
1853}
1854
1855pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1861 match outcome {
1862 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1863 Err(_) => true,
1864 Ok(out) => output_advances(out),
1865 }
1866}
1867
1868pub fn output_advances(out: &AgentOutput) -> bool {
1872 out.quota_exhausted() || !out.usable()
1873}
1874
1875fn ids(agents: &[AgentSpec]) -> String {
1876 if agents.is_empty() {
1877 return "no agents at all".to_owned();
1878 }
1879 agents
1880 .iter()
1881 .map(|a| a.id.clone())
1882 .collect::<Vec<_>>()
1883 .join(", ")
1884}
1885
1886#[cfg(test)]
1887mod tests {
1888 use super::*;
1889
1890 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1891
1892 fn command_helper(mode: &str) -> AgentSpec {
1895 crate::run::pin_test_home();
1897 AgentSpec {
1898 id: "helper".to_owned(),
1899 kind: AgentKind::Command,
1900 model: None,
1901 command: vec![
1902 std::env::current_exe()
1903 .expect("locate test helper")
1904 .to_string_lossy()
1905 .into_owned(),
1906 "--exact".to_owned(),
1907 "agent::tests::command_agent_test_helper".to_owned(),
1908 "--nocapture".to_owned(),
1909 ],
1910 extra_args: Vec::new(),
1911 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1912 prompt_delivery: None,
1913 max_turn_secs: None,
1914 }
1915 }
1916
1917 #[test]
1918 fn command_agent_test_helper() {
1919 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1920 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1921 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1922 Ok("no-cache") => println!(
1923 "{}",
1924 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1925 ),
1926 Ok("ignore-stdin") => println!("done"),
1927 Ok("chatty-sleep") => {
1928 println!("i-said-something");
1929 std::thread::sleep(Duration::from_secs(30));
1930 }
1931 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1932 Ok(other) => panic!("unknown command helper mode {other}"),
1933 Err(_) => {}
1934 }
1935 }
1936
1937 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1938 AgentSpec {
1939 id: "a".to_owned(),
1940 kind,
1941 model: model.map(str::to_owned),
1942 command: vec!["echo".to_owned(), "{label}".to_owned()],
1943 extra_args: Vec::new(),
1944 env: BTreeMap::new(),
1945 prompt_delivery: None,
1946 max_turn_secs: None,
1947 }
1948 }
1949
1950 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1951 Invocation {
1952 coordination: None,
1953 fetched_base: None,
1954 cwd,
1955 prompt: "do the thing",
1956 timeout: Duration::from_secs(900),
1957 allow_write,
1958 sessions: true,
1959 artifacts: art,
1960 stem: "t",
1961 run: "test-run",
1962 node: "test",
1963 cache_dir: None,
1964 attachments: &[],
1965 writable: &[],
1966 }
1967 }
1968
1969 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1970 build_command(
1971 &spec(kind, None),
1972 seat,
1973 &inv(Path::new("."), Path::new("/art"), allow_write),
1974 Path::new("/art/p.md"),
1975 )
1976 .unwrap()
1977 }
1978
1979 #[test]
1980 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
1981 let missing = editing_access_note(None);
1982 assert!(missing.contains("No successful launcher fetch evidence"));
1983 let base = crate::run::FetchedBase {
1984 remote: "origin".into(),
1985 branch: "main".into(),
1986 commit: "0123456789abcdef".into(),
1987 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
1988 };
1989 let note = editing_access_note(Some(&base));
1990 for expected in [
1991 "origin/main",
1992 "0123456789abcdef",
1993 "2026-10-18T00:00:00Z",
1994 "Do not fetch again",
1995 ] {
1996 assert!(note.contains(expected), "missing {expected}");
1997 }
1998 for text in [&missing, ¬e] {
2001 assert!(!text.to_lowercase().contains("sandbox"), "{text}");
2002 assert!(!text.contains("Network access is restricted"), "{text}");
2003 }
2004 let roundtrip: crate::run::FetchedBase =
2005 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
2006 assert_eq!(editing_access_note(Some(&roundtrip)), note);
2007 }
2008
2009 #[test]
2010 fn codex_editing_seats_bypass_the_sandbox_on_initial_and_resume() {
2011 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2012 let roots = [PathBuf::from("/shared/git"), PathBuf::from("/questions")];
2013 for resumed in [false, true] {
2014 let mut seat = SeatState::new("impl-A", "a", 7);
2015 if resumed {
2016 seat.turns = 1;
2017 seat.captured_session = Some("fixture-thread".into());
2018 }
2019 let build = |invocation: &Invocation<'_>| {
2020 build_command(
2021 &spec(AgentKind::Codex, None),
2022 &seat,
2023 invocation,
2024 Path::new("/art/p.md"),
2025 )
2026 .unwrap()
2027 };
2028 for node in [
2029 "implement",
2030 "fix",
2031 "graft",
2032 "gate-fix",
2033 "land",
2034 "rebase",
2035 crate::queue::CHAT_NODE,
2036 ] {
2037 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2038 invocation.node = node;
2039 invocation.writable = &roots;
2040 let plan = build(&invocation);
2041 let at = plan.argv.iter().position(|s| s == BYPASS);
2042 assert!(at.is_some(), "{node}: {:?}", plan.argv);
2043 if resumed {
2044 let resume = plan.argv.iter().position(|s| s == "resume").unwrap();
2045 assert!(at.unwrap() < resume, "{node}: {:?}", plan.argv);
2046 }
2047 assert!(!plan.argv.iter().any(|s| s == "--sandbox"), "{node}");
2049 assert!(!plan.argv.iter().any(|s| s == "-c"), "{node}");
2050 }
2051 for (node, write) in [
2052 ("review", false),
2053 ("judge", false),
2054 ("advise", false),
2055 ("implement", false),
2056 ("fix", false),
2057 ("dupes", false),
2058 ("conduct", false),
2059 ("waiter", false),
2060 ("talk-title", false),
2061 (crate::queue::CHAT_NODE, false),
2062 ("deputy", true),
2063 ] {
2064 let mut invocation = inv(Path::new("."), Path::new("/art"), write);
2065 invocation.node = node;
2066 let plan = build(&invocation);
2067 assert!(!plan.argv.iter().any(|s| s == BYPASS), "{node}");
2068 let mode = if write {
2069 "workspace-write"
2070 } else {
2071 "read-only"
2072 };
2073 assert!(
2074 plan.argv.windows(2).any(|w| w == ["--sandbox", mode]),
2075 "{node}: {:?}",
2076 plan.argv
2077 );
2078 assert!(
2079 plan.argv
2080 .windows(2)
2081 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2082 "{node}"
2083 );
2084 }
2085 }
2086 }
2087
2088 #[test]
2089 fn claude_mints_then_resumes_the_same_uuid() {
2090 let mut seat = SeatState::new("judge-1", "a", 7);
2091 let uuid = seat.claude_session.clone().unwrap();
2092 let first = plan_for(AgentKind::Claude, &seat, true);
2093 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2094 assert!(!first.argv.iter().any(|a| a == "--resume"));
2095
2096 seat.turns = 1;
2097 let second = plan_for(AgentKind::Claude, &seat, true);
2098 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2099 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2100 }
2101
2102 #[test]
2103 fn github_bound_claude_seats_override_the_operators_answer_language() {
2104 let seat = SeatState::new("change-summary", "a", 7);
2105 let art = Path::new("/art");
2106 for (language, name) in [("en", "English"), ("", "English"), ("ja", "Japanese")] {
2107 let claude =
2108 for_github_text(&spec(AgentKind::Claude, None), language, Path::new(".")).unwrap();
2109 let plan = build_command(
2110 &claude,
2111 &seat,
2112 &inv(Path::new("."), art, false),
2113 Path::new("/art/p.md"),
2114 )
2115 .unwrap();
2116 let settings = format!(r#"{{"language":"{name}"}}"#);
2117 assert!(
2118 plan.argv
2119 .windows(2)
2120 .any(|w| w == ["--settings", settings.as_str()]),
2121 "{:?}",
2122 plan.argv
2123 );
2124 assert_eq!(plan.stdin.as_deref(), Some("do the thing"));
2127 }
2128 for kind in [
2129 AgentKind::Codex,
2130 AgentKind::Opencode,
2131 AgentKind::Antigravity,
2132 AgentKind::Omp,
2133 ] {
2134 assert!(
2135 for_github_text(&spec(kind, None), "en", Path::new("."))
2136 .unwrap()
2137 .extra_args
2138 .is_empty()
2139 );
2140 }
2141 }
2142
2143 #[test]
2144 fn github_text_preserves_existing_settings() {
2145 let dir = tempfile::tempdir().unwrap();
2146 let original = serde_json::json!({
2147 "language": "Japanese",
2148 "env": { "SEAT_MODE": "configured" },
2149 "apiKeyHelper": "credential-helper",
2150 "hooks": { "Stop": [] }
2151 });
2152 std::fs::write(dir.path().join("seat-settings.json"), original.to_string()).unwrap();
2153 for value in [original.to_string(), "./seat-settings.json".to_owned()] {
2154 for equals in [false, true] {
2155 let mut spec = spec(AgentKind::Claude, None);
2156 spec.extra_args = vec!["--settings={\"language\":\"French\"}".to_owned()];
2158 if equals {
2159 spec.extra_args.push(format!("--settings={value}"));
2160 } else {
2161 spec.extra_args
2162 .extend(["--settings".to_owned(), value.clone()]);
2163 }
2164 spec.extra_args.push("--verbose".to_owned());
2165 let updated = for_github_text(&spec, "en", dir.path()).unwrap();
2166 assert_eq!(updated.extra_args.len(), spec.extra_args.len());
2167 assert_eq!(updated.extra_args[0], spec.extra_args[0]);
2168 assert_eq!(updated.extra_args.last(), spec.extra_args.last());
2169 let value = &updated.extra_args[updated.extra_args.len() - 2];
2170 let actual: serde_json::Value =
2171 serde_json::from_str(value.strip_prefix("--settings=").unwrap_or(value))
2172 .unwrap();
2173 let mut expected = original.clone();
2174 expected["language"] = serde_json::json!("English");
2175 assert_eq!(actual, expected);
2176 }
2177 }
2178 let on_disk: serde_json::Value = serde_json::from_str(
2179 &std::fs::read_to_string(dir.path().join("seat-settings.json")).unwrap(),
2180 )
2181 .unwrap();
2182 assert_eq!(on_disk, original);
2183 }
2184
2185 #[test]
2186 fn github_text_settings_are_redacted_from_command_logging() {
2187 let dir = tempfile::tempdir().unwrap();
2188 let settings = serde_json::json!({ "env": { "AUTH_TOKEN": "test-only-secret" } });
2189 std::fs::write(dir.path().join("seat-settings.json"), settings.to_string()).unwrap();
2190 for equals in [false, true] {
2191 let mut spec = spec(AgentKind::Claude, None);
2192 spec.extra_args =
2193 vec!["--settings={\"env\":{\"AUTH_TOKEN\":\"earlier-secret\"}}".to_owned()];
2194 if equals {
2195 spec.extra_args
2196 .push("--settings=./seat-settings.json".to_owned());
2197 } else {
2198 spec.extra_args
2199 .extend(["--settings".to_owned(), "./seat-settings.json".to_owned()]);
2200 }
2201 let spec = for_github_text(&spec, "en", dir.path()).unwrap();
2202 let plan = build_command(
2203 &spec,
2204 &SeatState::new("change-summary", "a", 7),
2205 &inv(dir.path(), dir.path(), false),
2206 &dir.path().join("prompt.md"),
2207 )
2208 .unwrap();
2209 let actual = plan.argv.last().unwrap();
2211 let actual: serde_json::Value =
2212 serde_json::from_str(actual.strip_prefix("--settings=").unwrap_or(actual)).unwrap();
2213 assert_eq!(actual["env"]["AUTH_TOKEN"], "test-only-secret");
2214 assert_eq!(actual["language"], "English");
2215 let logged = format!("{:?}", plan.logged_argv());
2216 assert!(!logged.contains("secret"));
2217 assert!(!logged.contains("AUTH_TOKEN"));
2218 assert!(logged.contains("[redacted]"));
2219 assert_eq!(plan.logged_argv()[0], "claude");
2220 assert!(plan.logged_argv().contains(&"--output-format"));
2221 }
2222 }
2223
2224 #[test]
2225 fn github_text_rejects_unreadable_or_invalid_settings() {
2226 let dir = tempfile::tempdir().unwrap();
2227 std::fs::write(dir.path().join("bad.json"), "invalid json").unwrap();
2228 std::fs::write(dir.path().join("array.json"), "[]").unwrap();
2229 for args in [
2230 vec!["--settings"],
2231 vec!["--settings", "missing.json"],
2232 vec!["--settings=bad.json"],
2233 vec!["--settings", "array.json"],
2234 ] {
2235 let mut spec = spec(AgentKind::Claude, None);
2236 spec.extra_args = args.into_iter().map(str::to_owned).collect();
2237 assert!(for_github_text(&spec, "en", dir.path()).is_err());
2238 }
2239 }
2240
2241 #[test]
2242 fn read_only_seats_cannot_edit() {
2243 let seat = SeatState::new("judge-1", "a", 7);
2244 let claude = plan_for(AgentKind::Claude, &seat, false);
2245 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2246 assert!(
2247 !plan_for(AgentKind::Claude, &seat, true)
2248 .argv
2249 .iter()
2250 .any(|a| a == "--disallowed-tools")
2251 );
2252
2253 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2254 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2255 assert!(
2256 !agy.argv
2257 .iter()
2258 .any(|a| a == "--dangerously-skip-permissions")
2259 );
2260 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2261 assert!(
2262 agy_rw
2263 .argv
2264 .windows(2)
2265 .any(|w| w == ["--mode", "accept-edits"])
2266 );
2267 assert!(
2268 agy_rw
2269 .argv
2270 .iter()
2271 .any(|a| a == "--dangerously-skip-permissions")
2272 );
2273 let agy_prompt = agy_rw
2278 .argv
2279 .iter()
2280 .position(|a| a == "-p")
2281 .map(|i| agy_rw.argv[i + 1].clone())
2282 .expect("agy takes its prompt with -p");
2283 assert!(
2284 agy_prompt.starts_with('@'),
2285 "agy must get a file reference, got {agy_prompt:?}"
2286 );
2287 assert!(
2288 !agy_prompt.contains("Read the file at"),
2289 "the prose pointer is for CLIs with no file syntax"
2290 );
2291
2292 for allow_write in [false, true] {
2297 assert!(
2298 plan_for(AgentKind::Opencode, &seat, allow_write)
2299 .argv
2300 .iter()
2301 .any(|a| a == "--auto"),
2302 "opencode needs --auto even to read (allow_write = {allow_write})"
2303 );
2304 }
2305 }
2306
2307 #[test]
2310 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2311 let seat = SeatState::new("deputy-x", "a", 7);
2312 let roots = [PathBuf::from("/data/questions")];
2313 let mk = |allow_write: bool| {
2314 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2315 i.writable = &roots;
2316 build_command(
2317 &spec(AgentKind::Codex, None),
2318 &seat,
2319 &i,
2320 Path::new("/art/p.md"),
2321 )
2322 .unwrap()
2323 };
2324 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2325 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2326 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2327 }
2328
2329 #[test]
2330 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2331 let mut i = inv(Path::new("."), Path::new("/art"), true);
2332 for node in [
2333 "implement",
2334 "fix",
2335 "graft",
2336 "gate-fix",
2337 "land",
2338 "rebase",
2339 "chat",
2340 ] {
2341 i.node = node;
2342 assert!(repository_writer(&i), "{node}");
2343 i.allow_write = false;
2344 assert!(!repository_writer(&i), "{node}");
2345 i.allow_write = true;
2346 }
2347 for node in ["deputy", "judge", "review", "bump", "conduct", "talk-title"] {
2348 i.node = node;
2349 assert!(!repository_writer(&i), "{node}");
2350 }
2351 i.node = "chat";
2352 let roots = [PathBuf::from("/questions")];
2353 i.writable = &roots;
2354 assert!(
2355 repository_writer(&i),
2356 "explicit roots do not change the role"
2357 );
2358 assert!(
2359 !repository_seat(&i),
2360 "chat turns bypass repository scheduling"
2361 );
2362 }
2363
2364 #[test]
2365 fn coordination_classification_is_independent_of_write_grants() {
2366 let roots = [PathBuf::from("/questions")];
2367 for node in [
2368 crate::queue::CHAT_NODE,
2369 "implement",
2370 "fix",
2371 "graft",
2372 "gate-fix",
2373 "land",
2374 "rebase",
2375 "review",
2376 "judge",
2377 "advise",
2378 "deputy",
2379 "waiter",
2380 "conduct",
2381 "dupes",
2382 "talk-title",
2383 ] {
2384 for allow_write in [false, true] {
2385 for writable in [&[][..], &roots[..]] {
2386 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2387 i.node = node;
2388 i.writable = writable;
2389 assert_eq!(
2390 repository_seat(&i),
2391 !matches!(
2392 node,
2393 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
2394 ),
2395 "{node}, allow_write={allow_write}, writable={writable:?}"
2396 );
2397 if node == crate::queue::CHAT_NODE {
2398 assert_eq!(repository_writer(&i), allow_write);
2399 }
2400 }
2401 }
2402 }
2403 }
2404
2405 #[test]
2406 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2407 let roots = [
2408 PathBuf::from("/shared/git"),
2409 PathBuf::from("/state/questions"),
2410 ];
2411 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2412 for resumed in [false, true] {
2413 let mut seat = SeatState::new("impl-A", "a", 7);
2414 if resumed {
2415 seat.turns = 1;
2416 seat.captured_session = Some("session-id".into());
2417 }
2418 for allow_write in [false, true] {
2419 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2420 i.writable = &roots;
2421 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2422 .unwrap();
2423 for root in &roots {
2424 if kind == AgentKind::Antigravity {
2425 assert_eq!(
2426 plan.argv.windows(2).any(|w| {
2427 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2428 }),
2429 allow_write,
2430 "resumed={resumed}: {:?}",
2431 plan.argv
2432 );
2433 }
2434 assert_eq!(
2435 plan.argv
2436 .iter()
2437 .any(|arg| arg.contains(root.to_str().unwrap())),
2438 allow_write,
2439 "{kind:?}, resumed={resumed}: {:?}",
2440 plan.argv
2441 );
2442 }
2443 if kind == AgentKind::Codex && resumed {
2444 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2445 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2446 }
2447 }
2448 }
2449 }
2450 }
2451
2452 #[test]
2453 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2454 let mut seat = SeatState::new("judge-1", "a", 7);
2455
2456 let ro = plan_for(AgentKind::Codex, &seat, false);
2462 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2463 let rw = plan_for(AgentKind::Codex, &seat, true);
2464 assert!(
2465 rw.argv
2466 .windows(2)
2467 .any(|w| w == ["--sandbox", "workspace-write"])
2468 );
2469 for p in [&ro, &rw] {
2470 assert!(
2471 !p.argv
2472 .iter()
2473 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2474 "the bypass defeats the only enforced read-only mode we have"
2475 );
2476 assert!(
2478 p.argv
2479 .windows(2)
2480 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2481 "an unattended seat that asks for approval blocks until timeout"
2482 );
2483 }
2484
2485 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2487 assert_eq!(
2488 ro.argv.last().map(String::as_str),
2489 Some("-"),
2490 "without the `-` argument codex waits for a prompt it never gets"
2491 );
2492
2493 seat.turns = 1;
2497 assert!(!has_session(AgentKind::Codex, &seat, true));
2498 assert!(
2499 !plan_for(AgentKind::Codex, &seat, true)
2500 .argv
2501 .iter()
2502 .any(|a| a == "resume")
2503 );
2504 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2505 let resumed = plan_for(AgentKind::Codex, &seat, true);
2506 let at = resumed
2507 .argv
2508 .iter()
2509 .position(|a| a == "resume")
2510 .expect("resumes by subcommand");
2511 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2512 assert!(
2513 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2514 "every option precedes the subcommand"
2515 );
2516 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2517 }
2518
2519 #[test]
2522 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2523 let mut seat = SeatState::new("review-1", "a", 7);
2524
2525 let first = plan_for(AgentKind::Omp, &seat, false);
2529 assert!(first.argv.iter().any(|a| a == "-p"));
2530 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2531 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2532 assert!(
2533 !first.argv.iter().any(|a| a == "do the thing"),
2534 "the prompt reached argv, where Windows caps it"
2535 );
2536
2537 for allow_write in [false, true] {
2543 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2544 assert!(
2545 p.argv.iter().any(|a| a == "--auto-approve"),
2546 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2547 );
2548 assert!(
2549 !p.argv
2550 .iter()
2551 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2552 "nothing ever asks for the bypass"
2553 );
2554 }
2555
2556 seat.turns = 1;
2559 assert!(!has_session(AgentKind::Omp, &seat, true));
2560 assert!(
2561 !plan_for(AgentKind::Omp, &seat, true)
2562 .argv
2563 .iter()
2564 .any(|a| a == "--resume")
2565 );
2566 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2567 let resumed = plan_for(AgentKind::Omp, &seat, true);
2568 assert!(
2569 resumed
2570 .argv
2571 .windows(2)
2572 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2573 "a captured id is what makes the next turn a resume"
2574 );
2575 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2578 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2580 }
2581
2582 #[test]
2587 fn omp_takes_the_answer_without_an_agent_end_line() {
2588 let stream = concat!(
2589 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2590 "\n",
2591 r#"{"type":"agent_start"}"#,
2592 "\n",
2593 r#"{"type":"turn_start"}"#,
2594 "\n",
2595 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2596 "\n",
2597 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2598 "\n",
2599 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2600 "\n",
2601 );
2602 let out = extract(AgentKind::Omp, stream);
2603 assert_eq!(
2604 out.text, "{\"vote\":\"approve\"}",
2605 "the last assistant text block is the answer even with no agent_end"
2606 );
2607 assert_eq!(
2608 out.session.as_deref(),
2609 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2610 );
2611 }
2612
2613 #[test]
2617 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2618 let stream = concat!(
2619 r#"{"type":"session","version":3,"id":"s1"}"#,
2620 "\n",
2621 "{\"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問題ありません。\"}]}]}",
2622 "\n",
2623 );
2624 let out = extract(AgentKind::Omp, stream);
2625 assert_eq!(
2626 out.text, "## 判定\n\n問題ありません。",
2627 "the narration is not the answer, and non-ASCII survives intact"
2628 );
2629 assert_eq!(out.session.as_deref(), Some("s1"));
2630 }
2631
2632 #[test]
2635 fn omp_skips_non_json_lines() {
2636 let stream = concat!(
2637 "Warning: some omp notice\n",
2638 r#"{"type":"session","version":3,"id":"s2"}"#,
2639 "\n",
2640 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2641 "\n",
2642 "trailing junk",
2643 "\n",
2644 );
2645 let out = extract(AgentKind::Omp, stream);
2646 assert_eq!(out.text, "the answer");
2647 assert_eq!(out.session.as_deref(), Some("s2"));
2648 }
2649
2650 #[test]
2652 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2653 let stream = concat!(
2654 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2655 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2656 "\n",
2657 r#"{"type":"turn.started"}"#,
2658 "\n",
2659 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2660 "\n",
2661 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2662 "\n",
2663 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2664 "\n",
2665 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2666 "\n",
2667 );
2668 let out = extract(AgentKind::Codex, stream);
2669 assert_eq!(
2670 out.text, "{\"verdict\": \"ok\"}",
2671 "the last agent message is the answer; earlier ones narrate"
2672 );
2673 assert_eq!(
2674 out.session.as_deref(),
2675 Some("01a07440-4545-7492-85c1-024e3259a90a")
2676 );
2677 assert_eq!(out.status.as_deref(), Some("success"));
2678
2679 let failed = concat!(
2680 r#"{"type":"thread.started","thread_id":"t1"}"#,
2681 "\n",
2682 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2683 "\n",
2684 );
2685 assert_eq!(
2686 extract(AgentKind::Codex, failed).status.as_deref(),
2687 Some("error")
2688 );
2689 }
2690
2691 #[test]
2692 fn captured_sessions_resume_only_once_reported() {
2693 let mut seat = SeatState::new("impl-A", "a", 7);
2694 seat.turns = 1;
2695 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2696 assert!(!has_session(kind, &seat, true));
2697 let p = plan_for(kind, &seat, true);
2698 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2699 }
2700
2701 seat.captured_session = Some("sid".to_owned());
2702 assert!(has_session(AgentKind::Opencode, &seat, true));
2703 assert!(
2704 plan_for(AgentKind::Opencode, &seat, true)
2705 .argv
2706 .windows(2)
2707 .any(|w| w == ["-s", "sid"])
2708 );
2709 assert!(
2710 plan_for(AgentKind::Antigravity, &seat, true)
2711 .argv
2712 .windows(2)
2713 .any(|w| w == ["--conversation", "sid"])
2714 );
2715 }
2716
2717 #[test]
2718 fn sessions_disabled_never_resumes() {
2719 let mut seat = SeatState::new("impl-A", "a", 7);
2720 seat.turns = 3;
2721 seat.captured_session = Some("sid".to_owned());
2722 for kind in [
2723 AgentKind::Claude,
2724 AgentKind::Opencode,
2725 AgentKind::Antigravity,
2726 ] {
2727 assert!(!has_session(kind, &seat, false));
2728 }
2729 }
2730
2731 #[test]
2732 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2733 let seat = SeatState::new("judge-1", "a", 7);
2734 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2735 let p = plan_for(kind, &seat, false);
2736 assert!(
2737 p.argv.iter().all(|a| a != "do the thing"),
2738 "{kind:?} put the prompt on the command line"
2739 );
2740 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2741 }
2742 let p = plan_for(AgentKind::Antigravity, &seat, false);
2744 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2745 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2746 assert!(p.stdin.is_none());
2747 }
2748
2749 #[test]
2750 fn agy_print_timeout_tracks_the_node_budget() {
2751 let seat = SeatState::new("impl-A", "a", 7);
2752 let p = build_command(
2753 &spec(AgentKind::Antigravity, None),
2754 &seat,
2755 &Invocation {
2756 coordination: None,
2757 fetched_base: None,
2758 cwd: Path::new("."),
2759 prompt: "p",
2760 timeout: Duration::from_secs(3600),
2761 allow_write: true,
2762 sessions: true,
2763 artifacts: Path::new("/art"),
2764 stem: "t",
2765 run: "test-run",
2766 node: "test",
2767 cache_dir: None,
2768 attachments: &[],
2769 writable: &[],
2770 },
2771 Path::new("/art/p.md"),
2772 )
2773 .unwrap();
2774 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2775 }
2776
2777 #[test]
2784 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2785 let mut s = spec(AgentKind::Antigravity, None);
2786 s.prompt_delivery = Some(Delivery::Argv);
2787 let seat = SeatState::new("talk", "a", 7);
2788 let atts = [PathBuf::from("/art/attachments/abc.png")];
2789
2790 let without = build_command(
2791 &s,
2792 &seat,
2793 &Invocation {
2794 attachments: &[],
2795 writable: &[],
2796 ..inv(Path::new("."), Path::new("/art"), true)
2797 },
2798 Path::new("/art/p.md"),
2799 )
2800 .unwrap();
2801 assert!(
2802 !without.argv.iter().any(|a| a == "--add-dir"),
2803 "no attachment, no reason to widen the sandbox: {without:?}"
2804 );
2805
2806 let with = build_command(
2807 &s,
2808 &seat,
2809 &Invocation {
2810 attachments: &atts,
2811 writable: &[],
2812 ..inv(Path::new("."), Path::new("/art"), true)
2813 },
2814 Path::new("/art/p.md"),
2815 )
2816 .unwrap();
2817 assert!(
2818 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2819 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2820 );
2821 }
2822
2823 #[test]
2829 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2830 let seat = SeatState::new("plan", "a", 7);
2831 let atts = [
2832 PathBuf::from("/art/attachments/own.png"),
2833 PathBuf::from("/other-chat/attachments/inherited.png"),
2834 ];
2835
2836 let p = build_command(
2837 &spec(AgentKind::Antigravity, None),
2838 &seat,
2839 &Invocation {
2840 attachments: &atts,
2841 writable: &[],
2842 ..inv(Path::new("."), Path::new("/art"), true)
2843 },
2844 Path::new("/art/p.md"),
2845 )
2846 .unwrap();
2847
2848 assert!(
2849 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2850 "this conversation's own artifacts dir must still be granted: {p:?}"
2851 );
2852 assert!(
2853 p.argv
2854 .windows(2)
2855 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2856 "the inherited attachment's own directory must be granted too: {p:?}"
2857 );
2858 }
2859
2860 #[test]
2861 fn command_agents_get_placeholders_substituted() {
2862 let seat = SeatState::new("impl-A", "a", 7);
2863 let p = plan_for(AgentKind::Command, &seat, true);
2864 assert_eq!(p.argv[0], "echo");
2865 assert_eq!(p.argv[1], "impl-A");
2866 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2867 }
2868
2869 #[test]
2870 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2871 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2873 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2874 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2875 let out = extract(AgentKind::Claude, stdout);
2876 let quota = out.quota.as_ref().expect("rate limit must be detected");
2877 assert_eq!(
2878 quota.reset.as_deref(),
2879 Some("4:50am (Asia/Tokyo)"),
2880 "reset time read from the body"
2881 );
2882 }
2883
2884 #[test]
2885 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2886 let out = extract(
2887 AgentKind::Claude,
2888 r#"{"is_error":true,"result":"session limit reached"}"#,
2889 );
2890 let quota = out.quota.expect("rate limit detected without a reset");
2891 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2892 }
2893
2894 #[test]
2895 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2896 for kind in [AgentKind::Claude, AgentKind::Command] {
2897 for reply in [
2898 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2899 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2900 ] {
2901 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2902 }
2903 for reply in [
2904 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2905 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2906 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2907 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2908 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2909 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2910 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2911 ] {
2912 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2913 }
2914 }
2915 }
2916
2917 #[test]
2918 fn ordinary_failures_are_never_quota() {
2919 let claude_fail = extract(
2921 AgentKind::Claude,
2922 r#"{"is_error":true,"result":"account does not exist"}"#,
2923 );
2924 assert!(claude_fail.quota.is_none());
2925
2926 let cmd_fail = extract(AgentKind::Command, "boom");
2928 assert!(cmd_fail.quota.is_none());
2929
2930 let success = extract(
2932 AgentKind::Command,
2933 r#"{"is_error":false,"result":"session limit is fine"}"#,
2934 );
2935 assert!(success.quota.is_none());
2936 }
2937
2938 #[test]
2946 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2947 let stream = concat!(
2948 r#"{"type":"thread.started","thread_id":"t1"}"#,
2949 "\n",
2950 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"}}"#,
2951 "\n",
2952 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"}}"#,
2953 "\n",
2954 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2955 "\n",
2956 r#"{"type":"turn.completed"}"#,
2957 "\n",
2958 );
2959 let out = extract(AgentKind::Codex, stream);
2960 assert_eq!(out.text, "Both tests in the target pass.");
2961 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2962
2963 let paired = &out.commands[0];
2964 assert_eq!(paired.id, "item49");
2965 assert_eq!(paired.exit_code, Some(1));
2966 assert!(paired.description.contains("graph_cached_gate"));
2967 assert!(paired.result_summary.contains("1 failed"));
2968
2969 let solo = &out.commands[1];
2970 assert_eq!(solo.exit_code, Some(0));
2971
2972 assert!(
2976 out.commands
2977 .iter()
2978 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2979 "a failed run of the actual target must still be visible: {:?}",
2980 out.commands
2981 );
2982 }
2983
2984 #[test]
2985 fn command_agent_can_carry_the_claude_quota_shape() {
2986 let out = extract(
2987 AgentKind::Command,
2988 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2989 );
2990 assert!(
2991 out.quota.is_some(),
2992 "a wrapper emitting the claude shape counts as quota"
2993 );
2994 }
2995
2996 #[test]
2997 fn claude_json_result_is_extracted() {
2998 let out = extract(
2999 AgentKind::Claude,
3000 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
3001 );
3002 assert_eq!(out.text, "all done");
3003 assert_eq!(out.session.as_deref(), Some("abc"));
3004 assert_eq!(out.status.as_deref(), Some("success"));
3005 }
3006
3007 #[test]
3022 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
3023 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"}"#;
3024 let out = extract(AgentKind::Claude, stdout);
3025 assert_eq!(out.status.as_deref(), Some("success"));
3026 assert!(out.quota.is_none());
3027 assert!(!out.text.trim().is_empty());
3028
3029 let agent_out = AgentOutput {
3030 text: out.text.clone(),
3031 exit_code: Some(0),
3032 timed_out: false,
3033 duration_ms: 500,
3034 artifacts: Vec::new(),
3035 quota: out.quota,
3036 dropped: out.dropped,
3037 commands: out.commands,
3038 context_tokens: None,
3039 };
3040 assert!(
3041 agent_out.usable(),
3042 "the CLI turn itself ended cleanly and must read as usable"
3043 );
3044 assert!(
3045 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
3046 "a clean CLI turn is not proof the node's own report ever arrived"
3047 );
3048 }
3049
3050 #[test]
3051 fn opencode_event_stream_is_concatenated() {
3052 let stream = concat!(
3053 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
3054 "\n",
3055 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
3056 "\n",
3057 "garbage line\n",
3058 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
3059 "\n"
3060 );
3061 let out = extract(AgentKind::Opencode, stream);
3062 assert_eq!(out.text, "first\nsecond");
3063 assert_eq!(out.session.as_deref(), Some("ses_1"));
3064 }
3065
3066 #[test]
3067 fn agy_json_survives_a_leading_warning_line() {
3068 let stdout = concat!(
3069 "warning: --mode plan has no effect while slash commands are disabled.\n",
3070 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
3071 "\n"
3072 );
3073 let out = extract(AgentKind::Antigravity, stdout);
3074 assert_eq!(out.text, "persimmon");
3075 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
3076 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
3077 }
3078
3079 const AGY_DROPPED: &str = concat!(
3086 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
3087 r#""response":"","error":"the connection to the agent was interrupted before "#,
3088 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
3089 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
3090 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
3091 r#""total_tokens":274380}}"#
3092 );
3093
3094 const AGY_QUOTA_OUT: &str = concat!(
3096 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
3097 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
3098 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
3099 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
3100 );
3101 const AGY_QUOTA_ERR: &str = concat!(
3102 "error: Individual quota reached. Resets in 1h2m49s.\n",
3103 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3104 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3105 r#""retryable":true}"#,
3106 "\n"
3107 );
3108
3109 #[test]
3110 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3111 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3112 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3113 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3114 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3115 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3117 assert!(stderr_only.reset.is_none());
3118 }
3119
3120 #[test]
3121 fn ordinary_agy_failures_are_not_a_quota() {
3122 assert!(agy_quota(AGY_DROPPED, "").is_none());
3123 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3124 assert!(
3125 agy_quota(
3126 "",
3127 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3128 )
3129 .is_none()
3130 );
3131 assert!(
3132 agy_quota(
3133 "",
3134 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3135 )
3136 .is_none()
3137 );
3138 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3139 }
3140
3141 #[test]
3142 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3143 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3144 let dropped = out.dropped.expect("recognised as undelivered work");
3145 assert_eq!(dropped.output_tokens, 14267);
3146 assert!(
3147 dropped.why.contains("subscriber fell behind"),
3148 "the CLI's own words are kept for the record: {}",
3149 dropped.why
3150 );
3151 assert_eq!(
3154 out.session.as_deref(),
3155 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3156 );
3157 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3158 }
3159
3160 #[test]
3161 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3162 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3166 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3167
3168 let answered = concat!(
3171 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3172 r#""usage":{"output_tokens":10}}"#
3173 );
3174 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3175
3176 let ok = concat!(
3178 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3179 r#""usage":{"output_tokens":10}}"#
3180 );
3181 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3182 }
3183
3184 #[test]
3185 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3186 let out = AgentOutput {
3187 text: String::new(),
3188 exit_code: Some(1),
3189 timed_out: false,
3190 duration_ms: 431_194,
3191 artifacts: Vec::new(),
3192 quota: None,
3193 dropped: Some(Dropped {
3194 why: "subscriber fell behind updates".to_owned(),
3195 output_tokens: 14267,
3196 }),
3197 commands: Vec::new(),
3198 context_tokens: None,
3199 };
3200 assert!(!out.usable());
3201 assert!(out.work_undelivered());
3202 assert!(!out.quota_exhausted());
3205 }
3206
3207 #[test]
3208 fn non_json_stdout_falls_back_to_raw_text() {
3209 let out = extract(AgentKind::Antigravity, "plain answer\n");
3210 assert_eq!(out.text, "plain answer");
3211 assert!(out.session.is_none());
3212 }
3213
3214 #[tokio::test]
3215 async fn command_agent_round_trip_writes_artifacts() {
3216 let dir = tempfile::tempdir().unwrap();
3217 let art = dir.path().join("artifacts");
3218 let mut seat = SeatState::new("impl-A", "a", 7);
3219 let s = command_helper("reply");
3220 let out = invoke(
3221 &s,
3222 &mut seat,
3223 &Invocation {
3224 coordination: None,
3225 fetched_base: None,
3226 cwd: dir.path(),
3227 prompt: "unused",
3228 timeout: Duration::from_secs(30),
3229 allow_write: true,
3230 sessions: true,
3231 artifacts: &art,
3232 stem: "impl-A",
3233 run: "test-run",
3234 node: "test",
3235 cache_dir: None,
3236 attachments: &[],
3237 writable: &[],
3238 },
3239 )
3240 .await
3241 .unwrap();
3242 assert!(out.usable(), "{out:?}");
3243 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3244 assert_eq!(seat.turns, 1);
3245 assert!(art.join("impl-A.prompt.md").is_file());
3246 assert!(art.join("impl-A.out").is_file());
3247 }
3248
3249 #[tokio::test]
3250 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3251 crate::run::pin_test_home();
3252 let dir = tempfile::tempdir().unwrap();
3256 let cache = dir.path().join("magi-cache");
3257 let mut seat = SeatState::new("impl-A", "a", 7);
3258 let s = command_helper("cache");
3259 let out = invoke(
3260 &s,
3261 &mut seat,
3262 &Invocation {
3263 coordination: None,
3264 fetched_base: None,
3265 cwd: dir.path(),
3266 prompt: "unused",
3267 timeout: Duration::from_secs(30),
3268 allow_write: true,
3269 sessions: true,
3270 artifacts: &dir.path().join("artifacts"),
3271 stem: "cache",
3272 run: "test-run",
3273 node: "test",
3274 cache_dir: Some(&cache),
3275 attachments: &[],
3276 writable: &[],
3277 },
3278 )
3279 .await
3280 .unwrap();
3281 assert!(out.usable(), "{out:?}");
3282 assert!(
3283 out.text.contains(cache.to_string_lossy().as_ref()),
3284 "the seat must see CARGO_TARGET_DIR = the shared cache"
3285 );
3286 }
3287
3288 #[tokio::test]
3289 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3290 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3298 unsafe {
3303 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3304 }
3305 let dir = tempfile::tempdir().unwrap();
3306 let mut seat = SeatState::new("review-1", "a", 7);
3307 let s = command_helper("no-cache");
3308 let result = invoke(
3309 &s,
3310 &mut seat,
3311 &Invocation {
3312 coordination: None,
3313 fetched_base: None,
3314 cwd: dir.path(),
3315 prompt: "unused",
3316 timeout: Duration::from_secs(30),
3317 allow_write: false,
3318 sessions: true,
3319 artifacts: &dir.path().join("artifacts"),
3320 stem: "no-cache",
3321 run: "test-run",
3322 node: "test",
3323 cache_dir: None,
3324 attachments: &[],
3325 writable: &[],
3326 },
3327 )
3328 .await;
3329 unsafe {
3334 match &previous {
3335 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3336 None => std::env::remove_var("CARGO_TARGET_DIR"),
3337 }
3338 }
3339 let out = result.unwrap();
3340 assert!(out.usable(), "{out:?}");
3341 assert!(
3342 out.text.contains("ABSENT"),
3343 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3344 out.text
3345 );
3346 }
3347
3348 #[tokio::test]
3349 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3350 let dir = tempfile::tempdir().unwrap();
3351 let mut seat = SeatState::new("impl-A", "a", 7);
3352 let s = command_helper("ignore-stdin");
3355 let big = "x".repeat(1_000_000);
3356 let out = invoke(
3357 &s,
3358 &mut seat,
3359 &Invocation {
3360 coordination: None,
3361 fetched_base: None,
3362 cwd: dir.path(),
3363 prompt: &big,
3364 timeout: Duration::from_secs(60),
3365 allow_write: true,
3366 sessions: true,
3367 artifacts: &dir.path().join("artifacts"),
3368 stem: "big",
3369 run: "test-run",
3370 node: "test",
3371 cache_dir: None,
3372 attachments: &[],
3373 writable: &[],
3374 },
3375 )
3376 .await
3377 .unwrap();
3378 assert!(out.usable(), "{out:?}");
3379 assert!(out.text.contains("done"), "{}", out.text);
3380 }
3381
3382 #[tokio::test]
3383 async fn agent_turn_cap_limits_a_direct_invocation() {
3384 let dir = tempfile::tempdir().unwrap();
3385 let artifacts = dir.path().join("artifacts");
3386 let mut seat = SeatState::new("impl-A", "helper", 7);
3387 let mut s = command_helper("sleep");
3388 s.max_turn_secs = Some(2);
3389 let out = invoke(&s, &mut seat, &inv(dir.path(), &artifacts, false))
3390 .await
3391 .unwrap();
3392 assert!(out.timed_out, "{out:?}");
3393 assert!(out.duration_ms < 20_000, "{out:?}");
3396 }
3397
3398 #[tokio::test]
3399 async fn timeout_is_reported_not_hung() {
3400 let dir = tempfile::tempdir().unwrap();
3401 let mut seat = SeatState::new("impl-A", "a", 7);
3402 let s = command_helper("sleep");
3403 let out = invoke(
3404 &s,
3405 &mut seat,
3406 &Invocation {
3407 coordination: None,
3408 fetched_base: None,
3409 cwd: dir.path(),
3410 prompt: "unused",
3411 timeout: Duration::from_millis(300),
3412 allow_write: true,
3413 sessions: true,
3414 artifacts: &dir.path().join("artifacts"),
3415 stem: "slow",
3416 run: "test-run",
3417 node: "test",
3418 cache_dir: None,
3419 attachments: &[],
3420 writable: &[],
3421 },
3422 )
3423 .await
3424 .unwrap();
3425 assert!(out.timed_out);
3426 assert!(!out.usable());
3427 }
3428
3429 #[tokio::test]
3430 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3431 let dir = tempfile::tempdir().unwrap();
3437 let artifacts = dir.path().join("artifacts");
3438 let mut seat = SeatState::new("impl-A", "a", 7);
3439 let s = command_helper("chatty-sleep");
3440 let out = invoke(
3441 &s,
3442 &mut seat,
3443 &Invocation {
3444 coordination: None,
3445 fetched_base: None,
3446 cwd: dir.path(),
3447 prompt: "unused",
3448 timeout: Duration::from_secs(10),
3453 allow_write: true,
3454 sessions: true,
3455 artifacts: &artifacts,
3456 stem: "chatty",
3457 run: "test-run",
3458 node: "test",
3459 cache_dir: None,
3460 attachments: &[],
3461 writable: &[],
3462 },
3463 )
3464 .await
3465 .unwrap();
3466
3467 assert!(out.timed_out, "{out:?}");
3468 assert!(!out.usable(), "a cut-off answer is still not an answer");
3469 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3470 assert!(
3471 recorded.contains("i-said-something"),
3472 "the artifact must keep what arrived before the kill, got {recorded:?}"
3473 );
3474 assert!(
3475 out.text.contains("i-said-something"),
3476 "and the graph must be able to see it too, got {:?}",
3477 out.text
3478 );
3479 }
3480
3481 #[test]
3482 fn missing_programs_reports_command_binaries() {
3483 let mut s = spec(AgentKind::Command, None);
3484 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3485 assert_eq!(
3486 missing_programs(&[s]),
3487 ["definitely-not-a-real-binary-xyz".to_owned()]
3488 );
3489 }
3490
3491 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3492 AgentSpec {
3493 id: id.to_owned(),
3494 kind,
3495 model: None,
3496 command: Vec::new(),
3497 extra_args: Vec::new(),
3498 env: BTreeMap::new(),
3499 prompt_delivery: None,
3500 max_turn_secs: None,
3501 }
3502 }
3503
3504 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3507 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3508 }
3509
3510 #[test]
3511 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3512 let agents = [
3513 pick_spec("oc", AgentKind::Opencode),
3514 pick_spec("opus", AgentKind::Claude),
3515 pick_spec("agy", AgentKind::Antigravity),
3516 ];
3517 let got = pick(&agents, None, &without(&[])).expect("a pick");
3518 assert_eq!(got.id, "opus");
3519 }
3520
3521 #[test]
3522 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3523 let agents = [
3524 pick_spec("opus", AgentKind::Claude),
3525 pick_spec("oc", AgentKind::Opencode),
3526 pick_spec("agy", AgentKind::Antigravity),
3527 ];
3528 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3529 assert_eq!(got.id, "agy");
3530 }
3531
3532 #[test]
3533 fn pick_on_an_empty_roster_says_what_to_install() {
3534 let msg = pick(&[], None, &without(&[]))
3535 .expect_err("nobody to ask")
3536 .to_string();
3537 assert!(msg.contains("roster is empty"), "{msg}");
3538 assert!(msg.contains("claude"), "{msg}");
3539 assert!(msg.contains("magi.toml"), "{msg}");
3540 }
3541
3542 #[test]
3543 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3544 let agents = [
3545 pick_spec("opus", AgentKind::Claude),
3546 pick_spec("oc", AgentKind::Opencode),
3547 ];
3548 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3549 let msg = format!("{err:#}");
3550 assert!(msg.contains("claude"), "{msg}");
3551 assert!(msg.contains("opencode"), "{msg}");
3552 }
3553
3554 #[test]
3555 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3556 let agents = [
3557 pick_spec("opus", AgentKind::Claude),
3558 pick_spec("oc", AgentKind::Opencode),
3559 ];
3560 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3561 assert_eq!(got.id, "oc");
3562 }
3563
3564 #[test]
3565 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3566 let agents = [
3567 pick_spec("opus", AgentKind::Claude),
3568 pick_spec("oc", AgentKind::Opencode),
3569 ];
3570 let msg = pick(&agents, Some("gemini"), &without(&[]))
3571 .expect_err("no such agent")
3572 .to_string();
3573 assert!(msg.contains("gemini"), "{msg}");
3574 assert!(msg.contains("opus, oc"), "{msg}");
3575 }
3576
3577 #[test]
3578 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3579 let agents = [
3580 pick_spec("opus", AgentKind::Claude),
3581 pick_spec("oc", AgentKind::Opencode),
3582 ];
3583 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3584 .expect_err("must not silently substitute another model")
3585 .to_string();
3586 assert!(msg.contains("opencode"), "{msg}");
3587 assert!(msg.contains("--agent"), "{msg}");
3588 }
3589
3590 fn named(id: &str) -> AgentSpec {
3591 AgentSpec {
3592 id: id.to_owned(),
3593 ..spec(AgentKind::Command, None)
3594 }
3595 }
3596
3597 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3598 AgentOutput {
3599 text: text.to_owned(),
3600 exit_code: Some(exit),
3601 timed_out: false,
3602 duration_ms: 0,
3603 artifacts: Vec::new(),
3604 quota: quota.then_some(Quota { reset: None }),
3605 dropped: None,
3606 commands: Vec::new(),
3607 context_tokens: None,
3608 }
3609 }
3610
3611 #[test]
3612 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3613 let agents = [named("a"), named("b"), named("c")];
3614 let all = |_: &AgentSpec| true;
3615 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3616 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3617 assert_eq!(
3618 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3619 ["c", "a"]
3620 );
3621
3622 let one = AgentChoice::from("b");
3623 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3624 assert_eq!(got.len(), 1);
3625 assert_eq!(got[0].id, "b");
3626
3627 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3628 assert_eq!(got.len(), 1, "unset keeps pick's default");
3629 assert_eq!(got[0].id, "a");
3630 let empty = AgentChoice::Chain(Vec::new());
3631 assert_eq!(
3632 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3633 "a"
3634 );
3635 }
3636
3637 #[test]
3638 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3639 let agents = [named("a"), named("b")];
3640 let not_a = |s: &AgentSpec| s.id != "a";
3641 let chain = AgentChoice::Chain(
3642 ["a", "ghost", "b", "b"]
3643 .iter()
3644 .map(|s| (*s).to_owned())
3645 .collect(),
3646 );
3647 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3648 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3649
3650 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3651 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3652 assert_eq!(
3653 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3654 ["b", "a"]
3655 );
3656 }
3657
3658 #[test]
3659 fn a_chain_with_nothing_runnable_names_the_role() {
3660 let agents = [named("a")];
3661 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3662 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3663 .unwrap_err()
3664 .to_string();
3665 assert!(err.contains("conductor"), "{err}");
3666 }
3667
3668 #[test]
3669 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3670 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3671 assert!(chain_advances(&Ok(output("limit", 0, true))));
3672 assert!(chain_advances(&Ok(output("", 0, false))));
3673 assert!(chain_advances(&Ok(output("x", 1, false))));
3674 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3675 }
3676
3677 #[test]
3678 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3679 for out in [
3680 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3681 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3682 r#"{"result":"ok"}"#,
3683 "not json",
3684 ] {
3685 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3686 }
3687 }
3688
3689 #[test]
3690 fn opencode_context_tokens_take_the_last_step_finish() {
3691 let out = concat!(
3692 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3693 "\n",
3694 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3695 "\n",
3696 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3697 "\n"
3698 );
3699 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3700 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3701 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3702 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3703 }
3704
3705 #[test]
3706 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3707 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3708 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3709 }
3710
3711 #[test]
3712 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3713 let out = concat!(
3714 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3715 "\n"
3716 );
3717 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3718 }
3719
3720 #[test]
3721 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3722 let out = concat!(
3723 r#"{"type":"session","id":"s"}"#,
3724 "\n",
3725 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3726 "\n",
3727 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3728 "\n"
3729 );
3730 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3731 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3732 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3733 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3734 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3735 }
3736
3737 #[test]
3738 fn command_agents_report_no_context_tokens() {
3739 let out = r#"{"usage":{"input_tokens":5}}"#;
3740 assert_eq!(context_tokens(AgentKind::Command, out), None);
3741 }
3742}