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