1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::process::Stdio;
35use std::time::{Duration, Instant};
36
37use anyhow::{Context as _, Result, bail};
38use serde::{Deserialize, Serialize};
39use std::sync::{Arc, Mutex};
40
41use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
42use tokio::process::Command;
43
44use crate::config::{AgentChoice, AgentKind, AgentSpec, Delivery};
45use crate::proc::Quiet as _;
46use crate::rng::SplitMix64;
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct SeatState {
52 pub key: String,
54 pub agent: String,
56 pub turns: usize,
58 pub claude_session: Option<String>,
61 pub captured_session: Option<String>,
63}
64
65impl SeatState {
66 pub fn new(key: &str, agent: &str, run_seed: u64) -> Self {
68 let mut rng = SplitMix64::new(run_seed ^ crate::rng::fnv1a(key));
69 Self {
70 key: key.to_owned(),
71 agent: agent.to_owned(),
72 turns: 0,
73 claude_session: Some(rng.uuid_v4()),
74 captured_session: None,
75 }
76 }
77}
78
79pub fn has_session(kind: AgentKind, seat: &SeatState, sessions_enabled: bool) -> bool {
81 if !sessions_enabled || seat.turns == 0 {
82 return false;
83 }
84 match kind {
85 AgentKind::Claude => seat.claude_session.is_some(),
86 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
87 seat.captured_session.is_some()
88 }
89 AgentKind::Command => true,
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct Invocation<'a> {
96 pub cwd: &'a Path,
99 pub prompt: &'a str,
101 pub timeout: Duration,
103 pub allow_write: bool,
105 pub unsandboxed: bool,
111 pub sessions: bool,
113 pub artifacts: &'a Path,
115 pub stem: &'a str,
117 pub run: &'a str,
121 pub node: &'a str,
125 pub cache_dir: Option<&'a Path>,
131 pub attachments: &'a [PathBuf],
138 pub writable: &'a [PathBuf],
145}
146
147fn repository_writer(inv: &Invocation<'_>) -> bool {
148 inv.allow_write
149 && (matches!(
150 inv.node,
151 "implement" | "fix" | "graft" | "gate-fix" | "land" | "rebase"
152 ) || (inv.node == "chat" && repository_seat(inv)))
153}
154
155fn consulted_chat(inv: &Invocation<'_>) -> bool {
156 inv.node == "chat" && inv.allow_write && !inv.writable.is_empty()
159}
160
161fn repository_seat(inv: &Invocation<'_>) -> bool {
162 !matches!(inv.node, "deputy" | "conduct" | "dupes") && !consulted_chat(inv)
166}
167
168pub async fn worktree_writable_roots(cwd: &Path, home: &Path) -> Result<Vec<PathBuf>> {
175 let mut roots = Vec::new();
176 if let Some(common) = crate::repo_lock::common_dir(cwd).await? {
177 let cwd = std::fs::canonicalize(cwd).context("canonicalize seat working directory")?;
178 if !common.starts_with(&cwd) {
179 roots.push(common);
180 }
181 }
182 for name in ["questions", "notifications"] {
186 let dir = home.join(name);
187 std::fs::create_dir_all(&dir).with_context(|| format!("prepare seat {name} directory"))?;
188 roots.push(std::fs::canonicalize(dir)?);
189 }
190 Ok(roots)
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200pub struct Quota {
201 #[serde(default)]
203 pub reset: Option<String>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
213pub struct Dropped {
214 pub why: String,
216 pub output_tokens: u64,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct CommandEvidence {
236 pub id: String,
238 pub description: String,
240 pub exit_code: Option<i32>,
242 pub result_summary: String,
244 pub source: String,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct AgentOutput {
251 pub text: String,
253 pub exit_code: Option<i32>,
255 pub timed_out: bool,
257 pub duration_ms: u64,
259 pub artifacts: Vec<String>,
261 #[serde(default)]
265 pub quota: Option<Quota>,
266 #[serde(default)]
270 pub dropped: Option<Dropped>,
271 #[serde(default)]
275 pub commands: Vec<CommandEvidence>,
276 #[serde(default)]
281 pub context_tokens: Option<u64>,
282}
283
284impl AgentOutput {
285 pub fn usable(&self) -> bool {
287 !self.timed_out && self.exit_code == Some(0) && !self.text.trim().is_empty()
288 }
289
290 pub fn quota_exhausted(&self) -> bool {
292 self.quota.is_some()
293 }
294
295 pub fn work_undelivered(&self) -> bool {
300 self.dropped.is_some()
301 }
302}
303
304const PIPE_GRACE: Duration = Duration::from_secs(3);
309
310type Captured = Arc<Mutex<Vec<u8>>>;
312
313fn drain<R>(pipe: Option<R>) -> (Captured, Option<tokio::task::JoinHandle<()>>)
323where
324 R: tokio::io::AsyncRead + Unpin + Send + 'static,
325{
326 let buf: Captured = Arc::new(Mutex::new(Vec::new()));
327 let Some(mut pipe) = pipe else {
328 return (buf, None);
329 };
330 let sink = Arc::clone(&buf);
331 let handle = tokio::spawn(async move {
332 let mut chunk = [0u8; 8192];
333 loop {
334 match pipe.read(&mut chunk).await {
335 Ok(0) | Err(_) => break,
336 Ok(n) => {
337 if let Ok(mut guard) = sink.lock() {
338 guard.extend_from_slice(&chunk[..n]);
339 }
340 }
341 }
342 }
343 });
344 (buf, Some(handle))
345}
346
347async fn collect(
352 buf: &Captured,
353 handle: Option<tokio::task::JoinHandle<()>>,
354 grace: Duration,
355) -> String {
356 if let Some(handle) = handle {
357 if tokio::time::timeout(grace, handle).await.is_err() {
358 tracing::debug!("a pipe is still held open after the child exited");
359 }
360 }
361 let bytes = buf.lock().map(|g| g.clone()).unwrap_or_default();
362 String::from_utf8_lossy(&bytes).into_owned()
363}
364
365pub async fn invoke(
367 spec: &AgentSpec,
368 seat: &mut SeatState,
369 inv: &Invocation<'_>,
370) -> Result<AgentOutput> {
371 let lease = if repository_seat(inv) {
375 crate::repo_lock::SeatLease::for_checkout(inv.cwd, inv.allow_write).await?
376 } else {
377 None
378 };
379 let prompt = if consulted_chat(inv) {
380 Some(format!(
381 "{}\n\nCurrent turn scope: answer the supplied consultation or authorized hold using the conversation and supplied question context and supported magi control-plane commands only. This overrides repository investigation permissions in the standing briefing or earlier turns. No repository lease is held: do not read repository files or history, fetch, run tests, or edit repository files. If repository investigation is needed, report that it must wait for a normal repository-coordinated turn after the consultation is resolved.",
382 inv.prompt
383 ))
384 } else {
385 lease.as_ref().filter(|lease| lease.delegated).map(|lease| format!(
386 "Repository coordination: the launcher holds the exclusive repository lock at {} on behalf of this read-only seat for this entire invocation. Do not acquire a second lock or release/delete that file. Your filesystem access remains read-only.\n\n{}",
387 lease.path.display(), inv.prompt
388 ))
389 };
390 let home = repository_writer(inv).then(crate::run::home);
393 let mut writable = inv.writable.to_vec();
394 if let Some(home) = &home {
395 for root in worktree_writable_roots(inv.cwd, home).await? {
396 if !writable.contains(&root) {
397 writable.push(root);
398 }
399 }
400 }
401 let inv = &Invocation {
402 writable: &writable,
403 prompt: prompt.as_deref().unwrap_or(inv.prompt),
404 ..inv.clone()
405 };
406 tokio::fs::create_dir_all(inv.artifacts)
407 .await
408 .with_context(|| format!("create {}", inv.artifacts.display()))?;
409 let prompt_path = inv.artifacts.join(format!("{}.prompt.md", inv.stem));
410 tokio::fs::write(&prompt_path, inv.prompt)
411 .await
412 .with_context(|| format!("write {}", prompt_path.display()))?;
413
414 let plan = build_command(spec, seat, inv, &prompt_path)?;
415 tracing::debug!(seat = %seat.key, agent = %spec.id, argv = ?plan.argv, "spawning agent");
416
417 let started = Instant::now();
418 let child_path = spec
421 .env
422 .iter()
423 .find(|(k, _)| k.eq_ignore_ascii_case("PATH"))
424 .map(|(_, v)| std::ffi::OsString::from(v))
425 .or_else(|| std::env::var_os("PATH"))
426 .unwrap_or_default();
427 let program = crate::config::find_program_on(&plan.argv[0], &child_path).map_or_else(
428 || plan.argv[0].clone().into(),
429 std::path::PathBuf::into_os_string,
430 );
431 let mut cmd = Command::new(program);
432 cmd.args(&plan.argv[1..])
433 .current_dir(inv.cwd)
434 .envs(&spec.env)
435 .env("MAGI_SEAT", &seat.key)
436 .env("MAGI_TURN", seat.turns.to_string())
437 .env("MAGI_RUN", inv.run)
438 .env("MAGI_NODE", inv.node)
439 .env("MAGI_PROMPT_FILE", &prompt_path)
440 .env("MAGI_ALLOW_WRITE", if inv.allow_write { "1" } else { "0" })
441 .env("GIT_TERMINAL_PROMPT", "0")
442 .stdin(if plan.stdin.is_some() {
443 Stdio::piped()
444 } else {
445 Stdio::null()
446 })
447 .stdout(Stdio::piped())
448 .stderr(Stdio::piped())
449 .kill_on_drop(true)
450 .quiet();
453 if let Some(home) = home {
454 cmd.env("MAGI_HOME", std::fs::canonicalize(home)?);
457 }
458 if let Some(cache) = inv.cache_dir {
459 cmd.env("CARGO_TARGET_DIR", cache);
462 } else {
463 cmd.env_remove("CARGO_TARGET_DIR");
471 }
472
473 #[cfg(unix)]
474 if lease.is_some() {
475 cmd.process_group(0);
476 }
477 let child = cmd
478 .spawn()
479 .with_context(|| format!("spawn `{}` for seat {}", plan.argv[0], seat.key))?;
480 let mut running = crate::seat_process::Running::new(child, lease);
482 if let (Some(body), Some(mut sink)) = (plan.stdin.clone(), running.child().stdin.take()) {
486 tokio::spawn(async move {
487 sink.write_all(body.as_bytes()).await.ok();
488 sink.shutdown().await.ok();
489 });
490 }
491
492 let (out_buf, out_reader) = drain(running.child().stdout.take());
509 let (err_buf, err_reader) = drain(running.child().stderr.take());
510
511 let (code, timed_out) = match tokio::time::timeout(inv.timeout, running.child().wait()).await {
512 Ok(res) => {
513 let status = res.with_context(|| format!("wait for seat {}", seat.key))?;
514 (status.code(), false)
515 }
516 Err(_) => {
517 tracing::warn!(seat = %seat.key, secs = inv.timeout.as_secs(), "agent timed out");
518 if !running.coordinated() {
520 running
521 .child()
522 .kill()
523 .await
524 .context("stop timed-out seat")?;
525 }
526 (None, true)
527 }
528 };
529 running.finish().await;
530
531 let stdout = collect(&out_buf, out_reader, PIPE_GRACE).await;
536 let stderr = collect(&err_buf, err_reader, PIPE_GRACE).await;
537
538 let out_path = inv.artifacts.join(format!("{}.out", inv.stem));
539 let err_path = inv.artifacts.join(format!("{}.err", inv.stem));
540 tokio::fs::write(&out_path, &stdout).await.ok();
541 tokio::fs::write(&err_path, &stderr).await.ok();
542
543 let mut extracted = extract(spec.kind, &stdout);
544 if spec.kind == AgentKind::Antigravity && extracted.quota.is_none() {
545 extracted.quota = agy_quota(&stdout, &stderr);
546 }
547 if let Some(quota) = &extracted.quota {
548 extracted.dropped = None;
551 tracing::warn!(
552 seat = %seat.key,
553 agent = %spec.id,
554 reset = ?quota.reset,
555 "agent is out of quota"
556 );
557 }
558 if let Some(session) = extracted.session {
559 match spec.kind {
560 AgentKind::Claude => seat.claude_session = Some(session),
561 AgentKind::Opencode | AgentKind::Antigravity | AgentKind::Codex | AgentKind::Omp => {
562 seat.captured_session = Some(session);
563 }
564 AgentKind::Command => {}
565 }
566 }
567 if let Some(status) = &extracted.status
568 && !status.eq_ignore_ascii_case("success")
569 {
570 tracing::warn!(seat = %seat.key, status = %status, "agent reported a non-success status");
571 }
572 let text = if extracted.text.trim().is_empty() {
573 if stdout.trim().is_empty() {
575 stderr.trim().to_owned()
576 } else {
577 stdout.trim().to_owned()
578 }
579 } else {
580 extracted.text
581 };
582 seat.turns += 1;
583
584 Ok(AgentOutput {
585 text,
586 exit_code: code,
587 timed_out,
588 duration_ms: started.elapsed().as_millis() as u64,
589 artifacts: vec![
590 file_name(&prompt_path),
591 file_name(&out_path),
592 file_name(&err_path),
593 ],
594 quota: extracted.quota,
595 dropped: extracted.dropped,
596 commands: extracted.commands,
597 context_tokens: extracted.context_tokens,
598 })
599}
600
601fn file_name(p: &Path) -> String {
602 p.file_name()
603 .unwrap_or_default()
604 .to_string_lossy()
605 .into_owned()
606}
607
608#[derive(Debug)]
610struct Plan {
611 argv: Vec<String>,
612 stdin: Option<String>,
613}
614
615fn pointer(kind: AgentKind, prompt_path: &Path) -> String {
626 if matches!(kind, AgentKind::Antigravity) {
627 return format!("@{}", prompt_path.display());
628 }
629 format!(
630 "Read the file at {} and follow every instruction in it exactly. That \
631 file is your complete task description; this message contains nothing \
632 else.",
633 prompt_path.display()
634 )
635}
636
637fn build_command(
638 spec: &AgentSpec,
639 seat: &SeatState,
640 inv: &Invocation<'_>,
641 prompt_path: &Path,
642) -> Result<Plan> {
643 let mut argv: Vec<String> = Vec::new();
644 let mut stdin: Option<String> = None;
645 let delivery = spec.delivery();
646 let resuming = has_session(spec.kind, seat, inv.sessions);
647
648 match spec.kind {
649 AgentKind::Claude => {
650 argv.push("claude".to_owned());
655 argv.push("-p".to_owned());
656 argv.push("--output-format".to_owned());
657 argv.push("json".to_owned());
658 if let Some(m) = &spec.model {
659 argv.push("--model".to_owned());
660 argv.push(m.clone());
661 }
662 if inv.sessions {
663 let uuid = seat
664 .claude_session
665 .as_deref()
666 .context("claude seat is missing its session uuid")?;
667 argv.push(if resuming { "--resume" } else { "--session-id" }.to_owned());
668 argv.push(uuid.to_owned());
669 }
670 argv.push("--permission-mode".to_owned());
671 argv.push("bypassPermissions".to_owned());
672 if !inv.allow_write {
673 argv.push("--disallowed-tools".to_owned());
674 argv.push("Edit,Write,MultiEdit,NotebookEdit".to_owned());
675 }
676 }
677 AgentKind::Opencode => {
678 argv.push("opencode".to_owned());
682 argv.push("run".to_owned());
683 argv.push("--format".to_owned());
684 argv.push("json".to_owned());
685 argv.push("--dir".to_owned());
686 argv.push(inv.cwd.to_string_lossy().into_owned());
687 argv.push("--auto".to_owned());
696 if let Some(m) = &spec.model {
697 argv.push("-m".to_owned());
698 argv.push(m.clone());
699 }
700 if resuming {
701 argv.push("-s".to_owned());
702 argv.push(
703 seat.captured_session
704 .clone()
705 .expect("has_session checked the id is present"),
706 );
707 }
708 }
709 AgentKind::Antigravity => {
710 argv.push("agy".to_owned());
711 argv.push("--output-format".to_owned());
712 argv.push("json".to_owned());
713 argv.push("--print-timeout".to_owned());
716 argv.push(format!("{}s", inv.timeout.as_secs()));
717 argv.push("--mode".to_owned());
718 argv.push(
719 if inv.allow_write {
720 "accept-edits"
721 } else {
722 "plan"
723 }
724 .to_owned(),
725 );
726 if inv.allow_write {
727 argv.push("--dangerously-skip-permissions".to_owned());
728 }
729 if let Some(m) = &spec.model {
730 argv.push("--model".to_owned());
731 argv.push(m.clone());
732 }
733 if resuming {
734 argv.push("--conversation".to_owned());
735 argv.push(
736 seat.captured_session
737 .clone()
738 .expect("has_session checked the id is present"),
739 );
740 }
741 let mut add_dirs: Vec<String> = Vec::new();
751 if delivery == Delivery::File || !inv.attachments.is_empty() {
752 add_dirs.push(inv.artifacts.to_string_lossy().into_owned());
753 }
754 for path in inv.attachments {
755 let Some(parent) = path.parent() else {
756 continue;
757 };
758 if parent.starts_with(inv.artifacts) {
759 continue;
760 }
761 let dir = parent.to_string_lossy().into_owned();
762 if !add_dirs.contains(&dir) {
763 add_dirs.push(dir);
764 }
765 }
766 if inv.allow_write {
767 for path in inv.writable {
768 let dir = path.to_string_lossy().into_owned();
769 if !add_dirs.contains(&dir) {
770 add_dirs.push(dir);
771 }
772 }
773 }
774 for dir in add_dirs {
775 argv.push("--add-dir".to_owned());
776 argv.push(dir);
777 }
778 }
779 AgentKind::Codex => {
780 argv.push("codex".to_owned());
786 argv.push("exec".to_owned());
787 argv.push("--json".to_owned());
788 argv.push("--skip-git-repo-check".to_owned());
791 argv.push("-C".to_owned());
792 argv.push(inv.cwd.to_string_lossy().into_owned());
793 if inv.allow_write && inv.unsandboxed {
802 argv.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
803 } else {
804 argv.push("--sandbox".to_owned());
805 argv.push(
806 if inv.allow_write {
807 "workspace-write"
808 } else {
809 "read-only"
810 }
811 .to_owned(),
812 );
813 argv.push("-c".to_owned());
816 argv.push("approval_policy=\"never\"".to_owned());
817 if inv.allow_write && !inv.writable.is_empty() {
818 let roots: Vec<String> = inv
819 .writable
820 .iter()
821 .map(|p| p.to_string_lossy().into_owned())
822 .collect();
823 argv.push("-c".to_owned());
824 argv.push(format!(
825 "sandbox_workspace_write.writable_roots={}",
826 serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_owned())
827 ));
828 }
829 }
830 if let Some(m) = &spec.model {
831 argv.push("-m".to_owned());
832 argv.push(m.clone());
833 }
834 if resuming {
840 argv.push("resume".to_owned());
841 argv.push(
842 seat.captured_session
843 .clone()
844 .expect("has_session checked the id is present"),
845 );
846 }
847 }
848 AgentKind::Omp => {
849 argv.push("omp".to_owned());
853 argv.push("-p".to_owned());
854 argv.push("--mode=json".to_owned());
855 argv.push("--auto-approve".to_owned());
865 if let Some(m) = &spec.model {
866 argv.push("--model".to_owned());
867 argv.push(m.clone());
868 }
869 if resuming {
875 argv.push("--resume".to_owned());
876 argv.push(
877 seat.captured_session
878 .clone()
879 .expect("has_session checked the id is present"),
880 );
881 }
882 }
883 AgentKind::Command => {
884 if spec.command.is_empty() {
889 bail!("agent `{}` has kind = \"command\" but no command", spec.id);
890 }
891 let vars: BTreeMap<&str, String> = BTreeMap::from([
892 ("{prompt_file}", prompt_path.to_string_lossy().into_owned()),
893 ("{cwd}", inv.cwd.to_string_lossy().into_owned()),
894 ("{label}", seat.key.clone()),
895 ("{session}", seat.claude_session.clone().unwrap_or_default()),
896 ]);
897 for raw in &spec.command {
898 let mut arg = raw.clone();
899 for (k, v) in &vars {
900 if arg.contains(k) {
901 arg = arg.replace(k, v);
902 }
903 }
904 argv.push(arg);
905 }
906 }
907 }
908
909 argv.extend(spec.extra_args.iter().cloned());
910
911 if spec.kind == AgentKind::Antigravity {
914 argv.push("-p".to_owned());
915 }
916 if spec.kind == AgentKind::Codex && delivery == Delivery::Stdin {
919 argv.push("-".to_owned());
920 }
921 match delivery {
922 Delivery::Stdin if spec.kind == AgentKind::Antigravity => {
923 argv.push(pointer(spec.kind, prompt_path));
925 }
926 Delivery::Stdin => stdin = Some(inv.prompt.to_owned()),
927 Delivery::Argv => argv.push(inv.prompt.to_owned()),
928 Delivery::File => argv.push(pointer(spec.kind, prompt_path)),
929 }
930
931 Ok(Plan { argv, stdin })
932}
933
934#[derive(Debug, Default)]
936struct Extracted {
937 text: String,
938 session: Option<String>,
939 status: Option<String>,
940 quota: Option<Quota>,
941 dropped: Option<Dropped>,
942 commands: Vec<CommandEvidence>,
943 context_tokens: Option<u64>,
944}
945
946fn extract(kind: AgentKind, stdout: &str) -> Extracted {
948 let mut extracted = extract_answer(kind, stdout);
949 extracted.context_tokens = context_tokens(kind, stdout);
950 extracted
951}
952
953fn uint(v: &serde_json::Value, key: &str) -> Option<u64> {
955 v.get(key).and_then(serde_json::Value::as_u64)
956}
957
958fn input_side(usage: &serde_json::Value, input: &[&str], cache: &[&str]) -> Option<u64> {
963 let first = |keys: &[&str]| keys.iter().find_map(|k| uint(usage, k));
964 let base = first(input)?;
965 let cached: u64 = cache.iter().filter_map(|k| uint(usage, k)).sum();
966 Some(base.saturating_add(cached))
967}
968
969fn context_tokens(kind: AgentKind, stdout: &str) -> Option<u64> {
990 use serde_json::Value;
991 let lines = || {
992 stdout
993 .lines()
994 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
995 };
996 match kind {
997 AgentKind::Claude => None,
998 AgentKind::Opencode => lines()
999 .filter(|v| {
1000 v.get("type").and_then(|t| t.as_str()) == Some("step_finish")
1002 || v.get("part")
1003 .and_then(|p| p.get("type"))
1004 .and_then(|t| t.as_str())
1005 == Some("step-finish")
1006 })
1007 .filter_map(|v| {
1008 let tokens = v.get("part")?.get("tokens")?;
1009 let base = uint(tokens, "input")?;
1010 let cache = tokens.get("cache");
1011 let cached = ["read", "write"]
1012 .iter()
1013 .filter_map(|k| cache.and_then(|c| uint(c, k)))
1014 .sum::<u64>();
1015 Some(base.saturating_add(cached))
1016 })
1017 .next_back(),
1018 AgentKind::Antigravity => None,
1019 AgentKind::Codex => None,
1020 AgentKind::Omp => lines()
1021 .flat_map(|v| {
1022 match v.get("type").and_then(|t| t.as_str()) {
1025 Some("agent_end") => v
1026 .get("messages")
1027 .and_then(|m| m.as_array())
1028 .cloned()
1029 .unwrap_or_default(),
1030 Some("turn_end") | Some("message_end") => {
1031 v.get("message").cloned().into_iter().collect()
1032 }
1033 _ => Vec::new(),
1034 }
1035 })
1036 .filter(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
1037 .filter_map(|m| {
1038 input_side(
1039 m.get("usage")?,
1040 &["input", "input_tokens"],
1041 &[
1042 "cacheRead",
1043 "cacheWrite",
1044 "cache_read_tokens",
1045 "cache_write_tokens",
1046 ],
1047 )
1048 })
1049 .next_back(),
1050 AgentKind::Command => None,
1051 }
1052}
1053
1054pub(crate) fn answer_text(kind: AgentKind, stdout: &str) -> String {
1057 extract_answer(kind, stdout).text
1058}
1059
1060fn extract_answer(kind: AgentKind, stdout: &str) -> Extracted {
1061 match kind {
1062 AgentKind::Claude => {
1063 let Ok(v) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
1064 return Extracted {
1065 text: stdout.trim().to_owned(),
1066 ..Extracted::default()
1067 };
1068 };
1069 Extracted {
1070 text: v
1071 .get("result")
1072 .and_then(|r| r.as_str())
1073 .unwrap_or_default()
1074 .to_owned(),
1075 session: v
1076 .get("session_id")
1077 .and_then(|s| s.as_str())
1078 .map(str::to_owned),
1079 status: v.get("is_error").and_then(|e| e.as_bool()).map(|e| {
1080 if e {
1081 "error".to_owned()
1082 } else {
1083 "success".to_owned()
1084 }
1085 }),
1086 quota: claude_quota(&v),
1087 dropped: None,
1090 commands: Vec::new(),
1091 context_tokens: None,
1092 }
1093 }
1094 AgentKind::Opencode => {
1095 let mut text = String::new();
1097 let mut session = None;
1098 for line in stdout.lines() {
1099 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1100 continue;
1101 };
1102 if session.is_none() {
1103 session = v
1104 .get("sessionID")
1105 .and_then(|s| s.as_str())
1106 .map(str::to_owned);
1107 }
1108 let part = v.get("part").unwrap_or(&serde_json::Value::Null);
1109 if part.get("type").and_then(|t| t.as_str()) == Some("text")
1110 && let Some(t) = part.get("text").and_then(|t| t.as_str())
1111 {
1112 if !text.is_empty() {
1113 text.push('\n');
1114 }
1115 text.push_str(t);
1116 }
1117 }
1118 Extracted {
1119 text,
1120 session,
1121 status: None,
1122 quota: None,
1123 dropped: None,
1124 commands: Vec::new(),
1125 context_tokens: None,
1126 }
1127 }
1128 AgentKind::Antigravity => {
1129 let obj = stdout
1132 .lines()
1133 .rev()
1134 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok());
1135 let Some(v) = obj else {
1136 return Extracted {
1137 text: stdout.trim().to_owned(),
1138 ..Extracted::default()
1139 };
1140 };
1141 Extracted {
1142 text: v
1143 .get("response")
1144 .and_then(|r| r.as_str())
1145 .unwrap_or_default()
1146 .trim()
1147 .to_owned(),
1148 session: v
1149 .get("conversation_id")
1150 .and_then(|s| s.as_str())
1151 .map(str::to_owned),
1152 status: v.get("status").and_then(|s| s.as_str()).map(str::to_owned),
1153 quota: None,
1154 dropped: dropped_stream(&v),
1155 commands: Vec::new(),
1156 context_tokens: None,
1157 }
1158 }
1159 AgentKind::Codex => {
1160 let mut text = String::new();
1182 let mut session = None;
1183 let mut status = None;
1184 let mut commands = Vec::new();
1185 for line in stdout.lines() {
1186 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1187 continue;
1188 };
1189 match v.get("type").and_then(|t| t.as_str()) {
1190 Some("thread.started") => {
1191 session = v
1192 .get("thread_id")
1193 .and_then(|s| s.as_str())
1194 .map(str::to_owned);
1195 }
1196 Some("item.completed") => {
1197 let item = v.get("item").unwrap_or(&serde_json::Value::Null);
1198 match item.get("type").and_then(|t| t.as_str()) {
1199 Some("agent_message") => {
1200 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
1201 text = t.trim().to_owned();
1202 }
1203 }
1204 Some("command_execution") => {
1205 commands.push(command_evidence(item));
1206 }
1207 _ => {}
1208 }
1209 }
1210 Some("turn.completed") => status = Some("success".to_owned()),
1211 Some("turn.failed") => status = Some("error".to_owned()),
1212 _ => {}
1213 }
1214 }
1215 Extracted {
1216 text,
1217 session,
1218 status,
1219 quota: None,
1220 dropped: None,
1221 commands,
1222 context_tokens: None,
1223 }
1224 }
1225 AgentKind::Omp => {
1226 let mut text = String::new();
1247 let mut session = None;
1248 for line in stdout.lines() {
1249 let Ok(v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
1250 continue;
1251 };
1252 if v.get("type").and_then(|t| t.as_str()) == Some("session") {
1253 session = v.get("id").and_then(|s| s.as_str()).map(str::to_owned);
1254 continue;
1255 }
1256 let messages: Vec<&serde_json::Value> = match v.get("type").and_then(|t| t.as_str())
1260 {
1261 Some("agent_end") => v
1262 .get("messages")
1263 .and_then(|m| m.as_array())
1264 .map(|m| m.iter().collect())
1265 .unwrap_or_default(),
1266 Some("turn_end") | Some("message_end") => {
1267 v.get("message").into_iter().collect()
1268 }
1269 _ => continue,
1270 };
1271 for message in messages {
1272 if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
1273 continue;
1274 }
1275 let Some(parts) = message.get("content").and_then(|c| c.as_array()) else {
1276 continue;
1277 };
1278 for part in parts {
1279 if part.get("type").and_then(|t| t.as_str()) != Some("text") {
1280 continue;
1281 }
1282 if let Some(t) = part.get("text").and_then(|t| t.as_str())
1283 && !t.trim().is_empty()
1284 {
1285 text = t.trim().to_owned();
1286 }
1287 }
1288 }
1289 }
1290 Extracted {
1291 text,
1292 session,
1293 status: None,
1294 quota: None,
1295 dropped: None,
1296 commands: Vec::new(),
1297 context_tokens: None,
1298 }
1299 }
1300 AgentKind::Command => {
1301 let parsed = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok();
1306 let quota = parsed.as_ref().and_then(claude_quota);
1307 let dropped = parsed.as_ref().and_then(dropped_stream);
1310 Extracted {
1311 text: stdout.trim().to_owned(),
1312 session: None,
1313 status: None,
1314 quota,
1315 dropped,
1316 commands: Vec::new(),
1317 context_tokens: None,
1318 }
1319 }
1320 }
1321}
1322
1323fn command_evidence(item: &serde_json::Value) -> CommandEvidence {
1330 let description = match item.get("command") {
1331 Some(serde_json::Value::String(s)) => s.clone(),
1332 Some(serde_json::Value::Array(parts)) => parts
1333 .iter()
1334 .filter_map(|p| p.as_str())
1335 .collect::<Vec<_>>()
1336 .join(" "),
1337 _ => String::new(),
1338 };
1339 let result_summary = item
1340 .get("aggregated_output")
1341 .and_then(|o| o.as_str())
1342 .map(|s| tail_chars(s.trim(), 400))
1343 .unwrap_or_default();
1344 CommandEvidence {
1345 id: item
1346 .get("id")
1347 .and_then(|s| s.as_str())
1348 .unwrap_or_default()
1349 .to_owned(),
1350 description,
1351 exit_code: item
1352 .get("exit_code")
1353 .and_then(serde_json::Value::as_i64)
1354 .map(|e| e as i32),
1355 result_summary,
1356 source: "codex".to_owned(),
1357 }
1358}
1359
1360fn tail_chars(s: &str, max: usize) -> String {
1362 let count = s.chars().count();
1363 if count <= max {
1364 return s.to_owned();
1365 }
1366 s.chars().skip(count - max).collect()
1367}
1368
1369fn claude_quota(v: &serde_json::Value) -> Option<Quota> {
1376 let is_err = v.get("is_error").and_then(|e| e.as_bool()).unwrap_or(false);
1377 if !is_err {
1378 return None;
1379 }
1380 let result = v.get("result").and_then(|r| r.as_str()).unwrap_or("");
1381 let status = v.get("api_error_status");
1382 let usage_limit = (status.and_then(serde_json::Value::as_u64) == Some(429)
1383 || status.and_then(serde_json::Value::as_str) == Some("429"))
1384 && v.get("api_error").and_then(serde_json::Value::as_str) == Some("usage_limit_reached");
1385 if !usage_limit && !result.to_lowercase().contains("session limit") {
1386 return None;
1387 }
1388 let reset = result
1391 .split("resets ")
1392 .nth(1)
1393 .map(str::trim)
1394 .filter(|s| !s.is_empty())
1395 .map(str::to_owned);
1396 Some(Quota { reset })
1397}
1398
1399fn agy_quota(stdout: &str, stderr: &str) -> Option<Quota> {
1414 let from_stdout = stdout
1415 .lines()
1416 .rev()
1417 .find_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
1418 .and_then(|v| {
1419 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1420 let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("");
1421 (status.eq_ignore_ascii_case("error") && error.to_lowercase().contains("quota reached"))
1422 .then(|| error.to_owned())
1423 });
1424 let from_stderr = || {
1425 stderr.lines().find_map(|l| {
1426 let v: serde_json::Value =
1427 serde_json::from_str(l.trim().strip_prefix("AGY_ERROR:")?.trim()).ok()?;
1428 let exhausted = v.get("status").and_then(|s| s.as_str()) == Some("RESOURCE_EXHAUSTED");
1429 let code = v.get("error_code").and_then(serde_json::Value::as_u64) == Some(429);
1430 (exhausted && code).then(|| {
1431 v.get("short_error")
1432 .and_then(|e| e.as_str())
1433 .unwrap_or_default()
1434 .to_owned()
1435 })
1436 })
1437 };
1438 let text = from_stdout.or_else(from_stderr)?;
1439 let reset = text
1440 .split_once("Resets ")
1441 .map(|(_, rest)| rest.trim().trim_end_matches('.').trim())
1442 .filter(|s| !s.is_empty())
1443 .map(str::to_owned);
1444 Some(Quota { reset })
1445}
1446
1447fn dropped_stream(v: &serde_json::Value) -> Option<Dropped> {
1478 let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1479 if !status.eq_ignore_ascii_case("error") {
1480 return None;
1481 }
1482 let response = v.get("response").and_then(|r| r.as_str()).unwrap_or("");
1483 if !response.trim().is_empty() {
1484 return None;
1486 }
1487 let produced = v
1488 .get("usage")
1489 .and_then(|u| u.get("output_tokens"))
1490 .and_then(serde_json::Value::as_u64)
1491 .unwrap_or(0);
1492 if produced == 0 {
1493 return None;
1495 }
1496 Some(Dropped {
1497 why: v
1498 .get("error")
1499 .and_then(|e| e.as_str())
1500 .unwrap_or("the CLI ended the stream without delivering its answer")
1501 .trim()
1502 .to_owned(),
1503 output_tokens: produced,
1504 })
1505}
1506
1507pub fn missing_programs(specs: &[AgentSpec]) -> Vec<String> {
1509 let mut missing = Vec::new();
1510 for s in specs {
1511 let program = match s.kind {
1512 AgentKind::Command => s.command.first().map(String::as_str),
1513 other => other.program(),
1514 };
1515 if let Some(p) = program
1516 && !crate::config::which(p)
1517 && !Path::new(p).is_file()
1518 && !missing.iter().any(|m: &String| m == p)
1519 {
1520 missing.push(p.to_owned());
1521 }
1522 }
1523 missing
1524}
1525
1526pub fn artifacts_dir(run_dir: &Path) -> PathBuf {
1528 run_dir.join("artifacts")
1529}
1530
1531pub fn installed(spec: &AgentSpec) -> bool {
1533 spec.kind.program().is_none_or(crate::config::which)
1536}
1537
1538pub fn pick(
1560 agents: &[AgentSpec],
1561 want: Option<&str>,
1562 available: &dyn Fn(&AgentSpec) -> bool,
1563) -> Result<AgentSpec> {
1564 if let Some(id) = want {
1565 let spec = agents
1566 .iter()
1567 .find(|a| a.id == id)
1568 .with_context(|| format!("no agent `{id}` in the roster; it has {}", ids(agents)))?;
1569 if !available(spec) {
1570 bail!(
1571 "agent `{}` needs `{}` on PATH; install it or pass a different \
1572 --agent",
1573 spec.id,
1574 spec.kind.program().unwrap_or("its command")
1575 );
1576 }
1577 return Ok(spec.clone());
1578 }
1579
1580 if agents.is_empty() {
1581 bail!(
1582 "the agent roster is empty, so there is nobody to ask: install one \
1583 of claude, opencode or agy - magi derives a roster from what is on \
1584 PATH - or add an [[agents]] entry to magi.toml."
1585 );
1586 }
1587
1588 if let Some(spec) = agents
1589 .iter()
1590 .find(|a| a.kind == AgentKind::Claude && available(a))
1591 {
1592 return Ok(spec.clone());
1593 }
1594
1595 agents
1596 .iter()
1597 .find(|a| available(a))
1598 .cloned()
1599 .with_context(|| {
1600 let missing = agents
1601 .iter()
1602 .filter_map(|a| a.kind.program())
1603 .collect::<Vec<_>>()
1604 .join(", ");
1605 format!(
1606 "no agent in the roster can be run here: install one of \
1607 {missing}, or add an [[agents]] entry to magi.toml for a CLI \
1608 you do have"
1609 )
1610 })
1611}
1612
1613pub fn pick_chain(
1621 agents: &[AgentSpec],
1622 choice: Option<&AgentChoice>,
1623 available: &dyn Fn(&AgentSpec) -> bool,
1624 role: &str,
1625) -> Result<Vec<AgentSpec>> {
1626 let wanted = choice.map(AgentChoice::ids).unwrap_or_default();
1627 if wanted.is_empty() {
1628 return pick(agents, None, available)
1629 .map(|s| vec![s])
1630 .with_context(|| format!("choose an agent for the {role} role"));
1631 }
1632 let mut chain: Vec<AgentSpec> = Vec::new();
1633 for id in wanted {
1634 if chain.iter().any(|s| s.id == id) {
1635 continue;
1636 }
1637 match pick(agents, Some(id), available) {
1638 Ok(spec) => chain.push(spec),
1639 Err(e) => tracing::warn!("[roles] {role}: skipping `{id}`: {e:#}"),
1640 }
1641 }
1642 if chain.is_empty() {
1643 bail!(
1644 "[roles] {role} names no agent that can run here; the roster has {}",
1645 ids(agents)
1646 );
1647 }
1648 Ok(chain)
1649}
1650
1651pub fn chain_advances(outcome: &Result<AgentOutput>) -> bool {
1657 match outcome {
1658 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => false,
1659 Err(_) => true,
1660 Ok(out) => output_advances(out),
1661 }
1662}
1663
1664pub fn output_advances(out: &AgentOutput) -> bool {
1668 out.quota_exhausted() || !out.usable()
1669}
1670
1671fn ids(agents: &[AgentSpec]) -> String {
1672 if agents.is_empty() {
1673 return "no agents at all".to_owned();
1674 }
1675 agents
1676 .iter()
1677 .map(|a| a.id.clone())
1678 .collect::<Vec<_>>()
1679 .join(", ")
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684 use super::*;
1685
1686 const COMMAND_HELPER_MODE: &str = "MAGI_TEST_COMMAND_HELPER_MODE";
1687
1688 fn command_helper(mode: &str) -> AgentSpec {
1691 AgentSpec {
1692 id: "helper".to_owned(),
1693 kind: AgentKind::Command,
1694 model: None,
1695 command: vec![
1696 std::env::current_exe()
1697 .expect("locate test helper")
1698 .to_string_lossy()
1699 .into_owned(),
1700 "--exact".to_owned(),
1701 "agent::tests::command_agent_test_helper".to_owned(),
1702 "--nocapture".to_owned(),
1703 ],
1704 extra_args: Vec::new(),
1705 env: BTreeMap::from([(COMMAND_HELPER_MODE.to_owned(), mode.to_owned())]),
1706 prompt_delivery: None,
1707 }
1708 }
1709
1710 #[test]
1711 fn command_agent_test_helper() {
1712 match std::env::var(COMMAND_HELPER_MODE).as_deref() {
1713 Ok("reply") => println!("hello {}", std::env::var("MAGI_SEAT").unwrap()),
1714 Ok("cache") => println!("{}", std::env::var("CARGO_TARGET_DIR").unwrap()),
1715 Ok("no-cache") => println!(
1716 "{}",
1717 std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "ABSENT".to_owned())
1718 ),
1719 Ok("ignore-stdin") => println!("done"),
1720 Ok("chatty-sleep") => {
1721 println!("i-said-something");
1722 std::thread::sleep(Duration::from_secs(30));
1723 }
1724 Ok("sleep") => std::thread::sleep(Duration::from_secs(30)),
1725 Ok(other) => panic!("unknown command helper mode {other}"),
1726 Err(_) => {}
1727 }
1728 }
1729
1730 fn spec(kind: AgentKind, model: Option<&str>) -> AgentSpec {
1731 AgentSpec {
1732 id: "a".to_owned(),
1733 kind,
1734 model: model.map(str::to_owned),
1735 command: vec!["echo".to_owned(), "{label}".to_owned()],
1736 extra_args: Vec::new(),
1737 env: BTreeMap::new(),
1738 prompt_delivery: None,
1739 }
1740 }
1741
1742 fn inv<'a>(cwd: &'a Path, art: &'a Path, allow_write: bool) -> Invocation<'a> {
1743 Invocation {
1744 cwd,
1745 prompt: "do the thing",
1746 timeout: Duration::from_secs(900),
1747 allow_write,
1748 unsandboxed: false,
1749 sessions: true,
1750 artifacts: art,
1751 stem: "t",
1752 run: "test-run",
1753 node: "test",
1754 cache_dir: None,
1755 attachments: &[],
1756 writable: &[],
1757 }
1758 }
1759
1760 fn plan_for(kind: AgentKind, seat: &SeatState, allow_write: bool) -> Plan {
1761 build_command(
1762 &spec(kind, None),
1763 seat,
1764 &inv(Path::new("."), Path::new("/art"), allow_write),
1765 Path::new("/art/p.md"),
1766 )
1767 .unwrap()
1768 }
1769
1770 #[test]
1771 fn claude_mints_then_resumes_the_same_uuid() {
1772 let mut seat = SeatState::new("judge-1", "a", 7);
1773 let uuid = seat.claude_session.clone().unwrap();
1774 let first = plan_for(AgentKind::Claude, &seat, true);
1775 assert!(first.argv.windows(2).any(|w| w == ["--session-id", &uuid]));
1776 assert!(!first.argv.iter().any(|a| a == "--resume"));
1777
1778 seat.turns = 1;
1779 let second = plan_for(AgentKind::Claude, &seat, true);
1780 assert!(second.argv.windows(2).any(|w| w == ["--resume", &uuid]));
1781 assert!(!second.argv.iter().any(|a| a == "--session-id"));
1782 }
1783
1784 #[test]
1785 fn read_only_seats_cannot_edit() {
1786 let seat = SeatState::new("judge-1", "a", 7);
1787 let claude = plan_for(AgentKind::Claude, &seat, false);
1788 assert!(claude.argv.iter().any(|a| a == "--disallowed-tools"));
1789 assert!(
1790 !plan_for(AgentKind::Claude, &seat, true)
1791 .argv
1792 .iter()
1793 .any(|a| a == "--disallowed-tools")
1794 );
1795
1796 let agy = plan_for(AgentKind::Antigravity, &seat, false);
1797 assert!(agy.argv.windows(2).any(|w| w == ["--mode", "plan"]));
1798 assert!(
1799 !agy.argv
1800 .iter()
1801 .any(|a| a == "--dangerously-skip-permissions")
1802 );
1803 let agy_rw = plan_for(AgentKind::Antigravity, &seat, true);
1804 assert!(
1805 agy_rw
1806 .argv
1807 .windows(2)
1808 .any(|w| w == ["--mode", "accept-edits"])
1809 );
1810 assert!(
1811 agy_rw
1812 .argv
1813 .iter()
1814 .any(|a| a == "--dangerously-skip-permissions")
1815 );
1816 let agy_prompt = agy_rw
1821 .argv
1822 .iter()
1823 .position(|a| a == "-p")
1824 .map(|i| agy_rw.argv[i + 1].clone())
1825 .expect("agy takes its prompt with -p");
1826 assert!(
1827 agy_prompt.starts_with('@'),
1828 "agy must get a file reference, got {agy_prompt:?}"
1829 );
1830 assert!(
1831 !agy_prompt.contains("Read the file at"),
1832 "the prose pointer is for CLIs with no file syntax"
1833 );
1834
1835 for allow_write in [false, true] {
1840 assert!(
1841 plan_for(AgentKind::Opencode, &seat, allow_write)
1842 .argv
1843 .iter()
1844 .any(|a| a == "--auto"),
1845 "opencode needs --auto even to read (allow_write = {allow_write})"
1846 );
1847 }
1848 }
1849
1850 #[test]
1853 fn codex_gets_extra_writable_roots_only_when_it_may_write() {
1854 let seat = SeatState::new("deputy-x", "a", 7);
1855 let roots = [PathBuf::from("/data/questions")];
1856 let mk = |allow_write: bool| {
1857 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1858 i.writable = &roots;
1859 build_command(
1860 &spec(AgentKind::Codex, None),
1861 &seat,
1862 &i,
1863 Path::new("/art/p.md"),
1864 )
1865 .unwrap()
1866 };
1867 let want = "sandbox_workspace_write.writable_roots=[\"/data/questions\"]";
1868 assert!(mk(true).argv.windows(2).any(|w| w == ["-c", want]));
1869 assert!(!mk(false).argv.iter().any(|a| a.contains("writable_roots")));
1870 }
1871
1872 #[test]
1873 fn repository_write_scope_covers_editing_nodes_without_changing_other_roles() {
1874 let mut i = inv(Path::new("."), Path::new("/art"), true);
1875 for node in [
1876 "implement",
1877 "fix",
1878 "graft",
1879 "gate-fix",
1880 "land",
1881 "rebase",
1882 "chat",
1883 ] {
1884 i.node = node;
1885 assert!(repository_writer(&i), "{node}");
1886 i.allow_write = false;
1887 assert!(!repository_writer(&i), "{node}");
1888 i.allow_write = true;
1889 }
1890 for node in ["deputy", "judge", "review", "bump", "conduct"] {
1891 i.node = node;
1892 assert!(!repository_writer(&i), "{node}");
1893 }
1894 i.node = "chat";
1895 let roots = [PathBuf::from("/questions")];
1896 i.writable = &roots;
1897 assert!(!repository_writer(&i), "consultation keeps explicit grants");
1898 }
1899
1900 #[test]
1901 fn writable_directories_reach_initial_and_resumed_adapter_commands() {
1902 let roots = [
1903 PathBuf::from("/shared/git"),
1904 PathBuf::from("/state/questions"),
1905 ];
1906 for kind in [AgentKind::Codex, AgentKind::Antigravity] {
1907 for resumed in [false, true] {
1908 let mut seat = SeatState::new("impl-A", "a", 7);
1909 if resumed {
1910 seat.turns = 1;
1911 seat.captured_session = Some("session-id".into());
1912 }
1913 for allow_write in [false, true] {
1914 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1915 i.writable = &roots;
1916 let plan = build_command(&spec(kind, None), &seat, &i, Path::new("/art/p.md"))
1917 .unwrap();
1918 for root in &roots {
1919 if kind == AgentKind::Antigravity {
1920 assert_eq!(
1921 plan.argv.windows(2).any(|w| {
1922 w[0] == "--add-dir" && w[1] == root.to_str().unwrap()
1923 }),
1924 allow_write,
1925 "resumed={resumed}: {:?}",
1926 plan.argv
1927 );
1928 }
1929 assert_eq!(
1930 plan.argv
1931 .iter()
1932 .any(|arg| arg.contains(root.to_str().unwrap())),
1933 allow_write,
1934 "{kind:?}, resumed={resumed}: {:?}",
1935 plan.argv
1936 );
1937 }
1938 if kind == AgentKind::Codex && resumed {
1939 let at = plan.argv.iter().position(|s| s == "resume").unwrap();
1940 assert!(!plan.argv[at..].iter().any(|s| s == "-c"));
1941 }
1942 }
1943 }
1944 }
1945 }
1946
1947 #[test]
1948 fn codex_talk_turn_bypasses_sandbox_only_when_opted_in() {
1949 const BYPASS: &str = "--dangerously-bypass-approvals-and-sandbox";
1950 let mut seat = SeatState::new("talk", "a", 7);
1951 let build = |seat: &SeatState, allow_write: bool, unsandboxed: bool| {
1952 let mut i = inv(Path::new("."), Path::new("/art"), allow_write);
1953 i.unsandboxed = unsandboxed;
1954 build_command(
1955 &spec(AgentKind::Codex, None),
1956 seat,
1957 &i,
1958 Path::new("/art/p.md"),
1959 )
1960 .unwrap()
1961 };
1962
1963 let first = build(&seat, true, true);
1965 assert!(first.argv.iter().any(|a| a == BYPASS));
1966 assert!(!first.argv.iter().any(|a| a == "--sandbox"));
1967 assert!(!first.argv.iter().any(|a| a.starts_with("approval_policy")));
1968
1969 seat.captured_session = Some("thread-1".to_owned());
1971 seat.turns = 1;
1972 let resumed = build(&seat, true, true);
1973 let at = |p: &Plan, x: &str| p.argv.iter().position(|a| a == x).unwrap();
1974 assert!(at(&resumed, BYPASS) < at(&resumed, "resume"));
1975
1976 let ro = build(&seat, false, true);
1978 assert!(!ro.argv.iter().any(|a| a == BYPASS));
1979 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1980 }
1981
1982 #[test]
1983 fn codex_is_sandboxed_reads_stdin_and_puts_resume_last() {
1984 let mut seat = SeatState::new("judge-1", "a", 7);
1985
1986 let ro = plan_for(AgentKind::Codex, &seat, false);
1990 assert!(ro.argv.windows(2).any(|w| w == ["--sandbox", "read-only"]));
1991 let rw = plan_for(AgentKind::Codex, &seat, true);
1992 assert!(
1993 rw.argv
1994 .windows(2)
1995 .any(|w| w == ["--sandbox", "workspace-write"])
1996 );
1997 let mut ro_flagged = inv(Path::new("."), Path::new("/art"), false);
2000 ro_flagged.unsandboxed = true;
2001 let ro_flagged = build_command(
2002 &spec(AgentKind::Codex, None),
2003 &seat,
2004 &ro_flagged,
2005 Path::new("/art/p.md"),
2006 )
2007 .unwrap();
2008 assert!(
2009 ro_flagged
2010 .argv
2011 .windows(2)
2012 .any(|w| w == ["--sandbox", "read-only"])
2013 );
2014 for p in [&ro, &rw, &ro_flagged] {
2015 assert!(
2016 !p.argv
2017 .iter()
2018 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2019 "the bypass defeats the only enforced read-only mode we have"
2020 );
2021 assert!(
2023 p.argv
2024 .windows(2)
2025 .any(|w| w == ["-c", "approval_policy=\"never\""]),
2026 "an unattended seat that asks for approval blocks until timeout"
2027 );
2028 }
2029
2030 assert_eq!(ro.stdin.as_deref(), Some("do the thing"));
2032 assert_eq!(
2033 ro.argv.last().map(String::as_str),
2034 Some("-"),
2035 "without the `-` argument codex waits for a prompt it never gets"
2036 );
2037
2038 seat.turns = 1;
2042 assert!(!has_session(AgentKind::Codex, &seat, true));
2043 assert!(
2044 !plan_for(AgentKind::Codex, &seat, true)
2045 .argv
2046 .iter()
2047 .any(|a| a == "resume")
2048 );
2049 seat.captured_session = Some("01a07440-4545-7492-85c1-024e3259a90a".to_owned());
2050 let resumed = plan_for(AgentKind::Codex, &seat, true);
2051 let at = resumed
2052 .argv
2053 .iter()
2054 .position(|a| a == "resume")
2055 .expect("resumes by subcommand");
2056 assert_eq!(resumed.argv[at + 1], "01a07440-4545-7492-85c1-024e3259a90a");
2057 assert!(
2058 resumed.argv[..at].iter().any(|a| a == "--sandbox"),
2059 "every option precedes the subcommand"
2060 );
2061 assert_eq!(resumed.argv.last().map(String::as_str), Some("-"));
2062 }
2063
2064 #[test]
2067 fn omp_reads_stdin_auto_approves_and_resumes_by_id() {
2068 let mut seat = SeatState::new("review-1", "a", 7);
2069
2070 let first = plan_for(AgentKind::Omp, &seat, false);
2074 assert!(first.argv.iter().any(|a| a == "-p"));
2075 assert!(first.argv.iter().any(|a| a == "--mode=json"));
2076 assert_eq!(first.stdin.as_deref(), Some("do the thing"));
2077 assert!(
2078 !first.argv.iter().any(|a| a == "do the thing"),
2079 "the prompt reached argv, where Windows caps it"
2080 );
2081
2082 for allow_write in [false, true] {
2088 let p = plan_for(AgentKind::Omp, &seat, allow_write);
2089 assert!(
2090 p.argv.iter().any(|a| a == "--auto-approve"),
2091 "omp needs --auto-approve even to read (allow_write = {allow_write})"
2092 );
2093 assert!(
2094 !p.argv
2095 .iter()
2096 .any(|a| a == "--dangerously-bypass-approvals-and-sandbox"),
2097 "nothing ever asks for the bypass"
2098 );
2099 }
2100
2101 seat.turns = 1;
2104 assert!(!has_session(AgentKind::Omp, &seat, true));
2105 assert!(
2106 !plan_for(AgentKind::Omp, &seat, true)
2107 .argv
2108 .iter()
2109 .any(|a| a == "--resume")
2110 );
2111 seat.captured_session = Some("01a09fe9-4e31-7226-85b3-fda6f46689d5".to_owned());
2112 let resumed = plan_for(AgentKind::Omp, &seat, true);
2113 assert!(
2114 resumed
2115 .argv
2116 .windows(2)
2117 .any(|w| w == ["--resume", "01a09fe9-4e31-7226-85b3-fda6f46689d5"]),
2118 "a captured id is what makes the next turn a resume"
2119 );
2120 assert!(!resumed.argv.iter().any(|a| a == "--continue"));
2123 assert_eq!(resumed.stdin.as_deref(), Some("do the thing"));
2125 }
2126
2127 #[test]
2132 fn omp_takes_the_answer_without_an_agent_end_line() {
2133 let stream = concat!(
2134 r#"{"type":"session","version":3,"id":"01a09fe9-4e31-7226-85b3-fda6f46689d5","cwd":"C:\\w"}"#,
2135 "\n",
2136 r#"{"type":"agent_start"}"#,
2137 "\n",
2138 r#"{"type":"turn_start"}"#,
2139 "\n",
2140 r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":1,"delta":"."}}"#,
2141 "\n",
2142 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"checking"},{"type":"text","text":"."}]}}"#,
2143 "\n",
2144 r#"{"type":"turn_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"done"},{"type":"text","text":"{\"vote\":\"approve\"}"}]}}"#,
2145 "\n",
2146 );
2147 let out = extract(AgentKind::Omp, stream);
2148 assert_eq!(
2149 out.text, "{\"vote\":\"approve\"}",
2150 "the last assistant text block is the answer even with no agent_end"
2151 );
2152 assert_eq!(
2153 out.session.as_deref(),
2154 Some("01a09fe9-4e31-7226-85b3-fda6f46689d5")
2155 );
2156 }
2157
2158 #[test]
2162 fn omp_walks_agent_end_and_ignores_tool_loop_narration() {
2163 let stream = concat!(
2164 r#"{"type":"session","version":3,"id":"s1"}"#,
2165 "\n",
2166 "{\"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問題ありません。\"}]}]}",
2167 "\n",
2168 );
2169 let out = extract(AgentKind::Omp, stream);
2170 assert_eq!(
2171 out.text, "## 判定\n\n問題ありません。",
2172 "the narration is not the answer, and non-ASCII survives intact"
2173 );
2174 assert_eq!(out.session.as_deref(), Some("s1"));
2175 }
2176
2177 #[test]
2180 fn omp_skips_non_json_lines() {
2181 let stream = concat!(
2182 "Warning: some omp notice\n",
2183 r#"{"type":"session","version":3,"id":"s2"}"#,
2184 "\n",
2185 r#"{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"the answer"}]}}"#,
2186 "\n",
2187 "trailing junk",
2188 "\n",
2189 );
2190 let out = extract(AgentKind::Omp, stream);
2191 assert_eq!(out.text, "the answer");
2192 assert_eq!(out.session.as_deref(), Some("s2"));
2193 }
2194
2195 #[test]
2197 fn codex_takes_the_last_agent_message_and_the_thread_id() {
2198 let stream = concat!(
2199 "2026-09-06T01:05:49.394445Z ERROR codex_models_manager: failed to load models cache\n",
2200 r#"{"type":"thread.started","thread_id":"01a07440-4545-7492-85c1-024e3259a90a"}"#,
2201 "\n",
2202 r#"{"type":"turn.started"}"#,
2203 "\n",
2204 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Looking into it."}}"#,
2205 "\n",
2206 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","text":"cargo test"}}"#,
2207 "\n",
2208 r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\"verdict\": \"ok\"}"}}"#,
2209 "\n",
2210 r#"{"type":"turn.completed","usage":{"input_tokens":17137}}"#,
2211 "\n",
2212 );
2213 let out = extract(AgentKind::Codex, stream);
2214 assert_eq!(
2215 out.text, "{\"verdict\": \"ok\"}",
2216 "the last agent message is the answer; earlier ones narrate"
2217 );
2218 assert_eq!(
2219 out.session.as_deref(),
2220 Some("01a07440-4545-7492-85c1-024e3259a90a")
2221 );
2222 assert_eq!(out.status.as_deref(), Some("success"));
2223
2224 let failed = concat!(
2225 r#"{"type":"thread.started","thread_id":"t1"}"#,
2226 "\n",
2227 r#"{"type":"turn.failed","error":{"message":"nope"}}"#,
2228 "\n",
2229 );
2230 assert_eq!(
2231 extract(AgentKind::Codex, failed).status.as_deref(),
2232 Some("error")
2233 );
2234 }
2235
2236 #[test]
2237 fn captured_sessions_resume_only_once_reported() {
2238 let mut seat = SeatState::new("impl-A", "a", 7);
2239 seat.turns = 1;
2240 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2241 assert!(!has_session(kind, &seat, true));
2242 let p = plan_for(kind, &seat, true);
2243 assert!(!p.argv.iter().any(|a| a == "-s" || a == "--conversation"));
2244 }
2245
2246 seat.captured_session = Some("sid".to_owned());
2247 assert!(has_session(AgentKind::Opencode, &seat, true));
2248 assert!(
2249 plan_for(AgentKind::Opencode, &seat, true)
2250 .argv
2251 .windows(2)
2252 .any(|w| w == ["-s", "sid"])
2253 );
2254 assert!(
2255 plan_for(AgentKind::Antigravity, &seat, true)
2256 .argv
2257 .windows(2)
2258 .any(|w| w == ["--conversation", "sid"])
2259 );
2260 }
2261
2262 #[test]
2263 fn sessions_disabled_never_resumes() {
2264 let mut seat = SeatState::new("impl-A", "a", 7);
2265 seat.turns = 3;
2266 seat.captured_session = Some("sid".to_owned());
2267 for kind in [
2268 AgentKind::Claude,
2269 AgentKind::Opencode,
2270 AgentKind::Antigravity,
2271 ] {
2272 assert!(!has_session(kind, &seat, false));
2273 }
2274 }
2275
2276 #[test]
2277 fn long_prompts_never_reach_argv_for_file_delivery_clis() {
2278 let seat = SeatState::new("judge-1", "a", 7);
2279 for kind in [AgentKind::Opencode, AgentKind::Antigravity] {
2280 let p = plan_for(kind, &seat, false);
2281 assert!(
2282 p.argv.iter().all(|a| a != "do the thing"),
2283 "{kind:?} put the prompt on the command line"
2284 );
2285 assert!(p.argv.iter().any(|a| a.contains("/art/p.md")));
2286 }
2287 let p = plan_for(AgentKind::Antigravity, &seat, false);
2289 let at = p.argv.iter().position(|a| a == "-p").unwrap();
2290 assert!(p.argv.get(at + 1).is_some_and(|v| v.contains("p.md")));
2291 assert!(p.stdin.is_none());
2292 }
2293
2294 #[test]
2295 fn agy_print_timeout_tracks_the_node_budget() {
2296 let seat = SeatState::new("impl-A", "a", 7);
2297 let p = build_command(
2298 &spec(AgentKind::Antigravity, None),
2299 &seat,
2300 &Invocation {
2301 cwd: Path::new("."),
2302 prompt: "p",
2303 timeout: Duration::from_secs(3600),
2304 allow_write: true,
2305 unsandboxed: false,
2306 sessions: true,
2307 artifacts: Path::new("/art"),
2308 stem: "t",
2309 run: "test-run",
2310 node: "test",
2311 cache_dir: None,
2312 attachments: &[],
2313 writable: &[],
2314 },
2315 Path::new("/art/p.md"),
2316 )
2317 .unwrap();
2318 assert!(p.argv.windows(2).any(|w| w == ["--print-timeout", "3600s"]));
2319 }
2320
2321 #[test]
2328 fn attachments_widen_antigravitys_add_dir_even_off_file_delivery() {
2329 let mut s = spec(AgentKind::Antigravity, None);
2330 s.prompt_delivery = Some(Delivery::Argv);
2331 let seat = SeatState::new("talk", "a", 7);
2332 let atts = [PathBuf::from("/art/attachments/abc.png")];
2333
2334 let without = build_command(
2335 &s,
2336 &seat,
2337 &Invocation {
2338 attachments: &[],
2339 writable: &[],
2340 ..inv(Path::new("."), Path::new("/art"), true)
2341 },
2342 Path::new("/art/p.md"),
2343 )
2344 .unwrap();
2345 assert!(
2346 !without.argv.iter().any(|a| a == "--add-dir"),
2347 "no attachment, no reason to widen the sandbox: {without:?}"
2348 );
2349
2350 let with = build_command(
2351 &s,
2352 &seat,
2353 &Invocation {
2354 attachments: &atts,
2355 writable: &[],
2356 ..inv(Path::new("."), Path::new("/art"), true)
2357 },
2358 Path::new("/art/p.md"),
2359 )
2360 .unwrap();
2361 assert!(
2362 with.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2363 "an attachment outside cwd must widen the sandbox even off File delivery: {with:?}"
2364 );
2365 }
2366
2367 #[test]
2373 fn an_inherited_attachment_outside_this_conversations_artifacts_dir_gets_its_own_add_dir() {
2374 let seat = SeatState::new("plan", "a", 7);
2375 let atts = [
2376 PathBuf::from("/art/attachments/own.png"),
2377 PathBuf::from("/other-chat/attachments/inherited.png"),
2378 ];
2379
2380 let p = build_command(
2381 &spec(AgentKind::Antigravity, None),
2382 &seat,
2383 &Invocation {
2384 attachments: &atts,
2385 writable: &[],
2386 ..inv(Path::new("."), Path::new("/art"), true)
2387 },
2388 Path::new("/art/p.md"),
2389 )
2390 .unwrap();
2391
2392 assert!(
2393 p.argv.windows(2).any(|w| w == ["--add-dir", "/art"]),
2394 "this conversation's own artifacts dir must still be granted: {p:?}"
2395 );
2396 assert!(
2397 p.argv
2398 .windows(2)
2399 .any(|w| w == ["--add-dir", "/other-chat/attachments"]),
2400 "the inherited attachment's own directory must be granted too: {p:?}"
2401 );
2402 }
2403
2404 #[test]
2405 fn command_agents_get_placeholders_substituted() {
2406 let seat = SeatState::new("impl-A", "a", 7);
2407 let p = plan_for(AgentKind::Command, &seat, true);
2408 assert_eq!(p.argv[0], "echo");
2409 assert_eq!(p.argv[1], "impl-A");
2410 assert_eq!(p.stdin.as_deref(), Some("do the thing"));
2411 }
2412
2413 #[test]
2414 fn claude_rate_limit_is_detected_and_reset_read_when_present() {
2415 let stdout = r#"{"is_error": true, "terminal_reason": "api_error",
2417 "result": "You've hit your session limit · resets 4:50am (Asia/Tokyo)",
2418 "session_id": "b8e928f1-754e-4bd3-86c5-0567763654e3"}"#;
2419 let out = extract(AgentKind::Claude, stdout);
2420 let quota = out.quota.as_ref().expect("rate limit must be detected");
2421 assert_eq!(
2422 quota.reset.as_deref(),
2423 Some("4:50am (Asia/Tokyo)"),
2424 "reset time read from the body"
2425 );
2426 }
2427
2428 #[test]
2429 fn claude_rate_limit_without_a_readable_reset_is_still_detected() {
2430 let out = extract(
2431 AgentKind::Claude,
2432 r#"{"is_error":true,"result":"session limit reached"}"#,
2433 );
2434 let quota = out.quota.expect("rate limit detected without a reset");
2435 assert!(quota.reset.is_none(), "unknown reset is kept as unknown");
2436 }
2437
2438 #[test]
2439 fn claude_structured_usage_limit_requires_the_error_and_exact_pair() {
2440 for kind in [AgentKind::Claude, AgentKind::Command] {
2441 for reply in [
2442 r#"{"is_error":true,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2443 r#"{"is_error":true,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2444 ] {
2445 assert!(extract(kind, reply).quota.is_some(), "{reply}");
2446 }
2447 for reply in [
2448 r#"{"is_error":false,"api_error_status":429,"api_error":"usage_limit_reached"}"#,
2449 r#"{"is_error":true,"api_error_status":500,"api_error":"usage_limit_reached"}"#,
2450 r#"{"is_error":true,"api_error_status":429,"api_error":"other_error"}"#,
2451 r#"{"is_error":false,"api_error_status":"429","api_error":"usage_limit_reached"}"#,
2452 r#"{"is_error":true,"api_error_status":"500","api_error":"usage_limit_reached"}"#,
2453 r#"{"is_error":true,"api_error_status":"429","api_error":"other_error"}"#,
2454 r#"{"is_error":true,"api_error":"usage_limit_reached"}"#,
2455 ] {
2456 assert!(extract(kind, reply).quota.is_none(), "{reply}");
2457 }
2458 }
2459 }
2460
2461 #[test]
2462 fn ordinary_failures_are_never_quota() {
2463 let claude_fail = extract(
2465 AgentKind::Claude,
2466 r#"{"is_error":true,"result":"account does not exist"}"#,
2467 );
2468 assert!(claude_fail.quota.is_none());
2469
2470 let cmd_fail = extract(AgentKind::Command, "boom");
2472 assert!(cmd_fail.quota.is_none());
2473
2474 let success = extract(
2476 AgentKind::Command,
2477 r#"{"is_error":false,"result":"session limit is fine"}"#,
2478 );
2479 assert!(success.quota.is_none());
2480 }
2481
2482 #[test]
2490 fn codex_command_execution_events_are_captured_alongside_the_final_message() {
2491 let stream = concat!(
2492 r#"{"type":"thread.started","thread_id":"t1"}"#,
2493 "\n",
2494 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"}}"#,
2495 "\n",
2496 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"}}"#,
2497 "\n",
2498 r#"{"type":"item.completed","item":{"id":"item99","type":"agent_message","text":"Both tests in the target pass."}}"#,
2499 "\n",
2500 r#"{"type":"turn.completed"}"#,
2501 "\n",
2502 );
2503 let out = extract(AgentKind::Codex, stream);
2504 assert_eq!(out.text, "Both tests in the target pass.");
2505 assert_eq!(out.commands.len(), 2, "{:?}", out.commands);
2506
2507 let paired = &out.commands[0];
2508 assert_eq!(paired.id, "item49");
2509 assert_eq!(paired.exit_code, Some(1));
2510 assert!(paired.description.contains("graph_cached_gate"));
2511 assert!(paired.result_summary.contains("1 failed"));
2512
2513 let solo = &out.commands[1];
2514 assert_eq!(solo.exit_code, Some(0));
2515
2516 assert!(
2520 out.commands
2521 .iter()
2522 .any(|c| c.exit_code != Some(0) && c.description.contains("graph_cached_gate")),
2523 "a failed run of the actual target must still be visible: {:?}",
2524 out.commands
2525 );
2526 }
2527
2528 #[test]
2529 fn command_agent_can_carry_the_claude_quota_shape() {
2530 let out = extract(
2531 AgentKind::Command,
2532 r#"{"is_error":true,"result":"You've hit your session limit · resets 1:00am (UTC)"}"#,
2533 );
2534 assert!(
2535 out.quota.is_some(),
2536 "a wrapper emitting the claude shape counts as quota"
2537 );
2538 }
2539
2540 #[test]
2541 fn claude_json_result_is_extracted() {
2542 let out = extract(
2543 AgentKind::Claude,
2544 r#"{"result":"all done","session_id":"abc","is_error":false}"#,
2545 );
2546 assert_eq!(out.text, "all done");
2547 assert_eq!(out.session.as_deref(), Some("abc"));
2548 assert_eq!(out.status.as_deref(), Some("success"));
2549 }
2550
2551 #[test]
2566 fn a_clean_cli_turn_is_not_the_same_fact_as_the_nodes_own_work_being_done() {
2567 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"}"#;
2568 let out = extract(AgentKind::Claude, stdout);
2569 assert_eq!(out.status.as_deref(), Some("success"));
2570 assert!(out.quota.is_none());
2571 assert!(!out.text.trim().is_empty());
2572
2573 let agent_out = AgentOutput {
2574 text: out.text.clone(),
2575 exit_code: Some(0),
2576 timed_out: false,
2577 duration_ms: 500,
2578 artifacts: Vec::new(),
2579 quota: out.quota,
2580 dropped: out.dropped,
2581 commands: out.commands,
2582 context_tokens: None,
2583 };
2584 assert!(
2585 agent_out.usable(),
2586 "the CLI turn itself ended cleanly and must read as usable"
2587 );
2588 assert!(
2589 crate::verdict::extract_json::<crate::verdict::FixReport>(&agent_out.text).is_err(),
2590 "a clean CLI turn is not proof the node's own report ever arrived"
2591 );
2592 }
2593
2594 #[test]
2595 fn opencode_event_stream_is_concatenated() {
2596 let stream = concat!(
2597 r#"{"type":"step_start","sessionID":"ses_1","part":{"type":"step-start"}}"#,
2598 "\n",
2599 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"first"}}"#,
2600 "\n",
2601 "garbage line\n",
2602 r#"{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"second"}}"#,
2603 "\n"
2604 );
2605 let out = extract(AgentKind::Opencode, stream);
2606 assert_eq!(out.text, "first\nsecond");
2607 assert_eq!(out.session.as_deref(), Some("ses_1"));
2608 }
2609
2610 #[test]
2611 fn agy_json_survives_a_leading_warning_line() {
2612 let stdout = concat!(
2613 "warning: --mode plan has no effect while slash commands are disabled.\n",
2614 r#"{"conversation_id":"eaf2d00a","status":"SUCCESS","response":"persimmon\n"}"#,
2615 "\n"
2616 );
2617 let out = extract(AgentKind::Antigravity, stdout);
2618 assert_eq!(out.text, "persimmon");
2619 assert_eq!(out.session.as_deref(), Some("eaf2d00a"));
2620 assert_eq!(out.status.as_deref(), Some("SUCCESS"));
2621 }
2622
2623 const AGY_DROPPED: &str = concat!(
2630 r#"{"conversation_id":"36743d06-c0b3-4b79-9fa2-23869289d7b6","status":"ERROR","#,
2631 r#""response":"","error":"the connection to the agent was interrupted before "#,
2632 r#"the response finished: subscriber fell behind updates, stalled for 5s","#,
2633 r#""duration_seconds":431.1941803,"num_turns":1,"usage":{"input_tokens":260113,"#,
2634 r#""output_tokens":14267,"thinking_tokens":9695,"cache_read_tokens":2200925,"#,
2635 r#""total_tokens":274380}}"#
2636 );
2637
2638 const AGY_QUOTA_OUT: &str = concat!(
2640 r#"{"conversation_id":"323c3b5b-0000","status":"ERROR","response":"","#,
2641 r#""error":"Individual quota reached. Please upgrade your subscription to "#,
2642 r#"increase your limits. Resets in 1h2m49s.","duration_seconds":265.9,"#,
2643 r#""num_turns":2,"usage":{"input_tokens":1000,"output_tokens":50}}"#
2644 );
2645 const AGY_QUOTA_ERR: &str = concat!(
2646 "error: Individual quota reached. Resets in 1h2m49s.\n",
2647 r#"AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota "#,
2648 r#"reached.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","#,
2649 r#""retryable":true}"#,
2650 "\n"
2651 );
2652
2653 #[test]
2654 fn agy_out_of_quota_is_a_quota_with_the_reset_hint() {
2655 let both = agy_quota(AGY_QUOTA_OUT, AGY_QUOTA_ERR).expect("both streams");
2656 assert_eq!(both.reset.as_deref(), Some("in 1h2m49s"));
2657 let stdout_only = agy_quota(AGY_QUOTA_OUT, "").expect("stdout alone");
2658 assert_eq!(stdout_only.reset.as_deref(), Some("in 1h2m49s"));
2659 let stderr_only = agy_quota("not json", AGY_QUOTA_ERR).expect("stderr alone");
2661 assert!(stderr_only.reset.is_none());
2662 }
2663
2664 #[test]
2665 fn ordinary_agy_failures_are_not_a_quota() {
2666 assert!(agy_quota(AGY_DROPPED, "").is_none());
2667 assert!(agy_quota(r#"{"status":"ERROR","error":"boom"}"#, "").is_none());
2668 assert!(
2669 agy_quota(
2670 "",
2671 r#"AGY_ERROR: {"status":"RESOURCE_EXHAUSTED","error_code":500}"#
2672 )
2673 .is_none()
2674 );
2675 assert!(
2676 agy_quota(
2677 "",
2678 r#"AGY_ERROR: {"status":"UNAVAILABLE","error_code":429}"#
2679 )
2680 .is_none()
2681 );
2682 assert!(agy_quota(r#"{"status":"SUCCESS","response":"ok"}"#, "").is_none());
2683 }
2684
2685 #[test]
2686 fn a_cli_that_hangs_up_on_billed_work_is_not_an_agent_that_produced_nothing() {
2687 let out = extract(AgentKind::Antigravity, AGY_DROPPED);
2688 let dropped = out.dropped.expect("recognised as undelivered work");
2689 assert_eq!(dropped.output_tokens, 14267);
2690 assert!(
2691 dropped.why.contains("subscriber fell behind"),
2692 "the CLI's own words are kept for the record: {}",
2693 dropped.why
2694 );
2695 assert_eq!(
2698 out.session.as_deref(),
2699 Some("36743d06-c0b3-4b79-9fa2-23869289d7b6")
2700 );
2701 assert!(out.quota.is_none(), "a dropped stream is not a rate limit");
2702 }
2703
2704 #[test]
2705 fn an_error_with_nothing_produced_stays_an_ordinary_failure() {
2706 let bare = r#"{"conversation_id":"c1","status":"ERROR","response":"","error":"boom"}"#;
2710 assert!(extract(AgentKind::Antigravity, bare).dropped.is_none());
2711
2712 let answered = concat!(
2715 r#"{"conversation_id":"c2","status":"ERROR","response":"here it is","#,
2716 r#""usage":{"output_tokens":10}}"#
2717 );
2718 assert!(extract(AgentKind::Antigravity, answered).dropped.is_none());
2719
2720 let ok = concat!(
2722 r#"{"conversation_id":"c3","status":"SUCCESS","response":"done","#,
2723 r#""usage":{"output_tokens":10}}"#
2724 );
2725 assert!(extract(AgentKind::Antigravity, ok).dropped.is_none());
2726 }
2727
2728 #[test]
2729 fn an_undelivered_output_is_not_usable_but_is_worth_asking_again() {
2730 let out = AgentOutput {
2731 text: String::new(),
2732 exit_code: Some(1),
2733 timed_out: false,
2734 duration_ms: 431_194,
2735 artifacts: Vec::new(),
2736 quota: None,
2737 dropped: Some(Dropped {
2738 why: "subscriber fell behind updates".to_owned(),
2739 output_tokens: 14267,
2740 }),
2741 commands: Vec::new(),
2742 context_tokens: None,
2743 };
2744 assert!(!out.usable());
2745 assert!(out.work_undelivered());
2746 assert!(!out.quota_exhausted());
2749 }
2750
2751 #[test]
2752 fn non_json_stdout_falls_back_to_raw_text() {
2753 let out = extract(AgentKind::Antigravity, "plain answer\n");
2754 assert_eq!(out.text, "plain answer");
2755 assert!(out.session.is_none());
2756 }
2757
2758 #[tokio::test]
2759 async fn command_agent_round_trip_writes_artifacts() {
2760 let dir = tempfile::tempdir().unwrap();
2761 let art = dir.path().join("artifacts");
2762 let mut seat = SeatState::new("impl-A", "a", 7);
2763 let s = command_helper("reply");
2764 let out = invoke(
2765 &s,
2766 &mut seat,
2767 &Invocation {
2768 cwd: dir.path(),
2769 prompt: "unused",
2770 timeout: Duration::from_secs(30),
2771 allow_write: true,
2772 unsandboxed: false,
2773 sessions: true,
2774 artifacts: &art,
2775 stem: "impl-A",
2776 run: "test-run",
2777 node: "test",
2778 cache_dir: None,
2779 attachments: &[],
2780 writable: &[],
2781 },
2782 )
2783 .await
2784 .unwrap();
2785 assert!(out.usable(), "{out:?}");
2786 assert!(out.text.contains("hello impl-A"), "{}", out.text);
2787 assert_eq!(seat.turns, 1);
2788 assert!(art.join("impl-A.prompt.md").is_file());
2789 assert!(art.join("impl-A.out").is_file());
2790 }
2791
2792 #[tokio::test]
2793 async fn the_invocation_cache_dir_reaches_the_seat_as_cargo_target_dir() {
2794 let dir = tempfile::tempdir().unwrap();
2798 let cache = dir.path().join("magi-cache");
2799 let mut seat = SeatState::new("impl-A", "a", 7);
2800 let s = command_helper("cache");
2801 let out = invoke(
2802 &s,
2803 &mut seat,
2804 &Invocation {
2805 cwd: dir.path(),
2806 prompt: "unused",
2807 timeout: Duration::from_secs(30),
2808 allow_write: true,
2809 unsandboxed: false,
2810 sessions: true,
2811 artifacts: &dir.path().join("artifacts"),
2812 stem: "cache",
2813 run: "test-run",
2814 node: "test",
2815 cache_dir: Some(&cache),
2816 attachments: &[],
2817 writable: &[],
2818 },
2819 )
2820 .await
2821 .unwrap();
2822 assert!(out.usable(), "{out:?}");
2823 assert!(
2824 out.text.contains(cache.to_string_lossy().as_ref()),
2825 "the seat must see CARGO_TARGET_DIR = the shared cache"
2826 );
2827 }
2828
2829 #[tokio::test]
2830 async fn cache_dir_none_strips_a_cargo_target_dir_inherited_from_this_process() {
2831 let previous = std::env::var("CARGO_TARGET_DIR").ok();
2839 unsafe {
2844 std::env::set_var("CARGO_TARGET_DIR", "/should/never/reach/a/read-only/seat");
2845 }
2846 let dir = tempfile::tempdir().unwrap();
2847 let mut seat = SeatState::new("review-1", "a", 7);
2848 let s = command_helper("no-cache");
2849 let result = invoke(
2850 &s,
2851 &mut seat,
2852 &Invocation {
2853 cwd: dir.path(),
2854 prompt: "unused",
2855 timeout: Duration::from_secs(30),
2856 allow_write: false,
2857 unsandboxed: false,
2858 sessions: true,
2859 artifacts: &dir.path().join("artifacts"),
2860 stem: "no-cache",
2861 run: "test-run",
2862 node: "test",
2863 cache_dir: None,
2864 attachments: &[],
2865 writable: &[],
2866 },
2867 )
2868 .await;
2869 unsafe {
2874 match &previous {
2875 Some(v) => std::env::set_var("CARGO_TARGET_DIR", v),
2876 None => std::env::remove_var("CARGO_TARGET_DIR"),
2877 }
2878 }
2879 let out = result.unwrap();
2880 assert!(out.usable(), "{out:?}");
2881 assert!(
2882 out.text.contains("ABSENT"),
2883 "a read-only seat must never inherit the process's own CARGO_TARGET_DIR: {}",
2884 out.text
2885 );
2886 }
2887
2888 #[tokio::test]
2889 async fn a_prompt_larger_than_the_pipe_buffer_does_not_deadlock() {
2890 let dir = tempfile::tempdir().unwrap();
2891 let mut seat = SeatState::new("impl-A", "a", 7);
2892 let s = command_helper("ignore-stdin");
2895 let big = "x".repeat(1_000_000);
2896 let out = invoke(
2897 &s,
2898 &mut seat,
2899 &Invocation {
2900 cwd: dir.path(),
2901 prompt: &big,
2902 timeout: Duration::from_secs(60),
2903 allow_write: true,
2904 unsandboxed: false,
2905 sessions: true,
2906 artifacts: &dir.path().join("artifacts"),
2907 stem: "big",
2908 run: "test-run",
2909 node: "test",
2910 cache_dir: None,
2911 attachments: &[],
2912 writable: &[],
2913 },
2914 )
2915 .await
2916 .unwrap();
2917 assert!(out.usable(), "{out:?}");
2918 assert!(out.text.contains("done"), "{}", out.text);
2919 }
2920
2921 #[tokio::test]
2922 async fn timeout_is_reported_not_hung() {
2923 let dir = tempfile::tempdir().unwrap();
2924 let mut seat = SeatState::new("impl-A", "a", 7);
2925 let s = command_helper("sleep");
2926 let out = invoke(
2927 &s,
2928 &mut seat,
2929 &Invocation {
2930 cwd: dir.path(),
2931 prompt: "unused",
2932 timeout: Duration::from_millis(300),
2933 allow_write: true,
2934 unsandboxed: false,
2935 sessions: true,
2936 artifacts: &dir.path().join("artifacts"),
2937 stem: "slow",
2938 run: "test-run",
2939 node: "test",
2940 cache_dir: None,
2941 attachments: &[],
2942 writable: &[],
2943 },
2944 )
2945 .await
2946 .unwrap();
2947 assert!(out.timed_out);
2948 assert!(!out.usable());
2949 }
2950
2951 #[tokio::test]
2952 async fn a_timeout_keeps_what_the_agent_had_already_printed() {
2953 let dir = tempfile::tempdir().unwrap();
2959 let artifacts = dir.path().join("artifacts");
2960 let mut seat = SeatState::new("impl-A", "a", 7);
2961 let s = command_helper("chatty-sleep");
2962 let out = invoke(
2963 &s,
2964 &mut seat,
2965 &Invocation {
2966 cwd: dir.path(),
2967 prompt: "unused",
2968 timeout: Duration::from_secs(10),
2973 allow_write: true,
2974 unsandboxed: false,
2975 sessions: true,
2976 artifacts: &artifacts,
2977 stem: "chatty",
2978 run: "test-run",
2979 node: "test",
2980 cache_dir: None,
2981 attachments: &[],
2982 writable: &[],
2983 },
2984 )
2985 .await
2986 .unwrap();
2987
2988 assert!(out.timed_out, "{out:?}");
2989 assert!(!out.usable(), "a cut-off answer is still not an answer");
2990 let recorded = std::fs::read_to_string(artifacts.join("chatty.out")).unwrap();
2991 assert!(
2992 recorded.contains("i-said-something"),
2993 "the artifact must keep what arrived before the kill, got {recorded:?}"
2994 );
2995 assert!(
2996 out.text.contains("i-said-something"),
2997 "and the graph must be able to see it too, got {:?}",
2998 out.text
2999 );
3000 }
3001
3002 #[test]
3003 fn missing_programs_reports_command_binaries() {
3004 let mut s = spec(AgentKind::Command, None);
3005 s.command = vec!["definitely-not-a-real-binary-xyz".to_owned()];
3006 assert_eq!(
3007 missing_programs(&[s]),
3008 ["definitely-not-a-real-binary-xyz".to_owned()]
3009 );
3010 }
3011
3012 fn pick_spec(id: &str, kind: AgentKind) -> AgentSpec {
3013 AgentSpec {
3014 id: id.to_owned(),
3015 kind,
3016 model: None,
3017 command: Vec::new(),
3018 extra_args: Vec::new(),
3019 env: BTreeMap::new(),
3020 prompt_delivery: None,
3021 }
3022 }
3023
3024 fn without<'a>(missing: &'a [&'a str]) -> impl Fn(&AgentSpec) -> bool + 'a {
3027 move |a: &AgentSpec| !missing.contains(&a.id.as_str())
3028 }
3029
3030 #[test]
3031 fn pick_prefers_the_claude_seat_even_when_it_is_not_first_in_the_roster() {
3032 let agents = [
3033 pick_spec("oc", AgentKind::Opencode),
3034 pick_spec("opus", AgentKind::Claude),
3035 pick_spec("agy", AgentKind::Antigravity),
3036 ];
3037 let got = pick(&agents, None, &without(&[])).expect("a pick");
3038 assert_eq!(got.id, "opus");
3039 }
3040
3041 #[test]
3042 fn pick_falls_back_to_the_first_installed_agent_in_roster_order() {
3043 let agents = [
3044 pick_spec("opus", AgentKind::Claude),
3045 pick_spec("oc", AgentKind::Opencode),
3046 pick_spec("agy", AgentKind::Antigravity),
3047 ];
3048 let got = pick(&agents, None, &without(&["opus", "oc"])).expect("a pick");
3049 assert_eq!(got.id, "agy");
3050 }
3051
3052 #[test]
3053 fn pick_on_an_empty_roster_says_what_to_install() {
3054 let msg = pick(&[], None, &without(&[]))
3055 .expect_err("nobody to ask")
3056 .to_string();
3057 assert!(msg.contains("roster is empty"), "{msg}");
3058 assert!(msg.contains("claude"), "{msg}");
3059 assert!(msg.contains("magi.toml"), "{msg}");
3060 }
3061
3062 #[test]
3063 fn pick_on_a_roster_with_nothing_installed_names_the_programs_that_are_missing() {
3064 let agents = [
3065 pick_spec("opus", AgentKind::Claude),
3066 pick_spec("oc", AgentKind::Opencode),
3067 ];
3068 let err = pick(&agents, None, &without(&["opus", "oc"])).expect_err("nothing runnable");
3069 let msg = format!("{err:#}");
3070 assert!(msg.contains("claude"), "{msg}");
3071 assert!(msg.contains("opencode"), "{msg}");
3072 }
3073
3074 #[test]
3075 fn an_explicitly_named_agent_wins_over_the_claude_preference() {
3076 let agents = [
3077 pick_spec("opus", AgentKind::Claude),
3078 pick_spec("oc", AgentKind::Opencode),
3079 ];
3080 let got = pick(&agents, Some("oc"), &without(&[])).expect("a pick");
3081 assert_eq!(got.id, "oc");
3082 }
3083
3084 #[test]
3085 fn an_unknown_agent_id_lists_the_ids_that_do_exist() {
3086 let agents = [
3087 pick_spec("opus", AgentKind::Claude),
3088 pick_spec("oc", AgentKind::Opencode),
3089 ];
3090 let msg = pick(&agents, Some("gemini"), &without(&[]))
3091 .expect_err("no such agent")
3092 .to_string();
3093 assert!(msg.contains("gemini"), "{msg}");
3094 assert!(msg.contains("opus, oc"), "{msg}");
3095 }
3096
3097 #[test]
3098 fn an_explicitly_named_agent_that_is_not_installed_is_an_error_not_a_fallback() {
3099 let agents = [
3100 pick_spec("opus", AgentKind::Claude),
3101 pick_spec("oc", AgentKind::Opencode),
3102 ];
3103 let msg = pick(&agents, Some("oc"), &without(&["oc"]))
3104 .expect_err("must not silently substitute another model")
3105 .to_string();
3106 assert!(msg.contains("opencode"), "{msg}");
3107 assert!(msg.contains("--agent"), "{msg}");
3108 }
3109
3110 fn named(id: &str) -> AgentSpec {
3111 AgentSpec {
3112 id: id.to_owned(),
3113 ..spec(AgentKind::Command, None)
3114 }
3115 }
3116
3117 fn output(text: &str, exit: i32, quota: bool) -> AgentOutput {
3118 AgentOutput {
3119 text: text.to_owned(),
3120 exit_code: Some(exit),
3121 timed_out: false,
3122 duration_ms: 0,
3123 artifacts: Vec::new(),
3124 quota: quota.then_some(Quota { reset: None }),
3125 dropped: None,
3126 commands: Vec::new(),
3127 context_tokens: None,
3128 }
3129 }
3130
3131 #[test]
3132 fn a_chain_keeps_the_written_order_and_a_string_is_a_chain_of_one() {
3133 let agents = [named("a"), named("b"), named("c")];
3134 let all = |_: &AgentSpec| true;
3135 let chain = AgentChoice::Chain(vec!["c".into(), "a".into()]);
3136 let got = pick_chain(&agents, Some(&chain), &all, "synthesizer").unwrap();
3137 assert_eq!(
3138 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3139 ["c", "a"]
3140 );
3141
3142 let one = AgentChoice::from("b");
3143 let got = pick_chain(&agents, Some(&one), &all, "synthesizer").unwrap();
3144 assert_eq!(got.len(), 1);
3145 assert_eq!(got[0].id, "b");
3146
3147 let got = pick_chain(&agents, None, &all, "synthesizer").unwrap();
3148 assert_eq!(got.len(), 1, "unset keeps pick's default");
3149 assert_eq!(got[0].id, "a");
3150 let empty = AgentChoice::Chain(Vec::new());
3151 assert_eq!(
3152 pick_chain(&agents, Some(&empty), &all, "x").unwrap()[0].id,
3153 "a"
3154 );
3155 }
3156
3157 #[test]
3158 fn a_chain_skips_unknown_and_uninstalled_ids_and_tries_each_once() {
3159 let agents = [named("a"), named("b")];
3160 let not_a = |s: &AgentSpec| s.id != "a";
3161 let chain = AgentChoice::Chain(
3162 ["a", "ghost", "b", "b"]
3163 .iter()
3164 .map(|s| (*s).to_owned())
3165 .collect(),
3166 );
3167 let got = pick_chain(&agents, Some(&chain), ¬_a, "chatter").unwrap();
3168 assert_eq!(got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["b"]);
3169
3170 let dup = AgentChoice::Chain(vec!["b".into(), "a".into(), "b".into()]);
3171 let got = pick_chain(&agents, Some(&dup), &|_| true, "chatter").unwrap();
3172 assert_eq!(
3173 got.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
3174 ["b", "a"]
3175 );
3176 }
3177
3178 #[test]
3179 fn a_chain_with_nothing_runnable_names_the_role() {
3180 let agents = [named("a")];
3181 let chain = AgentChoice::Chain(vec!["a".into(), "ghost".into()]);
3182 let err = pick_chain(&agents, Some(&chain), &|_| false, "conductor")
3183 .unwrap_err()
3184 .to_string();
3185 assert!(err.contains("conductor"), "{err}");
3186 }
3187
3188 #[test]
3189 fn a_chain_advances_on_error_quota_or_an_unusable_answer_only() {
3190 assert!(chain_advances(&Err(anyhow::anyhow!("spawn failed"))));
3191 assert!(chain_advances(&Ok(output("limit", 0, true))));
3192 assert!(chain_advances(&Ok(output("", 0, false))));
3193 assert!(chain_advances(&Ok(output("x", 1, false))));
3194 assert!(!chain_advances(&Ok(output("answer", 0, false))));
3195 }
3196
3197 #[test]
3198 fn claude_context_tokens_are_unknown_because_usage_is_aggregated() {
3199 for out in [
3200 r#"{"result":"ok","num_turns":1,"usage":{"input_tokens":10,"cache_read_input_tokens":3000}}"#,
3201 r#"{"result":"ok","num_turns":3,"usage":{"input_tokens":10}}"#,
3202 r#"{"result":"ok"}"#,
3203 "not json",
3204 ] {
3205 assert_eq!(context_tokens(AgentKind::Claude, out), None, "{out}");
3206 }
3207 }
3208
3209 #[test]
3210 fn opencode_context_tokens_take_the_last_step_finish() {
3211 let out = concat!(
3212 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":100,"output":5,"cache":{"read":1000,"write":50}}}}"#,
3213 "\n",
3214 r#"{"type":"text","part":{"type":"text","text":"hi"}}"#,
3215 "\n",
3216 r#"{"type":"step_finish","sessionID":"s","part":{"type":"step-finish","tokens":{"input":120,"output":9,"cache":{"read":1500}}}}"#,
3217 "\n"
3218 );
3219 assert_eq!(context_tokens(AgentKind::Opencode, out), Some(1620));
3220 let none = r#"{"type":"step_finish","part":{"type":"step-finish"}}"#;
3221 assert_eq!(context_tokens(AgentKind::Opencode, none), None);
3222 assert_eq!(context_tokens(AgentKind::Opencode, ""), None);
3223 }
3224
3225 #[test]
3226 fn agy_context_tokens_are_unknown_because_usage_is_aggregated() {
3227 let out = r#"{"conversation_id":"c","status":"OK","response":"x","usage":{"input_tokens":260113,"cache_read_tokens":2200925}}"#;
3228 assert_eq!(context_tokens(AgentKind::Antigravity, out), None);
3229 }
3230
3231 #[test]
3232 fn codex_context_tokens_are_unknown_because_usage_is_cumulative() {
3233 let out = concat!(
3234 r#"{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":900}}"#,
3235 "\n"
3236 );
3237 assert_eq!(context_tokens(AgentKind::Codex, out), None);
3238 }
3239
3240 #[test]
3241 fn omp_context_tokens_come_from_messages_without_an_agent_end() {
3242 let out = concat!(
3243 r#"{"type":"session","id":"s"}"#,
3244 "\n",
3245 r#"{"type":"message_end","message":{"role":"assistant","content":[],"usage":{"input":50,"cacheRead":400,"cacheWrite":10}}}"#,
3246 "\n",
3247 r#"{"type":"turn_end","message":{"role":"assistant","content":[],"usage":{"input":70,"cacheRead":500}}}"#,
3248 "\n"
3249 );
3250 assert_eq!(context_tokens(AgentKind::Omp, out), Some(570));
3251 let user_only = r#"{"type":"message_end","message":{"role":"user","usage":{"input":9}}}"#;
3252 assert_eq!(context_tokens(AgentKind::Omp, user_only), None);
3253 let no_usage = r#"{"type":"message_end","message":{"role":"assistant","content":[]}}"#;
3254 assert_eq!(context_tokens(AgentKind::Omp, no_usage), None);
3255 }
3256
3257 #[test]
3258 fn command_agents_report_no_context_tokens() {
3259 let out = r#"{"usage":{"input_tokens":5}}"#;
3260 assert_eq!(context_tokens(AgentKind::Command, out), None);
3261 }
3262}