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