1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::time::{Duration, Instant};
36
37use anyhow::{Context as _, Result, bail};
38use serde::{Deserialize, Serialize};
39use std::sync::{Arc, Mutex};
40
41use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
42use tokio::process::Command;
43
44use crate::config::{AgentChoice, AgentKind, AgentSpec, Delivery};
45use crate::proc::Quiet as _;
46use crate::rng::SplitMix64;
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct SeatState {
52 pub key: String,
54 pub agent: String,
56 pub turns: usize,
58 pub claude_session: Option<String>,
61 pub captured_session: Option<String>,
63}
64
65impl SeatState {
66 pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
68 let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
69 Self {
70 key: key.to_owned(),
71 agent: agent.to_owned(),
72 turns: 0,
73 claude_session: Some(rng.uuid_v4()),
74 captured_session: None,
75 }
76 }
77}
78
79pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
81 if !sessions_enabled || seat.turns == 0 {
82 return false;
83 }
84 match kind {
85 AgentKind::Claude => seat.claude_session.is_some(),
86 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
87 seat.captured_session.is_some()
88 }
89 AgentKind::Command => true,
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct Invocation<'a> {
96 pub coordination: Option<&'a crate::config::Coordination>,
98 pub editing_network: crate::config::EditingNetwork,
100 pub fetched_base: Option<&'a crate::run::FetchedBase>,
102 pub cwd: &'a Path,
105 pub prompt: &'a str,
107 pub timeout: Duration,
109 pub allow_write: bool,
111 pub unsandboxed: bool,
117 pub sessions: bool,
119 pub artifacts: &'a Path,
121 pub stem: &'a str,
123 pub run: &'a str,
127 pub node: &'a str,
131 pub cache_dir: Option<&'a Path>,
137 pub attachments: &'a [PathBuf],
144 pub writable: &'a [PathBuf],
149}
150
151fn repository_writer(inv: &Invocation<'_>) -> bool {
152 inv.allow_write
153 && (matches!(
154 inv.node,
155 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
156 ) || inv.node == crate::queue::CHAT_NODE)
157}
158
159fn requested_network_access(kind: AgentKind, inv: &Invocation<'_>) -> Option<bool> {
162 (kind == AgentKind::Codex && repository_writer(inv) && !inv.unsandboxed)
163 .then_some(inv.editing_network == crate::config::EditingNetwork::Enabled)
164}
165
166fn repository_seat(inv: &Invocation<'_>) -> bool {
167 !matches!(
173 inv.node,
174 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
175 )
176}
177
178pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
185 let mut roots = Vec::new();
186 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
187 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
188 if !common.starts_with(&cwd) {
189 roots.push(common);
190 }
191 }
192 for name in ["questions", "notifications"] {
196 let dir = home.join(name);
197 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
198 roots.push(std::fs::canonicalize(dir)?);
199 }
200 Ok(roots)
201}
202
203fn editing_access_note(
205 kind: AgentKind,
206 unsandboxed: bool,
207 base: Option<&crate::run::FetchedBase>,
208 network: Option<bool>,
209) -> String {
210 let mut note = String::from(
211 "Launcher repository access contract (applies to this turn and overrides earlier fetch/commit instructions):\n",
212 );
213 if let Some(base) = base {
214 note.push_str(&format!(
215 "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",
216 base.remote, base.branch, base.fetched_at, base.commit,
217 ));
218 } else {
219 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");
220 }
221 if kind != AgentKind::Codex || unsandboxed {
223 return note;
224 }
225 note.push_str(if network == Some(true) {
226 "The launcher requests network access for repository tests, including local test servers and outbound traffic. This request is not proof of effective access. "
227 } else {
228 "Network access is restricted by the seat sandbox. "
229 });
230 note.push_str("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 operation is denied, record the operation, target, exact error and impact; do not bypass the denial or repeat it without a changed environment. Distinguish reproducing the current defect, writing a correction for a future launcher process, and validating a deployed launcher. An expected reproduction failure (for example a denied local test-server bind during launcher repair) is defect evidence, not a reason to abandon independently permitted source edits. Continue those edits and focused checks, retain eligible changes, and hand real launcher/browser validation to the parent-owned verification path. Source changes affect future launches only: a child process cannot relax inherited parent restrictions. If a genuinely required operation (including rebase continuation or dependency download) blocks all remaining permitted work, stop with an actionable infrastructure report; identify the deployment or environment repair needed before retrying that operation. Do not claim deployment or browser success without real evidence. 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");
231 note
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
241pub struct Quota {
242 #[serde(default)]
244 pub reset: Option<String>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254pub struct Dropped {
255 pub why: String,
257 pub output_tokens: u64,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct CommandEvidence {
277 pub id: String,
279 pub description: String,
281 pub exit_code: Option<i32>,
283 pub result_summary: String,
285 pub source: String,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct AgentOutput {
292 pub text: String,
294 pub exit_code: Option<i32>,
296 pub timed_out: bool,
298 pub duration_ms: u64,
300 pub artifacts: Vec<String>,
302 #[serde(default)]
306 pub quota: Option<Quota>,
307 #[serde(default)]
311 pub dropped: Option<Dropped>,
312 #[serde(default)]
316 pub commands: Vec<CommandEvidence>,
317 #[serde(default)]
322 pub context_tokens: Option<u64>,
323}
324
325impl AgentOutput {
326 pub fn usable(&self) -> bool {
328 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
329 }
330
331 pub fn quota_exhausted(&self) -> bool {
333 self.quota.is_some()
334 }
335
336 pub fn work_undelivered(&self) -> bool {
341 self.dropped.is_some()
342 }
343}
344
345#[derive(Debug)]
348pub(crate) struct ExecutionInterrupted(pub String);
349
350impl std::fmt::Display for ExecutionInterrupted {
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 write!(f, "execution interrupted: {}", self.0)
353 }
354}
355
356impl std::error::Error for ExecutionInterrupted {}
357
358const PIPE_GRACE: Duration = Duration::from_secs(3);
363
364pub(crate) type Captured = Arc<Mutex<Vec<u8>>>;
366
367pub(crate) fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
377where
378 R: tokio::io::AsyncRead + Unpin + Send + 'static,
379{
380 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
381 let Some(mut pipe) = pipe else {
382 return (buf, None);
383 };
384 let sink = Arc::clone(&buf);
385 let handle = tokio::spawn(async move {
386 let mut chunk = [0u8; 8192];
387 loop {
388 match pipe.read(&mut chunk).await {
389 Ok(0) | Err(_) => break,
390 Ok(n) => {
391 if let Ok(mut guard) = sink.lock() {
392 guard.extend_from_slice(&chunk[..n]);
393 }
394 }
395 }
396 }
397 });
398 (buf, Some(handle))
399}
400
401pub(crate) async fn collect(
406 buf: &Captured,
407 handle: Option<tokio::task::JoinHandle<()>>,
408 grace: Duration,
409) -> String {
410 if let Some(handle) = handle {
411 if tokio::time::timeout(grace, handle).await.is_err() {
412 tracing::debug!("a pipe is still held open after the child exited");
413 }
414 }
415 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
416 String::from_utf8_lossy(&bytes).into_owned()
417}
418
419pub async fn invoke(
421 spec: &AgentSpec,
422 seat: &mut SeatState,
423 inv: &Invocation<'_>,
424) -> Result<AgentOutput> {
425 let lease = if repository_seat(inv) {
430 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write, inv.coordination)
431 .await?
432 } else {
433 None
434 };
435 let inv = &Invocation {
436 timeout: spec.turn_timeout(inv.timeout),
437 ..inv.clone()
438 };
439 let mut outputs = Vec::new();
440 if repository_seat(inv) {
441 if let Some(guard) = crate::retention::OutputLease::borrow(inv.cwd).map_err(|e| {
442 crate::repo_lock::ResourceBlocked(format!("worktree output ownership: {e:#}"))
443 })? {
444 outputs.push(guard);
445 }
446 if let Some(cache) = inv.cache_dir {
447 outputs.push(crate::retention::OutputLease::cache(
448 &crate::run::home(),
449 cache,
450 false,
451 )?);
452 }
453 }
454
455 let mut prompt = inv.prompt.to_owned();
456 if inv.node == crate::queue::CHAT_NODE {
457 prompt.push_str("\n\n");
458 prompt.push_str(crate::prompt::CHAT_FOLLOW_THROUGH);
459 }
460 if let Some(lease) = lease.as_ref().filter(|lease| lease.delegated) {
461 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()));
462 }
463 let home = repository_writer(inv).then(crate::run::home);
466 let mut writable = inv.writable.to_vec();
467 if let Some(home) = &home {
468 for root in worktree_writable_roots(inv.cwd, home).await? {
469 if !writable.contains(&root) {
470 writable.push(root);
471 }
472 }
473 }
474 if repository_writer(inv) {
475 prompt.push_str("\n\n");
476 prompt.push_str(&editing_access_note(
477 spec.kind,
478 inv.unsandboxed,
479 inv.fetched_base,
480 requested_network_access(spec.kind, inv),
481 ));
482 }
483 let inv = &Invocation {
484 writable: &writable,
485 prompt: &prompt,
486 ..inv.clone()
487 };
488 tokio::fs::create_dir_all(inv.artifacts)
489 .await
490 .with_context(|| format!("create {}", inv.artifacts.display()))?;
491 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
492 tokio::fs::write(&prompt_path, inv.prompt)
493 .await
494 .with_context(|| format!("write {}", prompt_path.display()))?;
495
496 let plan = build_command(spec, seat, inv, &prompt_path)?;
497 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.logged_argv(), "spawning agent");
498 tokio::fs::write(
500 inv.artifacts.join(format!("{}.access.json", inv.stem)),
501 serde_json::to_vec_pretty(&serde_json::json!({
502 "launcher_version": env!("CARGO_PKG_VERSION"),
503 "kind": spec.kind,
504 "allow_write": inv.allow_write,
505 "unsandboxed": inv.unsandboxed,
506 "requested_network_access": requested_network_access(spec.kind, inv),
507 "writable_roots": inv.writable,
508 "fetched_base": inv.fetched_base,
509 }))?,
510 )
511 .await
512 .context("write seat access evidence")?;
513
514 let started = Instant::now();
515 let child_path = spec
518 .env
519 .iter()
520 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
521 .map(|(_, v)| std::ffi::OsString::from(v))
522 .or_else(|| std::env::var_os("PATH"))
523 .unwrap_or_default();
524 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
525 || plan.argv[0].clone().into(),
526 std::path::PathBuf::into_os_string,
527 );
528 let mut cmd = Command::new(program);
529 cmd.args(&plan.argv[1..])
530 .current_dir(inv.cwd)
531 .envs(&spec.env)
532 .env("MAGI_SEAT", &seat.key)
533 .env("MAGI_TURN", seat.turns.to_string())
534 .env("MAGI_RUN", inv.run)
535 .env("MAGI_NODE", inv.node)
536 .env("MAGI_PROMPT_FILE", &prompt_path)
537 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
538 .env("GIT_TERMINAL_PROMPT", "0")
539 .stdin(if plan.stdin.is_some() {
540 Stdio::piped()
541 } else {
542 Stdio::null()
543 })
544 .stdout(Stdio::piped())
545 .stderr(Stdio::piped())
546 .kill_on_drop(true)
547 .quiet();
550 if let Some(home) = home {
551 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
554 }
555 if let Some(cache) = inv.cache_dir {
556 cmd.env("CARGO_TARGET_DIR", cache);
559 } else {
560 cmd.env_remove("CARGO_TARGET_DIR");
568 }
569
570 #[cfg(any(unix, windows))]
571 let _chat_controls = {
572 use crate::chat_control::bridge::{self, Bridge};
573 bridge::clear_environment(&mut cmd);
574 let bridge = if inv.node == crate::queue::CHAT_NODE {
575 crate::run::try_home().and_then(|home| {
576 match Bridge::start(&home, inv.run, inv.artifacts) {
577 Ok(bridge) => Some(bridge),
578 Err(error) => {
579 tracing::warn!(%error, "chat controls unavailable; continuing conversation");
580 None
581 }
582 }
583 })
584 } else {
585 None
586 };
587 if let Some(bridge) = &bridge {
588 cmd.env("MAGI_HOME", std::fs::canonicalize(crate::run::home())?);
592 bridge.attach(&mut cmd);
593 }
594 bridge
595 };
596
597 let mut running = crate::seat_process::Running::spawn(cmd, lease, outputs)
598 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
599 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
603 tokio::spawn(async move {
604 sink.write_all(body.as_bytes()).await.ok();
605 sink.shutdown().await.ok();
606 });
607 }
608
609 let (out_buf, out_reader) = drain(running.child().stdout.take());
626 let (err_buf, err_reader) = drain(running.child().stderr.take());
627
628 let min_free = crate::run::RunState::load_under(inv.run, &crate::run::home())
629 .ok()
630 .map_or(0, |state| state.config.disk.min_free_bytes);
631 let mut paths = vec![inv.cwd.to_owned()];
632 if let Some(cache) = inv.cache_dir {
633 paths.push(cache.to_owned());
634 }
635 let mut pressure = None;
636 let waited = tokio::select! {
637 waited = tokio::time::timeout(inv.timeout, running.child().wait()) => Some(waited),
638 reason = crate::retention::emergency(paths, min_free) => {
639 pressure = Some(reason);
640 None
641 }
642 };
643 let (code, timed_out) = match waited {
644 Some(Ok(res)) => {
645 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
646 (status.code(), false)
647 }
648 Some(Err(_)) | None => {
649 if let Some(reason) = &pressure {
650 tracing::warn!(seat = %seat.key, %reason, "agent execution interrupted");
651 } else {
652 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
653 }
654 if !running.coordinated() {
656 running
657 .child()
658 .kill()
659 .await
660 .context("stop timed-out seat")?;
661 }
662 (None, true)
663 }
664 };
665 running.finish().await;
666
667 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
672 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
673
674 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
675 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
676 tokio::fs::write(&out_path, &stdout).await.ok();
677 tokio::fs::write(&err_path, &stderr).await.ok();
678 if let Some(reason) = pressure {
679 return Err(
680 anyhow::Error::new(crate::repo_lock::ResourceBlocked(reason.clone()))
681 .context(ExecutionInterrupted(reason)),
682 );
683 }
684
685 let mut extracted = extract(spec.kind, &stdout);
686 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
687 extracted.quota = agy_quota(&stdout, &stderr);
688 }
689 if let Some(quota) = &extracted.quota {
690 extracted.dropped = None;
693 tracing::warn!(
694 seat = %seat.key,
695 agent = %spec.id,
696 reset = ?quota.reset,
697 "agent is out of quota"
698 );
699 }
700 if let Some(session) = extracted.session {
701 match spec.kind {
702 AgentKind::Claude => seat.claude_session = Some(session),
703 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
704 seat.captured_session = Some(session);
705 }
706 AgentKind::Command => {}
707 }
708 }
709 if let Some(status) = &extracted.status
710 && !status.eq_ignore_ascii_case("success")
711 {
712 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
713 }
714 let text = if extracted.text.trim().is_empty() {
715 if stdout.trim().is_empty() {
717 stderr.trim().to_owned()
718 } else {
719 stdout.trim().to_owned()
720 }
721 } else {
722 extracted.text
723 };
724 seat.turns += 1;
725
726 Ok(AgentOutput {
727 text,
728 exit_code: code,
729 timed_out,
730 duration_ms: started.elapsed().as_millis() as u64,
731 artifacts: vec![
732 file_name(&prompt_path),
733 file_name(&out_path),
734 file_name(&err_path),
735 ],
736 quota: extracted.quota,
737 dropped: extracted.dropped,
738 commands: extracted.commands,
739 context_tokens: extracted.context_tokens,
740 })
741}
742
743fn file_name(p: &Path) -> String {
744 p.file_name()
745 .unwrap_or_default()
746 .to_string_lossy()
747 .into_owned()
748}
749
750#[derive(Debug)]
752struct Plan {
753 argv: Vec<String>,
754 stdin: Option<String>,
755}
756
757impl Plan {
758 fn logged_argv(&self) -> Vec<&str> {
761 let mut settings_value = false;
762 self.argv
763 .iter()
764 .map(|arg| {
765 if settings_value {
766 settings_value = false;
767 "[redacted]"
768 } else if arg == "--settings" {
769 settings_value = true;
770 arg.as_str()
771 } else if arg.starts_with("--settings=") {
772 "--settings=[redacted]"
773 } else {
774 arg.as_str()
775 }
776 })
777 .collect()
778 }
779}
780
781fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
792 if matches!(kind, AgentKind::Antigravity) {
793 return format!("@{}", prompt_path.display());
794 }
795 format!(
796 "Read the file at {} and follow every instruction in it exactly. That \
797 file is your complete task description; this message contains nothing \
798 else.",
799 prompt_path.display()
800 )
801}
802
803fn build_command(
804 spec: &AgentSpec,
805 seat: &SeatState,
806 inv: &Invocation<'_>,
807 prompt_path: &Path,
808) -> Result<Plan> {
809 let mut argv: Vec<String> = Vec::new();
810 let mut stdin: Option<String> = None;
811 let delivery = spec.delivery();
812 let resuming = has_session(spec.kind, seat, inv.sessions);
813
814 match spec.kind {
815 AgentKind::Claude => {
816 argv.push("claude".to_owned());
821 argv.push("-p".to_owned());
822 argv.push("--output-format".to_owned());
823 argv.push("json".to_owned());
824 if let Some(m) = &spec.model {
825 argv.push("--model".to_owned());
826 argv.push(m.clone());
827 }
828 if inv.sessions {
829 let uuid = seat
830 .claude_session
831 .as_deref()
832 .context("claude seat is missing its session uuid")?;
833 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
834 argv.push(uuid.to_owned());
835 }
836 argv.push("--permission-mode".to_owned());
837 argv.push("bypassPermissions".to_owned());
838 if !inv.allow_write {
839 argv.push("--disallowed-tools".to_owned());
840 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
841 }
842 }
843 AgentKind::Opencode => {
844 argv.push("opencode".to_owned());
848 argv.push("run".to_owned());
849 argv.push("--format".to_owned());
850 argv.push("json".to_owned());
851 argv.push("--dir".to_owned());
852 argv.push(inv.cwd.to_string_lossy().into_owned());
853 argv.push("--auto".to_owned());
862 if let Some(m) = &spec.model {
863 argv.push("-m".to_owned());
864 argv.push(m.clone());
865 }
866 if resuming {
867 argv.push("-s".to_owned());
868 argv.push(
869 seat.captured_session
870 .clone()
871 .expect("has_session checked the id is present"),
872 );
873 }
874 }
875 AgentKind::Antigravity => {
876 argv.push("agy".to_owned());
877 argv.push("--output-format".to_owned());
878 argv.push("json".to_owned());
879 argv.push("--print-timeout".to_owned());
882 argv.push(format!("{}s", inv.timeout.as_secs()));
883 argv.push("--mode".to_owned());
884 argv.push(
885 if inv.allow_write {
886 "accept-edits"
887 } else {
888 "plan"
889 }
890 .to_owned(),
891 );
892 if inv.allow_write {
893 argv.push("--dangerously-skip-permissions".to_owned());
894 }
895 if let Some(m) = &spec.model {
896 argv.push("--model".to_owned());
897 argv.push(m.clone());
898 }
899 if resuming {
900 argv.push("--conversation".to_owned());
901 argv.push(
902 seat.captured_session
903 .clone()
904 .expect("has_session checked the id is present"),
905 );
906 }
907 let mut add_dirs: Vec<String> = Vec::new();
917 if delivery == Delivery::File || !inv.attachments.is_empty() {
918 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
919 }
920 for path in inv.attachments {
921 let Some(parent) = path.parent() else {
922 continue;
923 };
924 if parent.starts_with(inv.artifacts) {
925 continue;
926 }
927 let dir = parent.to_string_lossy().into_owned();
928 if !add_dirs.contains(&dir) {
929 add_dirs.push(dir);
930 }
931 }
932 if inv.allow_write {
933 for path in inv.writable {
934 let dir = path.to_string_lossy().into_owned();
935 if !add_dirs.contains(&dir) {
936 add_dirs.push(dir);
937 }
938 }
939 }
940 for dir in add_dirs {
941 argv.push("--add-dir".to_owned());
942 argv.push(dir);
943 }
944 }
945 AgentKind::Codex => {
946 argv.push("codex".to_owned());
952 argv.push("exec".to_owned());
953 argv.push("--json".to_owned());
954 argv.push("--skip-git-repo-check".to_owned());
957 argv.push("-C".to_owned());
958 argv.push(inv.cwd.to_string_lossy().into_owned());
959 if inv.allow_write && inv.unsandboxed {
968 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
969 } else {
970 argv.push("--sandbox".to_owned());
971 argv.push(
972 if inv.allow_write {
973 "workspace-write"
974 } else {
975 "read-only"
976 }
977 .to_owned(),
978 );
979 argv.push("-c".to_owned());
982 argv.push("approval_policy=\"never\"".to_owned());
983 if let Some(enabled) = requested_network_access(spec.kind, inv) {
984 argv.push("-c".to_owned());
985 argv.push(format!("sandbox_workspace_write.network_access={enabled}"));
986 }
987 if inv.allow_write && !inv.writable.is_empty() {
988 let roots: Vec<String> = inv
989 .writable
990 .iter()
991 .map(|p| p.to_string_lossy().into_owned())
992 .collect();
993 argv.push("-c".to_owned());
994 argv.push(format!(
995 "sandbox_workspace_write.writable_roots={}",
996 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
997 ));
998 }
999 }
1000 if let Some(m) = &spec.model {
1001 argv.push("-m".to_owned());
1002 argv.push(m.clone());
1003 }
1004 if resuming {
1010 argv.push("resume".to_owned());
1011 argv.push(
1012 seat.captured_session
1013 .clone()
1014 .expect("has_session checked the id is present"),
1015 );
1016 }
1017 }
1018 AgentKind::Omp => {
1019 argv.push("omp".to_owned());
1023 argv.push("-p".to_owned());
1024 argv.push("--mode=json".to_owned());
1025 argv.push("--auto-approve".to_owned());
1035 if let Some(m) = &spec.model {
1036 argv.push("--model".to_owned());
1037 argv.push(m.clone());
1038 }
1039 if resuming {
1045 argv.push("--resume".to_owned());
1046 argv.push(
1047 seat.captured_session
1048 .clone()
1049 .expect("has_session checked the id is present"),
1050 );
1051 }
1052 }
1053 AgentKind::Command => {
1054 if spec.command.is_empty() {
1059 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
1060 }
1061 let vars: BTreeMap<&str, String> = BTreeMap::from([
1062 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
1063 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
1064 ("{label}", seat.key.clone()),
1065 ("{session}", seat.claude_session.clone().unwrap_or_default()),
1066 ]);
1067 for raw in &spec.command {
1068 let mut arg = raw.clone();
1069 for (k, v) in &vars {
1070 if arg.contains(k) {
1071 arg = arg.replace(k, v);
1072 }
1073 }
1074 argv.push(arg);
1075 }
1076 }
1077 }
1078
1079 argv.extend(spec.extra_args.iter().cloned());
1080
1081 if spec.kind == AgentKind::Antigravity {
1084 argv.push("-p".to_owned());
1085 }
1086 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
1089 argv.push("-".to_owned());
1090 }
1091 match delivery {
1092 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
1093 argv.push(pointer(spec.kind, prompt_path));
1095 }
1096 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
1097 Delivery::Argv => argv.push(inv.prompt.to_owned()),
1098 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
1099 }
1100
1101 Ok(Plan { argv, stdin })
1102}
1103
1104#[derive(Debug, Default)]
1106struct Extracted {
1107 text: String,
1108 session: Option<String>,
1109 status: Option<String>,
1110 quota: Option<Quota>,
1111 dropped: Option<Dropped>,
1112 commands: Vec<CommandEvidence>,
1113 context_tokens: Option<u64>,
1114}
1115
1116fn extract(kind: AgentKind, stdout: &str) -> Extracted {
1118 let mut extracted = extract_answer(kind, stdout);
1119 extracted.context_tokens = context_tokens(kind, stdout);
1120 extracted
1121}
1122
1123fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
1125 v.get(key).and_then(serde_json::Value::as_u64)
1126}
1127
1128fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
1133 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
1134 let base = first(input)?;
1135 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
1136 Some(base.saturating_add(cached))
1137}
1138
1139fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
1160 use serde_json::Value;
1161 let lines = || {
1162 stdout
1163 .lines()
1164 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
1165 };
1166 match kind {
1167 AgentKind::Claude => None,
1168 AgentKind::Opencode => lines()
1169 .filter(|v| {
1170 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1172 || v.get("part")
1173 .and_then(|p| p.get("type"))
1174 .and_then(|t| t.as_str())
1175 == Some("step-finish")
1176 })
1177 .filter_map(|v| {
1178 let tokens = v.get("part")?.get("tokens")?;
1179 let base = uint(tokens, "input")?;
1180 let cache = tokens.get("cache");
1181 let cached = ["read", "write"]
1182 .iter()
1183 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1184 .sum::<u64>();
1185 Some(base.saturating_add(cached))
1186 })
1187 .next_back(),
1188 AgentKind::Antigravity => None,
1189 AgentKind::Codex => None,
1190 AgentKind::Omp => lines()
1191 .flat_map(|v| {
1192 match v.get("type").and_then(|t| t.as_str()) {
1195 Some("agent_end") => v
1196 .get("messages")
1197 .and_then(|m| m.as_array())
1198 .cloned()
1199 .unwrap_or_default(),
1200 Some("turn_end") | Some("message_end") => {
1201 v.get("message").cloned().into_iter().collect()
1202 }
1203 _ => Vec::new(),
1204 }
1205 })
1206 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1207 .filter_map(|m| {
1208 input_side(
1209 m.get("usage")?,
1210 &["input", "input_tokens"],
1211 &[
1212 "cacheRead",
1213 "cacheWrite",
1214 "cache_read_tokens",
1215 "cache_write_tokens",
1216 ],
1217 )
1218 })
1219 .next_back(),
1220 AgentKind::Command => None,
1221 }
1222}
1223
1224pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1227 extract_answer(kind, stdout).text
1228}
1229
1230fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1231 match kind {
1232 AgentKind::Claude => {
1233 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1234 return Extracted {
1235 text: stdout.trim().to_owned(),
1236 ..Extracted::default()
1237 };
1238 };
1239 Extracted {
1240 text: v
1241 .get("result")
1242 .and_then(|r| r.as_str())
1243 .unwrap_or_default()
1244 .to_owned(),
1245 session: v
1246 .get("session_id")
1247 .and_then(|s| s.as_str())
1248 .map(str::to_owned),
1249 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1250 if e {
1251 "error".to_owned()
1252 } else {
1253 "success".to_owned()
1254 }
1255 }),
1256 quota: claude_quota(&v),
1257 dropped: None,
1260 commands: Vec::new(),
1261 context_tokens: None,
1262 }
1263 }
1264 AgentKind::Opencode => {
1265 let mut text = String::new();
1267 let mut session = None;
1268 for line in stdout.lines() {
1269 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1270 continue;
1271 };
1272 if session.is_none() {
1273 session = v
1274 .get("sessionID")
1275 .and_then(|s| s.as_str())
1276 .map(str::to_owned);
1277 }
1278 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1279 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1280 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1281 {
1282 if !text.is_empty() {
1283 text.push('\n');
1284 }
1285 text.push_str(t);
1286 }
1287 }
1288 Extracted {
1289 text,
1290 session,
1291 status: None,
1292 quota: None,
1293 dropped: None,
1294 commands: Vec::new(),
1295 context_tokens: None,
1296 }
1297 }
1298 AgentKind::Antigravity => {
1299 let obj = stdout
1302 .lines()
1303 .rev()
1304 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1305 let Some(v) = obj else {
1306 return Extracted {
1307 text: stdout.trim().to_owned(),
1308 ..Extracted::default()
1309 };
1310 };
1311 Extracted {
1312 text: v
1313 .get("response")
1314 .and_then(|r| r.as_str())
1315 .unwrap_or_default()
1316 .trim()
1317 .to_owned(),
1318 session: v
1319 .get("conversation_id")
1320 .and_then(|s| s.as_str())
1321 .map(str::to_owned),
1322 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1323 quota: None,
1324 dropped: dropped_stream(&v),
1325 commands: Vec::new(),
1326 context_tokens: None,
1327 }
1328 }
1329 AgentKind::Codex => {
1330 let mut text = String::new();
1352 let mut session = None;
1353 let mut status = None;
1354 let mut commands = Vec::new();
1355 for line in stdout.lines() {
1356 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1357 continue;
1358 };
1359 match v.get("type").and_then(|t| t.as_str()) {
1360 Some("thread.started") => {
1361 session = v
1362 .get("thread_id")
1363 .and_then(|s| s.as_str())
1364 .map(str::to_owned);
1365 }
1366 Some("item.completed") => {
1367 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1368 match item.get("type").and_then(|t| t.as_str()) {
1369 Some("agent_message") => {
1370 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1371 text = t.trim().to_owned();
1372 }
1373 }
1374 Some("command_execution") => {
1375 commands.push(command_evidence(item));
1376 }
1377 _ => {}
1378 }
1379 }
1380 Some("turn.completed") => status = Some("success".to_owned()),
1381 Some("turn.failed") => status = Some("error".to_owned()),
1382 _ => {}
1383 }
1384 }
1385 Extracted {
1386 text,
1387 session,
1388 status,
1389 quota: None,
1390 dropped: None,
1391 commands,
1392 context_tokens: None,
1393 }
1394 }
1395 AgentKind::Omp => {
1396 let mut text = String::new();
1417 let mut session = None;
1418 for line in stdout.lines() {
1419 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1420 continue;
1421 };
1422 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1423 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1424 continue;
1425 }
1426 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1430 {
1431 Some("agent_end") => v
1432 .get("messages")
1433 .and_then(|m| m.as_array())
1434 .map(|m| m.iter().collect())
1435 .unwrap_or_default(),
1436 Some("turn_end") | Some("message_end") => {
1437 v.get("message").into_iter().collect()
1438 }
1439 _ => continue,
1440 };
1441 for message in messages {
1442 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1443 continue;
1444 }
1445 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1446 continue;
1447 };
1448 for part in parts {
1449 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1450 continue;
1451 }
1452 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1453 && !t.trim().is_empty()
1454 {
1455 text = t.trim().to_owned();
1456 }
1457 }
1458 }
1459 }
1460 Extracted {
1461 text,
1462 session,
1463 status: None,
1464 quota: None,
1465 dropped: None,
1466 commands: Vec::new(),
1467 context_tokens: None,
1468 }
1469 }
1470 AgentKind::Command => {
1471 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1476 let quota = parsed.as_ref().and_then(claude_quota);
1477 let dropped = parsed.as_ref().and_then(dropped_stream);
1480 Extracted {
1481 text: stdout.trim().to_owned(),
1482 session: None,
1483 status: None,
1484 quota,
1485 dropped,
1486 commands: Vec::new(),
1487 context_tokens: None,
1488 }
1489 }
1490 }
1491}
1492
1493fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1500 let description = match item.get("command") {
1501 Some(serde_json::Value::String(s)) => s.clone(),
1502 Some(serde_json::Value::Array(parts)) => parts
1503 .iter()
1504 .filter_map(|p| p.as_str())
1505 .collect::<Vec<_>>()
1506 .join(" "),
1507 _ => String::new(),
1508 };
1509 let result_summary = item
1510 .get("aggregated_output")
1511 .and_then(|o| o.as_str())
1512 .map(|s| tail_chars(s.trim(), 400))
1513 .unwrap_or_default();
1514 CommandEvidence {
1515 id: item
1516 .get("id")
1517 .and_then(|s| s.as_str())
1518 .unwrap_or_default()
1519 .to_owned(),
1520 description,
1521 exit_code: item
1522 .get("exit_code")
1523 .and_then(serde_json::Value::as_i64)
1524 .map(|e| e as i32),
1525 result_summary,
1526 source: "codex".to_owned(),
1527 }
1528}
1529
1530fn tail_chars(s: &str, max: usize) -> String {
1532 let count = s.chars().count();
1533 if count <= max {
1534 return s.to_owned();
1535 }
1536 s.chars().skip(count - max).collect()
1537}
1538
1539fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1546 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1547 if !is_err {
1548 return None;
1549 }
1550 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1551 let status = v.get("api_error_status");
1552 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1553 || status.and_then(serde_json::Value::as_str) == Some("429"))
1554 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1555 if !usage_limit && !result.to_lowercase().contains("session limit") {
1556 return None;
1557 }
1558 let reset = result
1561 .split("resets ")
1562 .nth(1)
1563 .map(str::trim)
1564 .filter(|s| !s.is_empty())
1565 .map(str::to_owned);
1566 Some(Quota { reset })
1567}
1568
1569fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1584 let from_stdout = stdout
1585 .lines()
1586 .rev()
1587 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1588 .and_then(|v| {
1589 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1590 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1591 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1592 .then(|| error.to_owned())
1593 });
1594 let from_stderr = || {
1595 stderr.lines().find_map(|l| {
1596 let v: serde_json::Value =
1597 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1598 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1599 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1600 (exhausted && code).then(|| {
1601 v.get("short_error")
1602 .and_then(|e| e.as_str())
1603 .unwrap_or_default()
1604 .to_owned()
1605 })
1606 })
1607 };
1608 let text = from_stdout.or_else(from_stderr)?;
1609 let reset = text
1610 .split_once("Resets ")
1611 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1612 .filter(|s| !s.is_empty())
1613 .map(str::to_owned);
1614 Some(Quota { reset })
1615}
1616
1617fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1648 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1649 if !status.eq_ignore_ascii_case("error") {
1650 return None;
1651 }
1652 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1653 if !response.trim().is_empty() {
1654 return None;
1656 }
1657 let produced = v
1658 .get("usage")
1659 .and_then(|u| u.get("output_tokens"))
1660 .and_then(serde_json::Value::as_u64)
1661 .unwrap_or(0);
1662 if produced == 0 {
1663 return None;
1665 }
1666 Some(Dropped {
1667 why: v
1668 .get("error")
1669 .and_then(|e| e.as_str())
1670 .unwrap_or("the CLI ended the stream without delivering its answer")
1671 .trim()
1672 .to_owned(),
1673 output_tokens: produced,
1674 })
1675}
1676
1677pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1679 let mut missing = Vec::new();
1680 for s in specs {
1681 let program = match s.kind {
1682 AgentKind::Command => s.command.first().map(String::as_str),
1683 other => other.program(),
1684 };
1685 if let Some(p) = program
1686 && !crate::config::which(p)
1687 && !Path::new(p).is_file()
1688 && !missing.iter().any(|m: &String| m == p)
1689 {
1690 missing.push(p.to_owned());
1691 }
1692 }
1693 missing
1694}
1695
1696pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1698 run_dir.join("artifacts")
1699}
1700
1701pub fn installed(spec: &AgentSpec) -> bool {
1703 spec.kind.program().is_none_or(crate::config::which)
1706}
1707
1708pub fn pick(
1730 agents: &[AgentSpec],
1731 want: Option<&str>,
1732 available: &dyn Fn(&AgentSpec) -> bool,
1733) -> Result<AgentSpec> {
1734 if let Some(id) = want {
1735 let spec = agents
1736 .iter()
1737 .find(|a| a.id == id)
1738 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1739 if !available(spec) {
1740 bail!(
1741 "agent `{}` needs `{}` on PATH; install it or pass a different \
1742 --agent",
1743 spec.id,
1744 spec.kind.program().unwrap_or("its command")
1745 );
1746 }
1747 return Ok(spec.clone());
1748 }
1749
1750 if agents.is_empty() {
1751 bail!(
1752 "the agent roster is empty, so there is nobody to ask: install one \
1753 of claude, opencode or agy - magi derives a roster from what is on \
1754 PATH - or add an [[agents]] entry to magi.toml."
1755 );
1756 }
1757
1758 if let Some(spec) = agents
1759 .iter()
1760 .find(|a| a.kind == AgentKind::Claude && available(a))
1761 {
1762 return Ok(spec.clone());
1763 }
1764
1765 agents
1766 .iter()
1767 .find(|a| available(a))
1768 .cloned()
1769 .with_context(|| {
1770 let missing = agents
1771 .iter()
1772 .filter_map(|a| a.kind.program())
1773 .collect::<Vec<_>>()
1774 .join(", ");
1775 format!(
1776 "no agent in the roster can be run here: install one of \
1777 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1778 you do have"
1779 )
1780 })
1781}
1782
1783pub fn for_github_text(spec: &AgentSpec, github_language: &str, cwd: &Path) -> Result<AgentSpec> {
1796 let mut spec = spec.clone();
1797 if spec.kind == AgentKind::Claude {
1798 let mut existing = None;
1801 let mut args = spec.extra_args.iter().enumerate();
1802 while let Some((index, arg)) = args.next() {
1803 if arg == "--settings" {
1804 let (value_index, value) = args.next().context("--settings requires a value")?;
1805 existing = Some((value_index, value.as_str(), false));
1806 } else if let Some(value) = arg.strip_prefix("--settings=") {
1807 existing = Some((index, value, true));
1808 }
1809 }
1810 let mut settings = if let Some((_, value, _)) = existing {
1811 let json = if value.trim_start().starts_with('{') {
1812 value.to_owned()
1813 } else {
1814 std::fs::read_to_string(cwd.join(value))
1815 .context("cannot read existing seat --settings file")?
1816 };
1817 serde_json::from_str::<serde_json::Value>(&json)
1818 .context("cannot parse existing seat --settings JSON")?
1819 } else {
1820 serde_json::json!({})
1821 };
1822 settings
1823 .as_object_mut()
1824 .context("seat --settings must be a JSON object")?
1825 .insert(
1826 "language".to_owned(),
1827 serde_json::json!(crate::lang::name(github_language)),
1828 );
1829 let settings = settings.to_string();
1830 if let Some((index, _, equals)) = existing {
1831 spec.extra_args[index] = if equals {
1832 format!("--settings={settings}")
1833 } else {
1834 settings
1835 };
1836 } else {
1837 spec.extra_args.extend(["--settings".to_owned(), settings]);
1838 }
1839 }
1840 Ok(spec)
1841}
1842
1843pub fn pick_chain(
1851 agents: &[AgentSpec],
1852 choice: Option<&AgentChoice>,
1853 available: &dyn Fn(&AgentSpec) -> bool,
1854 role: &str,
1855) -> Result<Vec<AgentSpec>> {
1856 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1857 if wanted.is_empty() {
1858 return pick(agents, None, available)
1859 .map(|s| vec![s])
1860 .with_context(|| format!("choose an agent for the {role} role"));
1861 }
1862 let mut chain: Vec<AgentSpec> = Vec::new();
1863 for id in wanted {
1864 if chain.iter().any(|s| s.id == id) {
1865 continue;
1866 }
1867 match pick(agents, Some(id), available) {
1868 Ok(spec) => chain.push(spec),
1869 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1870 }
1871 }
1872 if chain.is_empty() {
1873 bail!(
1874 "[roles] {role} names no agent that can run here; the roster has {}",
1875 ids(agents)
1876 );
1877 }
1878 Ok(chain)
1879}
1880
1881pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1887 match outcome {
1888 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1889 Err(_) => true,
1890 Ok(out) => output_advances(out),
1891 }
1892}
1893
1894pub fn output_advances(out: &AgentOutput) -> bool {
1898 out.quota_exhausted() || !out.usable()
1899}
1900
1901fn ids(agents: &[AgentSpec]) -> String {
1902 if agents.is_empty() {
1903 return "no agents at all".to_owned();
1904 }
1905 agents
1906 .iter()
1907 .map(|a| a.id.clone())
1908 .collect::<Vec<_>>()
1909 .join(", ")
1910}
1911
1912#[cfg(test)]
1913mod tests {
1914 use super::*;
1915
1916 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1917
1918 fn command_helper(mode: &str) -> AgentSpec {
1921 crate::run::pin_test_home();
1923 AgentSpec {
1924 id: "helper".to_owned(),
1925 kind: AgentKind::Command,
1926 model: None,
1927 command: vec![
1928 std::env::current_exe()
1929 .expect("locate test helper")
1930 .to_string_lossy()
1931 .into_owned(),
1932 "--exact".to_owned(),
1933 "agent::tests::command_agent_test_helper".to_owned(),
1934 "--nocapture".to_owned(),
1935 ],
1936 extra_args: Vec::new(),
1937 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1938 prompt_delivery: None,
1939 max_turn_secs: None,
1940 }
1941 }
1942
1943 #[test]
1944 fn command_agent_test_helper() {
1945 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1946 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1947 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1948 Ok("no-cache") => println!(
1949 "{}",
1950 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1951 ),
1952 Ok("ignore-stdin") => println!("done"),
1953 Ok("chatty-sleep") => {
1954 println!("i-said-something");
1955 std::thread::sleep(Duration::from_secs(30));
1956 }
1957 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1958 Ok(other) => panic!("unknown command helper mode {other}"),
1959 Err(_) => {}
1960 }
1961 }
1962
1963 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1964 AgentSpec {
1965 id: "a".to_owned(),
1966 kind,
1967 model: model.map(str::to_owned),
1968 command: vec!["echo".to_owned(), "{label}".to_owned()],
1969 extra_args: Vec::new(),
1970 env: BTreeMap::new(),
1971 prompt_delivery: None,
1972 max_turn_secs: None,
1973 }
1974 }
1975
1976 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1977 Invocation {
1978 coordination: None,
1979 editing_network: crate::config::EditingNetwork::Restricted,
1980 fetched_base: None,
1981 cwd,
1982 prompt: "do the thing",
1983 timeout: Duration::from_secs(900),
1984 allow_write,
1985 unsandboxed: false,
1986 sessions: true,
1987 artifacts: art,
1988 stem: "t",
1989 run: "test-run",
1990 node: "test",
1991 cache_dir: None,
1992 attachments: &[],
1993 writable: &[],
1994 }
1995 }
1996
1997 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1998 build_command(
1999 &spec(kind, None),
2000 seat,
2001 &inv(Path::new("."), Path::new("/art"), allow_write),
2002 Path::new("/art/p.md"),
2003 )
2004 .unwrap()
2005 }
2006
2007 #[test]
2008 fn editing_access_requires_fetch_evidence_and_keeps_snapshot() {
2009 let missing = editing_access_note(AgentKind::Codex, false, None, Some(false));
2010 assert!(missing.contains("No successful launcher fetch evidence"));
2011 let base = crate::run::FetchedBase {
2012 remote: "origin".into(),
2013 branch: "main".into(),
2014 commit: "0123456789abcdef".into(),
2015 fetched_at: "2026-10-18T00:00:00Z".parse().unwrap(),
2016 };
2017 let note = editing_access_note(AgentKind::Codex, false, Some(&base), Some(false));
2018 for expected in [
2019 "origin/main",
2020 "0123456789abcdef",
2021 "2026-10-18T00:00:00Z",
2022 "Do not fetch again",
2023 "launcher saves eligible edits",
2024 "Specification questions remain valid",
2025 ] {
2026 assert!(note.contains(expected), "missing {expected}");
2027 }
2028 for kind in [
2029 AgentKind::Codex,
2030 AgentKind::Claude,
2031 AgentKind::Opencode,
2032 AgentKind::Antigravity,
2033 AgentKind::Omp,
2034 AgentKind::Command,
2035 ] {
2036 for unsandboxed in [false, true] {
2037 let scoped = editing_access_note(kind, unsandboxed, Some(&base), Some(false));
2038 assert!(scoped.contains(&base.commit));
2039 assert!(scoped.contains("Do not fetch again"));
2040 let restricted = kind == AgentKind::Codex && !unsandboxed;
2041 for claim in [
2042 "Network access is restricted",
2043 "protected Git administration paths",
2044 "launcher saves eligible edits",
2045 "unavailable sandbox permissions",
2046 ] {
2047 assert_eq!(scoped.contains(claim), restricted, "{kind:?}/{unsandboxed}");
2048 assert_eq!(
2049 editing_access_note(kind, unsandboxed, None, Some(false)).contains(claim),
2050 restricted,
2051 "{kind:?}/{unsandboxed} without fetch evidence",
2052 );
2053 }
2054 }
2055 }
2056 let roundtrip: crate::run::FetchedBase =
2057 serde_json::from_str(&serde_json::to_string(&base).unwrap()).unwrap();
2058 assert_eq!(
2059 editing_access_note(AgentKind::Codex, false, Some(&roundtrip), Some(false)),
2060 note
2061 );
2062 }
2063
2064 #[test]
2065 fn editing_network_policy_is_explicit_on_initial_and_resume() {
2066 use crate::config::EditingNetwork::{Enabled, Restricted};
2067 for network in [Restricted, Enabled] {
2068 for resumed in [false, true] {
2069 let mut seat = SeatState::new("impl-A", "a", 7);
2070 if resumed {
2071 seat.turns = 1;
2072 seat.captured_session = Some("fixture-thread".into());
2073 }
2074 for node in [
2075 "implement",
2076 "fix",
2077 "graft",
2078 "gate-fix",
2079 "land",
2080 "rebase",
2081 crate::queue::CHAT_NODE,
2082 ] {
2083 let mut invocation = inv(Path::new("."), Path::new("/art"), true);
2084 invocation.node = node;
2085 invocation.editing_network = network;
2086 let plan = build_command(
2087 &spec(AgentKind::Codex, None),
2088 &seat,
2089 &invocation,
2090 Path::new("/art/p.md"),
2091 )
2092 .unwrap();
2093 let requested =
2094 requested_network_access(AgentKind::Codex, &invocation).unwrap();
2095 assert_eq!(requested, network == Enabled);
2096 let at = plan
2097 .argv
2098 .iter()
2099 .position(|s| {
2100 s == &format!("sandbox_workspace_write.network_access={requested}")
2101 })
2102 .unwrap();
2103 if resumed {
2104 assert!(at < plan.argv.iter().position(|s| s == "resume").unwrap());
2105 }
2106 assert!(plan.argv.iter().any(|s| s == "workspace-write"));
2107 assert!(plan.argv.iter().any(|s| s == "approval_policy=\"never\""));
2108 assert!(!plan.argv.iter().any(|s| s.contains("bypass")));
2109 let note = editing_access_note(AgentKind::Codex, false, None, Some(requested));
2110 assert_eq!(note.contains("requests network access"), requested);
2111 assert_eq!(note.contains("Network access is restricted"), !requested);
2112 for kind in [AgentKind::Claude, AgentKind::Command] {
2113 assert_eq!(requested_network_access(kind, &invocation), None);
2114 }
2115 invocation.unsandboxed = true;
2116 assert_eq!(
2117 requested_network_access(AgentKind::Codex, &invocation),
2118 None
2119 );
2120 }
2121 for (node, write) in [
2122 ("review", false),
2123 ("implement", false),
2124 ("deputy", true),
2125 ("conduct", true),
2126 ("talk-title", false),
2127 ] {
2128 let mut invocation = inv(Path::new("."), Path::new("/art"), write);
2129 invocation.node = node;
2130 invocation.editing_network = network;
2131 assert_eq!(
2132 requested_network_access(AgentKind::Codex, &invocation),
2133 None
2134 );
2135 let plan = build_command(
2136 &spec(AgentKind::Codex, None),
2137 &seat,
2138 &invocation,
2139 Path::new("/art/p.md"),
2140 )
2141 .unwrap();
2142 assert!(!plan.argv.iter().any(|s| s.contains("network_access")));
2143 }
2144 }
2145 }
2146 }
2147
2148 #[test]
2149 fn editing_access_keeps_infrastructure_repair_actionable() {
2150 for network in [Some(false), Some(true)] {
2151 let note = editing_access_note(AgentKind::Codex, false, None, network);
2152 for expected in [
2153 "expected reproduction failure",
2154 "not a reason to abandon independently permitted source edits",
2155 "Source changes affect future launches only",
2156 "a child process cannot relax inherited parent restrictions",
2157 "parent-owned verification path",
2158 "operation, target, exact error and impact",
2159 "blocks all remaining permitted work, stop",
2160 "Do not use bypass flags",
2161 "Do not claim deployment or browser success without real evidence",
2162 ] {
2163 assert!(note.contains(expected), "missing {expected}");
2164 }
2165 assert!(!note.contains("before a single new attempt"));
2166 }
2167 }
2168
2169 #[test]
2170 fn claude_mints_then_resumes_the_same_uuid() {
2171 let mut seat = SeatState::new("judge-1", "a", 7);
2172 let uuid = seat.claude_session.clone().unwrap();
2173 let first = plan_for(AgentKind::Claude, &seat, true);
2174 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
2175 assert!(!first.argv.iter().any(|a| a == "--resume"));
2176
2177 seat.turns = 1;
2178 let second = plan_for(AgentKind::Claude, &seat, true);
2179 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
2180 assert!(!second.argv.iter().any(|a| a == "--session-id"));
2181 }
2182
2183 #[test]
2184 fn github_bound_claude_seats_override_the_operators_answer_language() {
2185 let seat = SeatState::new("change-summary", "a", 7);
2186 let art = Path::new("/art");
2187 for (language, name) in [("en", "English"), ("", "English"), ("ja", "Japanese")] {
2188 let claude =
2189 for_github_text(&spec(AgentKind::Claude, None), language, Path::new(".")).unwrap();
2190 let plan = build_command(
2191 &claude,
2192 &seat,
2193 &inv(Path::new("."), art, false),
2194 Path::new("/art/p.md"),
2195 )
2196 .unwrap();
2197 let settings = format!(r#"{{"language":"{name}"}}"#);
2198 assert!(
2199 plan.argv
2200 .windows(2)
2201 .any(|w| w == ["--settings", settings.as_str()]),
2202 "{:?}",
2203 plan.argv
2204 );
2205 assert_eq!(plan.stdin.as_deref(), Some("do the thing"));
2208 }
2209 for kind in [
2210 AgentKind::Codex,
2211 AgentKind::Opencode,
2212 AgentKind::Antigravity,
2213 AgentKind::Omp,
2214 ] {
2215 assert!(
2216 for_github_text(&spec(kind, None), "en", Path::new("."))
2217 .unwrap()
2218 .extra_args
2219 .is_empty()
2220 );
2221 }
2222 }
2223
2224 #[test]
2225 fn github_text_preserves_existing_settings() {
2226 let dir = tempfile::tempdir().unwrap();
2227 let original = serde_json::json!({
2228 "language": "Japanese",
2229 "env": { "SEAT_MODE": "configured" },
2230 "apiKeyHelper": "credential-helper",
2231 "hooks": { "Stop": [] }
2232 });
2233 std::fs::write(dir.path().join("seat-settings.json"), original.to_string()).unwrap();
2234 for value in [original.to_string(), "./seat-settings.json".to_owned()] {
2235 for equals in [false, true] {
2236 let mut spec = spec(AgentKind::Claude, None);
2237 spec.extra_args = vec!["--settings={\"language\":\"French\"}".to_owned()];
2239 if equals {
2240 spec.extra_args.push(format!("--settings={value}"));
2241 } else {
2242 spec.extra_args
2243 .extend(["--settings".to_owned(), value.clone()]);
2244 }
2245 spec.extra_args.push("--verbose".to_owned());
2246 let updated = for_github_text(&spec, "en", dir.path()).unwrap();
2247 assert_eq!(updated.extra_args.len(), spec.extra_args.len());
2248 assert_eq!(updated.extra_args[0], spec.extra_args[0]);
2249 assert_eq!(updated.extra_args.last(), spec.extra_args.last());
2250 let value = &updated.extra_args[updated.extra_args.len() - 2];
2251 let actual: serde_json::Value =
2252 serde_json::from_str(value.strip_prefix("--settings=").unwrap_or(value))
2253 .unwrap();
2254 let mut expected = original.clone();
2255 expected["language"] = serde_json::json!("English");
2256 assert_eq!(actual, expected);
2257 }
2258 }
2259 let on_disk: serde_json::Value = serde_json::from_str(
2260 &std::fs::read_to_string(dir.path().join("seat-settings.json")).unwrap(),
2261 )
2262 .unwrap();
2263 assert_eq!(on_disk, original);
2264 }
2265
2266 #[test]
2267 fn github_text_settings_are_redacted_from_command_logging() {
2268 let dir = tempfile::tempdir().unwrap();
2269 let settings = serde_json::json!({ "env": { "AUTH_TOKEN": "test-only-secret" } });
2270 std::fs::write(dir.path().join("seat-settings.json"), settings.to_string()).unwrap();
2271 for equals in [false, true] {
2272 let mut spec = spec(AgentKind::Claude, None);
2273 spec.extra_args =
2274 vec!["--settings={\"env\":{\"AUTH_TOKEN\":\"earlier-secret\"}}".to_owned()];
2275 if equals {
2276 spec.extra_args
2277 .push("--settings=./seat-settings.json".to_owned());
2278 } else {
2279 spec.extra_args
2280 .extend(["--settings".to_owned(), "./seat-settings.json".to_owned()]);
2281 }
2282 let spec = for_github_text(&spec, "en", dir.path()).unwrap();
2283 let plan = build_command(
2284 &spec,
2285 &SeatState::new("change-summary", "a", 7),
2286 &inv(dir.path(), dir.path(), false),
2287 &dir.path().join("prompt.md"),
2288 )
2289 .unwrap();
2290 let actual = plan.argv.last().unwrap();
2292 let actual: serde_json::Value =
2293 serde_json::from_str(actual.strip_prefix("--settings=").unwrap_or(actual)).unwrap();
2294 assert_eq!(actual["env"]["AUTH_TOKEN"], "test-only-secret");
2295 assert_eq!(actual["language"], "English");
2296 let logged = format!("{:?}", plan.logged_argv());
2297 assert!(!logged.contains("secret"));
2298 assert!(!logged.contains("AUTH_TOKEN"));
2299 assert!(logged.contains("[redacted]"));
2300 assert_eq!(plan.logged_argv()[0], "claude");
2301 assert!(plan.logged_argv().contains(&"--output-format"));
2302 }
2303 }
2304
2305 #[test]
2306 fn github_text_rejects_unreadable_or_invalid_settings() {
2307 let dir = tempfile::tempdir().unwrap();
2308 std::fs::write(dir.path().join("bad.json"), "invalid json").unwrap();
2309 std::fs::write(dir.path().join("array.json"), "[]").unwrap();
2310 for args in [
2311 vec!["--settings"],
2312 vec!["--settings", "missing.json"],
2313 vec!["--settings=bad.json"],
2314 vec!["--settings", "array.json"],
2315 ] {
2316 let mut spec = spec(AgentKind::Claude, None);
2317 spec.extra_args = args.into_iter().map(str::to_owned).collect();
2318 assert!(for_github_text(&spec, "en", dir.path()).is_err());
2319 }
2320 }
2321
2322 #[test]
2323 fn read_only_seats_cannot_edit() {
2324 let seat = SeatState::new("judge-1", "a", 7);
2325 let claude = plan_for(AgentKind::Claude, &seat, false);
2326 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
2327 assert!(
2328 !plan_for(AgentKind::Claude, &seat, true)
2329 .argv
2330 .iter()
2331 .any(|a| a == "--disallowed-tools")
2332 );
2333
2334 let agy = plan_for(AgentKind::Antigravity, &seat, false);
2335 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
2336 assert!(
2337 !agy.argv
2338 .iter()
2339 .any(|a| a == "--dangerously-skip-permissions")
2340 );
2341 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
2342 assert!(
2343 agy_rw
2344 .argv
2345 .windows(2)
2346 .any(|w| w == ["--mode", "accept-edits"])
2347 );
2348 assert!(
2349 agy_rw
2350 .argv
2351 .iter()
2352 .any(|a| a == "--dangerously-skip-permissions")
2353 );
2354 let agy_prompt = agy_rw
2359 .argv
2360 .iter()
2361 .position(|a| a == "-p")
2362 .map(|i| agy_rw.argv[i + 1].clone())
2363 .expect("agy takes its prompt with -p");
2364 assert!(
2365 agy_prompt.starts_with('@'),
2366 "agy must get a file reference, got {agy_prompt:?}"
2367 );
2368 assert!(
2369 !agy_prompt.contains("Read the file at"),
2370 "the prose pointer is for CLIs with no file syntax"
2371 );
2372
2373 for allow_write in [false, true] {
2378 assert!(
2379 plan_for(AgentKind::Opencode, &seat, allow_write)
2380 .argv
2381 .iter()
2382 .any(|a| a == "--auto"),
2383 "opencode needs --auto even to read (allow_write = {allow_write})"
2384 );
2385 }
2386 }
2387
2388 #[test]
2391 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
2392 let seat = SeatState::new("deputy-x", "a", 7);
2393 let roots = [PathBuf::from("/data/questions")];
2394 let mk = |allow_write: bool| {
2395 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2396 i.writable = &roots;
2397 build_command(
2398 &spec(AgentKind::Codex, None),
2399 &seat,
2400 &i,
2401 Path::new("/art/p.md"),
2402 )
2403 .unwrap()
2404 };
2405 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
2406 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
2407 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
2408 }
2409
2410 #[test]
2411 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
2412 let mut i = inv(Path::new("."), Path::new("/art"), true);
2413 for node in [
2414 "implement",
2415 "fix",
2416 "graft",
2417 "gate-fix",
2418 "land",
2419 "rebase",
2420 "chat",
2421 ] {
2422 i.node = node;
2423 assert!(repository_writer(&i), "{node}");
2424 i.allow_write = false;
2425 assert!(!repository_writer(&i), "{node}");
2426 i.allow_write = true;
2427 }
2428 for node in ["deputy", "judge", "review", "bump", "conduct", "talk-title"] {
2429 i.node = node;
2430 assert!(!repository_writer(&i), "{node}");
2431 }
2432 i.node = "chat";
2433 let roots = [PathBuf::from("/questions")];
2434 i.writable = &roots;
2435 assert!(
2436 repository_writer(&i),
2437 "explicit roots do not change the role"
2438 );
2439 assert!(
2440 !repository_seat(&i),
2441 "chat turns bypass repository scheduling"
2442 );
2443 }
2444
2445 #[test]
2446 fn coordination_classification_is_independent_of_write_grants() {
2447 let roots = [PathBuf::from("/questions")];
2448 for node in [
2449 crate::queue::CHAT_NODE,
2450 "implement",
2451 "fix",
2452 "graft",
2453 "gate-fix",
2454 "land",
2455 "rebase",
2456 "review",
2457 "judge",
2458 "advise",
2459 "deputy",
2460 "waiter",
2461 "conduct",
2462 "dupes",
2463 "talk-title",
2464 ] {
2465 for allow_write in [false, true] {
2466 for writable in [&[][..], &roots[..]] {
2467 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2468 i.node = node;
2469 i.writable = writable;
2470 assert_eq!(
2471 repository_seat(&i),
2472 !matches!(
2473 node,
2474 crate::queue::CHAT_NODE | "talk-title" | "deputy" | "conduct" | "dupes"
2475 ),
2476 "{node}, allow_write={allow_write}, writable={writable:?}"
2477 );
2478 if node == crate::queue::CHAT_NODE {
2479 assert_eq!(repository_writer(&i), allow_write);
2480 }
2481 }
2482 }
2483 }
2484 }
2485
2486 #[test]
2487 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
2488 let roots = [
2489 PathBuf::from("/shared/git"),
2490 PathBuf::from("/state/questions"),
2491 ];
2492 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
2493 for resumed in [false, true] {
2494 let mut seat = SeatState::new("impl-A", "a", 7);
2495 if resumed {
2496 seat.turns = 1;
2497 seat.captured_session = Some("session-id".into());
2498 }
2499 for allow_write in [false, true] {
2500 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2501 i.writable = &roots;
2502 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
2503 .unwrap();
2504 for root in &roots {
2505 if kind == AgentKind::Antigravity {
2506 assert_eq!(
2507 plan.argv.windows(2).any(|w| {
2508 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
2509 }),
2510 allow_write,
2511 "resumed={resumed}: {:?}",
2512 plan.argv
2513 );
2514 }
2515 assert_eq!(
2516 plan.argv
2517 .iter()
2518 .any(|arg| arg.contains(root.to_str().unwrap())),
2519 allow_write,
2520 "{kind:?}, resumed={resumed}: {:?}",
2521 plan.argv
2522 );
2523 }
2524 if kind == AgentKind::Codex && resumed {
2525 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
2526 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
2527 }
2528 }
2529 }
2530 }
2531 }
2532
2533 #[test]
2534 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
2535 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
2536 let mut seat = SeatState::new("talk", "a", 7);
2537 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
2538 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
2539 i.unsandboxed = unsandboxed;
2540 build_command(
2541 &spec(AgentKind::Codex, None),
2542 seat,
2543 &i,
2544 Path::new("/art/p.md"),
2545 )
2546 .unwrap()
2547 };
2548
2549 let first = build(&seat, true, true);
2551 assert!(first.argv.iter().any(|a| a == BYPASS));
2552 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
2553 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
2554
2555 seat.captured_session = Some("thread-1".to_owned());
2557 seat.turns = 1;
2558 let resumed = build(&seat, true, true);
2559 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
2560 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
2561
2562 let ro = build(&seat, false, true);
2564 assert!(!ro.argv.iter().any(|a| a == BYPASS));
2565 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2566 }
2567
2568 #[test]
2569 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
2570 let mut seat = SeatState::new("judge-1", "a", 7);
2571
2572 let ro = plan_for(AgentKind::Codex, &seat, false);
2576 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
2577 let rw = plan_for(AgentKind::Codex, &seat, true);
2578 assert!(
2579 rw.argv
2580 .windows(2)
2581 .any(|w| w == ["--sandbox", "workspace-write"])
2582 );
2583 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2586 ro_flagged.unsandboxed = true;
2587 let ro_flagged = build_command(
2588 &spec(AgentKind::Codex, None),
2589 &seat,
2590 &ro_flagged,
2591 Path::new("/art/p.md"),
2592 )
2593 .unwrap();
2594 assert!(
2595 ro_flagged
2596 .argv
2597 .windows(2)
2598 .any(|w| w == ["--sandbox", "read-only"])
2599 );
2600 for p in [&ro, &rw, &ro_flagged] {
2601 assert!(
2602 !p.argv
2603 .iter()
2604 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2605 "the bypass defeats the only enforced read-only mode we have"
2606 );
2607 assert!(
2609 p.argv
2610 .windows(2)
2611 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2612 "an unattended seat that asks for approval blocks until timeout"
2613 );
2614 }
2615
2616 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2618 assert_eq!(
2619 ro.argv.last().map(String::as_str),
2620 Some("-"),
2621 "without the `-` argument codex waits for a prompt it never gets"
2622 );
2623
2624 seat.turns = 1;
2628 assert!(!has_session(AgentKind::Codex, &seat, true));
2629 assert!(
2630 !plan_for(AgentKind::Codex, &seat, true)
2631 .argv
2632 .iter()
2633 .any(|a| a == "resume")
2634 );
2635 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2636 let resumed = plan_for(AgentKind::Codex, &seat, true);
2637 let at = resumed
2638 .argv
2639 .iter()
2640 .position(|a| a == "resume")
2641 .expect("resumes by subcommand");
2642 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2643 assert!(
2644 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2645 "every option precedes the subcommand"
2646 );
2647 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2648 }
2649
2650 #[test]
2653 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2654 let mut seat = SeatState::new("review-1", "a", 7);
2655
2656 let first = plan_for(AgentKind::Omp, &seat, false);
2660 assert!(first.argv.iter().any(|a| a == "-p"));
2661 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2662 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2663 assert!(
2664 !first.argv.iter().any(|a| a == "do the thing"),
2665 "the prompt reached argv, where Windows caps it"
2666 );
2667
2668 for allow_write in [false, true] {
2674 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2675 assert!(
2676 p.argv.iter().any(|a| a == "--auto-approve"),
2677 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2678 );
2679 assert!(
2680 !p.argv
2681 .iter()
2682 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2683 "nothing ever asks for the bypass"
2684 );
2685 }
2686
2687 seat.turns = 1;
2690 assert!(!has_session(AgentKind::Omp, &seat, true));
2691 assert!(
2692 !plan_for(AgentKind::Omp, &seat, true)
2693 .argv
2694 .iter()
2695 .any(|a| a == "--resume")
2696 );
2697 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2698 let resumed = plan_for(AgentKind::Omp, &seat, true);
2699 assert!(
2700 resumed
2701 .argv
2702 .windows(2)
2703 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2704 "a captured id is what makes the next turn a resume"
2705 );
2706 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2709 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2711 }
2712
2713 #[test]
2718 fn omp_takes_the_answer_without_an_agent_end_line() {
2719 let stream = concat!(
2720 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2721 "\n",
2722 r#"{"type":"agent_start"}"#,
2723 "\n",
2724 r#"{"type":"turn_start"}"#,
2725 "\n",
2726 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2727 "\n",
2728 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2729 "\n",
2730 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2731 "\n",
2732 );
2733 let out = extract(AgentKind::Omp, stream);
2734 assert_eq!(
2735 out.text, "{\"vote\":\"approve\"}",
2736 "the last assistant text block is the answer even with no agent_end"
2737 );
2738 assert_eq!(
2739 out.session.as_deref(),
2740 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2741 );
2742 }
2743
2744 #[test]
2748 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2749 let stream = concat!(
2750 r#"{"type":"session","version":3,"id":"s1"}"#,
2751 "\n",
2752 "{\"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問題ありません。\"}]}]}",
2753 "\n",
2754 );
2755 let out = extract(AgentKind::Omp, stream);
2756 assert_eq!(
2757 out.text, "## 判定\n\n問題ありません。",
2758 "the narration is not the answer, and non-ASCII survives intact"
2759 );
2760 assert_eq!(out.session.as_deref(), Some("s1"));
2761 }
2762
2763 #[test]
2766 fn omp_skips_non_json_lines() {
2767 let stream = concat!(
2768 "Warning: some omp notice\n",
2769 r#"{"type":"session","version":3,"id":"s2"}"#,
2770 "\n",
2771 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2772 "\n",
2773 "trailing junk",
2774 "\n",
2775 );
2776 let out = extract(AgentKind::Omp, stream);
2777 assert_eq!(out.text, "the answer");
2778 assert_eq!(out.session.as_deref(), Some("s2"));
2779 }
2780
2781 #[test]
2783 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2784 let stream = concat!(
2785 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2786 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2787 "\n",
2788 r#"{"type":"turn.started"}"#,
2789 "\n",
2790 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2791 "\n",
2792 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2793 "\n",
2794 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2795 "\n",
2796 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2797 "\n",
2798 );
2799 let out = extract(AgentKind::Codex, stream);
2800 assert_eq!(
2801 out.text, "{\"verdict\": \"ok\"}",
2802 "the last agent message is the answer; earlier ones narrate"
2803 );
2804 assert_eq!(
2805 out.session.as_deref(),
2806 Some("01a07440-4545-7492-85c1-024e3259a90a")
2807 );
2808 assert_eq!(out.status.as_deref(), Some("success"));
2809
2810 let failed = concat!(
2811 r#"{"type":"thread.started","thread_id":"t1"}"#,
2812 "\n",
2813 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2814 "\n",
2815 );
2816 assert_eq!(
2817 extract(AgentKind::Codex, failed).status.as_deref(),
2818 Some("error")
2819 );
2820 }
2821
2822 #[test]
2823 fn captured_sessions_resume_only_once_reported() {
2824 let mut seat = SeatState::new("impl-A", "a", 7);
2825 seat.turns = 1;
2826 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2827 assert!(!has_session(kind, &seat, true));
2828 let p = plan_for(kind, &seat, true);
2829 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2830 }
2831
2832 seat.captured_session = Some("sid".to_owned());
2833 assert!(has_session(AgentKind::Opencode, &seat, true));
2834 assert!(
2835 plan_for(AgentKind::Opencode, &seat, true)
2836 .argv
2837 .windows(2)
2838 .any(|w| w == ["-s", "sid"])
2839 );
2840 assert!(
2841 plan_for(AgentKind::Antigravity, &seat, true)
2842 .argv
2843 .windows(2)
2844 .any(|w| w == ["--conversation", "sid"])
2845 );
2846 }
2847
2848 #[test]
2849 fn sessions_disabled_never_resumes() {
2850 let mut seat = SeatState::new("impl-A", "a", 7);
2851 seat.turns = 3;
2852 seat.captured_session = Some("sid".to_owned());
2853 for kind in [
2854 AgentKind::Claude,
2855 AgentKind::Opencode,
2856 AgentKind::Antigravity,
2857 ] {
2858 assert!(!has_session(kind, &seat, false));
2859 }
2860 }
2861
2862 #[test]
2863 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2864 let seat = SeatState::new("judge-1", "a", 7);
2865 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2866 let p = plan_for(kind, &seat, false);
2867 assert!(
2868 p.argv.iter().all(|a| a != "do the thing"),
2869 "{kind:?} put the prompt on the command line"
2870 );
2871 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2872 }
2873 let p = plan_for(AgentKind::Antigravity, &seat, false);
2875 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2876 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2877 assert!(p.stdin.is_none());
2878 }
2879
2880 #[test]
2881 fn agy_print_timeout_tracks_the_node_budget() {
2882 let seat = SeatState::new("impl-A", "a", 7);
2883 let p = build_command(
2884 &spec(AgentKind::Antigravity, None),
2885 &seat,
2886 &Invocation {
2887 coordination: None,
2888 editing_network: crate::config::EditingNetwork::Restricted,
2889 fetched_base: None,
2890 cwd: Path::new("."),
2891 prompt: "p",
2892 timeout: Duration::from_secs(3600),
2893 allow_write: true,
2894 unsandboxed: false,
2895 sessions: true,
2896 artifacts: Path::new("/art"),
2897 stem: "t",
2898 run: "test-run",
2899 node: "test",
2900 cache_dir: None,
2901 attachments: &[],
2902 writable: &[],
2903 },
2904 Path::new("/art/p.md"),
2905 )
2906 .unwrap();
2907 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2908 }
2909
2910 #[test]
2917 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2918 let mut s = spec(AgentKind::Antigravity, None);
2919 s.prompt_delivery = Some(Delivery::Argv);
2920 let seat = SeatState::new("talk", "a", 7);
2921 let atts = [PathBuf::from("/art/attachments/abc.png")];
2922
2923 let without = build_command(
2924 &s,
2925 &seat,
2926 &Invocation {
2927 attachments: &[],
2928 writable: &[],
2929 ..inv(Path::new("."), Path::new("/art"), true)
2930 },
2931 Path::new("/art/p.md"),
2932 )
2933 .unwrap();
2934 assert!(
2935 !without.argv.iter().any(|a| a == "--add-dir"),
2936 "no attachment, no reason to widen the sandbox: {without:?}"
2937 );
2938
2939 let with = build_command(
2940 &s,
2941 &seat,
2942 &Invocation {
2943 attachments: &atts,
2944 writable: &[],
2945 ..inv(Path::new("."), Path::new("/art"), true)
2946 },
2947 Path::new("/art/p.md"),
2948 )
2949 .unwrap();
2950 assert!(
2951 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2952 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2953 );
2954 }
2955
2956 #[test]
2962 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2963 let seat = SeatState::new("plan", "a", 7);
2964 let atts = [
2965 PathBuf::from("/art/attachments/own.png"),
2966 PathBuf::from("/other-chat/attachments/inherited.png"),
2967 ];
2968
2969 let p = build_command(
2970 &spec(AgentKind::Antigravity, None),
2971 &seat,
2972 &Invocation {
2973 attachments: &atts,
2974 writable: &[],
2975 ..inv(Path::new("."), Path::new("/art"), true)
2976 },
2977 Path::new("/art/p.md"),
2978 )
2979 .unwrap();
2980
2981 assert!(
2982 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2983 "this conversation's own artifacts dir must still be granted: {p:?}"
2984 );
2985 assert!(
2986 p.argv
2987 .windows(2)
2988 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2989 "the inherited attachment's own directory must be granted too: {p:?}"
2990 );
2991 }
2992
2993 #[test]
2994 fn command_agents_get_placeholders_substituted() {
2995 let seat = SeatState::new("impl-A", "a", 7);
2996 let p = plan_for(AgentKind::Command, &seat, true);
2997 assert_eq!(p.argv[0], "echo");
2998 assert_eq!(p.argv[1], "impl-A");
2999 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
3000 }
3001
3002 #[test]
3003 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
3004 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
3006 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
3007 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
3008 let out = extract(AgentKind::Claude, stdout);
3009 let quota = out.quota.as_ref().expect("rate limit must be detected");
3010 assert_eq!(
3011 quota.reset.as_deref(),
3012 Some("4:50am (Asia/Tokyo)"),
3013 "reset time read from the body"
3014 );
3015 }
3016
3017 #[test]
3018 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
3019 let out = extract(
3020 AgentKind::Claude,
3021 r#"{"is_error":true,"result":"session limit reached"}"#,
3022 );
3023 let quota = out.quota.expect("rate limit detected without a reset");
3024 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
3025 }
3026
3027 #[test]
3028 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
3029 for kind in [AgentKind::Claude, AgentKind::Command] {
3030 for reply in [
3031 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3032 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
3033 ] {
3034 assert!(extract(kind, reply).quota.is_some(), "{reply}");
3035 }
3036 for reply in [
3037 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
3038 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
3039 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
3040 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
3041 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
3042 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
3043 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
3044 ] {
3045 assert!(extract(kind, reply).quota.is_none(), "{reply}");
3046 }
3047 }
3048 }
3049
3050 #[test]
3051 fn ordinary_failures_are_never_quota() {
3052 let claude_fail = extract(
3054 AgentKind::Claude,
3055 r#"{"is_error":true,"result":"account does not exist"}"#,
3056 );
3057 assert!(claude_fail.quota.is_none());
3058
3059 let cmd_fail = extract(AgentKind::Command, "boom");
3061 assert!(cmd_fail.quota.is_none());
3062
3063 let success = extract(
3065 AgentKind::Command,
3066 r#"{"is_error":false,"result":"session limit is fine"}"#,
3067 );
3068 assert!(success.quota.is_none());
3069 }
3070
3071 #[test]
3079 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
3080 let stream = concat!(
3081 r#"{"type":"thread.started","thread_id":"t1"}"#,
3082 "\n",
3083 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"}}"#,
3084 "\n",
3085 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"}}"#,
3086 "\n",
3087 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
3088 "\n",
3089 r#"{"type":"turn.completed"}"#,
3090 "\n",
3091 );
3092 let out = extract(AgentKind::Codex, stream);
3093 assert_eq!(out.text, "Both tests in the target pass.");
3094 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
3095
3096 let paired = &out.commands[0];
3097 assert_eq!(paired.id, "item49");
3098 assert_eq!(paired.exit_code, Some(1));
3099 assert!(paired.description.contains("graph_cached_gate"));
3100 assert!(paired.result_summary.contains("1 failed"));
3101
3102 let solo = &out.commands[1];
3103 assert_eq!(solo.exit_code, Some(0));
3104
3105 assert!(
3109 out.commands
3110 .iter()
3111 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
3112 "a failed run of the actual target must still be visible: {:?}",
3113 out.commands
3114 );
3115 }
3116
3117 #[test]
3118 fn command_agent_can_carry_the_claude_quota_shape() {
3119 let out = extract(
3120 AgentKind::Command,
3121 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
3122 );
3123 assert!(
3124 out.quota.is_some(),
3125 "a wrapper emitting the claude shape counts as quota"
3126 );
3127 }
3128
3129 #[test]
3130 fn claude_json_result_is_extracted() {
3131 let out = extract(
3132 AgentKind::Claude,
3133 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
3134 );
3135 assert_eq!(out.text, "all done");
3136 assert_eq!(out.session.as_deref(), Some("abc"));
3137 assert_eq!(out.status.as_deref(), Some("success"));
3138 }
3139
3140 #[test]
3155 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
3156 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"}"#;
3157 let out = extract(AgentKind::Claude, stdout);
3158 assert_eq!(out.status.as_deref(), Some("success"));
3159 assert!(out.quota.is_none());
3160 assert!(!out.text.trim().is_empty());
3161
3162 let agent_out = AgentOutput {
3163 text: out.text.clone(),
3164 exit_code: Some(0),
3165 timed_out: false,
3166 duration_ms: 500,
3167 artifacts: Vec::new(),
3168 quota: out.quota,
3169 dropped: out.dropped,
3170 commands: out.commands,
3171 context_tokens: None,
3172 };
3173 assert!(
3174 agent_out.usable(),
3175 "the CLI turn itself ended cleanly and must read as usable"
3176 );
3177 assert!(
3178 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
3179 "a clean CLI turn is not proof the node's own report ever arrived"
3180 );
3181 }
3182
3183 #[test]
3184 fn opencode_event_stream_is_concatenated() {
3185 let stream = concat!(
3186 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
3187 "\n",
3188 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
3189 "\n",
3190 "garbage line\n",
3191 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
3192 "\n"
3193 );
3194 let out = extract(AgentKind::Opencode, stream);
3195 assert_eq!(out.text, "first\nsecond");
3196 assert_eq!(out.session.as_deref(), Some("ses_1"));
3197 }
3198
3199 #[test]
3200 fn agy_json_survives_a_leading_warning_line() {
3201 let stdout = concat!(
3202 "warning: --mode plan has no effect while slash commands are disabled.\n",
3203 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
3204 "\n"
3205 );
3206 let out = extract(AgentKind::Antigravity, stdout);
3207 assert_eq!(out.text, "persimmon");
3208 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
3209 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
3210 }
3211
3212 const AGY_DROPPED: &str = concat!(
3219 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
3220 r#""response":"","error":"the connection to the agent was interrupted before "#,
3221 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
3222 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
3223 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
3224 r#""total_tokens":274380}}"#
3225 );
3226
3227 const AGY_QUOTA_OUT: &str = concat!(
3229 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
3230 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
3231 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
3232 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
3233 );
3234 const AGY_QUOTA_ERR: &str = concat!(
3235 "error: Individual quota reached. Resets in 1h2m49s.\n",
3236 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
3237 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
3238 r#""retryable":true}"#,
3239 "\n"
3240 );
3241
3242 #[test]
3243 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
3244 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
3245 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
3246 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
3247 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
3248 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
3250 assert!(stderr_only.reset.is_none());
3251 }
3252
3253 #[test]
3254 fn ordinary_agy_failures_are_not_a_quota() {
3255 assert!(agy_quota(AGY_DROPPED, "").is_none());
3256 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
3257 assert!(
3258 agy_quota(
3259 "",
3260 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
3261 )
3262 .is_none()
3263 );
3264 assert!(
3265 agy_quota(
3266 "",
3267 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
3268 )
3269 .is_none()
3270 );
3271 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
3272 }
3273
3274 #[test]
3275 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
3276 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
3277 let dropped = out.dropped.expect("recognised as undelivered work");
3278 assert_eq!(dropped.output_tokens, 14267);
3279 assert!(
3280 dropped.why.contains("subscriber fell behind"),
3281 "the CLI's own words are kept for the record: {}",
3282 dropped.why
3283 );
3284 assert_eq!(
3287 out.session.as_deref(),
3288 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
3289 );
3290 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
3291 }
3292
3293 #[test]
3294 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
3295 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
3299 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
3300
3301 let answered = concat!(
3304 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
3305 r#""usage":{"output_tokens":10}}"#
3306 );
3307 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
3308
3309 let ok = concat!(
3311 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
3312 r#""usage":{"output_tokens":10}}"#
3313 );
3314 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
3315 }
3316
3317 #[test]
3318 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
3319 let out = AgentOutput {
3320 text: String::new(),
3321 exit_code: Some(1),
3322 timed_out: false,
3323 duration_ms: 431_194,
3324 artifacts: Vec::new(),
3325 quota: None,
3326 dropped: Some(Dropped {
3327 why: "subscriber fell behind updates".to_owned(),
3328 output_tokens: 14267,
3329 }),
3330 commands: Vec::new(),
3331 context_tokens: None,
3332 };
3333 assert!(!out.usable());
3334 assert!(out.work_undelivered());
3335 assert!(!out.quota_exhausted());
3338 }
3339
3340 #[test]
3341 fn non_json_stdout_falls_back_to_raw_text() {
3342 let out = extract(AgentKind::Antigravity, "plain answer\n");
3343 assert_eq!(out.text, "plain answer");
3344 assert!(out.session.is_none());
3345 }
3346
3347 #[tokio::test]
3348 async fn command_agent_round_trip_writes_artifacts() {
3349 let dir = tempfile::tempdir().unwrap();
3350 let art = dir.path().join("artifacts");
3351 let mut seat = SeatState::new("impl-A", "a", 7);
3352 let s = command_helper("reply");
3353 let out = invoke(
3354 &s,
3355 &mut seat,
3356 &Invocation {
3357 coordination: None,
3358 editing_network: crate::config::EditingNetwork::Restricted,
3359 fetched_base: None,
3360 cwd: dir.path(),
3361 prompt: "unused",
3362 timeout: Duration::from_secs(30),
3363 allow_write: true,
3364 unsandboxed: false,
3365 sessions: true,
3366 artifacts: &art,
3367 stem: "impl-A",
3368 run: "test-run",
3369 node: "test",
3370 cache_dir: None,
3371 attachments: &[],
3372 writable: &[],
3373 },
3374 )
3375 .await
3376 .unwrap();
3377 assert!(out.usable(), "{out:?}");
3378 assert!(out.text.contains("hello impl-A"), "{}", out.text);
3379 assert_eq!(seat.turns, 1);
3380 assert!(art.join("impl-A.prompt.md").is_file());
3381 assert!(art.join("impl-A.out").is_file());
3382 }
3383
3384 #[tokio::test]
3385 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
3386 crate::run::pin_test_home();
3387 let dir = tempfile::tempdir().unwrap();
3391 let cache = dir.path().join("magi-cache");
3392 let mut seat = SeatState::new("impl-A", "a", 7);
3393 let s = command_helper("cache");
3394 let out = invoke(
3395 &s,
3396 &mut seat,
3397 &Invocation {
3398 coordination: None,
3399 editing_network: crate::config::EditingNetwork::Restricted,
3400 fetched_base: None,
3401 cwd: dir.path(),
3402 prompt: "unused",
3403 timeout: Duration::from_secs(30),
3404 allow_write: true,
3405 unsandboxed: false,
3406 sessions: true,
3407 artifacts: &dir.path().join("artifacts"),
3408 stem: "cache",
3409 run: "test-run",
3410 node: "test",
3411 cache_dir: Some(&cache),
3412 attachments: &[],
3413 writable: &[],
3414 },
3415 )
3416 .await
3417 .unwrap();
3418 assert!(out.usable(), "{out:?}");
3419 assert!(
3420 out.text.contains(cache.to_string_lossy().as_ref()),
3421 "the seat must see CARGO_TARGET_DIR = the shared cache"
3422 );
3423 }
3424
3425 #[tokio::test]
3426 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
3427 let previous = std::env::var("CARGO_TARGET_DIR").ok();
3435 unsafe {
3440 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
3441 }
3442 let dir = tempfile::tempdir().unwrap();
3443 let mut seat = SeatState::new("review-1", "a", 7);
3444 let s = command_helper("no-cache");
3445 let result = invoke(
3446 &s,
3447 &mut seat,
3448 &Invocation {
3449 coordination: None,
3450 editing_network: crate::config::EditingNetwork::Restricted,
3451 fetched_base: None,
3452 cwd: dir.path(),
3453 prompt: "unused",
3454 timeout: Duration::from_secs(30),
3455 allow_write: false,
3456 unsandboxed: false,
3457 sessions: true,
3458 artifacts: &dir.path().join("artifacts"),
3459 stem: "no-cache",
3460 run: "test-run",
3461 node: "test",
3462 cache_dir: None,
3463 attachments: &[],
3464 writable: &[],
3465 },
3466 )
3467 .await;
3468 unsafe {
3473 match &previous {
3474 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
3475 None => std::env::remove_var("CARGO_TARGET_DIR"),
3476 }
3477 }
3478 let out = result.unwrap();
3479 assert!(out.usable(), "{out:?}");
3480 assert!(
3481 out.text.contains("ABSENT"),
3482 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
3483 out.text
3484 );
3485 }
3486
3487 #[tokio::test]
3488 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
3489 let dir = tempfile::tempdir().unwrap();
3490 let mut seat = SeatState::new("impl-A", "a", 7);
3491 let s = command_helper("ignore-stdin");
3494 let big = "x".repeat(1_000_000);
3495 let out = invoke(
3496 &s,
3497 &mut seat,
3498 &Invocation {
3499 coordination: None,
3500 editing_network: crate::config::EditingNetwork::Restricted,
3501 fetched_base: None,
3502 cwd: dir.path(),
3503 prompt: &big,
3504 timeout: Duration::from_secs(60),
3505 allow_write: true,
3506 unsandboxed: false,
3507 sessions: true,
3508 artifacts: &dir.path().join("artifacts"),
3509 stem: "big",
3510 run: "test-run",
3511 node: "test",
3512 cache_dir: None,
3513 attachments: &[],
3514 writable: &[],
3515 },
3516 )
3517 .await
3518 .unwrap();
3519 assert!(out.usable(), "{out:?}");
3520 assert!(out.text.contains("done"), "{}", out.text);
3521 }
3522
3523 #[tokio::test]
3524 async fn agent_turn_cap_limits_a_direct_invocation() {
3525 let dir = tempfile::tempdir().unwrap();
3526 let artifacts = dir.path().join("artifacts");
3527 let mut seat = SeatState::new("impl-A", "helper", 7);
3528 let mut s = command_helper("sleep");
3529 s.max_turn_secs = Some(2);
3530 let out = invoke(&s, &mut seat, &inv(dir.path(), &artifacts, false))
3531 .await
3532 .unwrap();
3533 assert!(out.timed_out, "{out:?}");
3534 assert!(out.duration_ms < 20_000, "{out:?}");
3537 }
3538
3539 #[tokio::test]
3540 async fn timeout_is_reported_not_hung() {
3541 let dir = tempfile::tempdir().unwrap();
3542 let mut seat = SeatState::new("impl-A", "a", 7);
3543 let s = command_helper("sleep");
3544 let out = invoke(
3545 &s,
3546 &mut seat,
3547 &Invocation {
3548 coordination: None,
3549 editing_network: crate::config::EditingNetwork::Restricted,
3550 fetched_base: None,
3551 cwd: dir.path(),
3552 prompt: "unused",
3553 timeout: Duration::from_millis(300),
3554 allow_write: true,
3555 unsandboxed: false,
3556 sessions: true,
3557 artifacts: &dir.path().join("artifacts"),
3558 stem: "slow",
3559 run: "test-run",
3560 node: "test",
3561 cache_dir: None,
3562 attachments: &[],
3563 writable: &[],
3564 },
3565 )
3566 .await
3567 .unwrap();
3568 assert!(out.timed_out);
3569 assert!(!out.usable());
3570 }
3571
3572 #[tokio::test]
3573 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
3574 let dir = tempfile::tempdir().unwrap();
3580 let artifacts = dir.path().join("artifacts");
3581 let mut seat = SeatState::new("impl-A", "a", 7);
3582 let s = command_helper("chatty-sleep");
3583 let out = invoke(
3584 &s,
3585 &mut seat,
3586 &Invocation {
3587 coordination: None,
3588 editing_network: crate::config::EditingNetwork::Restricted,
3589 fetched_base: None,
3590 cwd: dir.path(),
3591 prompt: "unused",
3592 timeout: Duration::from_secs(10),
3597 allow_write: true,
3598 unsandboxed: false,
3599 sessions: true,
3600 artifacts: &artifacts,
3601 stem: "chatty",
3602 run: "test-run",
3603 node: "test",
3604 cache_dir: None,
3605 attachments: &[],
3606 writable: &[],
3607 },
3608 )
3609 .await
3610 .unwrap();
3611
3612 assert!(out.timed_out, "{out:?}");
3613 assert!(!out.usable(), "a cut-off answer is still not an answer");
3614 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
3615 assert!(
3616 recorded.contains("i-said-something"),
3617 "the artifact must keep what arrived before the kill, got {recorded:?}"
3618 );
3619 assert!(
3620 out.text.contains("i-said-something"),
3621 "and the graph must be able to see it too, got {:?}",
3622 out.text
3623 );
3624 }
3625
3626 #[test]
3627 fn missing_programs_reports_command_binaries() {
3628 let mut s = spec(AgentKind::Command, None);
3629 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3630 assert_eq!(
3631 missing_programs(&[s]),
3632 ["definitely-not-a-real-binary-xyz".to_owned()]
3633 );
3634 }
3635
3636 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3637 AgentSpec {
3638 id: id.to_owned(),
3639 kind,
3640 model: None,
3641 command: Vec::new(),
3642 extra_args: Vec::new(),
3643 env: BTreeMap::new(),
3644 prompt_delivery: None,
3645 max_turn_secs: None,
3646 }
3647 }
3648
3649 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3652 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3653 }
3654
3655 #[test]
3656 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3657 let agents = [
3658 pick_spec("oc", AgentKind::Opencode),
3659 pick_spec("opus", AgentKind::Claude),
3660 pick_spec("agy", AgentKind::Antigravity),
3661 ];
3662 let got = pick(&agents, None, &without(&[])).expect("a pick");
3663 assert_eq!(got.id, "opus");
3664 }
3665
3666 #[test]
3667 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3668 let agents = [
3669 pick_spec("opus", AgentKind::Claude),
3670 pick_spec("oc", AgentKind::Opencode),
3671 pick_spec("agy", AgentKind::Antigravity),
3672 ];
3673 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3674 assert_eq!(got.id, "agy");
3675 }
3676
3677 #[test]
3678 fn pick_on_an_empty_roster_says_what_to_install() {
3679 let msg = pick(&[], None, &without(&[]))
3680 .expect_err("nobody to ask")
3681 .to_string();
3682 assert!(msg.contains("roster is empty"), "{msg}");
3683 assert!(msg.contains("claude"), "{msg}");
3684 assert!(msg.contains("magi.toml"), "{msg}");
3685 }
3686
3687 #[test]
3688 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3689 let agents = [
3690 pick_spec("opus", AgentKind::Claude),
3691 pick_spec("oc", AgentKind::Opencode),
3692 ];
3693 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3694 let msg = format!("{err:#}");
3695 assert!(msg.contains("claude"), "{msg}");
3696 assert!(msg.contains("opencode"), "{msg}");
3697 }
3698
3699 #[test]
3700 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3701 let agents = [
3702 pick_spec("opus", AgentKind::Claude),
3703 pick_spec("oc", AgentKind::Opencode),
3704 ];
3705 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3706 assert_eq!(got.id, "oc");
3707 }
3708
3709 #[test]
3710 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3711 let agents = [
3712 pick_spec("opus", AgentKind::Claude),
3713 pick_spec("oc", AgentKind::Opencode),
3714 ];
3715 let msg = pick(&agents, Some("gemini"), &without(&[]))
3716 .expect_err("no such agent")
3717 .to_string();
3718 assert!(msg.contains("gemini"), "{msg}");
3719 assert!(msg.contains("opus, oc"), "{msg}");
3720 }
3721
3722 #[test]
3723 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3724 let agents = [
3725 pick_spec("opus", AgentKind::Claude),
3726 pick_spec("oc", AgentKind::Opencode),
3727 ];
3728 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3729 .expect_err("must not silently substitute another model")
3730 .to_string();
3731 assert!(msg.contains("opencode"), "{msg}");
3732 assert!(msg.contains("--agent"), "{msg}");
3733 }
3734
3735 fn named(id: &str) -> AgentSpec {
3736 AgentSpec {
3737 id: id.to_owned(),
3738 ..spec(AgentKind::Command, None)
3739 }
3740 }
3741
3742 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3743 AgentOutput {
3744 text: text.to_owned(),
3745 exit_code: Some(exit),
3746 timed_out: false,
3747 duration_ms: 0,
3748 artifacts: Vec::new(),
3749 quota: quota.then_some(Quota { reset: None }),
3750 dropped: None,
3751 commands: Vec::new(),
3752 context_tokens: None,
3753 }
3754 }
3755
3756 #[test]
3757 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3758 let agents = [named("a"), named("b"), named("c")];
3759 let all = |_: &AgentSpec| true;
3760 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3761 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3762 assert_eq!(
3763 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3764 ["c", "a"]
3765 );
3766
3767 let one = AgentChoice::from("b");
3768 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3769 assert_eq!(got.len(), 1);
3770 assert_eq!(got[0].id, "b");
3771
3772 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3773 assert_eq!(got.len(), 1, "unset keeps pick's default");
3774 assert_eq!(got[0].id, "a");
3775 let empty = AgentChoice::Chain(Vec::new());
3776 assert_eq!(
3777 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3778 "a"
3779 );
3780 }
3781
3782 #[test]
3783 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3784 let agents = [named("a"), named("b")];
3785 let not_a = |s: &AgentSpec| s.id != "a";
3786 let chain = AgentChoice::Chain(
3787 ["a", "ghost", "b", "b"]
3788 .iter()
3789 .map(|s| (*s).to_owned())
3790 .collect(),
3791 );
3792 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3793 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3794
3795 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3796 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3797 assert_eq!(
3798 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3799 ["b", "a"]
3800 );
3801 }
3802
3803 #[test]
3804 fn a_chain_with_nothing_runnable_names_the_role() {
3805 let agents = [named("a")];
3806 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3807 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3808 .unwrap_err()
3809 .to_string();
3810 assert!(err.contains("conductor"), "{err}");
3811 }
3812
3813 #[test]
3814 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3815 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3816 assert!(chain_advances(&Ok(output("limit", 0, true))));
3817 assert!(chain_advances(&Ok(output("", 0, false))));
3818 assert!(chain_advances(&Ok(output("x", 1, false))));
3819 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3820 }
3821
3822 #[test]
3823 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3824 for out in [
3825 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3826 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3827 r#"{"result":"ok"}"#,
3828 "not json",
3829 ] {
3830 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3831 }
3832 }
3833
3834 #[test]
3835 fn opencode_context_tokens_take_the_last_step_finish() {
3836 let out = concat!(
3837 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3838 "\n",
3839 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3840 "\n",
3841 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3842 "\n"
3843 );
3844 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3845 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3846 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3847 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3848 }
3849
3850 #[test]
3851 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3852 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3853 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3854 }
3855
3856 #[test]
3857 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3858 let out = concat!(
3859 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3860 "\n"
3861 );
3862 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3863 }
3864
3865 #[test]
3866 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3867 let out = concat!(
3868 r#"{"type":"session","id":"s"}"#,
3869 "\n",
3870 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3871 "\n",
3872 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3873 "\n"
3874 );
3875 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3876 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3877 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3878 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3879 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3880 }
3881
3882 #[test]
3883 fn command_agents_report_no_context_tokens() {
3884 let out = r#"{"usage":{"input_tokens":5}}"#;
3885 assert_eq!(context_tokens(AgentKind::Command, out), None);
3886 }
3887}