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
347const PIPE_GRACE: Duration = Duration::from_secs(3);
352
353type Captured = Arc<Mutex<Vec<u8>>>;
355
356fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
366where
367 R: tokio::io::AsyncRead + Unpin + Send + 'static,
368{
369 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
370 let Some(mut pipe) = pipe else {
371 return (buf, None);
372 };
373 let sink = Arc::clone(&buf);
374 let handle = tokio::spawn(async move {
375 let mut chunk = [0u8; 8192];
376 loop {
377 match pipe.read(&mut chunk).await {
378 Ok(0) | Err(_) => break,
379 Ok(n) => {
380 if let Ok(mut guard) = sink.lock() {
381 guard.extend_from_slice(&chunk[..n]);
382 }
383 }
384 }
385 }
386 });
387 (buf, Some(handle))
388}
389
390async fn collect(
395 buf: &Captured,
396 handle: Option<tokio::task::JoinHandle<()>>,
397 grace: Duration,
398) -> String {
399 if let Some(handle) = handle {
400 if tokio::time::timeout(grace, handle).await.is_err() {
401 tracing::debug!("a pipe is still held open after the child exited");
402 }
403 }
404 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
405 String::from_utf8_lossy(&bytes).into_owned()
406}
407
408pub async fn invoke(
410 spec: &AgentSpec,
411 seat: &mut SeatState,
412 inv: &Invocation<'_>,
413) -> Result<AgentOutput> {
414 let lease = if inv.scope == InvocationScope::ConsultationInvestigation {
418 crate::repo_lock::SeatLease::try_reader(inv.cwd, inv.coordination).await?
419 } else if repository_seat(inv) {
420 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
421 .await?
422 } else {
423 None
424 };
425 invoke_leased(spec, seat, inv, lease, || Ok(())).await
426}
427
428pub(crate) async fn invoke_leased(
431 spec: &AgentSpec,
432 seat: &mut SeatState,
433 inv: &Invocation<'_>,
434 lease: Option<crate::repo_lock::SeatLease>,
435 start_check: impl FnOnce() -> Result<()> + Send,
436) -> Result<AgentOutput> {
437 let readonly = inv.scope == InvocationScope::ConsultationInvestigation;
438 let inv = &Invocation {
439 allow_write: inv.allow_write && !readonly,
440 unsandboxed: inv.unsandboxed && inv.scope == InvocationScope::Repository,
441 writable: if readonly { &[] } else { inv.writable },
442 ..inv.clone()
443 };
444 let scope = match inv.scope {
445 InvocationScope::ConsultationControl => Some(
446 "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.",
447 ),
448 InvocationScope::ConsultationInvestigation => Some(
449 "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.",
450 ),
451 InvocationScope::Repository if inv.node == "chat" => Some(
452 "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.",
453 ),
454 InvocationScope::Repository => None,
455 };
456 let mut prompt = inv.prompt.to_owned();
457 if let Some(scope) = scope {
458 prompt.push_str("\n\n");
459 prompt.push_str(scope);
460 if inv.scope == InvocationScope::ConsultationControl {
461 prompt.push_str("\n\n");
462 prompt.push_str(crate::prompt::CONSULT_WRITES);
463 }
464 }
465 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
466 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()));
467 }
468 let home = repository_writer(inv).then(crate::run::home);
471 let mut writable = inv.writable.to_vec();
472 if let Some(home) = &home {
473 for root in worktree_writable_roots(inv.cwd, home).await? {
474 if !writable.contains(&root) {
475 writable.push(root);
476 }
477 }
478 }
479 if repository_writer(inv) {
480 prompt.push_str("\n\n");
481 prompt.push_str(&editing_access_note(
482 spec.kind,
483 inv.unsandboxed,
484 inv.fetched_base,
485 ));
486 }
487 let inv = &Invocation {
488 writable: &writable,
489 prompt: &prompt,
490 ..inv.clone()
491 };
492 tokio::fs::create_dir_all(inv.artifacts)
493 .await
494 .with_context(|| format!("create {}", inv.artifacts.display()))?;
495 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
496 tokio::fs::write(&prompt_path, inv.prompt)
497 .await
498 .with_context(|| format!("write {}", prompt_path.display()))?;
499
500 let plan = build_command(spec, seat, inv, &prompt_path)?;
501 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
502 tokio::fs::write(
504 inv.artifacts.join(format!("{}.access.json", inv.stem)),
505 serde_json::to_vec_pretty(&serde_json::json!({
506 "launcher_version": env!("CARGO_PKG_VERSION"),
507 "kind": spec.kind,
508 "allow_write": inv.allow_write,
509 "unsandboxed": inv.unsandboxed,
510 "requested_network_access": if spec.kind == AgentKind::Codex && repository_writer(inv) && !inv.unsandboxed {
511 Some(false)
512 } else { None },
513 "writable_roots": inv.writable,
514 "fetched_base": inv.fetched_base,
515 }))?,
516 )
517 .await
518 .context("write seat access evidence")?;
519
520 let started = Instant::now();
521 let child_path = spec
524 .env
525 .iter()
526 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
527 .map(|(_, v)| std::ffi::OsString::from(v))
528 .or_else(|| std::env::var_os("PATH"))
529 .unwrap_or_default();
530 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
531 || plan.argv[0].clone().into(),
532 std::path::PathBuf::into_os_string,
533 );
534 let mut cmd = Command::new(program);
535 cmd.args(&plan.argv[1..])
536 .current_dir(inv.cwd)
537 .envs(&spec.env)
538 .env("MAGI_SEAT", &seat.key)
539 .env("MAGI_TURN", seat.turns.to_string())
540 .env("MAGI_RUN", inv.run)
541 .env("MAGI_NODE", inv.node)
542 .env(
543 "MAGI_TURN_SCOPE",
544 match inv.scope {
545 InvocationScope::ConsultationControl => "consultation-control",
546 InvocationScope::ConsultationInvestigation => "consultation-investigation",
547 InvocationScope::Repository => "repository",
548 },
549 )
550 .env("MAGI_PROMPT_FILE", &prompt_path)
551 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
552 .env("GIT_TERMINAL_PROMPT", "0")
553 .stdin(if plan.stdin.is_some() {
554 Stdio::piped()
555 } else {
556 Stdio::null()
557 })
558 .stdout(Stdio::piped())
559 .stderr(Stdio::piped())
560 .kill_on_drop(true)
561 .quiet();
564 if let Some(home) = home {
565 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
568 }
569 if let Some(cache) = inv.cache_dir {
570 cmd.env("CARGO_TARGET_DIR", cache);
573 } else {
574 cmd.env_remove("CARGO_TARGET_DIR");
582 }
583
584 #[cfg(unix)]
585 if lease.is_some() {
586 cmd.process_group(0);
587 }
588 start_check()?;
591 let child = cmd
592 .spawn()
593 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
594 let mut running = crate::seat_process::Running::new(child, lease);
596 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
600 tokio::spawn(async move {
601 sink.write_all(body.as_bytes()).await.ok();
602 sink.shutdown().await.ok();
603 });
604 }
605
606 let (out_buf, out_reader) = drain(running.child().stdout.take());
623 let (err_buf, err_reader) = drain(running.child().stderr.take());
624
625 let (code, timed_out) = match tokio::time::timeout(inv.timeout, running.child().wait()).await {
626 Ok(res) => {
627 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
628 (status.code(), false)
629 }
630 Err(_) => {
631 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
632 if !running.coordinated() {
634 running
635 .child()
636 .kill()
637 .await
638 .context("stop timed-out seat")?;
639 }
640 (None, true)
641 }
642 };
643 running.finish().await;
644
645 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
650 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
651
652 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
653 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
654 tokio::fs::write(&out_path, &stdout).await.ok();
655 tokio::fs::write(&err_path, &stderr).await.ok();
656
657 let mut extracted = extract(spec.kind, &stdout);
658 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
659 extracted.quota = agy_quota(&stdout, &stderr);
660 }
661 if let Some(quota) = &extracted.quota {
662 extracted.dropped = None;
665 tracing::warn!(
666 seat = %seat.key,
667 agent = %spec.id,
668 reset = ?quota.reset,
669 "agent is out of quota"
670 );
671 }
672 if let Some(session) = extracted.session {
673 match spec.kind {
674 AgentKind::Claude => seat.claude_session = Some(session),
675 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
676 seat.captured_session = Some(session);
677 }
678 AgentKind::Command => {}
679 }
680 }
681 if let Some(status) = &extracted.status
682 && !status.eq_ignore_ascii_case("success")
683 {
684 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
685 }
686 let text = if extracted.text.trim().is_empty() {
687 if stdout.trim().is_empty() {
689 stderr.trim().to_owned()
690 } else {
691 stdout.trim().to_owned()
692 }
693 } else {
694 extracted.text
695 };
696 seat.turns += 1;
697
698 Ok(AgentOutput {
699 text,
700 exit_code: code,
701 timed_out,
702 duration_ms: started.elapsed().as_millis() as u64,
703 artifacts: vec![
704 file_name(&prompt_path),
705 file_name(&out_path),
706 file_name(&err_path),
707 ],
708 quota: extracted.quota,
709 dropped: extracted.dropped,
710 commands: extracted.commands,
711 context_tokens: extracted.context_tokens,
712 })
713}
714
715fn file_name(p: &Path) -> String {
716 p.file_name()
717 .unwrap_or_default()
718 .to_string_lossy()
719 .into_owned()
720}
721
722#[derive(Debug)]
724struct Plan {
725 argv: Vec<String>,
726 stdin: Option<String>,
727}
728
729fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
740 if matches!(kind, AgentKind::Antigravity) {
741 return format!("@{}", prompt_path.display());
742 }
743 format!(
744 "Read the file at {} and follow every instruction in it exactly. That \
745 file is your complete task description; this message contains nothing \
746 else.",
747 prompt_path.display()
748 )
749}
750
751fn build_command(
752 spec: &AgentSpec,
753 seat: &SeatState,
754 inv: &Invocation<'_>,
755 prompt_path: &Path,
756) -> Result<Plan> {
757 let mut argv: Vec<String> = Vec::new();
758 let mut stdin: Option<String> = None;
759 let delivery = spec.delivery();
760 let resuming = has_session(spec.kind, seat, inv.sessions);
761
762 match spec.kind {
763 AgentKind::Claude => {
764 argv.push("claude".to_owned());
769 argv.push("-p".to_owned());
770 argv.push("--output-format".to_owned());
771 argv.push("json".to_owned());
772 if let Some(m) = &spec.model {
773 argv.push("--model".to_owned());
774 argv.push(m.clone());
775 }
776 if inv.sessions {
777 let uuid = seat
778 .claude_session
779 .as_deref()
780 .context("claude seat is missing its session uuid")?;
781 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
782 argv.push(uuid.to_owned());
783 }
784 argv.push("--permission-mode".to_owned());
785 argv.push("bypassPermissions".to_owned());
786 if !inv.allow_write {
787 argv.push("--disallowed-tools".to_owned());
788 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
789 }
790 }
791 AgentKind::Opencode => {
792 argv.push("opencode".to_owned());
796 argv.push("run".to_owned());
797 argv.push("--format".to_owned());
798 argv.push("json".to_owned());
799 argv.push("--dir".to_owned());
800 argv.push(inv.cwd.to_string_lossy().into_owned());
801 argv.push("--auto".to_owned());
810 if let Some(m) = &spec.model {
811 argv.push("-m".to_owned());
812 argv.push(m.clone());
813 }
814 if resuming {
815 argv.push("-s".to_owned());
816 argv.push(
817 seat.captured_session
818 .clone()
819 .expect("has_session checked the id is present"),
820 );
821 }
822 }
823 AgentKind::Antigravity => {
824 argv.push("agy".to_owned());
825 argv.push("--output-format".to_owned());
826 argv.push("json".to_owned());
827 argv.push("--print-timeout".to_owned());
830 argv.push(format!("{}s", inv.timeout.as_secs()));
831 argv.push("--mode".to_owned());
832 argv.push(
833 if inv.allow_write {
834 "accept-edits"
835 } else {
836 "plan"
837 }
838 .to_owned(),
839 );
840 if inv.allow_write {
841 argv.push("--dangerously-skip-permissions".to_owned());
842 }
843 if let Some(m) = &spec.model {
844 argv.push("--model".to_owned());
845 argv.push(m.clone());
846 }
847 if resuming {
848 argv.push("--conversation".to_owned());
849 argv.push(
850 seat.captured_session
851 .clone()
852 .expect("has_session checked the id is present"),
853 );
854 }
855 let mut add_dirs: Vec<String> = Vec::new();
865 if delivery == Delivery::File || !inv.attachments.is_empty() {
866 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
867 }
868 for path in inv.attachments {
869 let Some(parent) = path.parent() else {
870 continue;
871 };
872 if parent.starts_with(inv.artifacts) {
873 continue;
874 }
875 let dir = parent.to_string_lossy().into_owned();
876 if !add_dirs.contains(&dir) {
877 add_dirs.push(dir);
878 }
879 }
880 if inv.allow_write {
881 for path in inv.writable {
882 let dir = path.to_string_lossy().into_owned();
883 if !add_dirs.contains(&dir) {
884 add_dirs.push(dir);
885 }
886 }
887 }
888 for dir in add_dirs {
889 argv.push("--add-dir".to_owned());
890 argv.push(dir);
891 }
892 }
893 AgentKind::Codex => {
894 argv.push("codex".to_owned());
900 argv.push("exec".to_owned());
901 argv.push("--json".to_owned());
902 argv.push("--skip-git-repo-check".to_owned());
905 argv.push("-C".to_owned());
906 argv.push(inv.cwd.to_string_lossy().into_owned());
907 if inv.allow_write && inv.unsandboxed {
916 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
917 } else {
918 argv.push("--sandbox".to_owned());
919 argv.push(
920 if inv.allow_write {
921 "workspace-write"
922 } else {
923 "read-only"
924 }
925 .to_owned(),
926 );
927 argv.push("-c".to_owned());
930 argv.push("approval_policy=\"never\"".to_owned());
931 if repository_writer(inv) {
932 argv.push("-c".to_owned());
933 argv.push("sandbox_workspace_write.network_access=false".to_owned());
934 }
935 if inv.allow_write && !inv.writable.is_empty() {
936 let roots: Vec<String> = inv
937 .writable
938 .iter()
939 .map(|p| p.to_string_lossy().into_owned())
940 .collect();
941 argv.push("-c".to_owned());
942 argv.push(format!(
943 "sandbox_workspace_write.writable_roots={}",
944 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
945 ));
946 }
947 }
948 if let Some(m) = &spec.model {
949 argv.push("-m".to_owned());
950 argv.push(m.clone());
951 }
952 if resuming {
958 argv.push("resume".to_owned());
959 argv.push(
960 seat.captured_session
961 .clone()
962 .expect("has_session checked the id is present"),
963 );
964 }
965 }
966 AgentKind::Omp => {
967 argv.push("omp".to_owned());
971 argv.push("-p".to_owned());
972 argv.push("--mode=json".to_owned());
973 argv.push("--auto-approve".to_owned());
983 if let Some(m) = &spec.model {
984 argv.push("--model".to_owned());
985 argv.push(m.clone());
986 }
987 if resuming {
993 argv.push("--resume".to_owned());
994 argv.push(
995 seat.captured_session
996 .clone()
997 .expect("has_session checked the id is present"),
998 );
999 }
1000 }
1001 AgentKind::Command => {
1002 if spec.command.is_empty() {
1007 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1008 }
1009 let vars: BTreeMap<&str, String> = BTreeMap::from([
1010 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1011 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1012 ("{label}", seat.key.clone()),
1013 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1014 ]);
1015 for raw in &spec.command {
1016 let mut arg = raw.clone();
1017 for (k, v) in &vars {
1018 if arg.contains(k) {
1019 arg = arg.replace(k, v);
1020 }
1021 }
1022 argv.push(arg);
1023 }
1024 }
1025 }
1026
1027 argv.extend(spec.extra_args.iter().cloned());
1028
1029 if spec.kind == AgentKind::Antigravity {
1032 argv.push("-p".to_owned());
1033 }
1034 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1037 argv.push("-".to_owned());
1038 }
1039 match delivery {
1040 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1041 argv.push(pointer(spec.kind, prompt_path));
1043 }
1044 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1045 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1046 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1047 }
1048
1049 Ok(Plan { argv, stdin })
1050}
1051
1052#[derive(Debug, Default)]
1054struct Extracted {
1055 text: String,
1056 session: Option<String>,
1057 status: Option<String>,
1058 quota: Option<Quota>,
1059 dropped: Option<Dropped>,
1060 commands: Vec<CommandEvidence>,
1061 context_tokens: Option<u64>,
1062}
1063
1064fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1066 let mut extracted = extract_answer(kind, stdout);
1067 extracted.context_tokens = context_tokens(kind, stdout);
1068 extracted
1069}
1070
1071fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1073 v.get(key).and_then(serde_json::Value::as_u64)
1074}
1075
1076fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1081 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1082 let base = first(input)?;
1083 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1084 Some(base.saturating_add(cached))
1085}
1086
1087fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1108 use serde_json::Value;
1109 let lines = || {
1110 stdout
1111 .lines()
1112 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1113 };
1114 match kind {
1115 AgentKind::Claude => None,
1116 AgentKind::Opencode => lines()
1117 .filter(|v| {
1118 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1120 || v.get("part")
1121 .and_then(|p| p.get("type"))
1122 .and_then(|t| t.as_str())
1123 == Some("step-finish")
1124 })
1125 .filter_map(|v| {
1126 let tokens = v.get("part")?.get("tokens")?;
1127 let base = uint(tokens, "input")?;
1128 let cache = tokens.get("cache");
1129 let cached = ["read", "write"]
1130 .iter()
1131 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1132 .sum::<u64>();
1133 Some(base.saturating_add(cached))
1134 })
1135 .next_back(),
1136 AgentKind::Antigravity => None,
1137 AgentKind::Codex => None,
1138 AgentKind::Omp => lines()
1139 .flat_map(|v| {
1140 match v.get("type").and_then(|t| t.as_str()) {
1143 Some("agent_end") => v
1144 .get("messages")
1145 .and_then(|m| m.as_array())
1146 .cloned()
1147 .unwrap_or_default(),
1148 Some("turn_end") | Some("message_end") => {
1149 v.get("message").cloned().into_iter().collect()
1150 }
1151 _ => Vec::new(),
1152 }
1153 })
1154 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1155 .filter_map(|m| {
1156 input_side(
1157 m.get("usage")?,
1158 &["input", "input_tokens"],
1159 &[
1160 "cacheRead",
1161 "cacheWrite",
1162 "cache_read_tokens",
1163 "cache_write_tokens",
1164 ],
1165 )
1166 })
1167 .next_back(),
1168 AgentKind::Command => None,
1169 }
1170}
1171
1172pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1175 extract_answer(kind, stdout).text
1176}
1177
1178fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1179 match kind {
1180 AgentKind::Claude => {
1181 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1182 return Extracted {
1183 text: stdout.trim().to_owned(),
1184 ..Extracted::default()
1185 };
1186 };
1187 Extracted {
1188 text: v
1189 .get("result")
1190 .and_then(|r| r.as_str())
1191 .unwrap_or_default()
1192 .to_owned(),
1193 session: v
1194 .get("session_id")
1195 .and_then(|s| s.as_str())
1196 .map(str::to_owned),
1197 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1198 if e {
1199 "error".to_owned()
1200 } else {
1201 "success".to_owned()
1202 }
1203 }),
1204 quota: claude_quota(&v),
1205 dropped: None,
1208 commands: Vec::new(),
1209 context_tokens: None,
1210 }
1211 }
1212 AgentKind::Opencode => {
1213 let mut text = String::new();
1215 let mut session = None;
1216 for line in stdout.lines() {
1217 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1218 continue;
1219 };
1220 if session.is_none() {
1221 session = v
1222 .get("sessionID")
1223 .and_then(|s| s.as_str())
1224 .map(str::to_owned);
1225 }
1226 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1227 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1228 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1229 {
1230 if !text.is_empty() {
1231 text.push('\n');
1232 }
1233 text.push_str(t);
1234 }
1235 }
1236 Extracted {
1237 text,
1238 session,
1239 status: None,
1240 quota: None,
1241 dropped: None,
1242 commands: Vec::new(),
1243 context_tokens: None,
1244 }
1245 }
1246 AgentKind::Antigravity => {
1247 let obj = stdout
1250 .lines()
1251 .rev()
1252 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1253 let Some(v) = obj else {
1254 return Extracted {
1255 text: stdout.trim().to_owned(),
1256 ..Extracted::default()
1257 };
1258 };
1259 Extracted {
1260 text: v
1261 .get("response")
1262 .and_then(|r| r.as_str())
1263 .unwrap_or_default()
1264 .trim()
1265 .to_owned(),
1266 session: v
1267 .get("conversation_id")
1268 .and_then(|s| s.as_str())
1269 .map(str::to_owned),
1270 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1271 quota: None,
1272 dropped: dropped_stream(&v),
1273 commands: Vec::new(),
1274 context_tokens: None,
1275 }
1276 }
1277 AgentKind::Codex => {
1278 let mut text = String::new();
1300 let mut session = None;
1301 let mut status = None;
1302 let mut commands = Vec::new();
1303 for line in stdout.lines() {
1304 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1305 continue;
1306 };
1307 match v.get("type").and_then(|t| t.as_str()) {
1308 Some("thread.started") => {
1309 session = v
1310 .get("thread_id")
1311 .and_then(|s| s.as_str())
1312 .map(str::to_owned);
1313 }
1314 Some("item.completed") => {
1315 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1316 match item.get("type").and_then(|t| t.as_str()) {
1317 Some("agent_message") => {
1318 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1319 text = t.trim().to_owned();
1320 }
1321 }
1322 Some("command_execution") => {
1323 commands.push(command_evidence(item));
1324 }
1325 _ => {}
1326 }
1327 }
1328 Some("turn.completed") => status = Some("success".to_owned()),
1329 Some("turn.failed") => status = Some("error".to_owned()),
1330 _ => {}
1331 }
1332 }
1333 Extracted {
1334 text,
1335 session,
1336 status,
1337 quota: None,
1338 dropped: None,
1339 commands,
1340 context_tokens: None,
1341 }
1342 }
1343 AgentKind::Omp => {
1344 let mut text = String::new();
1365 let mut session = None;
1366 for line in stdout.lines() {
1367 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1368 continue;
1369 };
1370 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1371 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1372 continue;
1373 }
1374 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1378 {
1379 Some("agent_end") => v
1380 .get("messages")
1381 .and_then(|m| m.as_array())
1382 .map(|m| m.iter().collect())
1383 .unwrap_or_default(),
1384 Some("turn_end") | Some("message_end") => {
1385 v.get("message").into_iter().collect()
1386 }
1387 _ => continue,
1388 };
1389 for message in messages {
1390 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1391 continue;
1392 }
1393 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1394 continue;
1395 };
1396 for part in parts {
1397 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1398 continue;
1399 }
1400 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1401 && !t.trim().is_empty()
1402 {
1403 text = t.trim().to_owned();
1404 }
1405 }
1406 }
1407 }
1408 Extracted {
1409 text,
1410 session,
1411 status: None,
1412 quota: None,
1413 dropped: None,
1414 commands: Vec::new(),
1415 context_tokens: None,
1416 }
1417 }
1418 AgentKind::Command => {
1419 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1424 let quota = parsed.as_ref().and_then(claude_quota);
1425 let dropped = parsed.as_ref().and_then(dropped_stream);
1428 Extracted {
1429 text: stdout.trim().to_owned(),
1430 session: None,
1431 status: None,
1432 quota,
1433 dropped,
1434 commands: Vec::new(),
1435 context_tokens: None,
1436 }
1437 }
1438 }
1439}
1440
1441fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1448 let description = match item.get("command") {
1449 Some(serde_json::Value::String(s)) => s.clone(),
1450 Some(serde_json::Value::Array(parts)) => parts
1451 .iter()
1452 .filter_map(|p| p.as_str())
1453 .collect::<Vec<_>>()
1454 .join(" "),
1455 _ => String::new(),
1456 };
1457 let result_summary = item
1458 .get("aggregated_output")
1459 .and_then(|o| o.as_str())
1460 .map(|s| tail_chars(s.trim(), 400))
1461 .unwrap_or_default();
1462 CommandEvidence {
1463 id: item
1464 .get("id")
1465 .and_then(|s| s.as_str())
1466 .unwrap_or_default()
1467 .to_owned(),
1468 description,
1469 exit_code: item
1470 .get("exit_code")
1471 .and_then(serde_json::Value::as_i64)
1472 .map(|e| e as i32),
1473 result_summary,
1474 source: "codex".to_owned(),
1475 }
1476}
1477
1478fn tail_chars(s: &str, max: usize) -> String {
1480 let count = s.chars().count();
1481 if count <= max {
1482 return s.to_owned();
1483 }
1484 s.chars().skip(count - max).collect()
1485}
1486
1487fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1494 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1495 if !is_err {
1496 return None;
1497 }
1498 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1499 let status = v.get("api_error_status");
1500 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1501 || status.and_then(serde_json::Value::as_str) == Some("429"))
1502 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1503 if !usage_limit && !result.to_lowercase().contains("session limit") {
1504 return None;
1505 }
1506 let reset = result
1509 .split("resets ")
1510 .nth(1)
1511 .map(str::trim)
1512 .filter(|s| !s.is_empty())
1513 .map(str::to_owned);
1514 Some(Quota { reset })
1515}
1516
1517fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1532 let from_stdout = stdout
1533 .lines()
1534 .rev()
1535 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1536 .and_then(|v| {
1537 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1538 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1539 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1540 .then(|| error.to_owned())
1541 });
1542 let from_stderr = || {
1543 stderr.lines().find_map(|l| {
1544 let v: serde_json::Value =
1545 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1546 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1547 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1548 (exhausted && code).then(|| {
1549 v.get("short_error")
1550 .and_then(|e| e.as_str())
1551 .unwrap_or_default()
1552 .to_owned()
1553 })
1554 })
1555 };
1556 let text = from_stdout.or_else(from_stderr)?;
1557 let reset = text
1558 .split_once("Resets ")
1559 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1560 .filter(|s| !s.is_empty())
1561 .map(str::to_owned);
1562 Some(Quota { reset })
1563}
1564
1565fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1596 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1597 if !status.eq_ignore_ascii_case("error") {
1598 return None;
1599 }
1600 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1601 if !response.trim().is_empty() {
1602 return None;
1604 }
1605 let produced = v
1606 .get("usage")
1607 .and_then(|u| u.get("output_tokens"))
1608 .and_then(serde_json::Value::as_u64)
1609 .unwrap_or(0);
1610 if produced == 0 {
1611 return None;
1613 }
1614 Some(Dropped {
1615 why: v
1616 .get("error")
1617 .and_then(|e| e.as_str())
1618 .unwrap_or("the CLI ended the stream without delivering its answer")
1619 .trim()
1620 .to_owned(),
1621 output_tokens: produced,
1622 })
1623}
1624
1625pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1627 let mut missing = Vec::new();
1628 for s in specs {
1629 let program = match s.kind {
1630 AgentKind::Command => s.command.first().map(String::as_str),
1631 other => other.program(),
1632 };
1633 if let Some(p) = program
1634 && !crate::config::which(p)
1635 && !Path::new(p).is_file()
1636 && !missing.iter().any(|m: &String| m == p)
1637 {
1638 missing.push(p.to_owned());
1639 }
1640 }
1641 missing
1642}
1643
1644pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1646 run_dir.join("artifacts")
1647}
1648
1649pub fn installed(spec: &AgentSpec) -> bool {
1651 spec.kind.program().is_none_or(crate::config::which)
1654}
1655
1656pub fn pick(
1678 agents: &[AgentSpec],
1679 want: Option<&str>,
1680 available: &dyn Fn(&AgentSpec) -> bool,
1681) -> Result<AgentSpec> {
1682 if let Some(id) = want {
1683 let spec = agents
1684 .iter()
1685 .find(|a| a.id == id)
1686 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1687 if !available(spec) {
1688 bail!(
1689 "agent `{}` needs `{}` on PATH; install it or pass a different \
1690 --agent",
1691 spec.id,
1692 spec.kind.program().unwrap_or("its command")
1693 );
1694 }
1695 return Ok(spec.clone());
1696 }
1697
1698 if agents.is_empty() {
1699 bail!(
1700 "the agent roster is empty, so there is nobody to ask: install one \
1701 of claude, opencode or agy - magi derives a roster from what is on \
1702 PATH - or add an [[agents]] entry to magi.toml."
1703 );
1704 }
1705
1706 if let Some(spec) = agents
1707 .iter()
1708 .find(|a| a.kind == AgentKind::Claude && available(a))
1709 {
1710 return Ok(spec.clone());
1711 }
1712
1713 agents
1714 .iter()
1715 .find(|a| available(a))
1716 .cloned()
1717 .with_context(|| {
1718 let missing = agents
1719 .iter()
1720 .filter_map(|a| a.kind.program())
1721 .collect::<Vec<_>>()
1722 .join(", ");
1723 format!(
1724 "no agent in the roster can be run here: install one of \
1725 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1726 you do have"
1727 )
1728 })
1729}
1730
1731pub fn pick_chain(
1739 agents: &[AgentSpec],
1740 choice: Option<&AgentChoice>,
1741 available: &dyn Fn(&AgentSpec) -> bool,
1742 role: &str,
1743) -> Result<Vec<AgentSpec>> {
1744 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1745 if wanted.is_empty() {
1746 return pick(agents, None, available)
1747 .map(|s| vec![s])
1748 .with_context(|| format!("choose an agent for the {role} role"));
1749 }
1750 let mut chain: Vec<AgentSpec> = Vec::new();
1751 for id in wanted {
1752 if chain.iter().any(|s| s.id == id) {
1753 continue;
1754 }
1755 match pick(agents, Some(id), available) {
1756 Ok(spec) => chain.push(spec),
1757 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1758 }
1759 }
1760 if chain.is_empty() {
1761 bail!(
1762 "[roles] {role} names no agent that can run here; the roster has {}",
1763 ids(agents)
1764 );
1765 }
1766 Ok(chain)
1767}
1768
1769pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1775 match outcome {
1776 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1777 Err(_) => true,
1778 Ok(out) => output_advances(out),
1779 }
1780}
1781
1782pub fn output_advances(out: &AgentOutput) -> bool {
1786 out.quota_exhausted() || !out.usable()
1787}
1788
1789fn ids(agents: &[AgentSpec]) -> String {
1790 if agents.is_empty() {
1791 return "no agents at all".to_owned();
1792 }
1793 agents
1794 .iter()
1795 .map(|a| a.id.clone())
1796 .collect::<Vec<_>>()
1797 .join(", ")
1798}
1799
1800#[cfg(test)]
1801mod tests {
1802 use super::*;
1803
1804 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1805
1806 fn command_helper(mode: &str) -> AgentSpec {
1809 AgentSpec {
1810 id: "helper".to_owned(),
1811 kind: AgentKind::Command,
1812 model: None,
1813 command: vec![
1814 std::env::current_exe()
1815 .expect("locate test helper")
1816 .to_string_lossy()
1817 .into_owned(),
1818 "--exact".to_owned(),
1819 "agent::tests::command_agent_test_helper".to_owned(),
1820 "--nocapture".to_owned(),
1821 ],
1822 extra_args: Vec::new(),
1823 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1824 prompt_delivery: None,
1825 }
1826 }
1827
1828 #[test]
1829 fn command_agent_test_helper() {
1830 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1831 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1832 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1833 Ok("no-cache") => println!(
1834 "{}",
1835 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1836 ),
1837 Ok("ignore-stdin") => println!("done"),
1838 Ok("chatty-sleep") => {
1839 println!("i-said-something");
1840 std::thread::sleep(Duration::from_secs(30));
1841 }
1842 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1843 Ok(other) => panic!("unknown command helper mode {other}"),
1844 Err(_) => {}
1845 }
1846 }
1847
1848 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1849 AgentSpec {
1850 id: "a".to_owned(),
1851 kind,
1852 model: model.map(str::to_owned),
1853 command: vec!["echo".to_owned(), "{label}".to_owned()],
1854 extra_args: Vec::new(),
1855 env: BTreeMap::new(),
1856 prompt_delivery: None,
1857 }
1858 }
1859
1860 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1861 Invocation {
1862 scope: crate::agent::InvocationScope::Repository,
1863 coordination: None,
1864 fetched_base: None,
1865 cwd,
1866 prompt: "do the thing",
1867 timeout: Duration::from_secs(900),
1868 allow_write,
1869 unsandboxed: false,
1870 sessions: true,
1871 artifacts: art,
1872 stem: "t",
1873 run: "test-run",
1874 node: "test",
1875 cache_dir: None,
1876 attachments: &[],
1877 writable: &[],
1878 }
1879 }
1880
1881 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1882 build_command(
1883 &spec(kind, None),
1884 seat,
1885 &inv(Path::new("."), Path::new("/art"), allow_write),
1886 Path::new("/art/p.md"),
1887 )
1888 .unwrap()
1889 }
1890
1891 #[test]
1892 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
1893 let missing = editing_access_note(AgentKind::Codex, false, None);
1894 assert!(missing.contains("No successful launcher fetch evidence"));
1895 let base = crate::run::FetchedBase {
1896 remote: "origin".into(),
1897 branch: "main".into(),
1898 commit: "0123456789abcdef".into(),
1899 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
1900 };
1901 let note = editing_access_note(AgentKind::Codex, false, Some(&base));
1902 for expected in [
1903 "origin/main",
1904 "0123456789abcdef",
1905 "2026-10-18T00:00:00Z",
1906 "Do not fetch again",
1907 "launcher saves eligible edits",
1908 "Specification questions remain valid",
1909 ] {
1910 assert!(note.contains(expected), "missing {expected}");
1911 }
1912 for kind in [
1913 AgentKind::Codex,
1914 AgentKind::Claude,
1915 AgentKind::Opencode,
1916 AgentKind::Antigravity,
1917 AgentKind::Omp,
1918 AgentKind::Command,
1919 ] {
1920 for unsandboxed in [false, true] {
1921 let scoped = editing_access_note(kind, unsandboxed, Some(&base));
1922 assert!(scoped.contains(&base.commit));
1923 assert!(scoped.contains("Do not fetch again"));
1924 let restricted = kind == AgentKind::Codex && !unsandboxed;
1925 for claim in [
1926 "Network access is restricted",
1927 "protected Git administration paths",
1928 "launcher saves eligible edits",
1929 "unavailable sandbox permissions",
1930 ] {
1931 assert_eq!(scoped.contains(claim), restricted, "{kind:?}/{unsandboxed}");
1932 assert_eq!(
1933 editing_access_note(kind, unsandboxed, None).contains(claim),
1934 restricted,
1935 "{kind:?}/{unsandboxed} without fetch evidence",
1936 );
1937 }
1938 }
1939 }
1940 let roundtrip: crate::run::FetchedBase =
1941 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
1942 assert_eq!(
1943 editing_access_note(AgentKind::Codex, false, Some(&roundtrip)),
1944 note
1945 );
1946 }
1947
1948 #[test]
1949 fn editing_network_policy_is_explicit_on_initial_and_resume() {
1950 for resumed in [false, true] {
1951 let mut seat = SeatState::new("impl-A", "a", 7);
1952 if resumed {
1953 seat.turns = 1;
1954 seat.captured_session = Some("fixture-thread".into());
1955 }
1956 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
1957 invocation.node = "implement";
1958 let plan = build_command(
1959 &spec(AgentKind::Codex, None),
1960 &seat,
1961 &invocation,
1962 Path::new("/art/p.md"),
1963 )
1964 .unwrap();
1965 let policy = plan
1966 .argv
1967 .iter()
1968 .position(|s| s == "sandbox_workspace_write.network_access=false")
1969 .unwrap();
1970 if resumed {
1971 assert!(policy < plan.argv.iter().position(|s| s == "resume").unwrap());
1972 }
1973 assert!(!plan.argv.iter().any(|s| s.contains("bypass")));
1974 invocation.node = "deputy";
1975 let deputy = build_command(
1976 &spec(AgentKind::Codex, None),
1977 &seat,
1978 &invocation,
1979 Path::new("/art/p.md"),
1980 )
1981 .unwrap();
1982 assert!(!deputy.argv.iter().any(|s| s.contains("network_access")));
1983 assert!(
1984 !plan_for(AgentKind::Codex, &seat, false)
1985 .argv
1986 .iter()
1987 .any(|s| s.contains("writable_roots"))
1988 );
1989 }
1990 }
1991
1992 #[test]
1993 fn claude_mints_then_resumes_the_same_uuid() {
1994 let mut seat = SeatState::new("judge-1", "a", 7);
1995 let uuid = seat.claude_session.clone().unwrap();
1996 let first = plan_for(AgentKind::Claude, &seat, true);
1997 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1998 assert!(!first.argv.iter().any(|a| a == "--resume"));
1999
2000 seat.turns = 1;
2001 let second = plan_for(AgentKind::Claude, &seat, true);
2002 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2003 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2004 }
2005
2006 #[test]
2007 fn read_only_seats_cannot_edit() {
2008 let seat = SeatState::new("judge-1", "a", 7);
2009 let claude = plan_for(AgentKind::Claude, &seat, false);
2010 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2011 assert!(
2012 !plan_for(AgentKind::Claude, &seat, true)
2013 .argv
2014 .iter()
2015 .any(|a| a == "--disallowed-tools")
2016 );
2017
2018 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2019 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2020 assert!(
2021 !agy.argv
2022 .iter()
2023 .any(|a| a == "--dangerously-skip-permissions")
2024 );
2025 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2026 assert!(
2027 agy_rw
2028 .argv
2029 .windows(2)
2030 .any(|w| w == ["--mode", "accept-edits"])
2031 );
2032 assert!(
2033 agy_rw
2034 .argv
2035 .iter()
2036 .any(|a| a == "--dangerously-skip-permissions")
2037 );
2038 let agy_prompt = agy_rw
2043 .argv
2044 .iter()
2045 .position(|a| a == "-p")
2046 .map(|i| agy_rw.argv[i + 1].clone())
2047 .expect("agy takes its prompt with -p");
2048 assert!(
2049 agy_prompt.starts_with('@'),
2050 "agy must get a file reference, got {agy_prompt:?}"
2051 );
2052 assert!(
2053 !agy_prompt.contains("Read the file at"),
2054 "the prose pointer is for CLIs with no file syntax"
2055 );
2056
2057 for allow_write in [false, true] {
2062 assert!(
2063 plan_for(AgentKind::Opencode, &seat, allow_write)
2064 .argv
2065 .iter()
2066 .any(|a| a == "--auto"),
2067 "opencode needs --auto even to read (allow_write = {allow_write})"
2068 );
2069 }
2070 }
2071
2072 #[test]
2075 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2076 let seat = SeatState::new("deputy-x", "a", 7);
2077 let roots = [PathBuf::from("/data/questions")];
2078 let mk = |allow_write: bool| {
2079 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2080 i.writable = &roots;
2081 build_command(
2082 &spec(AgentKind::Codex, None),
2083 &seat,
2084 &i,
2085 Path::new("/art/p.md"),
2086 )
2087 .unwrap()
2088 };
2089 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2090 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2091 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2092 }
2093
2094 #[tokio::test]
2095 async fn investigation_checks_cancellation_after_artifacts_before_spawning() {
2096 let tmp = tempfile::tempdir().unwrap();
2097 let artifacts = tmp.path().join("artifacts");
2098 let mut invocation = inv(tmp.path(), &artifacts, true);
2099 invocation.scope = InvocationScope::ConsultationInvestigation;
2100 invocation.unsandboxed = true;
2101 let mut seat = SeatState::new("chat", "fixture", 1);
2102 let error = invoke_leased(
2103 &spec(AgentKind::Command, None),
2104 &mut seat,
2105 &invocation,
2106 None,
2107 || {
2108 assert!(artifacts.join("t.prompt.md").exists());
2109 bail!("question closed during preparation")
2110 },
2111 )
2112 .await
2113 .unwrap_err();
2114 assert!(error.to_string().contains("question closed"));
2115 assert_eq!(seat.turns, 0);
2116 assert!(!artifacts.join("t.out").exists());
2117 }
2118
2119 #[test]
2120 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2121 let mut i = inv(Path::new("."), Path::new("/art"), true);
2122 for node in [
2123 "implement",
2124 "fix",
2125 "graft",
2126 "gate-fix",
2127 "land",
2128 "rebase",
2129 "chat",
2130 ] {
2131 i.node = node;
2132 assert!(repository_writer(&i), "{node}");
2133 i.allow_write = false;
2134 assert!(!repository_writer(&i), "{node}");
2135 i.allow_write = true;
2136 }
2137 for node in ["deputy", "judge", "review", "bump", "conduct"] {
2138 i.node = node;
2139 assert!(!repository_writer(&i), "{node}");
2140 }
2141 i.node = "chat";
2142 let roots = [PathBuf::from("/questions")];
2143 i.writable = &roots;
2144 assert!(
2145 repository_writer(&i),
2146 "roots alone do not select consultation"
2147 );
2148 i.scope = InvocationScope::ConsultationControl;
2149 assert!(!repository_writer(&i), "consultation keeps explicit grants");
2150 assert!(!repository_seat(&i));
2151 i.allow_write = false;
2152 i.writable = &[];
2153 assert!(!repository_seat(&i), "control scope needs no write roots");
2154 i.scope = InvocationScope::ConsultationInvestigation;
2155 assert!(repository_seat(&i));
2156 i.allow_write = true;
2157 assert!(
2158 !repository_writer(&i),
2159 "investigation never gains editing grants"
2160 );
2161 }
2162
2163 #[test]
2164 fn coordination_classification_is_independent_of_write_grants() {
2165 let roots = [PathBuf::from("/questions")];
2166 for node in [
2167 crate::queue::CHAT_NODE,
2168 "implement",
2169 "fix",
2170 "graft",
2171 "gate-fix",
2172 "land",
2173 "rebase",
2174 "review",
2175 "judge",
2176 "advise",
2177 "deputy",
2178 "waiter",
2179 "conduct",
2180 "dupes",
2181 ] {
2182 for allow_write in [false, true] {
2183 for writable in [&[][..], &roots[..]] {
2184 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2185 i.node = node;
2186 i.writable = writable;
2187 assert_eq!(
2188 repository_seat(&i),
2189 !matches!(
2190 node,
2191 crate::queue::CHAT_NODE | "deputy" | "conduct" | "dupes"
2192 ),
2193 "{node}, allow_write={allow_write}, writable={writable:?}"
2194 );
2195 if node == crate::queue::CHAT_NODE {
2196 assert_eq!(repository_writer(&i), allow_write);
2197 }
2198 }
2199 }
2200 }
2201 }
2202
2203 #[test]
2204 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2205 let roots = [
2206 PathBuf::from("/shared/git"),
2207 PathBuf::from("/state/questions"),
2208 ];
2209 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2210 for resumed in [false, true] {
2211 let mut seat = SeatState::new("impl-A", "a", 7);
2212 if resumed {
2213 seat.turns = 1;
2214 seat.captured_session = Some("session-id".into());
2215 }
2216 for allow_write in [false, true] {
2217 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2218 i.writable = &roots;
2219 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2220 .unwrap();
2221 for root in &roots {
2222 if kind == AgentKind::Antigravity {
2223 assert_eq!(
2224 plan.argv.windows(2).any(|w| {
2225 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2226 }),
2227 allow_write,
2228 "resumed={resumed}: {:?}",
2229 plan.argv
2230 );
2231 }
2232 assert_eq!(
2233 plan.argv
2234 .iter()
2235 .any(|arg| arg.contains(root.to_str().unwrap())),
2236 allow_write,
2237 "{kind:?}, resumed={resumed}: {:?}",
2238 plan.argv
2239 );
2240 }
2241 if kind == AgentKind::Codex && resumed {
2242 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2243 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2244 }
2245 }
2246 }
2247 }
2248 }
2249
2250 #[test]
2251 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
2252 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2253 let mut seat = SeatState::new("talk", "a", 7);
2254 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2255 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2256 i.unsandboxed = unsandboxed;
2257 build_command(
2258 &spec(AgentKind::Codex, None),
2259 seat,
2260 &i,
2261 Path::new("/art/p.md"),
2262 )
2263 .unwrap()
2264 };
2265
2266 let first = build(&seat, true, true);
2268 assert!(first.argv.iter().any(|a| a == BYPASS));
2269 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2270 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2271
2272 seat.captured_session = Some("thread-1".to_owned());
2274 seat.turns = 1;
2275 let resumed = build(&seat, true, true);
2276 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2277 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2278
2279 let ro = build(&seat, false, true);
2281 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2282 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2283 }
2284
2285 #[test]
2286 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2287 let mut seat = SeatState::new("judge-1", "a", 7);
2288
2289 let ro = plan_for(AgentKind::Codex, &seat, false);
2293 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2294 let rw = plan_for(AgentKind::Codex, &seat, true);
2295 assert!(
2296 rw.argv
2297 .windows(2)
2298 .any(|w| w == ["--sandbox", "workspace-write"])
2299 );
2300 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2303 ro_flagged.unsandboxed = true;
2304 let ro_flagged = build_command(
2305 &spec(AgentKind::Codex, None),
2306 &seat,
2307 &ro_flagged,
2308 Path::new("/art/p.md"),
2309 )
2310 .unwrap();
2311 assert!(
2312 ro_flagged
2313 .argv
2314 .windows(2)
2315 .any(|w| w == ["--sandbox", "read-only"])
2316 );
2317 for p in [&ro, &rw, &ro_flagged] {
2318 assert!(
2319 !p.argv
2320 .iter()
2321 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2322 "the bypass defeats the only enforced read-only mode we have"
2323 );
2324 assert!(
2326 p.argv
2327 .windows(2)
2328 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2329 "an unattended seat that asks for approval blocks until timeout"
2330 );
2331 }
2332
2333 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2335 assert_eq!(
2336 ro.argv.last().map(String::as_str),
2337 Some("-"),
2338 "without the `-` argument codex waits for a prompt it never gets"
2339 );
2340
2341 seat.turns = 1;
2345 assert!(!has_session(AgentKind::Codex, &seat, true));
2346 assert!(
2347 !plan_for(AgentKind::Codex, &seat, true)
2348 .argv
2349 .iter()
2350 .any(|a| a == "resume")
2351 );
2352 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2353 let resumed = plan_for(AgentKind::Codex, &seat, true);
2354 let at = resumed
2355 .argv
2356 .iter()
2357 .position(|a| a == "resume")
2358 .expect("resumes by subcommand");
2359 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2360 assert!(
2361 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2362 "every option precedes the subcommand"
2363 );
2364 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2365 }
2366
2367 #[test]
2370 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2371 let mut seat = SeatState::new("review-1", "a", 7);
2372
2373 let first = plan_for(AgentKind::Omp, &seat, false);
2377 assert!(first.argv.iter().any(|a| a == "-p"));
2378 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2379 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2380 assert!(
2381 !first.argv.iter().any(|a| a == "do the thing"),
2382 "the prompt reached argv, where Windows caps it"
2383 );
2384
2385 for allow_write in [false, true] {
2391 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2392 assert!(
2393 p.argv.iter().any(|a| a == "--auto-approve"),
2394 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2395 );
2396 assert!(
2397 !p.argv
2398 .iter()
2399 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2400 "nothing ever asks for the bypass"
2401 );
2402 }
2403
2404 seat.turns = 1;
2407 assert!(!has_session(AgentKind::Omp, &seat, true));
2408 assert!(
2409 !plan_for(AgentKind::Omp, &seat, true)
2410 .argv
2411 .iter()
2412 .any(|a| a == "--resume")
2413 );
2414 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2415 let resumed = plan_for(AgentKind::Omp, &seat, true);
2416 assert!(
2417 resumed
2418 .argv
2419 .windows(2)
2420 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2421 "a captured id is what makes the next turn a resume"
2422 );
2423 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2426 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2428 }
2429
2430 #[test]
2435 fn omp_takes_the_answer_without_an_agent_end_line() {
2436 let stream = concat!(
2437 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2438 "\n",
2439 r#"{"type":"agent_start"}"#,
2440 "\n",
2441 r#"{"type":"turn_start"}"#,
2442 "\n",
2443 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2444 "\n",
2445 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2446 "\n",
2447 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2448 "\n",
2449 );
2450 let out = extract(AgentKind::Omp, stream);
2451 assert_eq!(
2452 out.text, "{\"vote\":\"approve\"}",
2453 "the last assistant text block is the answer even with no agent_end"
2454 );
2455 assert_eq!(
2456 out.session.as_deref(),
2457 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2458 );
2459 }
2460
2461 #[test]
2465 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2466 let stream = concat!(
2467 r#"{"type":"session","version":3,"id":"s1"}"#,
2468 "\n",
2469 "{\"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問題ありません。\"}]}]}",
2470 "\n",
2471 );
2472 let out = extract(AgentKind::Omp, stream);
2473 assert_eq!(
2474 out.text, "## 判定\n\n問題ありません。",
2475 "the narration is not the answer, and non-ASCII survives intact"
2476 );
2477 assert_eq!(out.session.as_deref(), Some("s1"));
2478 }
2479
2480 #[test]
2483 fn omp_skips_non_json_lines() {
2484 let stream = concat!(
2485 "Warning: some omp notice\n",
2486 r#"{"type":"session","version":3,"id":"s2"}"#,
2487 "\n",
2488 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2489 "\n",
2490 "trailing junk",
2491 "\n",
2492 );
2493 let out = extract(AgentKind::Omp, stream);
2494 assert_eq!(out.text, "the answer");
2495 assert_eq!(out.session.as_deref(), Some("s2"));
2496 }
2497
2498 #[test]
2500 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2501 let stream = concat!(
2502 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2503 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2504 "\n",
2505 r#"{"type":"turn.started"}"#,
2506 "\n",
2507 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2508 "\n",
2509 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2510 "\n",
2511 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2512 "\n",
2513 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2514 "\n",
2515 );
2516 let out = extract(AgentKind::Codex, stream);
2517 assert_eq!(
2518 out.text, "{\"verdict\": \"ok\"}",
2519 "the last agent message is the answer; earlier ones narrate"
2520 );
2521 assert_eq!(
2522 out.session.as_deref(),
2523 Some("01a07440-4545-7492-85c1-024e3259a90a")
2524 );
2525 assert_eq!(out.status.as_deref(), Some("success"));
2526
2527 let failed = concat!(
2528 r#"{"type":"thread.started","thread_id":"t1"}"#,
2529 "\n",
2530 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2531 "\n",
2532 );
2533 assert_eq!(
2534 extract(AgentKind::Codex, failed).status.as_deref(),
2535 Some("error")
2536 );
2537 }
2538
2539 #[test]
2540 fn captured_sessions_resume_only_once_reported() {
2541 let mut seat = SeatState::new("impl-A", "a", 7);
2542 seat.turns = 1;
2543 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2544 assert!(!has_session(kind, &seat, true));
2545 let p = plan_for(kind, &seat, true);
2546 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2547 }
2548
2549 seat.captured_session = Some("sid".to_owned());
2550 assert!(has_session(AgentKind::Opencode, &seat, true));
2551 assert!(
2552 plan_for(AgentKind::Opencode, &seat, true)
2553 .argv
2554 .windows(2)
2555 .any(|w| w == ["-s", "sid"])
2556 );
2557 assert!(
2558 plan_for(AgentKind::Antigravity, &seat, true)
2559 .argv
2560 .windows(2)
2561 .any(|w| w == ["--conversation", "sid"])
2562 );
2563 }
2564
2565 #[test]
2566 fn sessions_disabled_never_resumes() {
2567 let mut seat = SeatState::new("impl-A", "a", 7);
2568 seat.turns = 3;
2569 seat.captured_session = Some("sid".to_owned());
2570 for kind in [
2571 AgentKind::Claude,
2572 AgentKind::Opencode,
2573 AgentKind::Antigravity,
2574 ] {
2575 assert!(!has_session(kind, &seat, false));
2576 }
2577 }
2578
2579 #[test]
2580 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2581 let seat = SeatState::new("judge-1", "a", 7);
2582 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2583 let p = plan_for(kind, &seat, false);
2584 assert!(
2585 p.argv.iter().all(|a| a != "do the thing"),
2586 "{kind:?} put the prompt on the command line"
2587 );
2588 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2589 }
2590 let p = plan_for(AgentKind::Antigravity, &seat, false);
2592 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2593 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2594 assert!(p.stdin.is_none());
2595 }
2596
2597 #[test]
2598 fn agy_print_timeout_tracks_the_node_budget() {
2599 let seat = SeatState::new("impl-A", "a", 7);
2600 let p = build_command(
2601 &spec(AgentKind::Antigravity, None),
2602 &seat,
2603 &Invocation {
2604 scope: crate::agent::InvocationScope::Repository,
2605 coordination: None,
2606 fetched_base: None,
2607 cwd: Path::new("."),
2608 prompt: "p",
2609 timeout: Duration::from_secs(3600),
2610 allow_write: true,
2611 unsandboxed: false,
2612 sessions: true,
2613 artifacts: Path::new("/art"),
2614 stem: "t",
2615 run: "test-run",
2616 node: "test",
2617 cache_dir: None,
2618 attachments: &[],
2619 writable: &[],
2620 },
2621 Path::new("/art/p.md"),
2622 )
2623 .unwrap();
2624 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2625 }
2626
2627 #[test]
2634 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2635 let mut s = spec(AgentKind::Antigravity, None);
2636 s.prompt_delivery = Some(Delivery::Argv);
2637 let seat = SeatState::new("talk", "a", 7);
2638 let atts = [PathBuf::from("/art/attachments/abc.png")];
2639
2640 let without = build_command(
2641 &s,
2642 &seat,
2643 &Invocation {
2644 attachments: &[],
2645 writable: &[],
2646 ..inv(Path::new("."), Path::new("/art"), true)
2647 },
2648 Path::new("/art/p.md"),
2649 )
2650 .unwrap();
2651 assert!(
2652 !without.argv.iter().any(|a| a == "--add-dir"),
2653 "no attachment, no reason to widen the sandbox: {without:?}"
2654 );
2655
2656 let with = build_command(
2657 &s,
2658 &seat,
2659 &Invocation {
2660 attachments: &atts,
2661 writable: &[],
2662 ..inv(Path::new("."), Path::new("/art"), true)
2663 },
2664 Path::new("/art/p.md"),
2665 )
2666 .unwrap();
2667 assert!(
2668 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2669 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2670 );
2671 }
2672
2673 #[test]
2679 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2680 let seat = SeatState::new("plan", "a", 7);
2681 let atts = [
2682 PathBuf::from("/art/attachments/own.png"),
2683 PathBuf::from("/other-chat/attachments/inherited.png"),
2684 ];
2685
2686 let p = build_command(
2687 &spec(AgentKind::Antigravity, None),
2688 &seat,
2689 &Invocation {
2690 attachments: &atts,
2691 writable: &[],
2692 ..inv(Path::new("."), Path::new("/art"), true)
2693 },
2694 Path::new("/art/p.md"),
2695 )
2696 .unwrap();
2697
2698 assert!(
2699 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2700 "this conversation's own artifacts dir must still be granted: {p:?}"
2701 );
2702 assert!(
2703 p.argv
2704 .windows(2)
2705 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2706 "the inherited attachment's own directory must be granted too: {p:?}"
2707 );
2708 }
2709
2710 #[test]
2711 fn command_agents_get_placeholders_substituted() {
2712 let seat = SeatState::new("impl-A", "a", 7);
2713 let p = plan_for(AgentKind::Command, &seat, true);
2714 assert_eq!(p.argv[0], "echo");
2715 assert_eq!(p.argv[1], "impl-A");
2716 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2717 }
2718
2719 #[test]
2720 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2721 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2723 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2724 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2725 let out = extract(AgentKind::Claude, stdout);
2726 let quota = out.quota.as_ref().expect("rate limit must be detected");
2727 assert_eq!(
2728 quota.reset.as_deref(),
2729 Some("4:50am (Asia/Tokyo)"),
2730 "reset time read from the body"
2731 );
2732 }
2733
2734 #[test]
2735 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2736 let out = extract(
2737 AgentKind::Claude,
2738 r#"{"is_error":true,"result":"session limit reached"}"#,
2739 );
2740 let quota = out.quota.expect("rate limit detected without a reset");
2741 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2742 }
2743
2744 #[test]
2745 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2746 for kind in [AgentKind::Claude, AgentKind::Command] {
2747 for reply in [
2748 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2749 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2750 ] {
2751 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2752 }
2753 for reply in [
2754 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2755 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2756 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2757 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2758 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2759 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2760 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2761 ] {
2762 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2763 }
2764 }
2765 }
2766
2767 #[test]
2768 fn ordinary_failures_are_never_quota() {
2769 let claude_fail = extract(
2771 AgentKind::Claude,
2772 r#"{"is_error":true,"result":"account does not exist"}"#,
2773 );
2774 assert!(claude_fail.quota.is_none());
2775
2776 let cmd_fail = extract(AgentKind::Command, "boom");
2778 assert!(cmd_fail.quota.is_none());
2779
2780 let success = extract(
2782 AgentKind::Command,
2783 r#"{"is_error":false,"result":"session limit is fine"}"#,
2784 );
2785 assert!(success.quota.is_none());
2786 }
2787
2788 #[test]
2796 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2797 let stream = concat!(
2798 r#"{"type":"thread.started","thread_id":"t1"}"#,
2799 "\n",
2800 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"}}"#,
2801 "\n",
2802 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"}}"#,
2803 "\n",
2804 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2805 "\n",
2806 r#"{"type":"turn.completed"}"#,
2807 "\n",
2808 );
2809 let out = extract(AgentKind::Codex, stream);
2810 assert_eq!(out.text, "Both tests in the target pass.");
2811 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2812
2813 let paired = &out.commands[0];
2814 assert_eq!(paired.id, "item49");
2815 assert_eq!(paired.exit_code, Some(1));
2816 assert!(paired.description.contains("graph_cached_gate"));
2817 assert!(paired.result_summary.contains("1 failed"));
2818
2819 let solo = &out.commands[1];
2820 assert_eq!(solo.exit_code, Some(0));
2821
2822 assert!(
2826 out.commands
2827 .iter()
2828 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2829 "a failed run of the actual target must still be visible: {:?}",
2830 out.commands
2831 );
2832 }
2833
2834 #[test]
2835 fn command_agent_can_carry_the_claude_quota_shape() {
2836 let out = extract(
2837 AgentKind::Command,
2838 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2839 );
2840 assert!(
2841 out.quota.is_some(),
2842 "a wrapper emitting the claude shape counts as quota"
2843 );
2844 }
2845
2846 #[test]
2847 fn claude_json_result_is_extracted() {
2848 let out = extract(
2849 AgentKind::Claude,
2850 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2851 );
2852 assert_eq!(out.text, "all done");
2853 assert_eq!(out.session.as_deref(), Some("abc"));
2854 assert_eq!(out.status.as_deref(), Some("success"));
2855 }
2856
2857 #[test]
2872 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2873 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"}"#;
2874 let out = extract(AgentKind::Claude, stdout);
2875 assert_eq!(out.status.as_deref(), Some("success"));
2876 assert!(out.quota.is_none());
2877 assert!(!out.text.trim().is_empty());
2878
2879 let agent_out = AgentOutput {
2880 text: out.text.clone(),
2881 exit_code: Some(0),
2882 timed_out: false,
2883 duration_ms: 500,
2884 artifacts: Vec::new(),
2885 quota: out.quota,
2886 dropped: out.dropped,
2887 commands: out.commands,
2888 context_tokens: None,
2889 };
2890 assert!(
2891 agent_out.usable(),
2892 "the CLI turn itself ended cleanly and must read as usable"
2893 );
2894 assert!(
2895 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2896 "a clean CLI turn is not proof the node's own report ever arrived"
2897 );
2898 }
2899
2900 #[test]
2901 fn opencode_event_stream_is_concatenated() {
2902 let stream = concat!(
2903 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2904 "\n",
2905 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2906 "\n",
2907 "garbage line\n",
2908 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2909 "\n"
2910 );
2911 let out = extract(AgentKind::Opencode, stream);
2912 assert_eq!(out.text, "first\nsecond");
2913 assert_eq!(out.session.as_deref(), Some("ses_1"));
2914 }
2915
2916 #[test]
2917 fn agy_json_survives_a_leading_warning_line() {
2918 let stdout = concat!(
2919 "warning: --mode plan has no effect while slash commands are disabled.\n",
2920 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2921 "\n"
2922 );
2923 let out = extract(AgentKind::Antigravity, stdout);
2924 assert_eq!(out.text, "persimmon");
2925 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2926 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2927 }
2928
2929 const AGY_DROPPED: &str = concat!(
2936 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2937 r#""response":"","error":"the connection to the agent was interrupted before "#,
2938 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2939 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2940 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2941 r#""total_tokens":274380}}"#
2942 );
2943
2944 const AGY_QUOTA_OUT: &str = concat!(
2946 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2947 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2948 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2949 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2950 );
2951 const AGY_QUOTA_ERR: &str = concat!(
2952 "error: Individual quota reached. Resets in 1h2m49s.\n",
2953 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
2954 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
2955 r#""retryable":true}"#,
2956 "\n"
2957 );
2958
2959 #[test]
2960 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
2961 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
2962 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
2963 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
2964 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
2965 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
2967 assert!(stderr_only.reset.is_none());
2968 }
2969
2970 #[test]
2971 fn ordinary_agy_failures_are_not_a_quota() {
2972 assert!(agy_quota(AGY_DROPPED, "").is_none());
2973 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
2974 assert!(
2975 agy_quota(
2976 "",
2977 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
2978 )
2979 .is_none()
2980 );
2981 assert!(
2982 agy_quota(
2983 "",
2984 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
2985 )
2986 .is_none()
2987 );
2988 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
2989 }
2990
2991 #[test]
2992 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2993 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2994 let dropped = out.dropped.expect("recognised as undelivered work");
2995 assert_eq!(dropped.output_tokens, 14267);
2996 assert!(
2997 dropped.why.contains("subscriber fell behind"),
2998 "the CLI's own words are kept for the record: {}",
2999 dropped.why
3000 );
3001 assert_eq!(
3004 out.session.as_deref(),
3005 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3006 );
3007 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3008 }
3009
3010 #[test]
3011 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3012 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3016 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3017
3018 let answered = concat!(
3021 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3022 r#""usage":{"output_tokens":10}}"#
3023 );
3024 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3025
3026 let ok = concat!(
3028 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3029 r#""usage":{"output_tokens":10}}"#
3030 );
3031 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3032 }
3033
3034 #[test]
3035 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3036 let out = AgentOutput {
3037 text: String::new(),
3038 exit_code: Some(1),
3039 timed_out: false,
3040 duration_ms: 431_194,
3041 artifacts: Vec::new(),
3042 quota: None,
3043 dropped: Some(Dropped {
3044 why: "subscriber fell behind updates".to_owned(),
3045 output_tokens: 14267,
3046 }),
3047 commands: Vec::new(),
3048 context_tokens: None,
3049 };
3050 assert!(!out.usable());
3051 assert!(out.work_undelivered());
3052 assert!(!out.quota_exhausted());
3055 }
3056
3057 #[test]
3058 fn non_json_stdout_falls_back_to_raw_text() {
3059 let out = extract(AgentKind::Antigravity, "plain answer\n");
3060 assert_eq!(out.text, "plain answer");
3061 assert!(out.session.is_none());
3062 }
3063
3064 #[tokio::test]
3065 async fn command_agent_round_trip_writes_artifacts() {
3066 let dir = tempfile::tempdir().unwrap();
3067 let art = dir.path().join("artifacts");
3068 let mut seat = SeatState::new("impl-A", "a", 7);
3069 let s = command_helper("reply");
3070 let out = invoke(
3071 &s,
3072 &mut seat,
3073 &Invocation {
3074 scope: crate::agent::InvocationScope::Repository,
3075 coordination: None,
3076 fetched_base: None,
3077 cwd: dir.path(),
3078 prompt: "unused",
3079 timeout: Duration::from_secs(30),
3080 allow_write: true,
3081 unsandboxed: false,
3082 sessions: true,
3083 artifacts: &art,
3084 stem: "impl-A",
3085 run: "test-run",
3086 node: "test",
3087 cache_dir: None,
3088 attachments: &[],
3089 writable: &[],
3090 },
3091 )
3092 .await
3093 .unwrap();
3094 assert!(out.usable(), "{out:?}");
3095 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3096 assert_eq!(seat.turns, 1);
3097 assert!(art.join("impl-A.prompt.md").is_file());
3098 assert!(art.join("impl-A.out").is_file());
3099 }
3100
3101 #[tokio::test]
3102 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3103 let dir = tempfile::tempdir().unwrap();
3107 let cache = dir.path().join("magi-cache");
3108 let mut seat = SeatState::new("impl-A", "a", 7);
3109 let s = command_helper("cache");
3110 let out = invoke(
3111 &s,
3112 &mut seat,
3113 &Invocation {
3114 scope: crate::agent::InvocationScope::Repository,
3115 coordination: None,
3116 fetched_base: None,
3117 cwd: dir.path(),
3118 prompt: "unused",
3119 timeout: Duration::from_secs(30),
3120 allow_write: true,
3121 unsandboxed: false,
3122 sessions: true,
3123 artifacts: &dir.path().join("artifacts"),
3124 stem: "cache",
3125 run: "test-run",
3126 node: "test",
3127 cache_dir: Some(&cache),
3128 attachments: &[],
3129 writable: &[],
3130 },
3131 )
3132 .await
3133 .unwrap();
3134 assert!(out.usable(), "{out:?}");
3135 assert!(
3136 out.text.contains(cache.to_string_lossy().as_ref()),
3137 "the seat must see CARGO_TARGET_DIR = the shared cache"
3138 );
3139 }
3140
3141 #[tokio::test]
3142 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3143 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3151 unsafe {
3156 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3157 }
3158 let dir = tempfile::tempdir().unwrap();
3159 let mut seat = SeatState::new("review-1", "a", 7);
3160 let s = command_helper("no-cache");
3161 let result = invoke(
3162 &s,
3163 &mut seat,
3164 &Invocation {
3165 scope: crate::agent::InvocationScope::Repository,
3166 coordination: None,
3167 fetched_base: None,
3168 cwd: dir.path(),
3169 prompt: "unused",
3170 timeout: Duration::from_secs(30),
3171 allow_write: false,
3172 unsandboxed: false,
3173 sessions: true,
3174 artifacts: &dir.path().join("artifacts"),
3175 stem: "no-cache",
3176 run: "test-run",
3177 node: "test",
3178 cache_dir: None,
3179 attachments: &[],
3180 writable: &[],
3181 },
3182 )
3183 .await;
3184 unsafe {
3189 match &previous {
3190 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3191 None => std::env::remove_var("CARGO_TARGET_DIR"),
3192 }
3193 }
3194 let out = result.unwrap();
3195 assert!(out.usable(), "{out:?}");
3196 assert!(
3197 out.text.contains("ABSENT"),
3198 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3199 out.text
3200 );
3201 }
3202
3203 #[tokio::test]
3204 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3205 let dir = tempfile::tempdir().unwrap();
3206 let mut seat = SeatState::new("impl-A", "a", 7);
3207 let s = command_helper("ignore-stdin");
3210 let big = "x".repeat(1_000_000);
3211 let out = invoke(
3212 &s,
3213 &mut seat,
3214 &Invocation {
3215 scope: crate::agent::InvocationScope::Repository,
3216 coordination: None,
3217 fetched_base: None,
3218 cwd: dir.path(),
3219 prompt: &big,
3220 timeout: Duration::from_secs(60),
3221 allow_write: true,
3222 unsandboxed: false,
3223 sessions: true,
3224 artifacts: &dir.path().join("artifacts"),
3225 stem: "big",
3226 run: "test-run",
3227 node: "test",
3228 cache_dir: None,
3229 attachments: &[],
3230 writable: &[],
3231 },
3232 )
3233 .await
3234 .unwrap();
3235 assert!(out.usable(), "{out:?}");
3236 assert!(out.text.contains("done"), "{}", out.text);
3237 }
3238
3239 #[tokio::test]
3240 async fn timeout_is_reported_not_hung() {
3241 let dir = tempfile::tempdir().unwrap();
3242 let mut seat = SeatState::new("impl-A", "a", 7);
3243 let s = command_helper("sleep");
3244 let out = invoke(
3245 &s,
3246 &mut seat,
3247 &Invocation {
3248 scope: crate::agent::InvocationScope::Repository,
3249 coordination: None,
3250 fetched_base: None,
3251 cwd: dir.path(),
3252 prompt: "unused",
3253 timeout: Duration::from_millis(300),
3254 allow_write: true,
3255 unsandboxed: false,
3256 sessions: true,
3257 artifacts: &dir.path().join("artifacts"),
3258 stem: "slow",
3259 run: "test-run",
3260 node: "test",
3261 cache_dir: None,
3262 attachments: &[],
3263 writable: &[],
3264 },
3265 )
3266 .await
3267 .unwrap();
3268 assert!(out.timed_out);
3269 assert!(!out.usable());
3270 }
3271
3272 #[tokio::test]
3273 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3274 let dir = tempfile::tempdir().unwrap();
3280 let artifacts = dir.path().join("artifacts");
3281 let mut seat = SeatState::new("impl-A", "a", 7);
3282 let s = command_helper("chatty-sleep");
3283 let out = invoke(
3284 &s,
3285 &mut seat,
3286 &Invocation {
3287 scope: crate::agent::InvocationScope::Repository,
3288 coordination: None,
3289 fetched_base: None,
3290 cwd: dir.path(),
3291 prompt: "unused",
3292 timeout: Duration::from_secs(10),
3297 allow_write: true,
3298 unsandboxed: false,
3299 sessions: true,
3300 artifacts: &artifacts,
3301 stem: "chatty",
3302 run: "test-run",
3303 node: "test",
3304 cache_dir: None,
3305 attachments: &[],
3306 writable: &[],
3307 },
3308 )
3309 .await
3310 .unwrap();
3311
3312 assert!(out.timed_out, "{out:?}");
3313 assert!(!out.usable(), "a cut-off answer is still not an answer");
3314 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3315 assert!(
3316 recorded.contains("i-said-something"),
3317 "the artifact must keep what arrived before the kill, got {recorded:?}"
3318 );
3319 assert!(
3320 out.text.contains("i-said-something"),
3321 "and the graph must be able to see it too, got {:?}",
3322 out.text
3323 );
3324 }
3325
3326 #[test]
3327 fn missing_programs_reports_command_binaries() {
3328 let mut s = spec(AgentKind::Command, None);
3329 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3330 assert_eq!(
3331 missing_programs(&[s]),
3332 ["definitely-not-a-real-binary-xyz".to_owned()]
3333 );
3334 }
3335
3336 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3337 AgentSpec {
3338 id: id.to_owned(),
3339 kind,
3340 model: None,
3341 command: Vec::new(),
3342 extra_args: Vec::new(),
3343 env: BTreeMap::new(),
3344 prompt_delivery: None,
3345 }
3346 }
3347
3348 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3351 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3352 }
3353
3354 #[test]
3355 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3356 let agents = [
3357 pick_spec("oc", AgentKind::Opencode),
3358 pick_spec("opus", AgentKind::Claude),
3359 pick_spec("agy", AgentKind::Antigravity),
3360 ];
3361 let got = pick(&agents, None, &without(&[])).expect("a pick");
3362 assert_eq!(got.id, "opus");
3363 }
3364
3365 #[test]
3366 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3367 let agents = [
3368 pick_spec("opus", AgentKind::Claude),
3369 pick_spec("oc", AgentKind::Opencode),
3370 pick_spec("agy", AgentKind::Antigravity),
3371 ];
3372 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3373 assert_eq!(got.id, "agy");
3374 }
3375
3376 #[test]
3377 fn pick_on_an_empty_roster_says_what_to_install() {
3378 let msg = pick(&[], None, &without(&[]))
3379 .expect_err("nobody to ask")
3380 .to_string();
3381 assert!(msg.contains("roster is empty"), "{msg}");
3382 assert!(msg.contains("claude"), "{msg}");
3383 assert!(msg.contains("magi.toml"), "{msg}");
3384 }
3385
3386 #[test]
3387 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3388 let agents = [
3389 pick_spec("opus", AgentKind::Claude),
3390 pick_spec("oc", AgentKind::Opencode),
3391 ];
3392 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3393 let msg = format!("{err:#}");
3394 assert!(msg.contains("claude"), "{msg}");
3395 assert!(msg.contains("opencode"), "{msg}");
3396 }
3397
3398 #[test]
3399 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3400 let agents = [
3401 pick_spec("opus", AgentKind::Claude),
3402 pick_spec("oc", AgentKind::Opencode),
3403 ];
3404 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3405 assert_eq!(got.id, "oc");
3406 }
3407
3408 #[test]
3409 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3410 let agents = [
3411 pick_spec("opus", AgentKind::Claude),
3412 pick_spec("oc", AgentKind::Opencode),
3413 ];
3414 let msg = pick(&agents, Some("gemini"), &without(&[]))
3415 .expect_err("no such agent")
3416 .to_string();
3417 assert!(msg.contains("gemini"), "{msg}");
3418 assert!(msg.contains("opus, oc"), "{msg}");
3419 }
3420
3421 #[test]
3422 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3423 let agents = [
3424 pick_spec("opus", AgentKind::Claude),
3425 pick_spec("oc", AgentKind::Opencode),
3426 ];
3427 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3428 .expect_err("must not silently substitute another model")
3429 .to_string();
3430 assert!(msg.contains("opencode"), "{msg}");
3431 assert!(msg.contains("--agent"), "{msg}");
3432 }
3433
3434 fn named(id: &str) -> AgentSpec {
3435 AgentSpec {
3436 id: id.to_owned(),
3437 ..spec(AgentKind::Command, None)
3438 }
3439 }
3440
3441 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3442 AgentOutput {
3443 text: text.to_owned(),
3444 exit_code: Some(exit),
3445 timed_out: false,
3446 duration_ms: 0,
3447 artifacts: Vec::new(),
3448 quota: quota.then_some(Quota { reset: None }),
3449 dropped: None,
3450 commands: Vec::new(),
3451 context_tokens: None,
3452 }
3453 }
3454
3455 #[test]
3456 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3457 let agents = [named("a"), named("b"), named("c")];
3458 let all = |_: &AgentSpec| true;
3459 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3460 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3461 assert_eq!(
3462 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3463 ["c", "a"]
3464 );
3465
3466 let one = AgentChoice::from("b");
3467 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3468 assert_eq!(got.len(), 1);
3469 assert_eq!(got[0].id, "b");
3470
3471 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3472 assert_eq!(got.len(), 1, "unset keeps pick's default");
3473 assert_eq!(got[0].id, "a");
3474 let empty = AgentChoice::Chain(Vec::new());
3475 assert_eq!(
3476 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3477 "a"
3478 );
3479 }
3480
3481 #[test]
3482 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3483 let agents = [named("a"), named("b")];
3484 let not_a = |s: &AgentSpec| s.id != "a";
3485 let chain = AgentChoice::Chain(
3486 ["a", "ghost", "b", "b"]
3487 .iter()
3488 .map(|s| (*s).to_owned())
3489 .collect(),
3490 );
3491 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3492 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3493
3494 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3495 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3496 assert_eq!(
3497 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3498 ["b", "a"]
3499 );
3500 }
3501
3502 #[test]
3503 fn a_chain_with_nothing_runnable_names_the_role() {
3504 let agents = [named("a")];
3505 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3506 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3507 .unwrap_err()
3508 .to_string();
3509 assert!(err.contains("conductor"), "{err}");
3510 }
3511
3512 #[test]
3513 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3514 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3515 assert!(chain_advances(&Ok(output("limit", 0, true))));
3516 assert!(chain_advances(&Ok(output("", 0, false))));
3517 assert!(chain_advances(&Ok(output("x", 1, false))));
3518 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3519 }
3520
3521 #[test]
3522 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3523 for out in [
3524 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3525 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3526 r#"{"result":"ok"}"#,
3527 "not json",
3528 ] {
3529 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3530 }
3531 }
3532
3533 #[test]
3534 fn opencode_context_tokens_take_the_last_step_finish() {
3535 let out = concat!(
3536 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3537 "\n",
3538 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3539 "\n",
3540 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3541 "\n"
3542 );
3543 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3544 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3545 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3546 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3547 }
3548
3549 #[test]
3550 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3551 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3552 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3553 }
3554
3555 #[test]
3556 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3557 let out = concat!(
3558 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3559 "\n"
3560 );
3561 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3562 }
3563
3564 #[test]
3565 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3566 let out = concat!(
3567 r#"{"type":"session","id":"s"}"#,
3568 "\n",
3569 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3570 "\n",
3571 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3572 "\n"
3573 );
3574 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3575 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3576 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3577 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3578 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3579 }
3580
3581 #[test]
3582 fn command_agents_report_no_context_tokens() {
3583 let out = r#"{"usage":{"input_tokens":5}}"#;
3584 assert_eq!(context_tokens(AgentKind::Command, out), None);
3585 }
3586}