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 {
179 return true;
180 }
181 !matches!(
182 inv.node,
183 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
184 ) && inv.scope != InvocationScope::ConsultationControl
185}
186
187pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
194 let mut roots = Vec::new();
195 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
196 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
197 if !common.starts_with(&cwd) {
198 roots.push(common);
199 }
200 }
201 for name in ["questions", "notifications"] {
205 let dir = home.join(name);
206 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
207 roots.push(std::fs::canonicalize(dir)?);
208 }
209 Ok(roots)
210}
211
212fn editing_access_note(
214 kind: AgentKind,
215 unsandboxed: bool,
216 base: Option<&crate::run::FetchedBase>,
217) -> String {
218 let mut note = String::from(
219 "Launcher repository access contract (applies to this turn and overrides earlier fetch/commit instructions):\n",
220 );
221 if let Some(base) = base {
222 note.push_str(&format!(
223 "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",
224 base.remote, base.branch, base.fetched_at, base.commit,
225 ));
226 } else {
227 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");
228 }
229 if kind != AgentKind::Codex || unsandboxed {
231 return note;
232 }
233 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");
234 note
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244pub struct Quota {
245 #[serde(default)]
247 pub reset: Option<String>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257pub struct Dropped {
258 pub why: String,
260 pub output_tokens: u64,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct CommandEvidence {
280 pub id: String,
282 pub description: String,
284 pub exit_code: Option<i32>,
286 pub result_summary: String,
288 pub source: String,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct AgentOutput {
295 pub text: String,
297 pub exit_code: Option<i32>,
299 pub timed_out: bool,
301 pub duration_ms: u64,
303 pub artifacts: Vec<String>,
305 #[serde(default)]
309 pub quota: Option<Quota>,
310 #[serde(default)]
314 pub dropped: Option<Dropped>,
315 #[serde(default)]
319 pub commands: Vec<CommandEvidence>,
320 #[serde(default)]
325 pub context_tokens: Option<u64>,
326}
327
328impl AgentOutput {
329 pub fn usable(&self) -> bool {
331 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
332 }
333
334 pub fn quota_exhausted(&self) -> bool {
336 self.quota.is_some()
337 }
338
339 pub fn work_undelivered(&self) -> bool {
344 self.dropped.is_some()
345 }
346}
347
348#[derive(Debug)]
351pub(crate) struct ExecutionInterrupted(pub String);
352
353impl std::fmt::Display for ExecutionInterrupted {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 write!(f, "execution interrupted: {}", self.0)
356 }
357}
358
359impl std::error::Error for ExecutionInterrupted {}
360
361const PIPE_GRACE: Duration = Duration::from_secs(3);
366
367pub(crate) type Captured = Arc<Mutex<Vec<u8>>>;
369
370pub(crate) fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
380where
381 R: tokio::io::AsyncRead + Unpin + Send + 'static,
382{
383 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
384 let Some(mut pipe) = pipe else {
385 return (buf, None);
386 };
387 let sink = Arc::clone(&buf);
388 let handle = tokio::spawn(async move {
389 let mut chunk = [0u8; 8192];
390 loop {
391 match pipe.read(&mut chunk).await {
392 Ok(0) | Err(_) => break,
393 Ok(n) => {
394 if let Ok(mut guard) = sink.lock() {
395 guard.extend_from_slice(&chunk[..n]);
396 }
397 }
398 }
399 }
400 });
401 (buf, Some(handle))
402}
403
404pub(crate) async fn collect(
409 buf: &Captured,
410 handle: Option<tokio::task::JoinHandle<()>>,
411 grace: Duration,
412) -> String {
413 if let Some(handle) = handle {
414 if tokio::time::timeout(grace, handle).await.is_err() {
415 tracing::debug!("a pipe is still held open after the child exited");
416 }
417 }
418 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
419 String::from_utf8_lossy(&bytes).into_owned()
420}
421
422pub async fn invoke(
424 spec: &AgentSpec,
425 seat: &mut SeatState,
426 inv: &Invocation<'_>,
427) -> Result<AgentOutput> {
428 let lease = if inv.scope == InvocationScope::ConsultationInvestigation {
432 crate::repo_lock::SeatLease::try_reader(inv.cwd, inv.coordination).await?
433 } else if repository_seat(inv) {
434 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
435 .await?
436 } else {
437 None
438 };
439 invoke_leased(spec, seat, inv, lease, || Ok(())).await
440}
441
442pub(crate) async fn invoke_leased(
445 spec: &AgentSpec,
446 seat: &mut SeatState,
447 inv: &Invocation<'_>,
448 lease: Option<crate::repo_lock::SeatLease>,
449 start_check: impl FnOnce() -> Result<()> + Send,
450) -> Result<AgentOutput> {
451 let readonly = inv.scope == InvocationScope::ConsultationInvestigation;
452 let inv = &Invocation {
453 timeout: spec.turn_timeout(inv.timeout),
454 allow_write: inv.allow_write && !readonly,
455 unsandboxed: inv.unsandboxed && inv.scope == InvocationScope::Repository,
456 writable: if readonly { &[] } else { inv.writable },
457 ..inv.clone()
458 };
459 let mut outputs = Vec::new();
460 if repository_seat(inv) {
461 if let Some(guard) = crate::retention::OutputLease::borrow(inv.cwd).map_err(|e| {
462 crate::repo_lock::ResourceBlocked(format!("worktree output ownership: {e:#}"))
463 })? {
464 outputs.push(guard);
465 }
466 if let Some(cache) = inv.cache_dir {
467 outputs.push(crate::retention::OutputLease::cache(
468 &crate::run::home(),
469 cache,
470 false,
471 )?);
472 }
473 }
474
475 let scope = match inv.scope {
476 InvocationScope::ConsultationControl => Some(
477 "Current turn scope: consultation control. This replaces all earlier per-turn scope restrictions, including investigation's prohibition on answering. Repository access is suspended. Use supplied context and supported read-only magi control-plane diagnostics; do not inspect repository files/history, fetch, test or edit. If evidence is needed (including an operator request to investigate), return ONLY a JSON object {\"investigate_question\":\"<full open question id>\"}. The launcher will attempt a coordinated read-only continuation in this same conversation. Do not answer or resolve the question to obtain access. Only magi answer settles a question. Additional writes are limited to individually evidenced task add, release, hold, done and priority; use --quote of the entire latest persisted owner send after delivery. No direct merge, discard, withdrawal, posting or other write commands; existing answer-specific effect checks remain in force. These restrictions last this whole invocation even after consultation ends. Consultation task instructions use the literal task-operation JSON protocol explained below. --reply-other records the owner's literal decision and ends consultation only, leaving the question open and waiter held. Owner decisions still require magi answer with exact required evidence.",
478 ),
479 InvocationScope::ConsultationInvestigation => Some(
480 "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.",
481 ),
482 InvocationScope::Repository if inv.node == "chat" => Some(
483 "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.",
484 ),
485 InvocationScope::Repository => None,
486 };
487 let mut prompt = inv.prompt.to_owned();
488 if let Some(scope) = scope {
489 prompt.push_str("\n\n");
490 prompt.push_str(scope);
491 if inv.scope == InvocationScope::ConsultationControl {
492 prompt.push_str("\n\n");
493 prompt.push_str(crate::prompt::CONSULT_WRITES);
494 }
495 }
496 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
497 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()));
498 }
499 let home = repository_writer(inv).then(crate::run::home);
502 let mut writable = inv.writable.to_vec();
503 if let Some(home) = &home {
504 for root in worktree_writable_roots(inv.cwd, home).await? {
505 if !writable.contains(&root) {
506 writable.push(root);
507 }
508 }
509 }
510 if repository_writer(inv) {
511 prompt.push_str("\n\n");
512 prompt.push_str(&editing_access_note(
513 spec.kind,
514 inv.unsandboxed,
515 inv.fetched_base,
516 ));
517 }
518 let inv = &Invocation {
519 writable: &writable,
520 prompt: &prompt,
521 ..inv.clone()
522 };
523 tokio::fs::create_dir_all(inv.artifacts)
524 .await
525 .with_context(|| format!("create {}", inv.artifacts.display()))?;
526 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
527 tokio::fs::write(&prompt_path, inv.prompt)
528 .await
529 .with_context(|| format!("write {}", prompt_path.display()))?;
530
531 let plan = build_command(spec, seat, inv, &prompt_path)?;
532 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.logged_argv(), "spawning agent");
533 tokio::fs::write(
535 inv.artifacts.join(format!("{}.access.json", inv.stem)),
536 serde_json::to_vec_pretty(&serde_json::json!({
537 "launcher_version": env!("CARGO_PKG_VERSION"),
538 "kind": spec.kind,
539 "allow_write": inv.allow_write,
540 "unsandboxed": inv.unsandboxed,
541 "requested_network_access": if spec.kind == AgentKind::Codex && repository_writer(inv) && !inv.unsandboxed {
542 Some(false)
543 } else { None },
544 "writable_roots": inv.writable,
545 "fetched_base": inv.fetched_base,
546 }))?,
547 )
548 .await
549 .context("write seat access evidence")?;
550
551 let started = Instant::now();
552 let child_path = spec
555 .env
556 .iter()
557 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
558 .map(|(_, v)| std::ffi::OsString::from(v))
559 .or_else(|| std::env::var_os("PATH"))
560 .unwrap_or_default();
561 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
562 || plan.argv[0].clone().into(),
563 std::path::PathBuf::into_os_string,
564 );
565 let mut cmd = Command::new(program);
566 cmd.args(&plan.argv[1..])
567 .current_dir(inv.cwd)
568 .envs(&spec.env)
569 .env("MAGI_SEAT", &seat.key)
570 .env("MAGI_TURN", seat.turns.to_string())
571 .env("MAGI_RUN", inv.run)
572 .env("MAGI_NODE", inv.node)
573 .env(
574 "MAGI_TURN_SCOPE",
575 match inv.scope {
576 InvocationScope::ConsultationControl => "consultation-control",
577 InvocationScope::ConsultationInvestigation => "consultation-investigation",
578 InvocationScope::Repository => "repository",
579 },
580 )
581 .env("MAGI_PROMPT_FILE", &prompt_path)
582 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
583 .env("GIT_TERMINAL_PROMPT", "0")
584 .stdin(if plan.stdin.is_some() {
585 Stdio::piped()
586 } else {
587 Stdio::null()
588 })
589 .stdout(Stdio::piped())
590 .stderr(Stdio::piped())
591 .kill_on_drop(true)
592 .quiet();
595 if let Some(home) = home {
596 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
599 }
600 if let Some(cache) = inv.cache_dir {
601 cmd.env("CARGO_TARGET_DIR", cache);
604 } else {
605 cmd.env_remove("CARGO_TARGET_DIR");
613 }
614
615 #[cfg(any(unix, windows))]
616 let _chat_controls = {
617 use crate::chat_control::bridge::{self, Bridge};
618 bridge::clear_environment(&mut cmd);
619 let bridge = if inv.node == crate::queue::CHAT_NODE
620 && inv.scope == InvocationScope::Repository
621 {
622 crate::run::try_home().and_then(|home| {
623 match Bridge::start(&home, inv.run, inv.artifacts) {
624 Ok(bridge) => Some(bridge),
625 Err(error) => {
626 tracing::warn!(%error, "chat controls unavailable; continuing conversation");
627 None
628 }
629 }
630 })
631 } else {
632 None
633 };
634 if let Some(bridge) = &bridge {
635 cmd.env("MAGI_HOME", std::fs::canonicalize(crate::run::home())?);
639 bridge.attach(&mut cmd);
640 }
641 bridge
642 };
643
644 start_check()?;
647 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
648 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
649 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
653 tokio::spawn(async move {
654 sink.write_all(body.as_bytes()).await.ok();
655 sink.shutdown().await.ok();
656 });
657 }
658
659 let (out_buf, out_reader) = drain(running.child().stdout.take());
676 let (err_buf, err_reader) = drain(running.child().stderr.take());
677
678 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
679 .ok()
680 .map_or(0, |state| state.config.disk.min_free_bytes);
681 let mut paths = vec![inv.cwd.to_owned()];
682 if let Some(cache) = inv.cache_dir {
683 paths.push(cache.to_owned());
684 }
685 let mut pressure = None;
686 let waited = tokio::select! {
687 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
688 reason = crate::retention::emergency(paths, min_free) => {
689 pressure = Some(reason);
690 None
691 }
692 };
693 let (code, timed_out) = match waited {
694 Some(Ok(res)) => {
695 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
696 (status.code(), false)
697 }
698 Some(Err(_)) | None => {
699 if let Some(reason) = &pressure {
700 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
701 } else {
702 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
703 }
704 if !running.coordinated() {
706 running
707 .child()
708 .kill()
709 .await
710 .context("stop timed-out seat")?;
711 }
712 (None, true)
713 }
714 };
715 running.finish().await;
716
717 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
722 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
723
724 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
725 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
726 tokio::fs::write(&out_path, &stdout).await.ok();
727 tokio::fs::write(&err_path, &stderr).await.ok();
728 if let Some(reason) = pressure {
729 return Err(
730 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
731 .context(ExecutionInterrupted(reason)),
732 );
733 }
734
735 let mut extracted = extract(spec.kind, &stdout);
736 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
737 extracted.quota = agy_quota(&stdout, &stderr);
738 }
739 if let Some(quota) = &extracted.quota {
740 extracted.dropped = None;
743 tracing::warn!(
744 seat = %seat.key,
745 agent = %spec.id,
746 reset = ?quota.reset,
747 "agent is out of quota"
748 );
749 }
750 if let Some(session) = extracted.session {
751 match spec.kind {
752 AgentKind::Claude => seat.claude_session = Some(session),
753 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
754 seat.captured_session = Some(session);
755 }
756 AgentKind::Command => {}
757 }
758 }
759 if let Some(status) = &extracted.status
760 && !status.eq_ignore_ascii_case("success")
761 {
762 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
763 }
764 let text = if extracted.text.trim().is_empty() {
765 if stdout.trim().is_empty() {
767 stderr.trim().to_owned()
768 } else {
769 stdout.trim().to_owned()
770 }
771 } else {
772 extracted.text
773 };
774 seat.turns += 1;
775
776 Ok(AgentOutput {
777 text,
778 exit_code: code,
779 timed_out,
780 duration_ms: started.elapsed().as_millis() as u64,
781 artifacts: vec![
782 file_name(&prompt_path),
783 file_name(&out_path),
784 file_name(&err_path),
785 ],
786 quota: extracted.quota,
787 dropped: extracted.dropped,
788 commands: extracted.commands,
789 context_tokens: extracted.context_tokens,
790 })
791}
792
793fn file_name(p: &Path) -> String {
794 p.file_name()
795 .unwrap_or_default()
796 .to_string_lossy()
797 .into_owned()
798}
799
800#[derive(Debug)]
802struct Plan {
803 argv: Vec<String>,
804 stdin: Option<String>,
805}
806
807impl Plan {
808 fn logged_argv(&self) -> Vec<&str> {
811 let mut settings_value = false;
812 self.argv
813 .iter()
814 .map(|arg| {
815 if settings_value {
816 settings_value = false;
817 "[redacted]"
818 } else if arg == "--settings" {
819 settings_value = true;
820 arg.as_str()
821 } else if arg.starts_with("--settings=") {
822 "--settings=[redacted]"
823 } else {
824 arg.as_str()
825 }
826 })
827 .collect()
828 }
829}
830
831fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
842 if matches!(kind, AgentKind::Antigravity) {
843 return format!("@{}", prompt_path.display());
844 }
845 format!(
846 "Read the file at {} and follow every instruction in it exactly. That \
847 file is your complete task description; this message contains nothing \
848 else.",
849 prompt_path.display()
850 )
851}
852
853fn build_command(
854 spec: &AgentSpec,
855 seat: &SeatState,
856 inv: &Invocation<'_>,
857 prompt_path: &Path,
858) -> Result<Plan> {
859 let mut argv: Vec<String> = Vec::new();
860 let mut stdin: Option<String> = None;
861 let delivery = spec.delivery();
862 let resuming = has_session(spec.kind, seat, inv.sessions);
863
864 match spec.kind {
865 AgentKind::Claude => {
866 argv.push("claude".to_owned());
871 argv.push("-p".to_owned());
872 argv.push("--output-format".to_owned());
873 argv.push("json".to_owned());
874 if let Some(m) = &spec.model {
875 argv.push("--model".to_owned());
876 argv.push(m.clone());
877 }
878 if inv.sessions {
879 let uuid = seat
880 .claude_session
881 .as_deref()
882 .context("claude seat is missing its session uuid")?;
883 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
884 argv.push(uuid.to_owned());
885 }
886 argv.push("--permission-mode".to_owned());
887 argv.push("bypassPermissions".to_owned());
888 if !inv.allow_write {
889 argv.push("--disallowed-tools".to_owned());
890 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
891 }
892 }
893 AgentKind::Opencode => {
894 argv.push("opencode".to_owned());
898 argv.push("run".to_owned());
899 argv.push("--format".to_owned());
900 argv.push("json".to_owned());
901 argv.push("--dir".to_owned());
902 argv.push(inv.cwd.to_string_lossy().into_owned());
903 argv.push("--auto".to_owned());
912 if let Some(m) = &spec.model {
913 argv.push("-m".to_owned());
914 argv.push(m.clone());
915 }
916 if resuming {
917 argv.push("-s".to_owned());
918 argv.push(
919 seat.captured_session
920 .clone()
921 .expect("has_session checked the id is present"),
922 );
923 }
924 }
925 AgentKind::Antigravity => {
926 argv.push("agy".to_owned());
927 argv.push("--output-format".to_owned());
928 argv.push("json".to_owned());
929 argv.push("--print-timeout".to_owned());
932 argv.push(format!("{}s", inv.timeout.as_secs()));
933 argv.push("--mode".to_owned());
934 argv.push(
935 if inv.allow_write {
936 "accept-edits"
937 } else {
938 "plan"
939 }
940 .to_owned(),
941 );
942 if inv.allow_write {
943 argv.push("--dangerously-skip-permissions".to_owned());
944 }
945 if let Some(m) = &spec.model {
946 argv.push("--model".to_owned());
947 argv.push(m.clone());
948 }
949 if resuming {
950 argv.push("--conversation".to_owned());
951 argv.push(
952 seat.captured_session
953 .clone()
954 .expect("has_session checked the id is present"),
955 );
956 }
957 let mut add_dirs: Vec<String> = Vec::new();
967 if delivery == Delivery::File || !inv.attachments.is_empty() {
968 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
969 }
970 for path in inv.attachments {
971 let Some(parent) = path.parent() else {
972 continue;
973 };
974 if parent.starts_with(inv.artifacts) {
975 continue;
976 }
977 let dir = parent.to_string_lossy().into_owned();
978 if !add_dirs.contains(&dir) {
979 add_dirs.push(dir);
980 }
981 }
982 if inv.allow_write {
983 for path in inv.writable {
984 let dir = path.to_string_lossy().into_owned();
985 if !add_dirs.contains(&dir) {
986 add_dirs.push(dir);
987 }
988 }
989 }
990 for dir in add_dirs {
991 argv.push("--add-dir".to_owned());
992 argv.push(dir);
993 }
994 }
995 AgentKind::Codex => {
996 argv.push("codex".to_owned());
1002 argv.push("exec".to_owned());
1003 argv.push("--json".to_owned());
1004 argv.push("--skip-git-repo-check".to_owned());
1007 argv.push("-C".to_owned());
1008 argv.push(inv.cwd.to_string_lossy().into_owned());
1009 if inv.allow_write && inv.unsandboxed {
1018 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
1019 } else {
1020 argv.push("--sandbox".to_owned());
1021 argv.push(
1022 if inv.allow_write {
1023 "workspace-write"
1024 } else {
1025 "read-only"
1026 }
1027 .to_owned(),
1028 );
1029 argv.push("-c".to_owned());
1032 argv.push("approval_policy=\"never\"".to_owned());
1033 if repository_writer(inv) {
1034 argv.push("-c".to_owned());
1035 argv.push("sandbox_workspace_write.network_access=false".to_owned());
1036 }
1037 if inv.allow_write && !inv.writable.is_empty() {
1038 let roots: Vec<String> = inv
1039 .writable
1040 .iter()
1041 .map(|p| p.to_string_lossy().into_owned())
1042 .collect();
1043 argv.push("-c".to_owned());
1044 argv.push(format!(
1045 "sandbox_workspace_write.writable_roots={}",
1046 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
1047 ));
1048 }
1049 }
1050 if let Some(m) = &spec.model {
1051 argv.push("-m".to_owned());
1052 argv.push(m.clone());
1053 }
1054 if resuming {
1060 argv.push("resume".to_owned());
1061 argv.push(
1062 seat.captured_session
1063 .clone()
1064 .expect("has_session checked the id is present"),
1065 );
1066 }
1067 }
1068 AgentKind::Omp => {
1069 argv.push("omp".to_owned());
1073 argv.push("-p".to_owned());
1074 argv.push("--mode=json".to_owned());
1075 argv.push("--auto-approve".to_owned());
1085 if let Some(m) = &spec.model {
1086 argv.push("--model".to_owned());
1087 argv.push(m.clone());
1088 }
1089 if resuming {
1095 argv.push("--resume".to_owned());
1096 argv.push(
1097 seat.captured_session
1098 .clone()
1099 .expect("has_session checked the id is present"),
1100 );
1101 }
1102 }
1103 AgentKind::Command => {
1104 if spec.command.is_empty() {
1109 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1110 }
1111 let vars: BTreeMap<&str, String> = BTreeMap::from([
1112 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1113 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1114 ("{label}", seat.key.clone()),
1115 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1116 ]);
1117 for raw in &spec.command {
1118 let mut arg = raw.clone();
1119 for (k, v) in &vars {
1120 if arg.contains(k) {
1121 arg = arg.replace(k, v);
1122 }
1123 }
1124 argv.push(arg);
1125 }
1126 }
1127 }
1128
1129 argv.extend(spec.extra_args.iter().cloned());
1130
1131 if spec.kind == AgentKind::Antigravity {
1134 argv.push("-p".to_owned());
1135 }
1136 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1139 argv.push("-".to_owned());
1140 }
1141 match delivery {
1142 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1143 argv.push(pointer(spec.kind, prompt_path));
1145 }
1146 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1147 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1148 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1149 }
1150
1151 Ok(Plan { argv, stdin })
1152}
1153
1154#[derive(Debug, Default)]
1156struct Extracted {
1157 text: String,
1158 session: Option<String>,
1159 status: Option<String>,
1160 quota: Option<Quota>,
1161 dropped: Option<Dropped>,
1162 commands: Vec<CommandEvidence>,
1163 context_tokens: Option<u64>,
1164}
1165
1166fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1168 let mut extracted = extract_answer(kind, stdout);
1169 extracted.context_tokens = context_tokens(kind, stdout);
1170 extracted
1171}
1172
1173fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1175 v.get(key).and_then(serde_json::Value::as_u64)
1176}
1177
1178fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1183 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1184 let base = first(input)?;
1185 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1186 Some(base.saturating_add(cached))
1187}
1188
1189fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1210 use serde_json::Value;
1211 let lines = || {
1212 stdout
1213 .lines()
1214 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1215 };
1216 match kind {
1217 AgentKind::Claude => None,
1218 AgentKind::Opencode => lines()
1219 .filter(|v| {
1220 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1222 || v.get("part")
1223 .and_then(|p| p.get("type"))
1224 .and_then(|t| t.as_str())
1225 == Some("step-finish")
1226 })
1227 .filter_map(|v| {
1228 let tokens = v.get("part")?.get("tokens")?;
1229 let base = uint(tokens, "input")?;
1230 let cache = tokens.get("cache");
1231 let cached = ["read", "write"]
1232 .iter()
1233 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1234 .sum::<u64>();
1235 Some(base.saturating_add(cached))
1236 })
1237 .next_back(),
1238 AgentKind::Antigravity => None,
1239 AgentKind::Codex => None,
1240 AgentKind::Omp => lines()
1241 .flat_map(|v| {
1242 match v.get("type").and_then(|t| t.as_str()) {
1245 Some("agent_end") => v
1246 .get("messages")
1247 .and_then(|m| m.as_array())
1248 .cloned()
1249 .unwrap_or_default(),
1250 Some("turn_end") | Some("message_end") => {
1251 v.get("message").cloned().into_iter().collect()
1252 }
1253 _ => Vec::new(),
1254 }
1255 })
1256 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1257 .filter_map(|m| {
1258 input_side(
1259 m.get("usage")?,
1260 &["input", "input_tokens"],
1261 &[
1262 "cacheRead",
1263 "cacheWrite",
1264 "cache_read_tokens",
1265 "cache_write_tokens",
1266 ],
1267 )
1268 })
1269 .next_back(),
1270 AgentKind::Command => None,
1271 }
1272}
1273
1274pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1277 extract_answer(kind, stdout).text
1278}
1279
1280fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1281 match kind {
1282 AgentKind::Claude => {
1283 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1284 return Extracted {
1285 text: stdout.trim().to_owned(),
1286 ..Extracted::default()
1287 };
1288 };
1289 Extracted {
1290 text: v
1291 .get("result")
1292 .and_then(|r| r.as_str())
1293 .unwrap_or_default()
1294 .to_owned(),
1295 session: v
1296 .get("session_id")
1297 .and_then(|s| s.as_str())
1298 .map(str::to_owned),
1299 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1300 if e {
1301 "error".to_owned()
1302 } else {
1303 "success".to_owned()
1304 }
1305 }),
1306 quota: claude_quota(&v),
1307 dropped: None,
1310 commands: Vec::new(),
1311 context_tokens: None,
1312 }
1313 }
1314 AgentKind::Opencode => {
1315 let mut text = String::new();
1317 let mut session = None;
1318 for line in stdout.lines() {
1319 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1320 continue;
1321 };
1322 if session.is_none() {
1323 session = v
1324 .get("sessionID")
1325 .and_then(|s| s.as_str())
1326 .map(str::to_owned);
1327 }
1328 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1329 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1330 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1331 {
1332 if !text.is_empty() {
1333 text.push('\n');
1334 }
1335 text.push_str(t);
1336 }
1337 }
1338 Extracted {
1339 text,
1340 session,
1341 status: None,
1342 quota: None,
1343 dropped: None,
1344 commands: Vec::new(),
1345 context_tokens: None,
1346 }
1347 }
1348 AgentKind::Antigravity => {
1349 let obj = stdout
1352 .lines()
1353 .rev()
1354 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1355 let Some(v) = obj else {
1356 return Extracted {
1357 text: stdout.trim().to_owned(),
1358 ..Extracted::default()
1359 };
1360 };
1361 Extracted {
1362 text: v
1363 .get("response")
1364 .and_then(|r| r.as_str())
1365 .unwrap_or_default()
1366 .trim()
1367 .to_owned(),
1368 session: v
1369 .get("conversation_id")
1370 .and_then(|s| s.as_str())
1371 .map(str::to_owned),
1372 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1373 quota: None,
1374 dropped: dropped_stream(&v),
1375 commands: Vec::new(),
1376 context_tokens: None,
1377 }
1378 }
1379 AgentKind::Codex => {
1380 let mut text = String::new();
1402 let mut session = None;
1403 let mut status = None;
1404 let mut commands = Vec::new();
1405 for line in stdout.lines() {
1406 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1407 continue;
1408 };
1409 match v.get("type").and_then(|t| t.as_str()) {
1410 Some("thread.started") => {
1411 session = v
1412 .get("thread_id")
1413 .and_then(|s| s.as_str())
1414 .map(str::to_owned);
1415 }
1416 Some("item.completed") => {
1417 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1418 match item.get("type").and_then(|t| t.as_str()) {
1419 Some("agent_message") => {
1420 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1421 text = t.trim().to_owned();
1422 }
1423 }
1424 Some("command_execution") => {
1425 commands.push(command_evidence(item));
1426 }
1427 _ => {}
1428 }
1429 }
1430 Some("turn.completed") => status = Some("success".to_owned()),
1431 Some("turn.failed") => status = Some("error".to_owned()),
1432 _ => {}
1433 }
1434 }
1435 Extracted {
1436 text,
1437 session,
1438 status,
1439 quota: None,
1440 dropped: None,
1441 commands,
1442 context_tokens: None,
1443 }
1444 }
1445 AgentKind::Omp => {
1446 let mut text = String::new();
1467 let mut session = None;
1468 for line in stdout.lines() {
1469 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1470 continue;
1471 };
1472 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1473 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1474 continue;
1475 }
1476 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1480 {
1481 Some("agent_end") => v
1482 .get("messages")
1483 .and_then(|m| m.as_array())
1484 .map(|m| m.iter().collect())
1485 .unwrap_or_default(),
1486 Some("turn_end") | Some("message_end") => {
1487 v.get("message").into_iter().collect()
1488 }
1489 _ => continue,
1490 };
1491 for message in messages {
1492 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1493 continue;
1494 }
1495 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1496 continue;
1497 };
1498 for part in parts {
1499 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1500 continue;
1501 }
1502 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1503 && !t.trim().is_empty()
1504 {
1505 text = t.trim().to_owned();
1506 }
1507 }
1508 }
1509 }
1510 Extracted {
1511 text,
1512 session,
1513 status: None,
1514 quota: None,
1515 dropped: None,
1516 commands: Vec::new(),
1517 context_tokens: None,
1518 }
1519 }
1520 AgentKind::Command => {
1521 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1526 let quota = parsed.as_ref().and_then(claude_quota);
1527 let dropped = parsed.as_ref().and_then(dropped_stream);
1530 Extracted {
1531 text: stdout.trim().to_owned(),
1532 session: None,
1533 status: None,
1534 quota,
1535 dropped,
1536 commands: Vec::new(),
1537 context_tokens: None,
1538 }
1539 }
1540 }
1541}
1542
1543fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1550 let description = match item.get("command") {
1551 Some(serde_json::Value::String(s)) => s.clone(),
1552 Some(serde_json::Value::Array(parts)) => parts
1553 .iter()
1554 .filter_map(|p| p.as_str())
1555 .collect::<Vec<_>>()
1556 .join(" "),
1557 _ => String::new(),
1558 };
1559 let result_summary = item
1560 .get("aggregated_output")
1561 .and_then(|o| o.as_str())
1562 .map(|s| tail_chars(s.trim(), 400))
1563 .unwrap_or_default();
1564 CommandEvidence {
1565 id: item
1566 .get("id")
1567 .and_then(|s| s.as_str())
1568 .unwrap_or_default()
1569 .to_owned(),
1570 description,
1571 exit_code: item
1572 .get("exit_code")
1573 .and_then(serde_json::Value::as_i64)
1574 .map(|e| e as i32),
1575 result_summary,
1576 source: "codex".to_owned(),
1577 }
1578}
1579
1580fn tail_chars(s: &str, max: usize) -> String {
1582 let count = s.chars().count();
1583 if count <= max {
1584 return s.to_owned();
1585 }
1586 s.chars().skip(count - max).collect()
1587}
1588
1589fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1596 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1597 if !is_err {
1598 return None;
1599 }
1600 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1601 let status = v.get("api_error_status");
1602 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1603 || status.and_then(serde_json::Value::as_str) == Some("429"))
1604 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1605 if !usage_limit && !result.to_lowercase().contains("session limit") {
1606 return None;
1607 }
1608 let reset = result
1611 .split("resets ")
1612 .nth(1)
1613 .map(str::trim)
1614 .filter(|s| !s.is_empty())
1615 .map(str::to_owned);
1616 Some(Quota { reset })
1617}
1618
1619fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1634 let from_stdout = stdout
1635 .lines()
1636 .rev()
1637 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1638 .and_then(|v| {
1639 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1640 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1641 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1642 .then(|| error.to_owned())
1643 });
1644 let from_stderr = || {
1645 stderr.lines().find_map(|l| {
1646 let v: serde_json::Value =
1647 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1648 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1649 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1650 (exhausted && code).then(|| {
1651 v.get("short_error")
1652 .and_then(|e| e.as_str())
1653 .unwrap_or_default()
1654 .to_owned()
1655 })
1656 })
1657 };
1658 let text = from_stdout.or_else(from_stderr)?;
1659 let reset = text
1660 .split_once("Resets ")
1661 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1662 .filter(|s| !s.is_empty())
1663 .map(str::to_owned);
1664 Some(Quota { reset })
1665}
1666
1667fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1698 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1699 if !status.eq_ignore_ascii_case("error") {
1700 return None;
1701 }
1702 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1703 if !response.trim().is_empty() {
1704 return None;
1706 }
1707 let produced = v
1708 .get("usage")
1709 .and_then(|u| u.get("output_tokens"))
1710 .and_then(serde_json::Value::as_u64)
1711 .unwrap_or(0);
1712 if produced == 0 {
1713 return None;
1715 }
1716 Some(Dropped {
1717 why: v
1718 .get("error")
1719 .and_then(|e| e.as_str())
1720 .unwrap_or("the CLI ended the stream without delivering its answer")
1721 .trim()
1722 .to_owned(),
1723 output_tokens: produced,
1724 })
1725}
1726
1727pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1729 let mut missing = Vec::new();
1730 for s in specs {
1731 let program = match s.kind {
1732 AgentKind::Command => s.command.first().map(String::as_str),
1733 other => other.program(),
1734 };
1735 if let Some(p) = program
1736 && !crate::config::which(p)
1737 && !Path::new(p).is_file()
1738 && !missing.iter().any(|m: &String| m == p)
1739 {
1740 missing.push(p.to_owned());
1741 }
1742 }
1743 missing
1744}
1745
1746pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1748 run_dir.join("artifacts")
1749}
1750
1751pub fn installed(spec: &AgentSpec) -> bool {
1753 spec.kind.program().is_none_or(crate::config::which)
1756}
1757
1758pub fn pick(
1780 agents: &[AgentSpec],
1781 want: Option<&str>,
1782 available: &dyn Fn(&AgentSpec) -> bool,
1783) -> Result<AgentSpec> {
1784 if let Some(id) = want {
1785 let spec = agents
1786 .iter()
1787 .find(|a| a.id == id)
1788 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1789 if !available(spec) {
1790 bail!(
1791 "agent `{}` needs `{}` on PATH; install it or pass a different \
1792 --agent",
1793 spec.id,
1794 spec.kind.program().unwrap_or("its command")
1795 );
1796 }
1797 return Ok(spec.clone());
1798 }
1799
1800 if agents.is_empty() {
1801 bail!(
1802 "the agent roster is empty, so there is nobody to ask: install one \
1803 of claude, opencode or agy - magi derives a roster from what is on \
1804 PATH - or add an [[agents]] entry to magi.toml."
1805 );
1806 }
1807
1808 if let Some(spec) = agents
1809 .iter()
1810 .find(|a| a.kind == AgentKind::Claude && available(a))
1811 {
1812 return Ok(spec.clone());
1813 }
1814
1815 agents
1816 .iter()
1817 .find(|a| available(a))
1818 .cloned()
1819 .with_context(|| {
1820 let missing = agents
1821 .iter()
1822 .filter_map(|a| a.kind.program())
1823 .collect::<Vec<_>>()
1824 .join(", ");
1825 format!(
1826 "no agent in the roster can be run here: install one of \
1827 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1828 you do have"
1829 )
1830 })
1831}
1832
1833pub fn for_github_text(spec: &AgentSpec, github_language: &str, cwd: &Path) -> Result<AgentSpec> {
1846 let mut spec = spec.clone();
1847 if spec.kind == AgentKind::Claude {
1848 let mut existing = None;
1851 let mut args = spec.extra_args.iter().enumerate();
1852 while let Some((index, arg)) = args.next() {
1853 if arg == "--settings" {
1854 let (value_index, value) = args.next().context("--settings requires a value")?;
1855 existing = Some((value_index, value.as_str(), false));
1856 } else if let Some(value) = arg.strip_prefix("--settings=") {
1857 existing = Some((index, value, true));
1858 }
1859 }
1860 let mut settings = if let Some((_, value, _)) = existing {
1861 let json = if value.trim_start().starts_with('{') {
1862 value.to_owned()
1863 } else {
1864 std::fs::read_to_string(cwd.join(value))
1865 .context("cannot read existing seat --settings file")?
1866 };
1867 serde_json::from_str::<serde_json::Value>(&json)
1868 .context("cannot parse existing seat --settings JSON")?
1869 } else {
1870 serde_json::json!({})
1871 };
1872 settings
1873 .as_object_mut()
1874 .context("seat --settings must be a JSON object")?
1875 .insert(
1876 "language".to_owned(),
1877 serde_json::json!(crate::lang::name(github_language)),
1878 );
1879 let settings = settings.to_string();
1880 if let Some((index, _, equals)) = existing {
1881 spec.extra_args[index] = if equals {
1882 format!("--settings={settings}")
1883 } else {
1884 settings
1885 };
1886 } else {
1887 spec.extra_args.extend(["--settings".to_owned(), settings]);
1888 }
1889 }
1890 Ok(spec)
1891}
1892
1893pub fn pick_chain(
1901 agents: &[AgentSpec],
1902 choice: Option<&AgentChoice>,
1903 available: &dyn Fn(&AgentSpec) -> bool,
1904 role: &str,
1905) -> Result<Vec<AgentSpec>> {
1906 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1907 if wanted.is_empty() {
1908 return pick(agents, None, available)
1909 .map(|s| vec![s])
1910 .with_context(|| format!("choose an agent for the {role} role"));
1911 }
1912 let mut chain: Vec<AgentSpec> = Vec::new();
1913 for id in wanted {
1914 if chain.iter().any(|s| s.id == id) {
1915 continue;
1916 }
1917 match pick(agents, Some(id), available) {
1918 Ok(spec) => chain.push(spec),
1919 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1920 }
1921 }
1922 if chain.is_empty() {
1923 bail!(
1924 "[roles] {role} names no agent that can run here; the roster has {}",
1925 ids(agents)
1926 );
1927 }
1928 Ok(chain)
1929}
1930
1931pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1937 match outcome {
1938 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1939 Err(_) => true,
1940 Ok(out) => output_advances(out),
1941 }
1942}
1943
1944pub fn output_advances(out: &AgentOutput) -> bool {
1948 out.quota_exhausted() || !out.usable()
1949}
1950
1951fn ids(agents: &[AgentSpec]) -> String {
1952 if agents.is_empty() {
1953 return "no agents at all".to_owned();
1954 }
1955 agents
1956 .iter()
1957 .map(|a| a.id.clone())
1958 .collect::<Vec<_>>()
1959 .join(", ")
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964 use super::*;
1965
1966 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1967
1968 fn command_helper(mode: &str) -> AgentSpec {
1971 crate::run::pin_test_home();
1973 AgentSpec {
1974 id: "helper".to_owned(),
1975 kind: AgentKind::Command,
1976 model: None,
1977 command: vec![
1978 std::env::current_exe()
1979 .expect("locate test helper")
1980 .to_string_lossy()
1981 .into_owned(),
1982 "--exact".to_owned(),
1983 "agent::tests::command_agent_test_helper".to_owned(),
1984 "--nocapture".to_owned(),
1985 ],
1986 extra_args: Vec::new(),
1987 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1988 prompt_delivery: None,
1989 max_turn_secs: None,
1990 }
1991 }
1992
1993 #[test]
1994 fn command_agent_test_helper() {
1995 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1996 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1997 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1998 Ok("no-cache") => println!(
1999 "{}",
2000 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
2001 ),
2002 Ok("ignore-stdin") => println!("done"),
2003 Ok("chatty-sleep") => {
2004 println!("i-said-something");
2005 std::thread::sleep(Duration::from_secs(30));
2006 }
2007 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
2008 Ok(other) => panic!("unknown command helper mode {other}"),
2009 Err(_) => {}
2010 }
2011 }
2012
2013 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
2014 AgentSpec {
2015 id: "a".to_owned(),
2016 kind,
2017 model: model.map(str::to_owned),
2018 command: vec!["echo".to_owned(), "{label}".to_owned()],
2019 extra_args: Vec::new(),
2020 env: BTreeMap::new(),
2021 prompt_delivery: None,
2022 max_turn_secs: None,
2023 }
2024 }
2025
2026 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
2027 Invocation {
2028 scope: crate::agent::InvocationScope::Repository,
2029 coordination: None,
2030 fetched_base: None,
2031 cwd,
2032 prompt: "do the thing",
2033 timeout: Duration::from_secs(900),
2034 allow_write,
2035 unsandboxed: false,
2036 sessions: true,
2037 artifacts: art,
2038 stem: "t",
2039 run: "test-run",
2040 node: "test",
2041 cache_dir: None,
2042 attachments: &[],
2043 writable: &[],
2044 }
2045 }
2046
2047 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
2048 build_command(
2049 &spec(kind, None),
2050 seat,
2051 &inv(Path::new("."), Path::new("/art"), allow_write),
2052 Path::new("/art/p.md"),
2053 )
2054 .unwrap()
2055 }
2056
2057 #[test]
2058 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
2059 let missing = editing_access_note(AgentKind::Codex, false, None);
2060 assert!(missing.contains("No successful launcher fetch evidence"));
2061 let base = crate::run::FetchedBase {
2062 remote: "origin".into(),
2063 branch: "main".into(),
2064 commit: "0123456789abcdef".into(),
2065 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
2066 };
2067 let note = editing_access_note(AgentKind::Codex, false, Some(&base));
2068 for expected in [
2069 "origin/main",
2070 "0123456789abcdef",
2071 "2026-10-18T00:00:00Z",
2072 "Do not fetch again",
2073 "launcher saves eligible edits",
2074 "Specification questions remain valid",
2075 ] {
2076 assert!(note.contains(expected), "missing {expected}");
2077 }
2078 for kind in [
2079 AgentKind::Codex,
2080 AgentKind::Claude,
2081 AgentKind::Opencode,
2082 AgentKind::Antigravity,
2083 AgentKind::Omp,
2084 AgentKind::Command,
2085 ] {
2086 for unsandboxed in [false, true] {
2087 let scoped = editing_access_note(kind, unsandboxed, Some(&base));
2088 assert!(scoped.contains(&base.commit));
2089 assert!(scoped.contains("Do not fetch again"));
2090 let restricted = kind == AgentKind::Codex && !unsandboxed;
2091 for claim in [
2092 "Network access is restricted",
2093 "protected Git administration paths",
2094 "launcher saves eligible edits",
2095 "unavailable sandbox permissions",
2096 ] {
2097 assert_eq!(scoped.contains(claim), restricted, "{kind:?}/{unsandboxed}");
2098 assert_eq!(
2099 editing_access_note(kind, unsandboxed, None).contains(claim),
2100 restricted,
2101 "{kind:?}/{unsandboxed} without fetch evidence",
2102 );
2103 }
2104 }
2105 }
2106 let roundtrip: crate::run::FetchedBase =
2107 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
2108 assert_eq!(
2109 editing_access_note(AgentKind::Codex, false, Some(&roundtrip)),
2110 note
2111 );
2112 }
2113
2114 #[test]
2115 fn editing_network_policy_is_explicit_on_initial_and_resume() {
2116 for resumed in [false, true] {
2117 let mut seat = SeatState::new("impl-A", "a", 7);
2118 if resumed {
2119 seat.turns = 1;
2120 seat.captured_session = Some("fixture-thread".into());
2121 }
2122 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2123 invocation.node = "implement";
2124 let plan = build_command(
2125 &spec(AgentKind::Codex, None),
2126 &seat,
2127 &invocation,
2128 Path::new("/art/p.md"),
2129 )
2130 .unwrap();
2131 let policy = plan
2132 .argv
2133 .iter()
2134 .position(|s| s == "sandbox_workspace_write.network_access=false")
2135 .unwrap();
2136 if resumed {
2137 assert!(policy < plan.argv.iter().position(|s| s == "resume").unwrap());
2138 }
2139 assert!(!plan.argv.iter().any(|s| s.contains("bypass")));
2140 invocation.node = "deputy";
2141 let deputy = build_command(
2142 &spec(AgentKind::Codex, None),
2143 &seat,
2144 &invocation,
2145 Path::new("/art/p.md"),
2146 )
2147 .unwrap();
2148 assert!(!deputy.argv.iter().any(|s| s.contains("network_access")));
2149 assert!(
2150 !plan_for(AgentKind::Codex, &seat, false)
2151 .argv
2152 .iter()
2153 .any(|s| s.contains("writable_roots"))
2154 );
2155 }
2156 }
2157
2158 #[test]
2159 fn claude_mints_then_resumes_the_same_uuid() {
2160 let mut seat = SeatState::new("judge-1", "a", 7);
2161 let uuid = seat.claude_session.clone().unwrap();
2162 let first = plan_for(AgentKind::Claude, &seat, true);
2163 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2164 assert!(!first.argv.iter().any(|a| a == "--resume"));
2165
2166 seat.turns = 1;
2167 let second = plan_for(AgentKind::Claude, &seat, true);
2168 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2169 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2170 }
2171
2172 #[test]
2173 fn github_bound_claude_seats_override_the_operators_answer_language() {
2174 let seat = SeatState::new("change-summary", "a", 7);
2175 let art = Path::new("/art");
2176 for (language, name) in [("en", "English"), ("", "English"), ("ja", "Japanese")] {
2177 let claude =
2178 for_github_text(&spec(AgentKind::Claude, None), language, Path::new(".")).unwrap();
2179 let plan = build_command(
2180 &claude,
2181 &seat,
2182 &inv(Path::new("."), art, false),
2183 Path::new("/art/p.md"),
2184 )
2185 .unwrap();
2186 let settings = format!(r#"{{"language":"{name}"}}"#);
2187 assert!(
2188 plan.argv
2189 .windows(2)
2190 .any(|w| w == ["--settings", settings.as_str()]),
2191 "{:?}",
2192 plan.argv
2193 );
2194 assert_eq!(plan.stdin.as_deref(), Some("do the thing"));
2197 }
2198 for kind in [
2199 AgentKind::Codex,
2200 AgentKind::Opencode,
2201 AgentKind::Antigravity,
2202 AgentKind::Omp,
2203 ] {
2204 assert!(
2205 for_github_text(&spec(kind, None), "en", Path::new("."))
2206 .unwrap()
2207 .extra_args
2208 .is_empty()
2209 );
2210 }
2211 }
2212
2213 #[test]
2214 fn github_text_preserves_existing_settings() {
2215 let dir = tempfile::tempdir().unwrap();
2216 let original = serde_json::json!({
2217 "language": "Japanese",
2218 "env": { "SEAT_MODE": "configured" },
2219 "apiKeyHelper": "credential-helper",
2220 "hooks": { "Stop": [] }
2221 });
2222 std::fs::write(dir.path().join("seat-settings.json"), original.to_string()).unwrap();
2223 for value in [original.to_string(), "./seat-settings.json".to_owned()] {
2224 for equals in [false, true] {
2225 let mut spec = spec(AgentKind::Claude, None);
2226 spec.extra_args = vec!["--settings={\"language\":\"French\"}".to_owned()];
2228 if equals {
2229 spec.extra_args.push(format!("--settings={value}"));
2230 } else {
2231 spec.extra_args
2232 .extend(["--settings".to_owned(), value.clone()]);
2233 }
2234 spec.extra_args.push("--verbose".to_owned());
2235 let updated = for_github_text(&spec, "en", dir.path()).unwrap();
2236 assert_eq!(updated.extra_args.len(), spec.extra_args.len());
2237 assert_eq!(updated.extra_args[0], spec.extra_args[0]);
2238 assert_eq!(updated.extra_args.last(), spec.extra_args.last());
2239 let value = &updated.extra_args[updated.extra_args.len() - 2];
2240 let actual: serde_json::Value =
2241 serde_json::from_str(value.strip_prefix("--settings=").unwrap_or(value))
2242 .unwrap();
2243 let mut expected = original.clone();
2244 expected["language"] = serde_json::json!("English");
2245 assert_eq!(actual, expected);
2246 }
2247 }
2248 let on_disk: serde_json::Value = serde_json::from_str(
2249 &std::fs::read_to_string(dir.path().join("seat-settings.json")).unwrap(),
2250 )
2251 .unwrap();
2252 assert_eq!(on_disk, original);
2253 }
2254
2255 #[test]
2256 fn github_text_settings_are_redacted_from_command_logging() {
2257 let dir = tempfile::tempdir().unwrap();
2258 let settings = serde_json::json!({ "env": { "AUTH_TOKEN": "test-only-secret" } });
2259 std::fs::write(dir.path().join("seat-settings.json"), settings.to_string()).unwrap();
2260 for equals in [false, true] {
2261 let mut spec = spec(AgentKind::Claude, None);
2262 spec.extra_args =
2263 vec!["--settings={\"env\":{\"AUTH_TOKEN\":\"earlier-secret\"}}".to_owned()];
2264 if equals {
2265 spec.extra_args
2266 .push("--settings=./seat-settings.json".to_owned());
2267 } else {
2268 spec.extra_args
2269 .extend(["--settings".to_owned(), "./seat-settings.json".to_owned()]);
2270 }
2271 let spec = for_github_text(&spec, "en", dir.path()).unwrap();
2272 let plan = build_command(
2273 &spec,
2274 &SeatState::new("change-summary", "a", 7),
2275 &inv(dir.path(), dir.path(), false),
2276 &dir.path().join("prompt.md"),
2277 )
2278 .unwrap();
2279 let actual = plan.argv.last().unwrap();
2281 let actual: serde_json::Value =
2282 serde_json::from_str(actual.strip_prefix("--settings=").unwrap_or(actual)).unwrap();
2283 assert_eq!(actual["env"]["AUTH_TOKEN"], "test-only-secret");
2284 assert_eq!(actual["language"], "English");
2285 let logged = format!("{:?}", plan.logged_argv());
2286 assert!(!logged.contains("secret"));
2287 assert!(!logged.contains("AUTH_TOKEN"));
2288 assert!(logged.contains("[redacted]"));
2289 assert_eq!(plan.logged_argv()[0], "claude");
2290 assert!(plan.logged_argv().contains(&"--output-format"));
2291 }
2292 }
2293
2294 #[test]
2295 fn github_text_rejects_unreadable_or_invalid_settings() {
2296 let dir = tempfile::tempdir().unwrap();
2297 std::fs::write(dir.path().join("bad.json"), "invalid json").unwrap();
2298 std::fs::write(dir.path().join("array.json"), "[]").unwrap();
2299 for args in [
2300 vec!["--settings"],
2301 vec!["--settings", "missing.json"],
2302 vec!["--settings=bad.json"],
2303 vec!["--settings", "array.json"],
2304 ] {
2305 let mut spec = spec(AgentKind::Claude, None);
2306 spec.extra_args = args.into_iter().map(str::to_owned).collect();
2307 assert!(for_github_text(&spec, "en", dir.path()).is_err());
2308 }
2309 }
2310
2311 #[test]
2312 fn read_only_seats_cannot_edit() {
2313 let seat = SeatState::new("judge-1", "a", 7);
2314 let claude = plan_for(AgentKind::Claude, &seat, false);
2315 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2316 assert!(
2317 !plan_for(AgentKind::Claude, &seat, true)
2318 .argv
2319 .iter()
2320 .any(|a| a == "--disallowed-tools")
2321 );
2322
2323 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2324 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2325 assert!(
2326 !agy.argv
2327 .iter()
2328 .any(|a| a == "--dangerously-skip-permissions")
2329 );
2330 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2331 assert!(
2332 agy_rw
2333 .argv
2334 .windows(2)
2335 .any(|w| w == ["--mode", "accept-edits"])
2336 );
2337 assert!(
2338 agy_rw
2339 .argv
2340 .iter()
2341 .any(|a| a == "--dangerously-skip-permissions")
2342 );
2343 let agy_prompt = agy_rw
2348 .argv
2349 .iter()
2350 .position(|a| a == "-p")
2351 .map(|i| agy_rw.argv[i + 1].clone())
2352 .expect("agy takes its prompt with -p");
2353 assert!(
2354 agy_prompt.starts_with('@'),
2355 "agy must get a file reference, got {agy_prompt:?}"
2356 );
2357 assert!(
2358 !agy_prompt.contains("Read the file at"),
2359 "the prose pointer is for CLIs with no file syntax"
2360 );
2361
2362 for allow_write in [false, true] {
2367 assert!(
2368 plan_for(AgentKind::Opencode, &seat, allow_write)
2369 .argv
2370 .iter()
2371 .any(|a| a == "--auto"),
2372 "opencode needs --auto even to read (allow_write = {allow_write})"
2373 );
2374 }
2375 }
2376
2377 #[test]
2380 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2381 let seat = SeatState::new("deputy-x", "a", 7);
2382 let roots = [PathBuf::from("/data/questions")];
2383 let mk = |allow_write: bool| {
2384 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2385 i.writable = &roots;
2386 build_command(
2387 &spec(AgentKind::Codex, None),
2388 &seat,
2389 &i,
2390 Path::new("/art/p.md"),
2391 )
2392 .unwrap()
2393 };
2394 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2395 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2396 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2397 }
2398
2399 #[tokio::test]
2400 async fn investigation_checks_cancellation_after_artifacts_before_spawning() {
2401 let tmp = tempfile::tempdir().unwrap();
2402 let artifacts = tmp.path().join("artifacts");
2403 let mut invocation = inv(tmp.path(), &artifacts, true);
2404 invocation.scope = InvocationScope::ConsultationInvestigation;
2405 invocation.unsandboxed = true;
2406 let mut seat = SeatState::new("chat", "fixture", 1);
2407 let error = invoke_leased(
2408 &spec(AgentKind::Command, None),
2409 &mut seat,
2410 &invocation,
2411 None,
2412 || {
2413 assert!(artifacts.join("t.prompt.md").exists());
2414 bail!("question closed during preparation")
2415 },
2416 )
2417 .await
2418 .unwrap_err();
2419 assert!(error.to_string().contains("question closed"));
2420 assert_eq!(seat.turns, 0);
2421 assert!(!artifacts.join("t.out").exists());
2422 }
2423
2424 #[test]
2425 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2426 let mut i = inv(Path::new("."), Path::new("/art"), true);
2427 for node in [
2428 "implement",
2429 "fix",
2430 "graft",
2431 "gate-fix",
2432 "land",
2433 "rebase",
2434 "chat",
2435 ] {
2436 i.node = node;
2437 assert!(repository_writer(&i), "{node}");
2438 i.allow_write = false;
2439 assert!(!repository_writer(&i), "{node}");
2440 i.allow_write = true;
2441 }
2442 for node in ["deputy", "judge", "review", "bump", "conduct", "talk-title"] {
2443 i.node = node;
2444 assert!(!repository_writer(&i), "{node}");
2445 }
2446 i.node = "chat";
2447 let roots = [PathBuf::from("/questions")];
2448 i.writable = &roots;
2449 assert!(
2450 repository_writer(&i),
2451 "roots alone do not select consultation"
2452 );
2453 i.scope = InvocationScope::ConsultationControl;
2454 assert!(!repository_writer(&i), "consultation keeps explicit grants");
2455 assert!(!repository_seat(&i));
2456 i.allow_write = false;
2457 i.writable = &[];
2458 assert!(!repository_seat(&i), "control scope needs no write roots");
2459 i.scope = InvocationScope::ConsultationInvestigation;
2460 assert!(repository_seat(&i));
2461 i.allow_write = true;
2462 assert!(
2463 !repository_writer(&i),
2464 "investigation never gains editing grants"
2465 );
2466 }
2467
2468 #[test]
2469 fn coordination_classification_is_independent_of_write_grants() {
2470 let roots = [PathBuf::from("/questions")];
2471 for node in [
2472 crate::queue::CHAT_NODE,
2473 "implement",
2474 "fix",
2475 "graft",
2476 "gate-fix",
2477 "land",
2478 "rebase",
2479 "review",
2480 "judge",
2481 "advise",
2482 "deputy",
2483 "waiter",
2484 "conduct",
2485 "dupes",
2486 "talk-title",
2487 ] {
2488 for allow_write in [false, true] {
2489 for writable in [&[][..], &roots[..]] {
2490 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2491 i.node = node;
2492 i.writable = writable;
2493 assert_eq!(
2494 repository_seat(&i),
2495 !matches!(
2496 node,
2497 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
2498 ),
2499 "{node}, allow_write={allow_write}, writable={writable:?}"
2500 );
2501 if node == crate::queue::CHAT_NODE {
2502 assert_eq!(repository_writer(&i), allow_write);
2503 }
2504 }
2505 }
2506 }
2507 }
2508
2509 #[test]
2510 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2511 let roots = [
2512 PathBuf::from("/shared/git"),
2513 PathBuf::from("/state/questions"),
2514 ];
2515 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2516 for resumed in [false, true] {
2517 let mut seat = SeatState::new("impl-A", "a", 7);
2518 if resumed {
2519 seat.turns = 1;
2520 seat.captured_session = Some("session-id".into());
2521 }
2522 for allow_write in [false, true] {
2523 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2524 i.writable = &roots;
2525 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2526 .unwrap();
2527 for root in &roots {
2528 if kind == AgentKind::Antigravity {
2529 assert_eq!(
2530 plan.argv.windows(2).any(|w| {
2531 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2532 }),
2533 allow_write,
2534 "resumed={resumed}: {:?}",
2535 plan.argv
2536 );
2537 }
2538 assert_eq!(
2539 plan.argv
2540 .iter()
2541 .any(|arg| arg.contains(root.to_str().unwrap())),
2542 allow_write,
2543 "{kind:?}, resumed={resumed}: {:?}",
2544 plan.argv
2545 );
2546 }
2547 if kind == AgentKind::Codex && resumed {
2548 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2549 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2550 }
2551 }
2552 }
2553 }
2554 }
2555
2556 #[test]
2557 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
2558 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2559 let mut seat = SeatState::new("talk", "a", 7);
2560 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2561 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2562 i.unsandboxed = unsandboxed;
2563 build_command(
2564 &spec(AgentKind::Codex, None),
2565 seat,
2566 &i,
2567 Path::new("/art/p.md"),
2568 )
2569 .unwrap()
2570 };
2571
2572 let first = build(&seat, true, true);
2574 assert!(first.argv.iter().any(|a| a == BYPASS));
2575 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2576 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2577
2578 seat.captured_session = Some("thread-1".to_owned());
2580 seat.turns = 1;
2581 let resumed = build(&seat, true, true);
2582 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2583 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2584
2585 let ro = build(&seat, false, true);
2587 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2588 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2589 }
2590
2591 #[test]
2592 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2593 let mut seat = SeatState::new("judge-1", "a", 7);
2594
2595 let ro = plan_for(AgentKind::Codex, &seat, false);
2599 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2600 let rw = plan_for(AgentKind::Codex, &seat, true);
2601 assert!(
2602 rw.argv
2603 .windows(2)
2604 .any(|w| w == ["--sandbox", "workspace-write"])
2605 );
2606 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2609 ro_flagged.unsandboxed = true;
2610 let ro_flagged = build_command(
2611 &spec(AgentKind::Codex, None),
2612 &seat,
2613 &ro_flagged,
2614 Path::new("/art/p.md"),
2615 )
2616 .unwrap();
2617 assert!(
2618 ro_flagged
2619 .argv
2620 .windows(2)
2621 .any(|w| w == ["--sandbox", "read-only"])
2622 );
2623 for p in [&ro, &rw, &ro_flagged] {
2624 assert!(
2625 !p.argv
2626 .iter()
2627 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2628 "the bypass defeats the only enforced read-only mode we have"
2629 );
2630 assert!(
2632 p.argv
2633 .windows(2)
2634 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2635 "an unattended seat that asks for approval blocks until timeout"
2636 );
2637 }
2638
2639 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2641 assert_eq!(
2642 ro.argv.last().map(String::as_str),
2643 Some("-"),
2644 "without the `-` argument codex waits for a prompt it never gets"
2645 );
2646
2647 seat.turns = 1;
2651 assert!(!has_session(AgentKind::Codex, &seat, true));
2652 assert!(
2653 !plan_for(AgentKind::Codex, &seat, true)
2654 .argv
2655 .iter()
2656 .any(|a| a == "resume")
2657 );
2658 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2659 let resumed = plan_for(AgentKind::Codex, &seat, true);
2660 let at = resumed
2661 .argv
2662 .iter()
2663 .position(|a| a == "resume")
2664 .expect("resumes by subcommand");
2665 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2666 assert!(
2667 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2668 "every option precedes the subcommand"
2669 );
2670 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2671 }
2672
2673 #[test]
2676 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2677 let mut seat = SeatState::new("review-1", "a", 7);
2678
2679 let first = plan_for(AgentKind::Omp, &seat, false);
2683 assert!(first.argv.iter().any(|a| a == "-p"));
2684 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2685 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2686 assert!(
2687 !first.argv.iter().any(|a| a == "do the thing"),
2688 "the prompt reached argv, where Windows caps it"
2689 );
2690
2691 for allow_write in [false, true] {
2697 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2698 assert!(
2699 p.argv.iter().any(|a| a == "--auto-approve"),
2700 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2701 );
2702 assert!(
2703 !p.argv
2704 .iter()
2705 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2706 "nothing ever asks for the bypass"
2707 );
2708 }
2709
2710 seat.turns = 1;
2713 assert!(!has_session(AgentKind::Omp, &seat, true));
2714 assert!(
2715 !plan_for(AgentKind::Omp, &seat, true)
2716 .argv
2717 .iter()
2718 .any(|a| a == "--resume")
2719 );
2720 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2721 let resumed = plan_for(AgentKind::Omp, &seat, true);
2722 assert!(
2723 resumed
2724 .argv
2725 .windows(2)
2726 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2727 "a captured id is what makes the next turn a resume"
2728 );
2729 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2732 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2734 }
2735
2736 #[test]
2741 fn omp_takes_the_answer_without_an_agent_end_line() {
2742 let stream = concat!(
2743 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2744 "\n",
2745 r#"{"type":"agent_start"}"#,
2746 "\n",
2747 r#"{"type":"turn_start"}"#,
2748 "\n",
2749 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2750 "\n",
2751 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2752 "\n",
2753 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2754 "\n",
2755 );
2756 let out = extract(AgentKind::Omp, stream);
2757 assert_eq!(
2758 out.text, "{\"vote\":\"approve\"}",
2759 "the last assistant text block is the answer even with no agent_end"
2760 );
2761 assert_eq!(
2762 out.session.as_deref(),
2763 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2764 );
2765 }
2766
2767 #[test]
2771 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2772 let stream = concat!(
2773 r#"{"type":"session","version":3,"id":"s1"}"#,
2774 "\n",
2775 "{\"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問題ありません。\"}]}]}",
2776 "\n",
2777 );
2778 let out = extract(AgentKind::Omp, stream);
2779 assert_eq!(
2780 out.text, "## 判定\n\n問題ありません。",
2781 "the narration is not the answer, and non-ASCII survives intact"
2782 );
2783 assert_eq!(out.session.as_deref(), Some("s1"));
2784 }
2785
2786 #[test]
2789 fn omp_skips_non_json_lines() {
2790 let stream = concat!(
2791 "Warning: some omp notice\n",
2792 r#"{"type":"session","version":3,"id":"s2"}"#,
2793 "\n",
2794 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2795 "\n",
2796 "trailing junk",
2797 "\n",
2798 );
2799 let out = extract(AgentKind::Omp, stream);
2800 assert_eq!(out.text, "the answer");
2801 assert_eq!(out.session.as_deref(), Some("s2"));
2802 }
2803
2804 #[test]
2806 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2807 let stream = concat!(
2808 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2809 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2810 "\n",
2811 r#"{"type":"turn.started"}"#,
2812 "\n",
2813 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2814 "\n",
2815 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2816 "\n",
2817 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2818 "\n",
2819 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2820 "\n",
2821 );
2822 let out = extract(AgentKind::Codex, stream);
2823 assert_eq!(
2824 out.text, "{\"verdict\": \"ok\"}",
2825 "the last agent message is the answer; earlier ones narrate"
2826 );
2827 assert_eq!(
2828 out.session.as_deref(),
2829 Some("01a07440-4545-7492-85c1-024e3259a90a")
2830 );
2831 assert_eq!(out.status.as_deref(), Some("success"));
2832
2833 let failed = concat!(
2834 r#"{"type":"thread.started","thread_id":"t1"}"#,
2835 "\n",
2836 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2837 "\n",
2838 );
2839 assert_eq!(
2840 extract(AgentKind::Codex, failed).status.as_deref(),
2841 Some("error")
2842 );
2843 }
2844
2845 #[test]
2846 fn captured_sessions_resume_only_once_reported() {
2847 let mut seat = SeatState::new("impl-A", "a", 7);
2848 seat.turns = 1;
2849 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2850 assert!(!has_session(kind, &seat, true));
2851 let p = plan_for(kind, &seat, true);
2852 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2853 }
2854
2855 seat.captured_session = Some("sid".to_owned());
2856 assert!(has_session(AgentKind::Opencode, &seat, true));
2857 assert!(
2858 plan_for(AgentKind::Opencode, &seat, true)
2859 .argv
2860 .windows(2)
2861 .any(|w| w == ["-s", "sid"])
2862 );
2863 assert!(
2864 plan_for(AgentKind::Antigravity, &seat, true)
2865 .argv
2866 .windows(2)
2867 .any(|w| w == ["--conversation", "sid"])
2868 );
2869 }
2870
2871 #[test]
2872 fn sessions_disabled_never_resumes() {
2873 let mut seat = SeatState::new("impl-A", "a", 7);
2874 seat.turns = 3;
2875 seat.captured_session = Some("sid".to_owned());
2876 for kind in [
2877 AgentKind::Claude,
2878 AgentKind::Opencode,
2879 AgentKind::Antigravity,
2880 ] {
2881 assert!(!has_session(kind, &seat, false));
2882 }
2883 }
2884
2885 #[test]
2886 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2887 let seat = SeatState::new("judge-1", "a", 7);
2888 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2889 let p = plan_for(kind, &seat, false);
2890 assert!(
2891 p.argv.iter().all(|a| a != "do the thing"),
2892 "{kind:?} put the prompt on the command line"
2893 );
2894 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2895 }
2896 let p = plan_for(AgentKind::Antigravity, &seat, false);
2898 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2899 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2900 assert!(p.stdin.is_none());
2901 }
2902
2903 #[test]
2904 fn agy_print_timeout_tracks_the_node_budget() {
2905 let seat = SeatState::new("impl-A", "a", 7);
2906 let p = build_command(
2907 &spec(AgentKind::Antigravity, None),
2908 &seat,
2909 &Invocation {
2910 scope: crate::agent::InvocationScope::Repository,
2911 coordination: None,
2912 fetched_base: None,
2913 cwd: Path::new("."),
2914 prompt: "p",
2915 timeout: Duration::from_secs(3600),
2916 allow_write: true,
2917 unsandboxed: false,
2918 sessions: true,
2919 artifacts: Path::new("/art"),
2920 stem: "t",
2921 run: "test-run",
2922 node: "test",
2923 cache_dir: None,
2924 attachments: &[],
2925 writable: &[],
2926 },
2927 Path::new("/art/p.md"),
2928 )
2929 .unwrap();
2930 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2931 }
2932
2933 #[test]
2940 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2941 let mut s = spec(AgentKind::Antigravity, None);
2942 s.prompt_delivery = Some(Delivery::Argv);
2943 let seat = SeatState::new("talk", "a", 7);
2944 let atts = [PathBuf::from("/art/attachments/abc.png")];
2945
2946 let without = build_command(
2947 &s,
2948 &seat,
2949 &Invocation {
2950 attachments: &[],
2951 writable: &[],
2952 ..inv(Path::new("."), Path::new("/art"), true)
2953 },
2954 Path::new("/art/p.md"),
2955 )
2956 .unwrap();
2957 assert!(
2958 !without.argv.iter().any(|a| a == "--add-dir"),
2959 "no attachment, no reason to widen the sandbox: {without:?}"
2960 );
2961
2962 let with = build_command(
2963 &s,
2964 &seat,
2965 &Invocation {
2966 attachments: &atts,
2967 writable: &[],
2968 ..inv(Path::new("."), Path::new("/art"), true)
2969 },
2970 Path::new("/art/p.md"),
2971 )
2972 .unwrap();
2973 assert!(
2974 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2975 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2976 );
2977 }
2978
2979 #[test]
2985 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2986 let seat = SeatState::new("plan", "a", 7);
2987 let atts = [
2988 PathBuf::from("/art/attachments/own.png"),
2989 PathBuf::from("/other-chat/attachments/inherited.png"),
2990 ];
2991
2992 let p = build_command(
2993 &spec(AgentKind::Antigravity, None),
2994 &seat,
2995 &Invocation {
2996 attachments: &atts,
2997 writable: &[],
2998 ..inv(Path::new("."), Path::new("/art"), true)
2999 },
3000 Path::new("/art/p.md"),
3001 )
3002 .unwrap();
3003
3004 assert!(
3005 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
3006 "this conversation's own artifacts dir must still be granted: {p:?}"
3007 );
3008 assert!(
3009 p.argv
3010 .windows(2)
3011 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
3012 "the inherited attachment's own directory must be granted too: {p:?}"
3013 );
3014 }
3015
3016 #[test]
3017 fn command_agents_get_placeholders_substituted() {
3018 let seat = SeatState::new("impl-A", "a", 7);
3019 let p = plan_for(AgentKind::Command, &seat, true);
3020 assert_eq!(p.argv[0], "echo");
3021 assert_eq!(p.argv[1], "impl-A");
3022 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
3023 }
3024
3025 #[test]
3026 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
3027 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
3029 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
3030 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
3031 let out = extract(AgentKind::Claude, stdout);
3032 let quota = out.quota.as_ref().expect("rate limit must be detected");
3033 assert_eq!(
3034 quota.reset.as_deref(),
3035 Some("4:50am (Asia/Tokyo)"),
3036 "reset time read from the body"
3037 );
3038 }
3039
3040 #[test]
3041 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
3042 let out = extract(
3043 AgentKind::Claude,
3044 r#"{"is_error":true,"result":"session limit reached"}"#,
3045 );
3046 let quota = out.quota.expect("rate limit detected without a reset");
3047 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
3048 }
3049
3050 #[test]
3051 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
3052 for kind in [AgentKind::Claude, AgentKind::Command] {
3053 for reply in [
3054 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3055 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
3056 ] {
3057 assert!(extract(kind, reply).quota.is_some(), "{reply}");
3058 }
3059 for reply in [
3060 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3061 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
3062 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
3063 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
3064 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
3065 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
3066 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
3067 ] {
3068 assert!(extract(kind, reply).quota.is_none(), "{reply}");
3069 }
3070 }
3071 }
3072
3073 #[test]
3074 fn ordinary_failures_are_never_quota() {
3075 let claude_fail = extract(
3077 AgentKind::Claude,
3078 r#"{"is_error":true,"result":"account does not exist"}"#,
3079 );
3080 assert!(claude_fail.quota.is_none());
3081
3082 let cmd_fail = extract(AgentKind::Command, "boom");
3084 assert!(cmd_fail.quota.is_none());
3085
3086 let success = extract(
3088 AgentKind::Command,
3089 r#"{"is_error":false,"result":"session limit is fine"}"#,
3090 );
3091 assert!(success.quota.is_none());
3092 }
3093
3094 #[test]
3102 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
3103 let stream = concat!(
3104 r#"{"type":"thread.started","thread_id":"t1"}"#,
3105 "\n",
3106 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"}}"#,
3107 "\n",
3108 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"}}"#,
3109 "\n",
3110 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
3111 "\n",
3112 r#"{"type":"turn.completed"}"#,
3113 "\n",
3114 );
3115 let out = extract(AgentKind::Codex, stream);
3116 assert_eq!(out.text, "Both tests in the target pass.");
3117 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
3118
3119 let paired = &out.commands[0];
3120 assert_eq!(paired.id, "item49");
3121 assert_eq!(paired.exit_code, Some(1));
3122 assert!(paired.description.contains("graph_cached_gate"));
3123 assert!(paired.result_summary.contains("1 failed"));
3124
3125 let solo = &out.commands[1];
3126 assert_eq!(solo.exit_code, Some(0));
3127
3128 assert!(
3132 out.commands
3133 .iter()
3134 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
3135 "a failed run of the actual target must still be visible: {:?}",
3136 out.commands
3137 );
3138 }
3139
3140 #[test]
3141 fn command_agent_can_carry_the_claude_quota_shape() {
3142 let out = extract(
3143 AgentKind::Command,
3144 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
3145 );
3146 assert!(
3147 out.quota.is_some(),
3148 "a wrapper emitting the claude shape counts as quota"
3149 );
3150 }
3151
3152 #[test]
3153 fn claude_json_result_is_extracted() {
3154 let out = extract(
3155 AgentKind::Claude,
3156 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
3157 );
3158 assert_eq!(out.text, "all done");
3159 assert_eq!(out.session.as_deref(), Some("abc"));
3160 assert_eq!(out.status.as_deref(), Some("success"));
3161 }
3162
3163 #[test]
3178 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
3179 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"}"#;
3180 let out = extract(AgentKind::Claude, stdout);
3181 assert_eq!(out.status.as_deref(), Some("success"));
3182 assert!(out.quota.is_none());
3183 assert!(!out.text.trim().is_empty());
3184
3185 let agent_out = AgentOutput {
3186 text: out.text.clone(),
3187 exit_code: Some(0),
3188 timed_out: false,
3189 duration_ms: 500,
3190 artifacts: Vec::new(),
3191 quota: out.quota,
3192 dropped: out.dropped,
3193 commands: out.commands,
3194 context_tokens: None,
3195 };
3196 assert!(
3197 agent_out.usable(),
3198 "the CLI turn itself ended cleanly and must read as usable"
3199 );
3200 assert!(
3201 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
3202 "a clean CLI turn is not proof the node's own report ever arrived"
3203 );
3204 }
3205
3206 #[test]
3207 fn opencode_event_stream_is_concatenated() {
3208 let stream = concat!(
3209 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
3210 "\n",
3211 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
3212 "\n",
3213 "garbage line\n",
3214 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
3215 "\n"
3216 );
3217 let out = extract(AgentKind::Opencode, stream);
3218 assert_eq!(out.text, "first\nsecond");
3219 assert_eq!(out.session.as_deref(), Some("ses_1"));
3220 }
3221
3222 #[test]
3223 fn agy_json_survives_a_leading_warning_line() {
3224 let stdout = concat!(
3225 "warning: --mode plan has no effect while slash commands are disabled.\n",
3226 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
3227 "\n"
3228 );
3229 let out = extract(AgentKind::Antigravity, stdout);
3230 assert_eq!(out.text, "persimmon");
3231 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
3232 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
3233 }
3234
3235 const AGY_DROPPED: &str = concat!(
3242 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
3243 r#""response":"","error":"the connection to the agent was interrupted before "#,
3244 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
3245 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
3246 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
3247 r#""total_tokens":274380}}"#
3248 );
3249
3250 const AGY_QUOTA_OUT: &str = concat!(
3252 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
3253 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
3254 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
3255 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
3256 );
3257 const AGY_QUOTA_ERR: &str = concat!(
3258 "error: Individual quota reached. Resets in 1h2m49s.\n",
3259 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3260 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3261 r#""retryable":true}"#,
3262 "\n"
3263 );
3264
3265 #[test]
3266 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3267 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3268 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3269 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3270 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3271 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3273 assert!(stderr_only.reset.is_none());
3274 }
3275
3276 #[test]
3277 fn ordinary_agy_failures_are_not_a_quota() {
3278 assert!(agy_quota(AGY_DROPPED, "").is_none());
3279 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3280 assert!(
3281 agy_quota(
3282 "",
3283 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3284 )
3285 .is_none()
3286 );
3287 assert!(
3288 agy_quota(
3289 "",
3290 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3291 )
3292 .is_none()
3293 );
3294 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3295 }
3296
3297 #[test]
3298 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3299 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3300 let dropped = out.dropped.expect("recognised as undelivered work");
3301 assert_eq!(dropped.output_tokens, 14267);
3302 assert!(
3303 dropped.why.contains("subscriber fell behind"),
3304 "the CLI's own words are kept for the record: {}",
3305 dropped.why
3306 );
3307 assert_eq!(
3310 out.session.as_deref(),
3311 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3312 );
3313 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3314 }
3315
3316 #[test]
3317 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3318 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3322 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3323
3324 let answered = concat!(
3327 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3328 r#""usage":{"output_tokens":10}}"#
3329 );
3330 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3331
3332 let ok = concat!(
3334 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3335 r#""usage":{"output_tokens":10}}"#
3336 );
3337 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3338 }
3339
3340 #[test]
3341 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3342 let out = AgentOutput {
3343 text: String::new(),
3344 exit_code: Some(1),
3345 timed_out: false,
3346 duration_ms: 431_194,
3347 artifacts: Vec::new(),
3348 quota: None,
3349 dropped: Some(Dropped {
3350 why: "subscriber fell behind updates".to_owned(),
3351 output_tokens: 14267,
3352 }),
3353 commands: Vec::new(),
3354 context_tokens: None,
3355 };
3356 assert!(!out.usable());
3357 assert!(out.work_undelivered());
3358 assert!(!out.quota_exhausted());
3361 }
3362
3363 #[test]
3364 fn non_json_stdout_falls_back_to_raw_text() {
3365 let out = extract(AgentKind::Antigravity, "plain answer\n");
3366 assert_eq!(out.text, "plain answer");
3367 assert!(out.session.is_none());
3368 }
3369
3370 #[tokio::test]
3371 async fn command_agent_round_trip_writes_artifacts() {
3372 let dir = tempfile::tempdir().unwrap();
3373 let art = dir.path().join("artifacts");
3374 let mut seat = SeatState::new("impl-A", "a", 7);
3375 let s = command_helper("reply");
3376 let out = invoke(
3377 &s,
3378 &mut seat,
3379 &Invocation {
3380 scope: crate::agent::InvocationScope::Repository,
3381 coordination: None,
3382 fetched_base: None,
3383 cwd: dir.path(),
3384 prompt: "unused",
3385 timeout: Duration::from_secs(30),
3386 allow_write: true,
3387 unsandboxed: false,
3388 sessions: true,
3389 artifacts: &art,
3390 stem: "impl-A",
3391 run: "test-run",
3392 node: "test",
3393 cache_dir: None,
3394 attachments: &[],
3395 writable: &[],
3396 },
3397 )
3398 .await
3399 .unwrap();
3400 assert!(out.usable(), "{out:?}");
3401 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3402 assert_eq!(seat.turns, 1);
3403 assert!(art.join("impl-A.prompt.md").is_file());
3404 assert!(art.join("impl-A.out").is_file());
3405 }
3406
3407 #[tokio::test]
3408 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3409 crate::run::pin_test_home();
3410 let dir = tempfile::tempdir().unwrap();
3414 let cache = dir.path().join("magi-cache");
3415 let mut seat = SeatState::new("impl-A", "a", 7);
3416 let s = command_helper("cache");
3417 let out = invoke(
3418 &s,
3419 &mut seat,
3420 &Invocation {
3421 scope: crate::agent::InvocationScope::Repository,
3422 coordination: None,
3423 fetched_base: None,
3424 cwd: dir.path(),
3425 prompt: "unused",
3426 timeout: Duration::from_secs(30),
3427 allow_write: true,
3428 unsandboxed: false,
3429 sessions: true,
3430 artifacts: &dir.path().join("artifacts"),
3431 stem: "cache",
3432 run: "test-run",
3433 node: "test",
3434 cache_dir: Some(&cache),
3435 attachments: &[],
3436 writable: &[],
3437 },
3438 )
3439 .await
3440 .unwrap();
3441 assert!(out.usable(), "{out:?}");
3442 assert!(
3443 out.text.contains(cache.to_string_lossy().as_ref()),
3444 "the seat must see CARGO_TARGET_DIR = the shared cache"
3445 );
3446 }
3447
3448 #[tokio::test]
3449 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3450 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3458 unsafe {
3463 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3464 }
3465 let dir = tempfile::tempdir().unwrap();
3466 let mut seat = SeatState::new("review-1", "a", 7);
3467 let s = command_helper("no-cache");
3468 let result = invoke(
3469 &s,
3470 &mut seat,
3471 &Invocation {
3472 scope: crate::agent::InvocationScope::Repository,
3473 coordination: None,
3474 fetched_base: None,
3475 cwd: dir.path(),
3476 prompt: "unused",
3477 timeout: Duration::from_secs(30),
3478 allow_write: false,
3479 unsandboxed: false,
3480 sessions: true,
3481 artifacts: &dir.path().join("artifacts"),
3482 stem: "no-cache",
3483 run: "test-run",
3484 node: "test",
3485 cache_dir: None,
3486 attachments: &[],
3487 writable: &[],
3488 },
3489 )
3490 .await;
3491 unsafe {
3496 match &previous {
3497 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3498 None => std::env::remove_var("CARGO_TARGET_DIR"),
3499 }
3500 }
3501 let out = result.unwrap();
3502 assert!(out.usable(), "{out:?}");
3503 assert!(
3504 out.text.contains("ABSENT"),
3505 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3506 out.text
3507 );
3508 }
3509
3510 #[tokio::test]
3511 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3512 let dir = tempfile::tempdir().unwrap();
3513 let mut seat = SeatState::new("impl-A", "a", 7);
3514 let s = command_helper("ignore-stdin");
3517 let big = "x".repeat(1_000_000);
3518 let out = invoke(
3519 &s,
3520 &mut seat,
3521 &Invocation {
3522 scope: crate::agent::InvocationScope::Repository,
3523 coordination: None,
3524 fetched_base: None,
3525 cwd: dir.path(),
3526 prompt: &big,
3527 timeout: Duration::from_secs(60),
3528 allow_write: true,
3529 unsandboxed: false,
3530 sessions: true,
3531 artifacts: &dir.path().join("artifacts"),
3532 stem: "big",
3533 run: "test-run",
3534 node: "test",
3535 cache_dir: None,
3536 attachments: &[],
3537 writable: &[],
3538 },
3539 )
3540 .await
3541 .unwrap();
3542 assert!(out.usable(), "{out:?}");
3543 assert!(out.text.contains("done"), "{}", out.text);
3544 }
3545
3546 #[tokio::test]
3547 async fn agent_turn_cap_limits_a_direct_invocation() {
3548 let dir = tempfile::tempdir().unwrap();
3549 let artifacts = dir.path().join("artifacts");
3550 let mut seat = SeatState::new("impl-A", "helper", 7);
3551 let mut s = command_helper("sleep");
3552 s.max_turn_secs = Some(2);
3553 let out = invoke(&s, &mut seat, &inv(dir.path(), &artifacts, false))
3554 .await
3555 .unwrap();
3556 assert!(out.timed_out, "{out:?}");
3557 assert!(out.duration_ms < 20_000, "{out:?}");
3560 }
3561
3562 #[tokio::test]
3563 async fn timeout_is_reported_not_hung() {
3564 let dir = tempfile::tempdir().unwrap();
3565 let mut seat = SeatState::new("impl-A", "a", 7);
3566 let s = command_helper("sleep");
3567 let out = invoke(
3568 &s,
3569 &mut seat,
3570 &Invocation {
3571 scope: crate::agent::InvocationScope::Repository,
3572 coordination: None,
3573 fetched_base: None,
3574 cwd: dir.path(),
3575 prompt: "unused",
3576 timeout: Duration::from_millis(300),
3577 allow_write: true,
3578 unsandboxed: false,
3579 sessions: true,
3580 artifacts: &dir.path().join("artifacts"),
3581 stem: "slow",
3582 run: "test-run",
3583 node: "test",
3584 cache_dir: None,
3585 attachments: &[],
3586 writable: &[],
3587 },
3588 )
3589 .await
3590 .unwrap();
3591 assert!(out.timed_out);
3592 assert!(!out.usable());
3593 }
3594
3595 #[tokio::test]
3596 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3597 let dir = tempfile::tempdir().unwrap();
3603 let artifacts = dir.path().join("artifacts");
3604 let mut seat = SeatState::new("impl-A", "a", 7);
3605 let s = command_helper("chatty-sleep");
3606 let out = invoke(
3607 &s,
3608 &mut seat,
3609 &Invocation {
3610 scope: crate::agent::InvocationScope::Repository,
3611 coordination: None,
3612 fetched_base: None,
3613 cwd: dir.path(),
3614 prompt: "unused",
3615 timeout: Duration::from_secs(10),
3620 allow_write: true,
3621 unsandboxed: false,
3622 sessions: true,
3623 artifacts: &artifacts,
3624 stem: "chatty",
3625 run: "test-run",
3626 node: "test",
3627 cache_dir: None,
3628 attachments: &[],
3629 writable: &[],
3630 },
3631 )
3632 .await
3633 .unwrap();
3634
3635 assert!(out.timed_out, "{out:?}");
3636 assert!(!out.usable(), "a cut-off answer is still not an answer");
3637 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3638 assert!(
3639 recorded.contains("i-said-something"),
3640 "the artifact must keep what arrived before the kill, got {recorded:?}"
3641 );
3642 assert!(
3643 out.text.contains("i-said-something"),
3644 "and the graph must be able to see it too, got {:?}",
3645 out.text
3646 );
3647 }
3648
3649 #[test]
3650 fn missing_programs_reports_command_binaries() {
3651 let mut s = spec(AgentKind::Command, None);
3652 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3653 assert_eq!(
3654 missing_programs(&[s]),
3655 ["definitely-not-a-real-binary-xyz".to_owned()]
3656 );
3657 }
3658
3659 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3660 AgentSpec {
3661 id: id.to_owned(),
3662 kind,
3663 model: None,
3664 command: Vec::new(),
3665 extra_args: Vec::new(),
3666 env: BTreeMap::new(),
3667 prompt_delivery: None,
3668 max_turn_secs: None,
3669 }
3670 }
3671
3672 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3675 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3676 }
3677
3678 #[test]
3679 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3680 let agents = [
3681 pick_spec("oc", AgentKind::Opencode),
3682 pick_spec("opus", AgentKind::Claude),
3683 pick_spec("agy", AgentKind::Antigravity),
3684 ];
3685 let got = pick(&agents, None, &without(&[])).expect("a pick");
3686 assert_eq!(got.id, "opus");
3687 }
3688
3689 #[test]
3690 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3691 let agents = [
3692 pick_spec("opus", AgentKind::Claude),
3693 pick_spec("oc", AgentKind::Opencode),
3694 pick_spec("agy", AgentKind::Antigravity),
3695 ];
3696 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3697 assert_eq!(got.id, "agy");
3698 }
3699
3700 #[test]
3701 fn pick_on_an_empty_roster_says_what_to_install() {
3702 let msg = pick(&[], None, &without(&[]))
3703 .expect_err("nobody to ask")
3704 .to_string();
3705 assert!(msg.contains("roster is empty"), "{msg}");
3706 assert!(msg.contains("claude"), "{msg}");
3707 assert!(msg.contains("magi.toml"), "{msg}");
3708 }
3709
3710 #[test]
3711 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3712 let agents = [
3713 pick_spec("opus", AgentKind::Claude),
3714 pick_spec("oc", AgentKind::Opencode),
3715 ];
3716 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3717 let msg = format!("{err:#}");
3718 assert!(msg.contains("claude"), "{msg}");
3719 assert!(msg.contains("opencode"), "{msg}");
3720 }
3721
3722 #[test]
3723 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3724 let agents = [
3725 pick_spec("opus", AgentKind::Claude),
3726 pick_spec("oc", AgentKind::Opencode),
3727 ];
3728 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3729 assert_eq!(got.id, "oc");
3730 }
3731
3732 #[test]
3733 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3734 let agents = [
3735 pick_spec("opus", AgentKind::Claude),
3736 pick_spec("oc", AgentKind::Opencode),
3737 ];
3738 let msg = pick(&agents, Some("gemini"), &without(&[]))
3739 .expect_err("no such agent")
3740 .to_string();
3741 assert!(msg.contains("gemini"), "{msg}");
3742 assert!(msg.contains("opus, oc"), "{msg}");
3743 }
3744
3745 #[test]
3746 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3747 let agents = [
3748 pick_spec("opus", AgentKind::Claude),
3749 pick_spec("oc", AgentKind::Opencode),
3750 ];
3751 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3752 .expect_err("must not silently substitute another model")
3753 .to_string();
3754 assert!(msg.contains("opencode"), "{msg}");
3755 assert!(msg.contains("--agent"), "{msg}");
3756 }
3757
3758 fn named(id: &str) -> AgentSpec {
3759 AgentSpec {
3760 id: id.to_owned(),
3761 ..spec(AgentKind::Command, None)
3762 }
3763 }
3764
3765 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3766 AgentOutput {
3767 text: text.to_owned(),
3768 exit_code: Some(exit),
3769 timed_out: false,
3770 duration_ms: 0,
3771 artifacts: Vec::new(),
3772 quota: quota.then_some(Quota { reset: None }),
3773 dropped: None,
3774 commands: Vec::new(),
3775 context_tokens: None,
3776 }
3777 }
3778
3779 #[test]
3780 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3781 let agents = [named("a"), named("b"), named("c")];
3782 let all = |_: &AgentSpec| true;
3783 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3784 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3785 assert_eq!(
3786 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3787 ["c", "a"]
3788 );
3789
3790 let one = AgentChoice::from("b");
3791 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3792 assert_eq!(got.len(), 1);
3793 assert_eq!(got[0].id, "b");
3794
3795 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3796 assert_eq!(got.len(), 1, "unset keeps pick's default");
3797 assert_eq!(got[0].id, "a");
3798 let empty = AgentChoice::Chain(Vec::new());
3799 assert_eq!(
3800 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3801 "a"
3802 );
3803 }
3804
3805 #[test]
3806 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3807 let agents = [named("a"), named("b")];
3808 let not_a = |s: &AgentSpec| s.id != "a";
3809 let chain = AgentChoice::Chain(
3810 ["a", "ghost", "b", "b"]
3811 .iter()
3812 .map(|s| (*s).to_owned())
3813 .collect(),
3814 );
3815 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3816 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3817
3818 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3819 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3820 assert_eq!(
3821 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3822 ["b", "a"]
3823 );
3824 }
3825
3826 #[test]
3827 fn a_chain_with_nothing_runnable_names_the_role() {
3828 let agents = [named("a")];
3829 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3830 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3831 .unwrap_err()
3832 .to_string();
3833 assert!(err.contains("conductor"), "{err}");
3834 }
3835
3836 #[test]
3837 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3838 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3839 assert!(chain_advances(&Ok(output("limit", 0, true))));
3840 assert!(chain_advances(&Ok(output("", 0, false))));
3841 assert!(chain_advances(&Ok(output("x", 1, false))));
3842 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3843 }
3844
3845 #[test]
3846 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3847 for out in [
3848 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3849 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3850 r#"{"result":"ok"}"#,
3851 "not json",
3852 ] {
3853 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3854 }
3855 }
3856
3857 #[test]
3858 fn opencode_context_tokens_take_the_last_step_finish() {
3859 let out = concat!(
3860 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3861 "\n",
3862 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3863 "\n",
3864 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3865 "\n"
3866 );
3867 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3868 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3869 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3870 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3871 }
3872
3873 #[test]
3874 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3875 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3876 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3877 }
3878
3879 #[test]
3880 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3881 let out = concat!(
3882 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3883 "\n"
3884 );
3885 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3886 }
3887
3888 #[test]
3889 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3890 let out = concat!(
3891 r#"{"type":"session","id":"s"}"#,
3892 "\n",
3893 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3894 "\n",
3895 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3896 "\n"
3897 );
3898 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3899 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3900 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3901 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3902 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3903 }
3904
3905 #[test]
3906 fn command_agents_report_no_context_tokens() {
3907 let out = r#"{"usage":{"input_tokens":5}}"#;
3908 assert_eq!(context_tokens(AgentKind::Command, out), None);
3909 }
3910}