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, Copy, PartialEq, Eq)]
95pub enum InvocationScope {
96 Repository,
98 ConsultationControl,
100 ConsultationInvestigation,
102}
103
104#[derive(Debug, Clone)]
106pub struct Invocation<'a> {
107 pub scope: InvocationScope,
109 pub coordination: Option<&'a crate::config::Coordination>,
111 pub fetched_base: Option<&'a crate::run::FetchedBase>,
113 pub cwd: &'a Path,
116 pub prompt: &'a str,
118 pub timeout: Duration,
120 pub allow_write: bool,
122 pub unsandboxed: bool,
128 pub sessions: bool,
130 pub artifacts: &'a Path,
132 pub stem: &'a str,
134 pub run: &'a str,
138 pub node: &'a str,
142 pub cache_dir: Option<&'a Path>,
148 pub attachments: &'a [PathBuf],
155 pub writable: &'a [PathBuf],
160}
161
162fn repository_writer(inv: &Invocation<'_>) -> bool {
163 inv.scope == InvocationScope::Repository
164 && inv.allow_write
165 && (matches!(
166 inv.node,
167 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
168 ) || inv.node == crate::queue::CHAT_NODE)
169}
170
171fn repository_seat(inv: &Invocation<'_>) -> bool {
172 if inv.scope == InvocationScope::ConsultationInvestigation {
178 return true;
179 }
180 !matches!(
181 inv.node,
182 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
183 ) && inv.scope != InvocationScope::ConsultationControl
184}
185
186pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
193 let mut roots = Vec::new();
194 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
195 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
196 if !common.starts_with(&cwd) {
197 roots.push(common);
198 }
199 }
200 for name in ["questions", "notifications"] {
204 let dir = home.join(name);
205 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
206 roots.push(std::fs::canonicalize(dir)?);
207 }
208 Ok(roots)
209}
210
211fn editing_access_note(
213 kind: AgentKind,
214 unsandboxed: bool,
215 base: Option<&crate::run::FetchedBase>,
216) -> String {
217 let mut note = String::from(
218 "Launcher repository access contract (applies to this turn and overrides earlier fetch/commit instructions):\n",
219 );
220 if let Some(base) = base {
221 note.push_str(&format!(
222 "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",
223 base.remote, base.branch, base.fetched_at, base.commit,
224 ));
225 } else {
226 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");
227 }
228 if kind != AgentKind::Codex || unsandboxed {
230 return note;
231 }
232 note.push_str("Network access is restricted by the seat sandbox. A shared Git directory grant does not guarantee access to protected Git administration paths. Use local status, diff and log to inspect the checkout. If add/commit is denied, leave the implementation files in this assigned worktree: the launcher saves eligible edits after the seat exits. Do not repeatedly retry denied fetches or ask the owner to approve unavailable sandbox permissions. If an essential operation (including rebase continuation or dependency download) is still impossible, stop with an actionable infrastructure report naming the operation, target and error; the operator must repair/deploy the launcher environment before a single new attempt. Do not use bypass flags, change permissions, edit another checkout, or claim a network failure proves host DNS is broken. Specification questions remain valid.\n");
233 note
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243pub struct Quota {
244 #[serde(default)]
246 pub reset: Option<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256pub struct Dropped {
257 pub why: String,
259 pub output_tokens: u64,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct CommandEvidence {
279 pub id: String,
281 pub description: String,
283 pub exit_code: Option<i32>,
285 pub result_summary: String,
287 pub source: String,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct AgentOutput {
294 pub text: String,
296 pub exit_code: Option<i32>,
298 pub timed_out: bool,
300 pub duration_ms: u64,
302 pub artifacts: Vec<String>,
304 #[serde(default)]
308 pub quota: Option<Quota>,
309 #[serde(default)]
313 pub dropped: Option<Dropped>,
314 #[serde(default)]
318 pub commands: Vec<CommandEvidence>,
319 #[serde(default)]
324 pub context_tokens: Option<u64>,
325}
326
327impl AgentOutput {
328 pub fn usable(&self) -> bool {
330 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
331 }
332
333 pub fn quota_exhausted(&self) -> bool {
335 self.quota.is_some()
336 }
337
338 pub fn work_undelivered(&self) -> bool {
343 self.dropped.is_some()
344 }
345}
346
347#[derive(Debug)]
350pub(crate) struct ExecutionInterrupted(pub String);
351
352impl std::fmt::Display for ExecutionInterrupted {
353 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354 write!(f, "execution interrupted: {}", self.0)
355 }
356}
357
358impl std::error::Error for ExecutionInterrupted {}
359
360const PIPE_GRACE: Duration = Duration::from_secs(3);
365
366pub(crate) type Captured = Arc<Mutex<Vec<u8>>>;
368
369pub(crate) fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
379where
380 R: tokio::io::AsyncRead + Unpin + Send + 'static,
381{
382 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
383 let Some(mut pipe) = pipe else {
384 return (buf, None);
385 };
386 let sink = Arc::clone(&buf);
387 let handle = tokio::spawn(async move {
388 let mut chunk = [0u8; 8192];
389 loop {
390 match pipe.read(&mut chunk).await {
391 Ok(0) | Err(_) => break,
392 Ok(n) => {
393 if let Ok(mut guard) = sink.lock() {
394 guard.extend_from_slice(&chunk[..n]);
395 }
396 }
397 }
398 }
399 });
400 (buf, Some(handle))
401}
402
403pub(crate) async fn collect(
408 buf: &Captured,
409 handle: Option<tokio::task::JoinHandle<()>>,
410 grace: Duration,
411) -> String {
412 if let Some(handle) = handle {
413 if tokio::time::timeout(grace, handle).await.is_err() {
414 tracing::debug!("a pipe is still held open after the child exited");
415 }
416 }
417 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
418 String::from_utf8_lossy(&bytes).into_owned()
419}
420
421pub async fn invoke(
423 spec: &AgentSpec,
424 seat: &mut SeatState,
425 inv: &Invocation<'_>,
426) -> Result<AgentOutput> {
427 let lease = if inv.scope == InvocationScope::ConsultationInvestigation {
431 crate::repo_lock::SeatLease::try_reader(inv.cwd, inv.coordination).await?
432 } else if repository_seat(inv) {
433 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
434 .await?
435 } else {
436 None
437 };
438 invoke_leased(spec, seat, inv, lease, || Ok(())).await
439}
440
441pub(crate) async fn invoke_leased(
444 spec: &AgentSpec,
445 seat: &mut SeatState,
446 inv: &Invocation<'_>,
447 lease: Option<crate::repo_lock::SeatLease>,
448 start_check: impl FnOnce() -> Result<()> + Send,
449) -> Result<AgentOutput> {
450 let readonly = inv.scope == InvocationScope::ConsultationInvestigation;
451 let inv = &Invocation {
452 timeout: spec.turn_timeout(inv.timeout),
453 allow_write: inv.allow_write && !readonly,
454 unsandboxed: inv.unsandboxed && inv.scope == InvocationScope::Repository,
455 writable: if readonly { &[] } else { inv.writable },
456 ..inv.clone()
457 };
458 let mut outputs = Vec::new();
459 if repository_seat(inv) {
460 if let Some(guard) = crate::retention::OutputLease::borrow(inv.cwd).map_err(|e| {
461 crate::repo_lock::ResourceBlocked(format!("worktree output ownership: {e:#}"))
462 })? {
463 outputs.push(guard);
464 }
465 if let Some(cache) = inv.cache_dir {
466 outputs.push(crate::retention::OutputLease::cache(
467 &crate::run::home(),
468 cache,
469 false,
470 )?);
471 }
472 }
473
474 let scope = match inv.scope {
475 InvocationScope::ConsultationControl => Some(
476 "Current turn scope: consultation control. This replaces all earlier per-turn scope restrictions, including investigation's prohibition on answering. Repository access is suspended. Use supplied context and supported read-only magi control-plane diagnostics; do not inspect repository files/history, fetch, test or edit. If evidence is needed (including an operator request to investigate), return ONLY a JSON object {\"investigate_question\":\"<full open question id>\"}. The launcher will attempt a coordinated read-only continuation in this same conversation. Do not answer or resolve the question to obtain access. Only magi answer settles a question. Additional writes are limited to individually evidenced task add, release, hold, done and priority; use --quote of the entire latest persisted owner send after delivery. No direct merge, discard, withdrawal, posting or other write commands; existing answer-specific effect checks remain in force. These restrictions last this whole invocation even after consultation ends. Consultation task instructions use the literal task-operation JSON protocol explained below. --reply-other records the owner's literal decision and ends consultation only, leaving the question open and waiter held. Owner decisions still require magi answer with exact required evidence.",
477 ),
478 InvocationScope::ConsultationInvestigation => Some(
479 "Current turn scope: consultation investigation. This supersedes earlier control-only restrictions for this invocation. Investigate repository evidence within the standing conversation's read-only permissions. No repository edits, fetch or other Git/network mutations; use existing history and files. Do not run tests that write into the repository. Do not answer, close, withdraw, discard or resume anything, or invoke magi answer. Return evidence and remaining decision points; the next operator turn returns to consultation control while the question remains open.",
480 ),
481 InvocationScope::Repository if inv.node == "chat" => Some(
482 "Current turn scope: ordinary repository conversation. This replaces earlier per-turn consultation restrictions. Use the standing conversation permissions and current filesystem sandbox; owner decisions still require magi answer and exact authorization evidence.",
483 ),
484 InvocationScope::Repository => None,
485 };
486 let mut prompt = inv.prompt.to_owned();
487 if let Some(scope) = scope {
488 prompt.push_str("\n\n");
489 prompt.push_str(scope);
490 if inv.scope == InvocationScope::ConsultationControl {
491 prompt.push_str("\n\n");
492 prompt.push_str(crate::prompt::CONSULT_WRITES);
493 }
494 }
495 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
496 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()));
497 }
498 let home = repository_writer(inv).then(crate::run::home);
501 let mut writable = inv.writable.to_vec();
502 if let Some(home) = &home {
503 for root in worktree_writable_roots(inv.cwd, home).await? {
504 if !writable.contains(&root) {
505 writable.push(root);
506 }
507 }
508 }
509 if repository_writer(inv) {
510 prompt.push_str("\n\n");
511 prompt.push_str(&editing_access_note(
512 spec.kind,
513 inv.unsandboxed,
514 inv.fetched_base,
515 ));
516 }
517 let inv = &Invocation {
518 writable: &writable,
519 prompt: &prompt,
520 ..inv.clone()
521 };
522 tokio::fs::create_dir_all(inv.artifacts)
523 .await
524 .with_context(|| format!("create {}", inv.artifacts.display()))?;
525 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
526 tokio::fs::write(&prompt_path, inv.prompt)
527 .await
528 .with_context(|| format!("write {}", prompt_path.display()))?;
529
530 let plan = build_command(spec, seat, inv, &prompt_path)?;
531 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
532 tokio::fs::write(
534 inv.artifacts.join(format!("{}.access.json", inv.stem)),
535 serde_json::to_vec_pretty(&serde_json::json!({
536 "launcher_version": env!("CARGO_PKG_VERSION"),
537 "kind": spec.kind,
538 "allow_write": inv.allow_write,
539 "unsandboxed": inv.unsandboxed,
540 "requested_network_access": if spec.kind == AgentKind::Codex && repository_writer(inv) && !inv.unsandboxed {
541 Some(false)
542 } else { None },
543 "writable_roots": inv.writable,
544 "fetched_base": inv.fetched_base,
545 }))?,
546 )
547 .await
548 .context("write seat access evidence")?;
549
550 let started = Instant::now();
551 let child_path = spec
554 .env
555 .iter()
556 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
557 .map(|(_, v)| std::ffi::OsString::from(v))
558 .or_else(|| std::env::var_os("PATH"))
559 .unwrap_or_default();
560 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
561 || plan.argv[0].clone().into(),
562 std::path::PathBuf::into_os_string,
563 );
564 let mut cmd = Command::new(program);
565 cmd.args(&plan.argv[1..])
566 .current_dir(inv.cwd)
567 .envs(&spec.env)
568 .env("MAGI_SEAT", &seat.key)
569 .env("MAGI_TURN", seat.turns.to_string())
570 .env("MAGI_RUN", inv.run)
571 .env("MAGI_NODE", inv.node)
572 .env(
573 "MAGI_TURN_SCOPE",
574 match inv.scope {
575 InvocationScope::ConsultationControl => "consultation-control",
576 InvocationScope::ConsultationInvestigation => "consultation-investigation",
577 InvocationScope::Repository => "repository",
578 },
579 )
580 .env("MAGI_PROMPT_FILE", &prompt_path)
581 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
582 .env("GIT_TERMINAL_PROMPT", "0")
583 .stdin(if plan.stdin.is_some() {
584 Stdio::piped()
585 } else {
586 Stdio::null()
587 })
588 .stdout(Stdio::piped())
589 .stderr(Stdio::piped())
590 .kill_on_drop(true)
591 .quiet();
594 if let Some(home) = home {
595 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
598 }
599 if let Some(cache) = inv.cache_dir {
600 cmd.env("CARGO_TARGET_DIR", cache);
603 } else {
604 cmd.env_remove("CARGO_TARGET_DIR");
612 }
613
614 #[cfg(any(unix, windows))]
615 let _chat_controls = {
616 use crate::chat_control::bridge::{self, Bridge};
617 bridge::clear_environment(&mut cmd);
618 let bridge = if inv.node == crate::queue::CHAT_NODE
619 && inv.scope == InvocationScope::Repository
620 {
621 crate::run::try_home().and_then(|home| {
622 match Bridge::start(&home, inv.run, inv.artifacts) {
623 Ok(bridge) => Some(bridge),
624 Err(error) => {
625 tracing::warn!(%error, "chat controls unavailable; continuing conversation");
626 None
627 }
628 }
629 })
630 } else {
631 None
632 };
633 if let Some(bridge) = &bridge {
634 cmd.env("MAGI_HOME", std::fs::canonicalize(crate::run::home())?);
638 bridge.attach(&mut cmd);
639 }
640 bridge
641 };
642
643 start_check()?;
646 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
647 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
648 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
652 tokio::spawn(async move {
653 sink.write_all(body.as_bytes()).await.ok();
654 sink.shutdown().await.ok();
655 });
656 }
657
658 let (out_buf, out_reader) = drain(running.child().stdout.take());
675 let (err_buf, err_reader) = drain(running.child().stderr.take());
676
677 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
678 .ok()
679 .map_or(0, |state| state.config.disk.min_free_bytes);
680 let mut paths = vec![inv.cwd.to_owned()];
681 if let Some(cache) = inv.cache_dir {
682 paths.push(cache.to_owned());
683 }
684 let mut pressure = None;
685 let waited = tokio::select! {
686 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
687 reason = crate::retention::emergency(paths, min_free) => {
688 pressure = Some(reason);
689 None
690 }
691 };
692 let (code, timed_out) = match waited {
693 Some(Ok(res)) => {
694 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
695 (status.code(), false)
696 }
697 Some(Err(_)) | None => {
698 if let Some(reason) = &pressure {
699 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
700 } else {
701 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
702 }
703 if !running.coordinated() {
705 running
706 .child()
707 .kill()
708 .await
709 .context("stop timed-out seat")?;
710 }
711 (None, true)
712 }
713 };
714 running.finish().await;
715
716 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
721 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
722
723 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
724 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
725 tokio::fs::write(&out_path, &stdout).await.ok();
726 tokio::fs::write(&err_path, &stderr).await.ok();
727 if let Some(reason) = pressure {
728 return Err(
729 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
730 .context(ExecutionInterrupted(reason)),
731 );
732 }
733
734 let mut extracted = extract(spec.kind, &stdout);
735 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
736 extracted.quota = agy_quota(&stdout, &stderr);
737 }
738 if let Some(quota) = &extracted.quota {
739 extracted.dropped = None;
742 tracing::warn!(
743 seat = %seat.key,
744 agent = %spec.id,
745 reset = ?quota.reset,
746 "agent is out of quota"
747 );
748 }
749 if let Some(session) = extracted.session {
750 match spec.kind {
751 AgentKind::Claude => seat.claude_session = Some(session),
752 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
753 seat.captured_session = Some(session);
754 }
755 AgentKind::Command => {}
756 }
757 }
758 if let Some(status) = &extracted.status
759 && !status.eq_ignore_ascii_case("success")
760 {
761 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
762 }
763 let text = if extracted.text.trim().is_empty() {
764 if stdout.trim().is_empty() {
766 stderr.trim().to_owned()
767 } else {
768 stdout.trim().to_owned()
769 }
770 } else {
771 extracted.text
772 };
773 seat.turns += 1;
774
775 Ok(AgentOutput {
776 text,
777 exit_code: code,
778 timed_out,
779 duration_ms: started.elapsed().as_millis() as u64,
780 artifacts: vec![
781 file_name(&prompt_path),
782 file_name(&out_path),
783 file_name(&err_path),
784 ],
785 quota: extracted.quota,
786 dropped: extracted.dropped,
787 commands: extracted.commands,
788 context_tokens: extracted.context_tokens,
789 })
790}
791
792fn file_name(p: &Path) -> String {
793 p.file_name()
794 .unwrap_or_default()
795 .to_string_lossy()
796 .into_owned()
797}
798
799#[derive(Debug)]
801struct Plan {
802 argv: Vec<String>,
803 stdin: Option<String>,
804}
805
806fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
817 if matches!(kind, AgentKind::Antigravity) {
818 return format!("@{}", prompt_path.display());
819 }
820 format!(
821 "Read the file at {} and follow every instruction in it exactly. That \
822 file is your complete task description; this message contains nothing \
823 else.",
824 prompt_path.display()
825 )
826}
827
828fn build_command(
829 spec: &AgentSpec,
830 seat: &SeatState,
831 inv: &Invocation<'_>,
832 prompt_path: &Path,
833) -> Result<Plan> {
834 let mut argv: Vec<String> = Vec::new();
835 let mut stdin: Option<String> = None;
836 let delivery = spec.delivery();
837 let resuming = has_session(spec.kind, seat, inv.sessions);
838
839 match spec.kind {
840 AgentKind::Claude => {
841 argv.push("claude".to_owned());
846 argv.push("-p".to_owned());
847 argv.push("--output-format".to_owned());
848 argv.push("json".to_owned());
849 if let Some(m) = &spec.model {
850 argv.push("--model".to_owned());
851 argv.push(m.clone());
852 }
853 if inv.sessions {
854 let uuid = seat
855 .claude_session
856 .as_deref()
857 .context("claude seat is missing its session uuid")?;
858 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
859 argv.push(uuid.to_owned());
860 }
861 argv.push("--permission-mode".to_owned());
862 argv.push("bypassPermissions".to_owned());
863 if !inv.allow_write {
864 argv.push("--disallowed-tools".to_owned());
865 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
866 }
867 }
868 AgentKind::Opencode => {
869 argv.push("opencode".to_owned());
873 argv.push("run".to_owned());
874 argv.push("--format".to_owned());
875 argv.push("json".to_owned());
876 argv.push("--dir".to_owned());
877 argv.push(inv.cwd.to_string_lossy().into_owned());
878 argv.push("--auto".to_owned());
887 if let Some(m) = &spec.model {
888 argv.push("-m".to_owned());
889 argv.push(m.clone());
890 }
891 if resuming {
892 argv.push("-s".to_owned());
893 argv.push(
894 seat.captured_session
895 .clone()
896 .expect("has_session checked the id is present"),
897 );
898 }
899 }
900 AgentKind::Antigravity => {
901 argv.push("agy".to_owned());
902 argv.push("--output-format".to_owned());
903 argv.push("json".to_owned());
904 argv.push("--print-timeout".to_owned());
907 argv.push(format!("{}s", inv.timeout.as_secs()));
908 argv.push("--mode".to_owned());
909 argv.push(
910 if inv.allow_write {
911 "accept-edits"
912 } else {
913 "plan"
914 }
915 .to_owned(),
916 );
917 if inv.allow_write {
918 argv.push("--dangerously-skip-permissions".to_owned());
919 }
920 if let Some(m) = &spec.model {
921 argv.push("--model".to_owned());
922 argv.push(m.clone());
923 }
924 if resuming {
925 argv.push("--conversation".to_owned());
926 argv.push(
927 seat.captured_session
928 .clone()
929 .expect("has_session checked the id is present"),
930 );
931 }
932 let mut add_dirs: Vec<String> = Vec::new();
942 if delivery == Delivery::File || !inv.attachments.is_empty() {
943 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
944 }
945 for path in inv.attachments {
946 let Some(parent) = path.parent() else {
947 continue;
948 };
949 if parent.starts_with(inv.artifacts) {
950 continue;
951 }
952 let dir = parent.to_string_lossy().into_owned();
953 if !add_dirs.contains(&dir) {
954 add_dirs.push(dir);
955 }
956 }
957 if inv.allow_write {
958 for path in inv.writable {
959 let dir = path.to_string_lossy().into_owned();
960 if !add_dirs.contains(&dir) {
961 add_dirs.push(dir);
962 }
963 }
964 }
965 for dir in add_dirs {
966 argv.push("--add-dir".to_owned());
967 argv.push(dir);
968 }
969 }
970 AgentKind::Codex => {
971 argv.push("codex".to_owned());
977 argv.push("exec".to_owned());
978 argv.push("--json".to_owned());
979 argv.push("--skip-git-repo-check".to_owned());
982 argv.push("-C".to_owned());
983 argv.push(inv.cwd.to_string_lossy().into_owned());
984 if inv.allow_write && inv.unsandboxed {
993 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
994 } else {
995 argv.push("--sandbox".to_owned());
996 argv.push(
997 if inv.allow_write {
998 "workspace-write"
999 } else {
1000 "read-only"
1001 }
1002 .to_owned(),
1003 );
1004 argv.push("-c".to_owned());
1007 argv.push("approval_policy=\"never\"".to_owned());
1008 if repository_writer(inv) {
1009 argv.push("-c".to_owned());
1010 argv.push("sandbox_workspace_write.network_access=false".to_owned());
1011 }
1012 if inv.allow_write && !inv.writable.is_empty() {
1013 let roots: Vec<String> = inv
1014 .writable
1015 .iter()
1016 .map(|p| p.to_string_lossy().into_owned())
1017 .collect();
1018 argv.push("-c".to_owned());
1019 argv.push(format!(
1020 "sandbox_workspace_write.writable_roots={}",
1021 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
1022 ));
1023 }
1024 }
1025 if let Some(m) = &spec.model {
1026 argv.push("-m".to_owned());
1027 argv.push(m.clone());
1028 }
1029 if resuming {
1035 argv.push("resume".to_owned());
1036 argv.push(
1037 seat.captured_session
1038 .clone()
1039 .expect("has_session checked the id is present"),
1040 );
1041 }
1042 }
1043 AgentKind::Omp => {
1044 argv.push("omp".to_owned());
1048 argv.push("-p".to_owned());
1049 argv.push("--mode=json".to_owned());
1050 argv.push("--auto-approve".to_owned());
1060 if let Some(m) = &spec.model {
1061 argv.push("--model".to_owned());
1062 argv.push(m.clone());
1063 }
1064 if resuming {
1070 argv.push("--resume".to_owned());
1071 argv.push(
1072 seat.captured_session
1073 .clone()
1074 .expect("has_session checked the id is present"),
1075 );
1076 }
1077 }
1078 AgentKind::Command => {
1079 if spec.command.is_empty() {
1084 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1085 }
1086 let vars: BTreeMap<&str, String> = BTreeMap::from([
1087 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1088 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1089 ("{label}", seat.key.clone()),
1090 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1091 ]);
1092 for raw in &spec.command {
1093 let mut arg = raw.clone();
1094 for (k, v) in &vars {
1095 if arg.contains(k) {
1096 arg = arg.replace(k, v);
1097 }
1098 }
1099 argv.push(arg);
1100 }
1101 }
1102 }
1103
1104 argv.extend(spec.extra_args.iter().cloned());
1105
1106 if spec.kind == AgentKind::Antigravity {
1109 argv.push("-p".to_owned());
1110 }
1111 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1114 argv.push("-".to_owned());
1115 }
1116 match delivery {
1117 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1118 argv.push(pointer(spec.kind, prompt_path));
1120 }
1121 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1122 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1123 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1124 }
1125
1126 Ok(Plan { argv, stdin })
1127}
1128
1129#[derive(Debug, Default)]
1131struct Extracted {
1132 text: String,
1133 session: Option<String>,
1134 status: Option<String>,
1135 quota: Option<Quota>,
1136 dropped: Option<Dropped>,
1137 commands: Vec<CommandEvidence>,
1138 context_tokens: Option<u64>,
1139}
1140
1141fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1143 let mut extracted = extract_answer(kind, stdout);
1144 extracted.context_tokens = context_tokens(kind, stdout);
1145 extracted
1146}
1147
1148fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1150 v.get(key).and_then(serde_json::Value::as_u64)
1151}
1152
1153fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1158 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1159 let base = first(input)?;
1160 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1161 Some(base.saturating_add(cached))
1162}
1163
1164fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1185 use serde_json::Value;
1186 let lines = || {
1187 stdout
1188 .lines()
1189 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1190 };
1191 match kind {
1192 AgentKind::Claude => None,
1193 AgentKind::Opencode => lines()
1194 .filter(|v| {
1195 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1197 || v.get("part")
1198 .and_then(|p| p.get("type"))
1199 .and_then(|t| t.as_str())
1200 == Some("step-finish")
1201 })
1202 .filter_map(|v| {
1203 let tokens = v.get("part")?.get("tokens")?;
1204 let base = uint(tokens, "input")?;
1205 let cache = tokens.get("cache");
1206 let cached = ["read", "write"]
1207 .iter()
1208 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1209 .sum::<u64>();
1210 Some(base.saturating_add(cached))
1211 })
1212 .next_back(),
1213 AgentKind::Antigravity => None,
1214 AgentKind::Codex => None,
1215 AgentKind::Omp => lines()
1216 .flat_map(|v| {
1217 match v.get("type").and_then(|t| t.as_str()) {
1220 Some("agent_end") => v
1221 .get("messages")
1222 .and_then(|m| m.as_array())
1223 .cloned()
1224 .unwrap_or_default(),
1225 Some("turn_end") | Some("message_end") => {
1226 v.get("message").cloned().into_iter().collect()
1227 }
1228 _ => Vec::new(),
1229 }
1230 })
1231 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1232 .filter_map(|m| {
1233 input_side(
1234 m.get("usage")?,
1235 &["input", "input_tokens"],
1236 &[
1237 "cacheRead",
1238 "cacheWrite",
1239 "cache_read_tokens",
1240 "cache_write_tokens",
1241 ],
1242 )
1243 })
1244 .next_back(),
1245 AgentKind::Command => None,
1246 }
1247}
1248
1249pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1252 extract_answer(kind, stdout).text
1253}
1254
1255fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1256 match kind {
1257 AgentKind::Claude => {
1258 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1259 return Extracted {
1260 text: stdout.trim().to_owned(),
1261 ..Extracted::default()
1262 };
1263 };
1264 Extracted {
1265 text: v
1266 .get("result")
1267 .and_then(|r| r.as_str())
1268 .unwrap_or_default()
1269 .to_owned(),
1270 session: v
1271 .get("session_id")
1272 .and_then(|s| s.as_str())
1273 .map(str::to_owned),
1274 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1275 if e {
1276 "error".to_owned()
1277 } else {
1278 "success".to_owned()
1279 }
1280 }),
1281 quota: claude_quota(&v),
1282 dropped: None,
1285 commands: Vec::new(),
1286 context_tokens: None,
1287 }
1288 }
1289 AgentKind::Opencode => {
1290 let mut text = String::new();
1292 let mut session = None;
1293 for line in stdout.lines() {
1294 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1295 continue;
1296 };
1297 if session.is_none() {
1298 session = v
1299 .get("sessionID")
1300 .and_then(|s| s.as_str())
1301 .map(str::to_owned);
1302 }
1303 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1304 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1305 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1306 {
1307 if !text.is_empty() {
1308 text.push('\n');
1309 }
1310 text.push_str(t);
1311 }
1312 }
1313 Extracted {
1314 text,
1315 session,
1316 status: None,
1317 quota: None,
1318 dropped: None,
1319 commands: Vec::new(),
1320 context_tokens: None,
1321 }
1322 }
1323 AgentKind::Antigravity => {
1324 let obj = stdout
1327 .lines()
1328 .rev()
1329 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1330 let Some(v) = obj else {
1331 return Extracted {
1332 text: stdout.trim().to_owned(),
1333 ..Extracted::default()
1334 };
1335 };
1336 Extracted {
1337 text: v
1338 .get("response")
1339 .and_then(|r| r.as_str())
1340 .unwrap_or_default()
1341 .trim()
1342 .to_owned(),
1343 session: v
1344 .get("conversation_id")
1345 .and_then(|s| s.as_str())
1346 .map(str::to_owned),
1347 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1348 quota: None,
1349 dropped: dropped_stream(&v),
1350 commands: Vec::new(),
1351 context_tokens: None,
1352 }
1353 }
1354 AgentKind::Codex => {
1355 let mut text = String::new();
1377 let mut session = None;
1378 let mut status = None;
1379 let mut commands = Vec::new();
1380 for line in stdout.lines() {
1381 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1382 continue;
1383 };
1384 match v.get("type").and_then(|t| t.as_str()) {
1385 Some("thread.started") => {
1386 session = v
1387 .get("thread_id")
1388 .and_then(|s| s.as_str())
1389 .map(str::to_owned);
1390 }
1391 Some("item.completed") => {
1392 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1393 match item.get("type").and_then(|t| t.as_str()) {
1394 Some("agent_message") => {
1395 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1396 text = t.trim().to_owned();
1397 }
1398 }
1399 Some("command_execution") => {
1400 commands.push(command_evidence(item));
1401 }
1402 _ => {}
1403 }
1404 }
1405 Some("turn.completed") => status = Some("success".to_owned()),
1406 Some("turn.failed") => status = Some("error".to_owned()),
1407 _ => {}
1408 }
1409 }
1410 Extracted {
1411 text,
1412 session,
1413 status,
1414 quota: None,
1415 dropped: None,
1416 commands,
1417 context_tokens: None,
1418 }
1419 }
1420 AgentKind::Omp => {
1421 let mut text = String::new();
1442 let mut session = None;
1443 for line in stdout.lines() {
1444 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1445 continue;
1446 };
1447 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1448 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1449 continue;
1450 }
1451 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1455 {
1456 Some("agent_end") => v
1457 .get("messages")
1458 .and_then(|m| m.as_array())
1459 .map(|m| m.iter().collect())
1460 .unwrap_or_default(),
1461 Some("turn_end") | Some("message_end") => {
1462 v.get("message").into_iter().collect()
1463 }
1464 _ => continue,
1465 };
1466 for message in messages {
1467 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1468 continue;
1469 }
1470 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1471 continue;
1472 };
1473 for part in parts {
1474 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1475 continue;
1476 }
1477 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1478 && !t.trim().is_empty()
1479 {
1480 text = t.trim().to_owned();
1481 }
1482 }
1483 }
1484 }
1485 Extracted {
1486 text,
1487 session,
1488 status: None,
1489 quota: None,
1490 dropped: None,
1491 commands: Vec::new(),
1492 context_tokens: None,
1493 }
1494 }
1495 AgentKind::Command => {
1496 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1501 let quota = parsed.as_ref().and_then(claude_quota);
1502 let dropped = parsed.as_ref().and_then(dropped_stream);
1505 Extracted {
1506 text: stdout.trim().to_owned(),
1507 session: None,
1508 status: None,
1509 quota,
1510 dropped,
1511 commands: Vec::new(),
1512 context_tokens: None,
1513 }
1514 }
1515 }
1516}
1517
1518fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1525 let description = match item.get("command") {
1526 Some(serde_json::Value::String(s)) => s.clone(),
1527 Some(serde_json::Value::Array(parts)) => parts
1528 .iter()
1529 .filter_map(|p| p.as_str())
1530 .collect::<Vec<_>>()
1531 .join(" "),
1532 _ => String::new(),
1533 };
1534 let result_summary = item
1535 .get("aggregated_output")
1536 .and_then(|o| o.as_str())
1537 .map(|s| tail_chars(s.trim(), 400))
1538 .unwrap_or_default();
1539 CommandEvidence {
1540 id: item
1541 .get("id")
1542 .and_then(|s| s.as_str())
1543 .unwrap_or_default()
1544 .to_owned(),
1545 description,
1546 exit_code: item
1547 .get("exit_code")
1548 .and_then(serde_json::Value::as_i64)
1549 .map(|e| e as i32),
1550 result_summary,
1551 source: "codex".to_owned(),
1552 }
1553}
1554
1555fn tail_chars(s: &str, max: usize) -> String {
1557 let count = s.chars().count();
1558 if count <= max {
1559 return s.to_owned();
1560 }
1561 s.chars().skip(count - max).collect()
1562}
1563
1564fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1571 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1572 if !is_err {
1573 return None;
1574 }
1575 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1576 let status = v.get("api_error_status");
1577 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1578 || status.and_then(serde_json::Value::as_str) == Some("429"))
1579 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1580 if !usage_limit && !result.to_lowercase().contains("session limit") {
1581 return None;
1582 }
1583 let reset = result
1586 .split("resets ")
1587 .nth(1)
1588 .map(str::trim)
1589 .filter(|s| !s.is_empty())
1590 .map(str::to_owned);
1591 Some(Quota { reset })
1592}
1593
1594fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1609 let from_stdout = stdout
1610 .lines()
1611 .rev()
1612 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1613 .and_then(|v| {
1614 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1615 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1616 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1617 .then(|| error.to_owned())
1618 });
1619 let from_stderr = || {
1620 stderr.lines().find_map(|l| {
1621 let v: serde_json::Value =
1622 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1623 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1624 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1625 (exhausted && code).then(|| {
1626 v.get("short_error")
1627 .and_then(|e| e.as_str())
1628 .unwrap_or_default()
1629 .to_owned()
1630 })
1631 })
1632 };
1633 let text = from_stdout.or_else(from_stderr)?;
1634 let reset = text
1635 .split_once("Resets ")
1636 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1637 .filter(|s| !s.is_empty())
1638 .map(str::to_owned);
1639 Some(Quota { reset })
1640}
1641
1642fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1673 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1674 if !status.eq_ignore_ascii_case("error") {
1675 return None;
1676 }
1677 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1678 if !response.trim().is_empty() {
1679 return None;
1681 }
1682 let produced = v
1683 .get("usage")
1684 .and_then(|u| u.get("output_tokens"))
1685 .and_then(serde_json::Value::as_u64)
1686 .unwrap_or(0);
1687 if produced == 0 {
1688 return None;
1690 }
1691 Some(Dropped {
1692 why: v
1693 .get("error")
1694 .and_then(|e| e.as_str())
1695 .unwrap_or("the CLI ended the stream without delivering its answer")
1696 .trim()
1697 .to_owned(),
1698 output_tokens: produced,
1699 })
1700}
1701
1702pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1704 let mut missing = Vec::new();
1705 for s in specs {
1706 let program = match s.kind {
1707 AgentKind::Command => s.command.first().map(String::as_str),
1708 other => other.program(),
1709 };
1710 if let Some(p) = program
1711 && !crate::config::which(p)
1712 && !Path::new(p).is_file()
1713 && !missing.iter().any(|m: &String| m == p)
1714 {
1715 missing.push(p.to_owned());
1716 }
1717 }
1718 missing
1719}
1720
1721pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1723 run_dir.join("artifacts")
1724}
1725
1726pub fn installed(spec: &AgentSpec) -> bool {
1728 spec.kind.program().is_none_or(crate::config::which)
1731}
1732
1733pub fn pick(
1755 agents: &[AgentSpec],
1756 want: Option<&str>,
1757 available: &dyn Fn(&AgentSpec) -> bool,
1758) -> Result<AgentSpec> {
1759 if let Some(id) = want {
1760 let spec = agents
1761 .iter()
1762 .find(|a| a.id == id)
1763 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1764 if !available(spec) {
1765 bail!(
1766 "agent `{}` needs `{}` on PATH; install it or pass a different \
1767 --agent",
1768 spec.id,
1769 spec.kind.program().unwrap_or("its command")
1770 );
1771 }
1772 return Ok(spec.clone());
1773 }
1774
1775 if agents.is_empty() {
1776 bail!(
1777 "the agent roster is empty, so there is nobody to ask: install one \
1778 of claude, opencode or agy - magi derives a roster from what is on \
1779 PATH - or add an [[agents]] entry to magi.toml."
1780 );
1781 }
1782
1783 if let Some(spec) = agents
1784 .iter()
1785 .find(|a| a.kind == AgentKind::Claude && available(a))
1786 {
1787 return Ok(spec.clone());
1788 }
1789
1790 agents
1791 .iter()
1792 .find(|a| available(a))
1793 .cloned()
1794 .with_context(|| {
1795 let missing = agents
1796 .iter()
1797 .filter_map(|a| a.kind.program())
1798 .collect::<Vec<_>>()
1799 .join(", ");
1800 format!(
1801 "no agent in the roster can be run here: install one of \
1802 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1803 you do have"
1804 )
1805 })
1806}
1807
1808pub fn pick_chain(
1816 agents: &[AgentSpec],
1817 choice: Option<&AgentChoice>,
1818 available: &dyn Fn(&AgentSpec) -> bool,
1819 role: &str,
1820) -> Result<Vec<AgentSpec>> {
1821 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1822 if wanted.is_empty() {
1823 return pick(agents, None, available)
1824 .map(|s| vec![s])
1825 .with_context(|| format!("choose an agent for the {role} role"));
1826 }
1827 let mut chain: Vec<AgentSpec> = Vec::new();
1828 for id in wanted {
1829 if chain.iter().any(|s| s.id == id) {
1830 continue;
1831 }
1832 match pick(agents, Some(id), available) {
1833 Ok(spec) => chain.push(spec),
1834 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1835 }
1836 }
1837 if chain.is_empty() {
1838 bail!(
1839 "[roles] {role} names no agent that can run here; the roster has {}",
1840 ids(agents)
1841 );
1842 }
1843 Ok(chain)
1844}
1845
1846pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1852 match outcome {
1853 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1854 Err(_) => true,
1855 Ok(out) => output_advances(out),
1856 }
1857}
1858
1859pub fn output_advances(out: &AgentOutput) -> bool {
1863 out.quota_exhausted() || !out.usable()
1864}
1865
1866fn ids(agents: &[AgentSpec]) -> String {
1867 if agents.is_empty() {
1868 return "no agents at all".to_owned();
1869 }
1870 agents
1871 .iter()
1872 .map(|a| a.id.clone())
1873 .collect::<Vec<_>>()
1874 .join(", ")
1875}
1876
1877#[cfg(test)]
1878mod tests {
1879 use super::*;
1880
1881 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1882
1883 fn command_helper(mode: &str) -> AgentSpec {
1886 crate::run::pin_test_home();
1888 AgentSpec {
1889 id: "helper".to_owned(),
1890 kind: AgentKind::Command,
1891 model: None,
1892 command: vec![
1893 std::env::current_exe()
1894 .expect("locate test helper")
1895 .to_string_lossy()
1896 .into_owned(),
1897 "--exact".to_owned(),
1898 "agent::tests::command_agent_test_helper".to_owned(),
1899 "--nocapture".to_owned(),
1900 ],
1901 extra_args: Vec::new(),
1902 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1903 prompt_delivery: None,
1904 max_turn_secs: None,
1905 }
1906 }
1907
1908 #[test]
1909 fn command_agent_test_helper() {
1910 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1911 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1912 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1913 Ok("no-cache") => println!(
1914 "{}",
1915 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1916 ),
1917 Ok("ignore-stdin") => println!("done"),
1918 Ok("chatty-sleep") => {
1919 println!("i-said-something");
1920 std::thread::sleep(Duration::from_secs(30));
1921 }
1922 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1923 Ok(other) => panic!("unknown command helper mode {other}"),
1924 Err(_) => {}
1925 }
1926 }
1927
1928 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1929 AgentSpec {
1930 id: "a".to_owned(),
1931 kind,
1932 model: model.map(str::to_owned),
1933 command: vec!["echo".to_owned(), "{label}".to_owned()],
1934 extra_args: Vec::new(),
1935 env: BTreeMap::new(),
1936 prompt_delivery: None,
1937 max_turn_secs: None,
1938 }
1939 }
1940
1941 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1942 Invocation {
1943 scope: crate::agent::InvocationScope::Repository,
1944 coordination: None,
1945 fetched_base: None,
1946 cwd,
1947 prompt: "do the thing",
1948 timeout: Duration::from_secs(900),
1949 allow_write,
1950 unsandboxed: false,
1951 sessions: true,
1952 artifacts: art,
1953 stem: "t",
1954 run: "test-run",
1955 node: "test",
1956 cache_dir: None,
1957 attachments: &[],
1958 writable: &[],
1959 }
1960 }
1961
1962 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1963 build_command(
1964 &spec(kind, None),
1965 seat,
1966 &inv(Path::new("."), Path::new("/art"), allow_write),
1967 Path::new("/art/p.md"),
1968 )
1969 .unwrap()
1970 }
1971
1972 #[test]
1973 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
1974 let missing = editing_access_note(AgentKind::Codex, false, None);
1975 assert!(missing.contains("No successful launcher fetch evidence"));
1976 let base = crate::run::FetchedBase {
1977 remote: "origin".into(),
1978 branch: "main".into(),
1979 commit: "0123456789abcdef".into(),
1980 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
1981 };
1982 let note = editing_access_note(AgentKind::Codex, false, Some(&base));
1983 for expected in [
1984 "origin/main",
1985 "0123456789abcdef",
1986 "2026-10-18T00:00:00Z",
1987 "Do not fetch again",
1988 "launcher saves eligible edits",
1989 "Specification questions remain valid",
1990 ] {
1991 assert!(note.contains(expected), "missing {expected}");
1992 }
1993 for kind in [
1994 AgentKind::Codex,
1995 AgentKind::Claude,
1996 AgentKind::Opencode,
1997 AgentKind::Antigravity,
1998 AgentKind::Omp,
1999 AgentKind::Command,
2000 ] {
2001 for unsandboxed in [false, true] {
2002 let scoped = editing_access_note(kind, unsandboxed, Some(&base));
2003 assert!(scoped.contains(&base.commit));
2004 assert!(scoped.contains("Do not fetch again"));
2005 let restricted = kind == AgentKind::Codex && !unsandboxed;
2006 for claim in [
2007 "Network access is restricted",
2008 "protected Git administration paths",
2009 "launcher saves eligible edits",
2010 "unavailable sandbox permissions",
2011 ] {
2012 assert_eq!(scoped.contains(claim), restricted, "{kind:?}/{unsandboxed}");
2013 assert_eq!(
2014 editing_access_note(kind, unsandboxed, None).contains(claim),
2015 restricted,
2016 "{kind:?}/{unsandboxed} without fetch evidence",
2017 );
2018 }
2019 }
2020 }
2021 let roundtrip: crate::run::FetchedBase =
2022 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
2023 assert_eq!(
2024 editing_access_note(AgentKind::Codex, false, Some(&roundtrip)),
2025 note
2026 );
2027 }
2028
2029 #[test]
2030 fn editing_network_policy_is_explicit_on_initial_and_resume() {
2031 for resumed in [false, true] {
2032 let mut seat = SeatState::new("impl-A", "a", 7);
2033 if resumed {
2034 seat.turns = 1;
2035 seat.captured_session = Some("fixture-thread".into());
2036 }
2037 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2038 invocation.node = "implement";
2039 let plan = build_command(
2040 &spec(AgentKind::Codex, None),
2041 &seat,
2042 &invocation,
2043 Path::new("/art/p.md"),
2044 )
2045 .unwrap();
2046 let policy = plan
2047 .argv
2048 .iter()
2049 .position(|s| s == "sandbox_workspace_write.network_access=false")
2050 .unwrap();
2051 if resumed {
2052 assert!(policy < plan.argv.iter().position(|s| s == "resume").unwrap());
2053 }
2054 assert!(!plan.argv.iter().any(|s| s.contains("bypass")));
2055 invocation.node = "deputy";
2056 let deputy = build_command(
2057 &spec(AgentKind::Codex, None),
2058 &seat,
2059 &invocation,
2060 Path::new("/art/p.md"),
2061 )
2062 .unwrap();
2063 assert!(!deputy.argv.iter().any(|s| s.contains("network_access")));
2064 assert!(
2065 !plan_for(AgentKind::Codex, &seat, false)
2066 .argv
2067 .iter()
2068 .any(|s| s.contains("writable_roots"))
2069 );
2070 }
2071 }
2072
2073 #[test]
2074 fn claude_mints_then_resumes_the_same_uuid() {
2075 let mut seat = SeatState::new("judge-1", "a", 7);
2076 let uuid = seat.claude_session.clone().unwrap();
2077 let first = plan_for(AgentKind::Claude, &seat, true);
2078 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2079 assert!(!first.argv.iter().any(|a| a == "--resume"));
2080
2081 seat.turns = 1;
2082 let second = plan_for(AgentKind::Claude, &seat, true);
2083 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2084 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2085 }
2086
2087 #[test]
2088 fn read_only_seats_cannot_edit() {
2089 let seat = SeatState::new("judge-1", "a", 7);
2090 let claude = plan_for(AgentKind::Claude, &seat, false);
2091 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2092 assert!(
2093 !plan_for(AgentKind::Claude, &seat, true)
2094 .argv
2095 .iter()
2096 .any(|a| a == "--disallowed-tools")
2097 );
2098
2099 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2100 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2101 assert!(
2102 !agy.argv
2103 .iter()
2104 .any(|a| a == "--dangerously-skip-permissions")
2105 );
2106 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2107 assert!(
2108 agy_rw
2109 .argv
2110 .windows(2)
2111 .any(|w| w == ["--mode", "accept-edits"])
2112 );
2113 assert!(
2114 agy_rw
2115 .argv
2116 .iter()
2117 .any(|a| a == "--dangerously-skip-permissions")
2118 );
2119 let agy_prompt = agy_rw
2124 .argv
2125 .iter()
2126 .position(|a| a == "-p")
2127 .map(|i| agy_rw.argv[i + 1].clone())
2128 .expect("agy takes its prompt with -p");
2129 assert!(
2130 agy_prompt.starts_with('@'),
2131 "agy must get a file reference, got {agy_prompt:?}"
2132 );
2133 assert!(
2134 !agy_prompt.contains("Read the file at"),
2135 "the prose pointer is for CLIs with no file syntax"
2136 );
2137
2138 for allow_write in [false, true] {
2143 assert!(
2144 plan_for(AgentKind::Opencode, &seat, allow_write)
2145 .argv
2146 .iter()
2147 .any(|a| a == "--auto"),
2148 "opencode needs --auto even to read (allow_write = {allow_write})"
2149 );
2150 }
2151 }
2152
2153 #[test]
2156 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2157 let seat = SeatState::new("deputy-x", "a", 7);
2158 let roots = [PathBuf::from("/data/questions")];
2159 let mk = |allow_write: bool| {
2160 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2161 i.writable = &roots;
2162 build_command(
2163 &spec(AgentKind::Codex, None),
2164 &seat,
2165 &i,
2166 Path::new("/art/p.md"),
2167 )
2168 .unwrap()
2169 };
2170 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2171 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2172 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2173 }
2174
2175 #[tokio::test]
2176 async fn investigation_checks_cancellation_after_artifacts_before_spawning() {
2177 let tmp = tempfile::tempdir().unwrap();
2178 let artifacts = tmp.path().join("artifacts");
2179 let mut invocation = inv(tmp.path(), &artifacts, true);
2180 invocation.scope = InvocationScope::ConsultationInvestigation;
2181 invocation.unsandboxed = true;
2182 let mut seat = SeatState::new("chat", "fixture", 1);
2183 let error = invoke_leased(
2184 &spec(AgentKind::Command, None),
2185 &mut seat,
2186 &invocation,
2187 None,
2188 || {
2189 assert!(artifacts.join("t.prompt.md").exists());
2190 bail!("question closed during preparation")
2191 },
2192 )
2193 .await
2194 .unwrap_err();
2195 assert!(error.to_string().contains("question closed"));
2196 assert_eq!(seat.turns, 0);
2197 assert!(!artifacts.join("t.out").exists());
2198 }
2199
2200 #[test]
2201 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2202 let mut i = inv(Path::new("."), Path::new("/art"), true);
2203 for node in [
2204 "implement",
2205 "fix",
2206 "graft",
2207 "gate-fix",
2208 "land",
2209 "rebase",
2210 "chat",
2211 ] {
2212 i.node = node;
2213 assert!(repository_writer(&i), "{node}");
2214 i.allow_write = false;
2215 assert!(!repository_writer(&i), "{node}");
2216 i.allow_write = true;
2217 }
2218 for node in ["deputy", "judge", "review", "bump", "conduct"] {
2219 i.node = node;
2220 assert!(!repository_writer(&i), "{node}");
2221 }
2222 i.node = "chat";
2223 let roots = [PathBuf::from("/questions")];
2224 i.writable = &roots;
2225 assert!(
2226 repository_writer(&i),
2227 "roots alone do not select consultation"
2228 );
2229 i.scope = InvocationScope::ConsultationControl;
2230 assert!(!repository_writer(&i), "consultation keeps explicit grants");
2231 assert!(!repository_seat(&i));
2232 i.allow_write = false;
2233 i.writable = &[];
2234 assert!(!repository_seat(&i), "control scope needs no write roots");
2235 i.scope = InvocationScope::ConsultationInvestigation;
2236 assert!(repository_seat(&i));
2237 i.allow_write = true;
2238 assert!(
2239 !repository_writer(&i),
2240 "investigation never gains editing grants"
2241 );
2242 }
2243
2244 #[test]
2245 fn coordination_classification_is_independent_of_write_grants() {
2246 let roots = [PathBuf::from("/questions")];
2247 for node in [
2248 crate::queue::CHAT_NODE,
2249 "implement",
2250 "fix",
2251 "graft",
2252 "gate-fix",
2253 "land",
2254 "rebase",
2255 "review",
2256 "judge",
2257 "advise",
2258 "deputy",
2259 "waiter",
2260 "conduct",
2261 "dupes",
2262 ] {
2263 for allow_write in [false, true] {
2264 for writable in [&[][..], &roots[..]] {
2265 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2266 i.node = node;
2267 i.writable = writable;
2268 assert_eq!(
2269 repository_seat(&i),
2270 !matches!(
2271 node,
2272 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
2273 ),
2274 "{node}, allow_write={allow_write}, writable={writable:?}"
2275 );
2276 if node == crate::queue::CHAT_NODE {
2277 assert_eq!(repository_writer(&i), allow_write);
2278 }
2279 }
2280 }
2281 }
2282 }
2283
2284 #[test]
2285 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2286 let roots = [
2287 PathBuf::from("/shared/git"),
2288 PathBuf::from("/state/questions"),
2289 ];
2290 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2291 for resumed in [false, true] {
2292 let mut seat = SeatState::new("impl-A", "a", 7);
2293 if resumed {
2294 seat.turns = 1;
2295 seat.captured_session = Some("session-id".into());
2296 }
2297 for allow_write in [false, true] {
2298 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2299 i.writable = &roots;
2300 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2301 .unwrap();
2302 for root in &roots {
2303 if kind == AgentKind::Antigravity {
2304 assert_eq!(
2305 plan.argv.windows(2).any(|w| {
2306 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2307 }),
2308 allow_write,
2309 "resumed={resumed}: {:?}",
2310 plan.argv
2311 );
2312 }
2313 assert_eq!(
2314 plan.argv
2315 .iter()
2316 .any(|arg| arg.contains(root.to_str().unwrap())),
2317 allow_write,
2318 "{kind:?}, resumed={resumed}: {:?}",
2319 plan.argv
2320 );
2321 }
2322 if kind == AgentKind::Codex && resumed {
2323 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2324 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2325 }
2326 }
2327 }
2328 }
2329 }
2330
2331 #[test]
2332 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
2333 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2334 let mut seat = SeatState::new("talk", "a", 7);
2335 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2336 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2337 i.unsandboxed = unsandboxed;
2338 build_command(
2339 &spec(AgentKind::Codex, None),
2340 seat,
2341 &i,
2342 Path::new("/art/p.md"),
2343 )
2344 .unwrap()
2345 };
2346
2347 let first = build(&seat, true, true);
2349 assert!(first.argv.iter().any(|a| a == BYPASS));
2350 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2351 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2352
2353 seat.captured_session = Some("thread-1".to_owned());
2355 seat.turns = 1;
2356 let resumed = build(&seat, true, true);
2357 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2358 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2359
2360 let ro = build(&seat, false, true);
2362 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2363 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2364 }
2365
2366 #[test]
2367 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2368 let mut seat = SeatState::new("judge-1", "a", 7);
2369
2370 let ro = plan_for(AgentKind::Codex, &seat, false);
2374 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2375 let rw = plan_for(AgentKind::Codex, &seat, true);
2376 assert!(
2377 rw.argv
2378 .windows(2)
2379 .any(|w| w == ["--sandbox", "workspace-write"])
2380 );
2381 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2384 ro_flagged.unsandboxed = true;
2385 let ro_flagged = build_command(
2386 &spec(AgentKind::Codex, None),
2387 &seat,
2388 &ro_flagged,
2389 Path::new("/art/p.md"),
2390 )
2391 .unwrap();
2392 assert!(
2393 ro_flagged
2394 .argv
2395 .windows(2)
2396 .any(|w| w == ["--sandbox", "read-only"])
2397 );
2398 for p in [&ro, &rw, &ro_flagged] {
2399 assert!(
2400 !p.argv
2401 .iter()
2402 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2403 "the bypass defeats the only enforced read-only mode we have"
2404 );
2405 assert!(
2407 p.argv
2408 .windows(2)
2409 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2410 "an unattended seat that asks for approval blocks until timeout"
2411 );
2412 }
2413
2414 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2416 assert_eq!(
2417 ro.argv.last().map(String::as_str),
2418 Some("-"),
2419 "without the `-` argument codex waits for a prompt it never gets"
2420 );
2421
2422 seat.turns = 1;
2426 assert!(!has_session(AgentKind::Codex, &seat, true));
2427 assert!(
2428 !plan_for(AgentKind::Codex, &seat, true)
2429 .argv
2430 .iter()
2431 .any(|a| a == "resume")
2432 );
2433 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2434 let resumed = plan_for(AgentKind::Codex, &seat, true);
2435 let at = resumed
2436 .argv
2437 .iter()
2438 .position(|a| a == "resume")
2439 .expect("resumes by subcommand");
2440 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2441 assert!(
2442 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2443 "every option precedes the subcommand"
2444 );
2445 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2446 }
2447
2448 #[test]
2451 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2452 let mut seat = SeatState::new("review-1", "a", 7);
2453
2454 let first = plan_for(AgentKind::Omp, &seat, false);
2458 assert!(first.argv.iter().any(|a| a == "-p"));
2459 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2460 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2461 assert!(
2462 !first.argv.iter().any(|a| a == "do the thing"),
2463 "the prompt reached argv, where Windows caps it"
2464 );
2465
2466 for allow_write in [false, true] {
2472 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2473 assert!(
2474 p.argv.iter().any(|a| a == "--auto-approve"),
2475 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2476 );
2477 assert!(
2478 !p.argv
2479 .iter()
2480 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2481 "nothing ever asks for the bypass"
2482 );
2483 }
2484
2485 seat.turns = 1;
2488 assert!(!has_session(AgentKind::Omp, &seat, true));
2489 assert!(
2490 !plan_for(AgentKind::Omp, &seat, true)
2491 .argv
2492 .iter()
2493 .any(|a| a == "--resume")
2494 );
2495 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2496 let resumed = plan_for(AgentKind::Omp, &seat, true);
2497 assert!(
2498 resumed
2499 .argv
2500 .windows(2)
2501 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2502 "a captured id is what makes the next turn a resume"
2503 );
2504 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2507 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2509 }
2510
2511 #[test]
2516 fn omp_takes_the_answer_without_an_agent_end_line() {
2517 let stream = concat!(
2518 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2519 "\n",
2520 r#"{"type":"agent_start"}"#,
2521 "\n",
2522 r#"{"type":"turn_start"}"#,
2523 "\n",
2524 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2525 "\n",
2526 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2527 "\n",
2528 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2529 "\n",
2530 );
2531 let out = extract(AgentKind::Omp, stream);
2532 assert_eq!(
2533 out.text, "{\"vote\":\"approve\"}",
2534 "the last assistant text block is the answer even with no agent_end"
2535 );
2536 assert_eq!(
2537 out.session.as_deref(),
2538 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2539 );
2540 }
2541
2542 #[test]
2546 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2547 let stream = concat!(
2548 r#"{"type":"session","version":3,"id":"s1"}"#,
2549 "\n",
2550 "{\"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問題ありません。\"}]}]}",
2551 "\n",
2552 );
2553 let out = extract(AgentKind::Omp, stream);
2554 assert_eq!(
2555 out.text, "## 判定\n\n問題ありません。",
2556 "the narration is not the answer, and non-ASCII survives intact"
2557 );
2558 assert_eq!(out.session.as_deref(), Some("s1"));
2559 }
2560
2561 #[test]
2564 fn omp_skips_non_json_lines() {
2565 let stream = concat!(
2566 "Warning: some omp notice\n",
2567 r#"{"type":"session","version":3,"id":"s2"}"#,
2568 "\n",
2569 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2570 "\n",
2571 "trailing junk",
2572 "\n",
2573 );
2574 let out = extract(AgentKind::Omp, stream);
2575 assert_eq!(out.text, "the answer");
2576 assert_eq!(out.session.as_deref(), Some("s2"));
2577 }
2578
2579 #[test]
2581 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2582 let stream = concat!(
2583 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2584 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2585 "\n",
2586 r#"{"type":"turn.started"}"#,
2587 "\n",
2588 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2589 "\n",
2590 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2591 "\n",
2592 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2593 "\n",
2594 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2595 "\n",
2596 );
2597 let out = extract(AgentKind::Codex, stream);
2598 assert_eq!(
2599 out.text, "{\"verdict\": \"ok\"}",
2600 "the last agent message is the answer; earlier ones narrate"
2601 );
2602 assert_eq!(
2603 out.session.as_deref(),
2604 Some("01a07440-4545-7492-85c1-024e3259a90a")
2605 );
2606 assert_eq!(out.status.as_deref(), Some("success"));
2607
2608 let failed = concat!(
2609 r#"{"type":"thread.started","thread_id":"t1"}"#,
2610 "\n",
2611 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2612 "\n",
2613 );
2614 assert_eq!(
2615 extract(AgentKind::Codex, failed).status.as_deref(),
2616 Some("error")
2617 );
2618 }
2619
2620 #[test]
2621 fn captured_sessions_resume_only_once_reported() {
2622 let mut seat = SeatState::new("impl-A", "a", 7);
2623 seat.turns = 1;
2624 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2625 assert!(!has_session(kind, &seat, true));
2626 let p = plan_for(kind, &seat, true);
2627 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2628 }
2629
2630 seat.captured_session = Some("sid".to_owned());
2631 assert!(has_session(AgentKind::Opencode, &seat, true));
2632 assert!(
2633 plan_for(AgentKind::Opencode, &seat, true)
2634 .argv
2635 .windows(2)
2636 .any(|w| w == ["-s", "sid"])
2637 );
2638 assert!(
2639 plan_for(AgentKind::Antigravity, &seat, true)
2640 .argv
2641 .windows(2)
2642 .any(|w| w == ["--conversation", "sid"])
2643 );
2644 }
2645
2646 #[test]
2647 fn sessions_disabled_never_resumes() {
2648 let mut seat = SeatState::new("impl-A", "a", 7);
2649 seat.turns = 3;
2650 seat.captured_session = Some("sid".to_owned());
2651 for kind in [
2652 AgentKind::Claude,
2653 AgentKind::Opencode,
2654 AgentKind::Antigravity,
2655 ] {
2656 assert!(!has_session(kind, &seat, false));
2657 }
2658 }
2659
2660 #[test]
2661 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2662 let seat = SeatState::new("judge-1", "a", 7);
2663 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2664 let p = plan_for(kind, &seat, false);
2665 assert!(
2666 p.argv.iter().all(|a| a != "do the thing"),
2667 "{kind:?} put the prompt on the command line"
2668 );
2669 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2670 }
2671 let p = plan_for(AgentKind::Antigravity, &seat, false);
2673 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2674 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2675 assert!(p.stdin.is_none());
2676 }
2677
2678 #[test]
2679 fn agy_print_timeout_tracks_the_node_budget() {
2680 let seat = SeatState::new("impl-A", "a", 7);
2681 let p = build_command(
2682 &spec(AgentKind::Antigravity, None),
2683 &seat,
2684 &Invocation {
2685 scope: crate::agent::InvocationScope::Repository,
2686 coordination: None,
2687 fetched_base: None,
2688 cwd: Path::new("."),
2689 prompt: "p",
2690 timeout: Duration::from_secs(3600),
2691 allow_write: true,
2692 unsandboxed: false,
2693 sessions: true,
2694 artifacts: Path::new("/art"),
2695 stem: "t",
2696 run: "test-run",
2697 node: "test",
2698 cache_dir: None,
2699 attachments: &[],
2700 writable: &[],
2701 },
2702 Path::new("/art/p.md"),
2703 )
2704 .unwrap();
2705 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2706 }
2707
2708 #[test]
2715 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2716 let mut s = spec(AgentKind::Antigravity, None);
2717 s.prompt_delivery = Some(Delivery::Argv);
2718 let seat = SeatState::new("talk", "a", 7);
2719 let atts = [PathBuf::from("/art/attachments/abc.png")];
2720
2721 let without = build_command(
2722 &s,
2723 &seat,
2724 &Invocation {
2725 attachments: &[],
2726 writable: &[],
2727 ..inv(Path::new("."), Path::new("/art"), true)
2728 },
2729 Path::new("/art/p.md"),
2730 )
2731 .unwrap();
2732 assert!(
2733 !without.argv.iter().any(|a| a == "--add-dir"),
2734 "no attachment, no reason to widen the sandbox: {without:?}"
2735 );
2736
2737 let with = build_command(
2738 &s,
2739 &seat,
2740 &Invocation {
2741 attachments: &atts,
2742 writable: &[],
2743 ..inv(Path::new("."), Path::new("/art"), true)
2744 },
2745 Path::new("/art/p.md"),
2746 )
2747 .unwrap();
2748 assert!(
2749 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2750 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2751 );
2752 }
2753
2754 #[test]
2760 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2761 let seat = SeatState::new("plan", "a", 7);
2762 let atts = [
2763 PathBuf::from("/art/attachments/own.png"),
2764 PathBuf::from("/other-chat/attachments/inherited.png"),
2765 ];
2766
2767 let p = build_command(
2768 &spec(AgentKind::Antigravity, None),
2769 &seat,
2770 &Invocation {
2771 attachments: &atts,
2772 writable: &[],
2773 ..inv(Path::new("."), Path::new("/art"), true)
2774 },
2775 Path::new("/art/p.md"),
2776 )
2777 .unwrap();
2778
2779 assert!(
2780 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2781 "this conversation's own artifacts dir must still be granted: {p:?}"
2782 );
2783 assert!(
2784 p.argv
2785 .windows(2)
2786 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2787 "the inherited attachment's own directory must be granted too: {p:?}"
2788 );
2789 }
2790
2791 #[test]
2792 fn command_agents_get_placeholders_substituted() {
2793 let seat = SeatState::new("impl-A", "a", 7);
2794 let p = plan_for(AgentKind::Command, &seat, true);
2795 assert_eq!(p.argv[0], "echo");
2796 assert_eq!(p.argv[1], "impl-A");
2797 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2798 }
2799
2800 #[test]
2801 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2802 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2804 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2805 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2806 let out = extract(AgentKind::Claude, stdout);
2807 let quota = out.quota.as_ref().expect("rate limit must be detected");
2808 assert_eq!(
2809 quota.reset.as_deref(),
2810 Some("4:50am (Asia/Tokyo)"),
2811 "reset time read from the body"
2812 );
2813 }
2814
2815 #[test]
2816 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2817 let out = extract(
2818 AgentKind::Claude,
2819 r#"{"is_error":true,"result":"session limit reached"}"#,
2820 );
2821 let quota = out.quota.expect("rate limit detected without a reset");
2822 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2823 }
2824
2825 #[test]
2826 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2827 for kind in [AgentKind::Claude, AgentKind::Command] {
2828 for reply in [
2829 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2830 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2831 ] {
2832 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2833 }
2834 for reply in [
2835 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2836 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2837 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2838 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2839 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2840 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2841 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2842 ] {
2843 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2844 }
2845 }
2846 }
2847
2848 #[test]
2849 fn ordinary_failures_are_never_quota() {
2850 let claude_fail = extract(
2852 AgentKind::Claude,
2853 r#"{"is_error":true,"result":"account does not exist"}"#,
2854 );
2855 assert!(claude_fail.quota.is_none());
2856
2857 let cmd_fail = extract(AgentKind::Command, "boom");
2859 assert!(cmd_fail.quota.is_none());
2860
2861 let success = extract(
2863 AgentKind::Command,
2864 r#"{"is_error":false,"result":"session limit is fine"}"#,
2865 );
2866 assert!(success.quota.is_none());
2867 }
2868
2869 #[test]
2877 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2878 let stream = concat!(
2879 r#"{"type":"thread.started","thread_id":"t1"}"#,
2880 "\n",
2881 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"}}"#,
2882 "\n",
2883 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"}}"#,
2884 "\n",
2885 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2886 "\n",
2887 r#"{"type":"turn.completed"}"#,
2888 "\n",
2889 );
2890 let out = extract(AgentKind::Codex, stream);
2891 assert_eq!(out.text, "Both tests in the target pass.");
2892 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2893
2894 let paired = &out.commands[0];
2895 assert_eq!(paired.id, "item49");
2896 assert_eq!(paired.exit_code, Some(1));
2897 assert!(paired.description.contains("graph_cached_gate"));
2898 assert!(paired.result_summary.contains("1 failed"));
2899
2900 let solo = &out.commands[1];
2901 assert_eq!(solo.exit_code, Some(0));
2902
2903 assert!(
2907 out.commands
2908 .iter()
2909 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2910 "a failed run of the actual target must still be visible: {:?}",
2911 out.commands
2912 );
2913 }
2914
2915 #[test]
2916 fn command_agent_can_carry_the_claude_quota_shape() {
2917 let out = extract(
2918 AgentKind::Command,
2919 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2920 );
2921 assert!(
2922 out.quota.is_some(),
2923 "a wrapper emitting the claude shape counts as quota"
2924 );
2925 }
2926
2927 #[test]
2928 fn claude_json_result_is_extracted() {
2929 let out = extract(
2930 AgentKind::Claude,
2931 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2932 );
2933 assert_eq!(out.text, "all done");
2934 assert_eq!(out.session.as_deref(), Some("abc"));
2935 assert_eq!(out.status.as_deref(), Some("success"));
2936 }
2937
2938 #[test]
2953 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2954 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"}"#;
2955 let out = extract(AgentKind::Claude, stdout);
2956 assert_eq!(out.status.as_deref(), Some("success"));
2957 assert!(out.quota.is_none());
2958 assert!(!out.text.trim().is_empty());
2959
2960 let agent_out = AgentOutput {
2961 text: out.text.clone(),
2962 exit_code: Some(0),
2963 timed_out: false,
2964 duration_ms: 500,
2965 artifacts: Vec::new(),
2966 quota: out.quota,
2967 dropped: out.dropped,
2968 commands: out.commands,
2969 context_tokens: None,
2970 };
2971 assert!(
2972 agent_out.usable(),
2973 "the CLI turn itself ended cleanly and must read as usable"
2974 );
2975 assert!(
2976 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2977 "a clean CLI turn is not proof the node's own report ever arrived"
2978 );
2979 }
2980
2981 #[test]
2982 fn opencode_event_stream_is_concatenated() {
2983 let stream = concat!(
2984 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2985 "\n",
2986 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2987 "\n",
2988 "garbage line\n",
2989 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2990 "\n"
2991 );
2992 let out = extract(AgentKind::Opencode, stream);
2993 assert_eq!(out.text, "first\nsecond");
2994 assert_eq!(out.session.as_deref(), Some("ses_1"));
2995 }
2996
2997 #[test]
2998 fn agy_json_survives_a_leading_warning_line() {
2999 let stdout = concat!(
3000 "warning: --mode plan has no effect while slash commands are disabled.\n",
3001 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
3002 "\n"
3003 );
3004 let out = extract(AgentKind::Antigravity, stdout);
3005 assert_eq!(out.text, "persimmon");
3006 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
3007 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
3008 }
3009
3010 const AGY_DROPPED: &str = concat!(
3017 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
3018 r#""response":"","error":"the connection to the agent was interrupted before "#,
3019 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
3020 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
3021 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
3022 r#""total_tokens":274380}}"#
3023 );
3024
3025 const AGY_QUOTA_OUT: &str = concat!(
3027 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
3028 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
3029 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
3030 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
3031 );
3032 const AGY_QUOTA_ERR: &str = concat!(
3033 "error: Individual quota reached. Resets in 1h2m49s.\n",
3034 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3035 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3036 r#""retryable":true}"#,
3037 "\n"
3038 );
3039
3040 #[test]
3041 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3042 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3043 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3044 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3045 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3046 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3048 assert!(stderr_only.reset.is_none());
3049 }
3050
3051 #[test]
3052 fn ordinary_agy_failures_are_not_a_quota() {
3053 assert!(agy_quota(AGY_DROPPED, "").is_none());
3054 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3055 assert!(
3056 agy_quota(
3057 "",
3058 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3059 )
3060 .is_none()
3061 );
3062 assert!(
3063 agy_quota(
3064 "",
3065 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3066 )
3067 .is_none()
3068 );
3069 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3070 }
3071
3072 #[test]
3073 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3074 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3075 let dropped = out.dropped.expect("recognised as undelivered work");
3076 assert_eq!(dropped.output_tokens, 14267);
3077 assert!(
3078 dropped.why.contains("subscriber fell behind"),
3079 "the CLI's own words are kept for the record: {}",
3080 dropped.why
3081 );
3082 assert_eq!(
3085 out.session.as_deref(),
3086 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3087 );
3088 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3089 }
3090
3091 #[test]
3092 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3093 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3097 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3098
3099 let answered = concat!(
3102 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3103 r#""usage":{"output_tokens":10}}"#
3104 );
3105 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3106
3107 let ok = concat!(
3109 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3110 r#""usage":{"output_tokens":10}}"#
3111 );
3112 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3113 }
3114
3115 #[test]
3116 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3117 let out = AgentOutput {
3118 text: String::new(),
3119 exit_code: Some(1),
3120 timed_out: false,
3121 duration_ms: 431_194,
3122 artifacts: Vec::new(),
3123 quota: None,
3124 dropped: Some(Dropped {
3125 why: "subscriber fell behind updates".to_owned(),
3126 output_tokens: 14267,
3127 }),
3128 commands: Vec::new(),
3129 context_tokens: None,
3130 };
3131 assert!(!out.usable());
3132 assert!(out.work_undelivered());
3133 assert!(!out.quota_exhausted());
3136 }
3137
3138 #[test]
3139 fn non_json_stdout_falls_back_to_raw_text() {
3140 let out = extract(AgentKind::Antigravity, "plain answer\n");
3141 assert_eq!(out.text, "plain answer");
3142 assert!(out.session.is_none());
3143 }
3144
3145 #[tokio::test]
3146 async fn command_agent_round_trip_writes_artifacts() {
3147 let dir = tempfile::tempdir().unwrap();
3148 let art = dir.path().join("artifacts");
3149 let mut seat = SeatState::new("impl-A", "a", 7);
3150 let s = command_helper("reply");
3151 let out = invoke(
3152 &s,
3153 &mut seat,
3154 &Invocation {
3155 scope: crate::agent::InvocationScope::Repository,
3156 coordination: None,
3157 fetched_base: None,
3158 cwd: dir.path(),
3159 prompt: "unused",
3160 timeout: Duration::from_secs(30),
3161 allow_write: true,
3162 unsandboxed: false,
3163 sessions: true,
3164 artifacts: &art,
3165 stem: "impl-A",
3166 run: "test-run",
3167 node: "test",
3168 cache_dir: None,
3169 attachments: &[],
3170 writable: &[],
3171 },
3172 )
3173 .await
3174 .unwrap();
3175 assert!(out.usable(), "{out:?}");
3176 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3177 assert_eq!(seat.turns, 1);
3178 assert!(art.join("impl-A.prompt.md").is_file());
3179 assert!(art.join("impl-A.out").is_file());
3180 }
3181
3182 #[tokio::test]
3183 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3184 crate::run::pin_test_home();
3185 let dir = tempfile::tempdir().unwrap();
3189 let cache = dir.path().join("magi-cache");
3190 let mut seat = SeatState::new("impl-A", "a", 7);
3191 let s = command_helper("cache");
3192 let out = invoke(
3193 &s,
3194 &mut seat,
3195 &Invocation {
3196 scope: crate::agent::InvocationScope::Repository,
3197 coordination: None,
3198 fetched_base: None,
3199 cwd: dir.path(),
3200 prompt: "unused",
3201 timeout: Duration::from_secs(30),
3202 allow_write: true,
3203 unsandboxed: false,
3204 sessions: true,
3205 artifacts: &dir.path().join("artifacts"),
3206 stem: "cache",
3207 run: "test-run",
3208 node: "test",
3209 cache_dir: Some(&cache),
3210 attachments: &[],
3211 writable: &[],
3212 },
3213 )
3214 .await
3215 .unwrap();
3216 assert!(out.usable(), "{out:?}");
3217 assert!(
3218 out.text.contains(cache.to_string_lossy().as_ref()),
3219 "the seat must see CARGO_TARGET_DIR = the shared cache"
3220 );
3221 }
3222
3223 #[tokio::test]
3224 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3225 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3233 unsafe {
3238 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3239 }
3240 let dir = tempfile::tempdir().unwrap();
3241 let mut seat = SeatState::new("review-1", "a", 7);
3242 let s = command_helper("no-cache");
3243 let result = invoke(
3244 &s,
3245 &mut seat,
3246 &Invocation {
3247 scope: crate::agent::InvocationScope::Repository,
3248 coordination: None,
3249 fetched_base: None,
3250 cwd: dir.path(),
3251 prompt: "unused",
3252 timeout: Duration::from_secs(30),
3253 allow_write: false,
3254 unsandboxed: false,
3255 sessions: true,
3256 artifacts: &dir.path().join("artifacts"),
3257 stem: "no-cache",
3258 run: "test-run",
3259 node: "test",
3260 cache_dir: None,
3261 attachments: &[],
3262 writable: &[],
3263 },
3264 )
3265 .await;
3266 unsafe {
3271 match &previous {
3272 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3273 None => std::env::remove_var("CARGO_TARGET_DIR"),
3274 }
3275 }
3276 let out = result.unwrap();
3277 assert!(out.usable(), "{out:?}");
3278 assert!(
3279 out.text.contains("ABSENT"),
3280 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3281 out.text
3282 );
3283 }
3284
3285 #[tokio::test]
3286 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3287 let dir = tempfile::tempdir().unwrap();
3288 let mut seat = SeatState::new("impl-A", "a", 7);
3289 let s = command_helper("ignore-stdin");
3292 let big = "x".repeat(1_000_000);
3293 let out = invoke(
3294 &s,
3295 &mut seat,
3296 &Invocation {
3297 scope: crate::agent::InvocationScope::Repository,
3298 coordination: None,
3299 fetched_base: None,
3300 cwd: dir.path(),
3301 prompt: &big,
3302 timeout: Duration::from_secs(60),
3303 allow_write: true,
3304 unsandboxed: false,
3305 sessions: true,
3306 artifacts: &dir.path().join("artifacts"),
3307 stem: "big",
3308 run: "test-run",
3309 node: "test",
3310 cache_dir: None,
3311 attachments: &[],
3312 writable: &[],
3313 },
3314 )
3315 .await
3316 .unwrap();
3317 assert!(out.usable(), "{out:?}");
3318 assert!(out.text.contains("done"), "{}", out.text);
3319 }
3320
3321 #[tokio::test]
3322 async fn agent_turn_cap_limits_a_direct_invocation() {
3323 let dir = tempfile::tempdir().unwrap();
3324 let artifacts = dir.path().join("artifacts");
3325 let mut seat = SeatState::new("impl-A", "helper", 7);
3326 let mut s = command_helper("sleep");
3327 s.max_turn_secs = Some(2);
3328 let out = invoke(&s, &mut seat, &inv(dir.path(), &artifacts, false))
3329 .await
3330 .unwrap();
3331 assert!(out.timed_out, "{out:?}");
3332 assert!(out.duration_ms < 20_000, "{out:?}");
3335 }
3336
3337 #[tokio::test]
3338 async fn timeout_is_reported_not_hung() {
3339 let dir = tempfile::tempdir().unwrap();
3340 let mut seat = SeatState::new("impl-A", "a", 7);
3341 let s = command_helper("sleep");
3342 let out = invoke(
3343 &s,
3344 &mut seat,
3345 &Invocation {
3346 scope: crate::agent::InvocationScope::Repository,
3347 coordination: None,
3348 fetched_base: None,
3349 cwd: dir.path(),
3350 prompt: "unused",
3351 timeout: Duration::from_millis(300),
3352 allow_write: true,
3353 unsandboxed: false,
3354 sessions: true,
3355 artifacts: &dir.path().join("artifacts"),
3356 stem: "slow",
3357 run: "test-run",
3358 node: "test",
3359 cache_dir: None,
3360 attachments: &[],
3361 writable: &[],
3362 },
3363 )
3364 .await
3365 .unwrap();
3366 assert!(out.timed_out);
3367 assert!(!out.usable());
3368 }
3369
3370 #[tokio::test]
3371 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3372 let dir = tempfile::tempdir().unwrap();
3378 let artifacts = dir.path().join("artifacts");
3379 let mut seat = SeatState::new("impl-A", "a", 7);
3380 let s = command_helper("chatty-sleep");
3381 let out = invoke(
3382 &s,
3383 &mut seat,
3384 &Invocation {
3385 scope: crate::agent::InvocationScope::Repository,
3386 coordination: None,
3387 fetched_base: None,
3388 cwd: dir.path(),
3389 prompt: "unused",
3390 timeout: Duration::from_secs(10),
3395 allow_write: true,
3396 unsandboxed: false,
3397 sessions: true,
3398 artifacts: &artifacts,
3399 stem: "chatty",
3400 run: "test-run",
3401 node: "test",
3402 cache_dir: None,
3403 attachments: &[],
3404 writable: &[],
3405 },
3406 )
3407 .await
3408 .unwrap();
3409
3410 assert!(out.timed_out, "{out:?}");
3411 assert!(!out.usable(), "a cut-off answer is still not an answer");
3412 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3413 assert!(
3414 recorded.contains("i-said-something"),
3415 "the artifact must keep what arrived before the kill, got {recorded:?}"
3416 );
3417 assert!(
3418 out.text.contains("i-said-something"),
3419 "and the graph must be able to see it too, got {:?}",
3420 out.text
3421 );
3422 }
3423
3424 #[test]
3425 fn missing_programs_reports_command_binaries() {
3426 let mut s = spec(AgentKind::Command, None);
3427 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3428 assert_eq!(
3429 missing_programs(&[s]),
3430 ["definitely-not-a-real-binary-xyz".to_owned()]
3431 );
3432 }
3433
3434 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3435 AgentSpec {
3436 id: id.to_owned(),
3437 kind,
3438 model: None,
3439 command: Vec::new(),
3440 extra_args: Vec::new(),
3441 env: BTreeMap::new(),
3442 prompt_delivery: None,
3443 max_turn_secs: None,
3444 }
3445 }
3446
3447 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3450 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3451 }
3452
3453 #[test]
3454 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3455 let agents = [
3456 pick_spec("oc", AgentKind::Opencode),
3457 pick_spec("opus", AgentKind::Claude),
3458 pick_spec("agy", AgentKind::Antigravity),
3459 ];
3460 let got = pick(&agents, None, &without(&[])).expect("a pick");
3461 assert_eq!(got.id, "opus");
3462 }
3463
3464 #[test]
3465 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3466 let agents = [
3467 pick_spec("opus", AgentKind::Claude),
3468 pick_spec("oc", AgentKind::Opencode),
3469 pick_spec("agy", AgentKind::Antigravity),
3470 ];
3471 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3472 assert_eq!(got.id, "agy");
3473 }
3474
3475 #[test]
3476 fn pick_on_an_empty_roster_says_what_to_install() {
3477 let msg = pick(&[], None, &without(&[]))
3478 .expect_err("nobody to ask")
3479 .to_string();
3480 assert!(msg.contains("roster is empty"), "{msg}");
3481 assert!(msg.contains("claude"), "{msg}");
3482 assert!(msg.contains("magi.toml"), "{msg}");
3483 }
3484
3485 #[test]
3486 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3487 let agents = [
3488 pick_spec("opus", AgentKind::Claude),
3489 pick_spec("oc", AgentKind::Opencode),
3490 ];
3491 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3492 let msg = format!("{err:#}");
3493 assert!(msg.contains("claude"), "{msg}");
3494 assert!(msg.contains("opencode"), "{msg}");
3495 }
3496
3497 #[test]
3498 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3499 let agents = [
3500 pick_spec("opus", AgentKind::Claude),
3501 pick_spec("oc", AgentKind::Opencode),
3502 ];
3503 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3504 assert_eq!(got.id, "oc");
3505 }
3506
3507 #[test]
3508 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3509 let agents = [
3510 pick_spec("opus", AgentKind::Claude),
3511 pick_spec("oc", AgentKind::Opencode),
3512 ];
3513 let msg = pick(&agents, Some("gemini"), &without(&[]))
3514 .expect_err("no such agent")
3515 .to_string();
3516 assert!(msg.contains("gemini"), "{msg}");
3517 assert!(msg.contains("opus, oc"), "{msg}");
3518 }
3519
3520 #[test]
3521 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3522 let agents = [
3523 pick_spec("opus", AgentKind::Claude),
3524 pick_spec("oc", AgentKind::Opencode),
3525 ];
3526 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3527 .expect_err("must not silently substitute another model")
3528 .to_string();
3529 assert!(msg.contains("opencode"), "{msg}");
3530 assert!(msg.contains("--agent"), "{msg}");
3531 }
3532
3533 fn named(id: &str) -> AgentSpec {
3534 AgentSpec {
3535 id: id.to_owned(),
3536 ..spec(AgentKind::Command, None)
3537 }
3538 }
3539
3540 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3541 AgentOutput {
3542 text: text.to_owned(),
3543 exit_code: Some(exit),
3544 timed_out: false,
3545 duration_ms: 0,
3546 artifacts: Vec::new(),
3547 quota: quota.then_some(Quota { reset: None }),
3548 dropped: None,
3549 commands: Vec::new(),
3550 context_tokens: None,
3551 }
3552 }
3553
3554 #[test]
3555 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3556 let agents = [named("a"), named("b"), named("c")];
3557 let all = |_: &AgentSpec| true;
3558 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3559 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3560 assert_eq!(
3561 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3562 ["c", "a"]
3563 );
3564
3565 let one = AgentChoice::from("b");
3566 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3567 assert_eq!(got.len(), 1);
3568 assert_eq!(got[0].id, "b");
3569
3570 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3571 assert_eq!(got.len(), 1, "unset keeps pick's default");
3572 assert_eq!(got[0].id, "a");
3573 let empty = AgentChoice::Chain(Vec::new());
3574 assert_eq!(
3575 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3576 "a"
3577 );
3578 }
3579
3580 #[test]
3581 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3582 let agents = [named("a"), named("b")];
3583 let not_a = |s: &AgentSpec| s.id != "a";
3584 let chain = AgentChoice::Chain(
3585 ["a", "ghost", "b", "b"]
3586 .iter()
3587 .map(|s| (*s).to_owned())
3588 .collect(),
3589 );
3590 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3591 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3592
3593 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3594 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3595 assert_eq!(
3596 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3597 ["b", "a"]
3598 );
3599 }
3600
3601 #[test]
3602 fn a_chain_with_nothing_runnable_names_the_role() {
3603 let agents = [named("a")];
3604 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3605 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3606 .unwrap_err()
3607 .to_string();
3608 assert!(err.contains("conductor"), "{err}");
3609 }
3610
3611 #[test]
3612 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3613 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3614 assert!(chain_advances(&Ok(output("limit", 0, true))));
3615 assert!(chain_advances(&Ok(output("", 0, false))));
3616 assert!(chain_advances(&Ok(output("x", 1, false))));
3617 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3618 }
3619
3620 #[test]
3621 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3622 for out in [
3623 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3624 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3625 r#"{"result":"ok"}"#,
3626 "not json",
3627 ] {
3628 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3629 }
3630 }
3631
3632 #[test]
3633 fn opencode_context_tokens_take_the_last_step_finish() {
3634 let out = concat!(
3635 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3636 "\n",
3637 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3638 "\n",
3639 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3640 "\n"
3641 );
3642 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3643 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3644 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3645 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3646 }
3647
3648 #[test]
3649 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3650 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3651 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3652 }
3653
3654 #[test]
3655 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3656 let out = concat!(
3657 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3658 "\n"
3659 );
3660 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3661 }
3662
3663 #[test]
3664 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3665 let out = concat!(
3666 r#"{"type":"session","id":"s"}"#,
3667 "\n",
3668 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3669 "\n",
3670 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3671 "\n"
3672 );
3673 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3674 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3675 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3676 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3677 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3678 }
3679
3680 #[test]
3681 fn command_agents_report_no_context_tokens() {
3682 let out = r#"{"usage":{"input_tokens":5}}"#;
3683 assert_eq!(context_tokens(AgentKind::Command, out), None);
3684 }
3685}