use std::path::Path;
use std::time::Duration;
use argonauts_core::app::{
AnswerTarget, AppEvent, ChoiceKind, ConversationEvent, TurnReply, UiCommand,
};
use argonauts_core::asking::{Ask, AskLine, AskReply};
use argonauts_core::event::AgentEvent;
use argonauts_core::events::approval::AnswerAction;
use argonauts_core::events::run::{InstructionOutcome, RunMode};
use argonauts_core::events::session::{CallCount, EndReason, InputPartKind, Watchdog};
use argonauts_core::input::SlashCommand;
use argonauts_core::session_runner::{Access, RunnerEvent, SessionControl, SessionSpec};
use argonauts_core::settings::approvals::ApprovalMode;
use argonauts_core::{Asks, sha256_hex};
use argonauts_testkit::{FakeRunner, FakeSession};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use crate::controller::test_support::{CliOptions, TestDriver, WAIT};
use super::direct_outside_tests::written_by;
const TASK: &str = "The tests in tests/test_money.py fail. Fix invoice/money.py so every test \
passes, without changing the tests.";
const ANSWER: &str = "Fixed both functions in invoice/money.py. All 6 tests pass.";
fn captain_workflow_options(config: &'static str) -> CliOptions {
CliOptions {
prepare: Some(Box::new(move |repo: &argonauts_testkit::TempRepo| {
repo.write("invoice/money.py", "sign = 1\n").unwrap();
repo.commit_all("money").unwrap();
std::fs::write(
repo.global_config(),
format!("{config}\n[captain_or_crew]\nforce = \"captain\"\n"),
)
.unwrap();
})),
..CliOptions::default()
}
}
const SUPERVISED: &str = "[approvals.understand]\nmode = \"supervised\"\n";
fn fixing(delay: Duration) -> impl Fn(&SessionSpec) -> FakeSession + Send + Sync + 'static {
move |spec: &SessionSpec| {
std::fs::write(spec.workdir.join("invoice/money.py"), "negative = True\n").unwrap();
FakeSession::completed(ANSWER)
.with_event(tool("t1", "Edit", json!({"file_path": "invoice/money.py"})))
.with_event(RunnerEvent::Agent(AgentEvent::ToolFinished {
id: "t1".into(),
ok: true,
output: Some("Added 1 line, removed 1 line".into()),
parent: None,
}))
.with_delay(delay)
}
}
fn tool(id: &str, name: &str, input: Value) -> RunnerEvent {
RunnerEvent::Agent(AgentEvent::ToolStarted {
id: id.into(),
name: name.into(),
input,
parent: None,
})
}
fn changed(path: &str) -> RunnerEvent {
RunnerEvent::Agent(AgentEvent::FileChanged {
path: path.into(),
kind: "update".into(),
})
}
fn conversation(event: &AppEvent) -> Option<&ConversationEvent> {
match event {
AppEvent::Conversation { event } => Some(event),
_ => None,
}
}
fn turn_ended(event: &AppEvent) -> bool {
matches!(
conversation(event),
Some(ConversationEvent::TurnFinished { .. } | ConversationEvent::TurnFailed { .. })
)
}
fn choose(id: argonauts_core::names::ChoiceId, value: &str) -> UiCommand {
UiCommand::Answer {
to: AnswerTarget::Choice { id },
action: AnswerAction::Choose {
value: value.to_owned(),
},
text: None,
evidence_sha256: None,
tasks: Vec::new(),
remember: false,
}
}
fn read(root: &Path, path: &str) -> String {
std::fs::read_to_string(root.join(path)).unwrap()
}
async fn ask(spec: &SessionSpec, ask: Ask) -> AskReply {
let asking = spec.asking.clone().expect("the turn listens for questions");
let mut stream = TcpStream::connect(&asking.endpoint).await.unwrap();
let line = AskLine {
token: asking.token,
ask,
};
let text = format!("{}\n", serde_json::to_string(&line).unwrap());
stream.write_all(text.as_bytes()).await.unwrap();
let mut answer = String::new();
BufReader::new(stream).read_line(&mut answer).await.unwrap();
serde_json::from_str(answer.trim()).unwrap()
}
async fn first_spec(driver: &TestDriver) -> SessionSpec {
let deadline = tokio::time::Instant::now() + WAIT;
loop {
if let Some(spec) = driver.agent.specs().into_iter().next() {
return spec;
}
assert!(tokio::time::Instant::now() < deadline, "no session started");
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
#[tokio::test]
async fn a_direct_turn_edits_your_checkout_and_ends_with_the_agents_answer() {
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
let root = driver.repo.root().to_owned();
driver.agent.respond(fixing(Duration::ZERO));
let worker = driver.agent.clone();
let text = format!("{TASK} See @README.md");
driver.submit(&text).await;
let ended = driver.until(turn_ended).await;
let money = read(&root, "invoice/money.py");
let events = driver.exit().await;
assert!(matches!(
conversation(&ended),
Some(ConversationEvent::TurnFinished { reply: TurnReply::Answer { markdown }, .. }) if markdown == ANSWER
));
let spec = worker.specs().remove(0);
assert_eq!(spec.access, Access::Direct);
assert_eq!(spec.workdir, root);
assert!(spec.json_schema.is_none());
assert_eq!(spec.goal_sha256, Some(sha256_hex(text.as_bytes())));
let kinds: Vec<_> = spec.prompt.iter().map(|part| part.kind).collect();
assert_eq!(kinds, [InputPartKind::Message, InputPartKind::File]);
assert_eq!(spec.prompt[0].text, text);
assert_eq!(money, "negative = True\n");
let started = events.iter().find_map(|event| match conversation(event) {
Some(ConversationEvent::TurnStarted { read_only, .. }) => Some(*read_only),
_ => None,
});
assert_eq!(started, Some(false), "a direct turn says it may write");
assert!(cli_starts_nothing(&events));
assert!(
!events
.iter()
.any(|event| matches!(event, AppEvent::Recap { .. })),
"no recap repeats the answer"
);
}
fn cli_starts_nothing(events: &[AppEvent]) -> bool {
!events.iter().any(|event| {
matches!(event, AppEvent::Run { .. })
|| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::StartWork)
})
}
#[tokio::test]
async fn slash_captain_is_direct_and_checked_captain_is_a_run() {
let captain = |config: &'static str| CliOptions {
prepare: Some(Box::new(move |repo: &argonauts_testkit::TempRepo| {
std::fs::write(repo.global_config(), config).unwrap();
})),
..CliOptions::default()
};
let slash = UiCommand::Slash {
command: SlashCommand::Captain {
task: "fix the typo in the README".to_owned(),
},
};
let mut driver = TestDriver::start_with(captain("")).await;
driver.agent.push(FakeSession::completed("Fixed the typo."));
driver.send(slash.clone()).await;
driver.until(turn_ended).await;
let conductor = driver.conductor.clone();
let specs = driver.agent.specs();
driver.exit().await;
assert_eq!(specs[0].access, Access::Direct);
assert!(conductor.starts().is_empty());
for checked in [
"[captain]\nkind = \"checked\"\n",
"[captain]\nreview = true\n",
] {
let driver = TestDriver::start_with(captain(checked)).await;
driver.send(slash.clone()).await;
let conductor = driver.conductor.clone();
let deadline = tokio::time::Instant::now() + WAIT;
while conductor.starts().is_empty() {
assert!(tokio::time::Instant::now() < deadline, "{checked}");
tokio::time::sleep(Duration::from_millis(5)).await;
}
let start = conductor.starts().remove(0);
assert_eq!((start.mode, start.forced), (RunMode::Captain, true));
assert!(driver.agent.specs().is_empty());
driver.exit().await;
}
}
fn asking_controls(driver: &TestDriver) -> Vec<SessionControl> {
driver
.agent
.controls()
.into_iter()
.map(|(_, control)| control)
.filter(|control| matches!(control, SessionControl::Asking | SessionControl::Answered))
.collect()
}
#[tokio::test]
async fn a_question_waiting_on_you_holds_the_turns_inactivity_clock() {
let mut driver = TestDriver::start_with(captain_workflow_options(SUPERVISED)).await;
driver
.agent
.push(FakeSession::completed(ANSWER).with_delay(Duration::from_secs(2)));
driver.submit(TASK).await;
let spec = first_spec(&driver).await;
let command = Ask::Permission {
tool_name: "Bash".into(),
input: json!({"command": "python -m pytest -q"}),
tool_use_id: None,
};
let answered = tokio::spawn({
let spec = spec.clone();
async move { ask(&spec, command).await }
});
let question = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Permission))
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
driver.send(choose(choice.id, "yes")).await;
assert!(answered.await.unwrap().text.contains("\"allow\""));
driver.until(turn_ended).await;
assert_eq!(
asking_controls(&driver),
[SessionControl::Asking, SessionControl::Answered]
);
driver.exit().await;
}
#[tokio::test]
async fn supervised_asks_like_the_cli_and_remembers_what_you_allow() {
let mut driver = TestDriver::start_with(captain_workflow_options(SUPERVISED)).await;
driver
.agent
.push(FakeSession::completed(ANSWER).with_delay(Duration::from_secs(2)));
driver.submit(TASK).await;
let spec = first_spec(&driver).await;
let asking = spec.asking.clone().expect("supervised asks");
assert_eq!(asking.asks, Asks::LikeCli);
let edit = Ask::Permission {
tool_name: "Edit".into(),
input: json!({"file_path": "invoice/money.py", "old_string": "sign = 1", "new_string": "x"}),
tool_use_id: Some("t1".into()),
};
let answered = tokio::spawn({
let spec = spec.clone();
let edit = edit.clone();
async move { ask(&spec, edit).await }
});
let question = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Permission))
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
assert_eq!(choice.title, "Make this edit to invoice/money.py?");
assert_eq!(choice.body, ["- sign = 1", "+ x"]);
driver.send(choose(choice.id, "always")).await;
let reply = answered.await.unwrap();
assert!(reply.text.contains("\"allow\""), "{reply:?}");
let again = ask(&spec, edit).await;
assert!(again.text.contains("\"allow\""));
let command = Ask::Permission {
tool_name: "Bash".into(),
input: json!({"command": "python -m pytest -q"}),
tool_use_id: None,
};
let answered = tokio::spawn({
let spec = spec.clone();
async move { ask(&spec, command).await }
});
let question = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Permission))
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
assert_eq!(choice.title, "Run this command?");
assert!(
choice.options[1].label.contains("python -m pytest"),
"{:?}",
choice.options
);
driver.send(choose(choice.id, "always")).await;
assert!(answered.await.unwrap().text.contains("\"allow\""));
let local = driver.repo.root().join(".argonauts/config.local.toml");
let saved = std::fs::read_to_string(&local).unwrap();
assert!(saved.contains("python -m pytest *"), "{saved}");
let fetch = Ask::Permission {
tool_name: "WebFetch".into(),
input: json!({"url": "https://example.com"}),
tool_use_id: None,
};
let answered = tokio::spawn({
let spec = spec.clone();
async move { ask(&spec, fetch).await }
});
let question = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Permission))
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
assert_eq!(choice.title, "Let Claude Code use WebFetch?");
driver
.send(UiCommand::SetAside {
to: AnswerTarget::Choice { id: choice.id },
})
.await;
assert!(answered.await.unwrap().text.contains("\"deny\""));
driver
.until(|event| {
matches!(
conversation(event),
Some(ConversationEvent::TurnCancelled { .. })
)
})
.await;
driver.exit().await;
}
fn permission(tool: &str, input: Value) -> Ask {
Ask::Permission {
tool_name: tool.into(),
input,
tool_use_id: None,
}
}
#[tokio::test]
async fn autopilot_coming_on_answers_the_open_question_and_the_ones_after() {
let mut driver = TestDriver::start_with(captain_workflow_options(SUPERVISED)).await;
driver
.agent
.push(FakeSession::completed(ANSWER).with_delay(Duration::from_secs(2)));
driver.submit(TASK).await;
let spec = first_spec(&driver).await;
let edit = permission(
"Edit",
json!({"file_path": "invoice/money.py", "old_string": "sign = 1", "new_string": "x"}),
);
let answered = tokio::spawn({
let spec = spec.clone();
async move { ask(&spec, edit).await }
});
let question = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Permission))
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
driver
.send(UiCommand::ApprovalMode {
mode: ApprovalMode::Autopilot,
})
.await;
let reply = tokio::time::timeout(WAIT, answered)
.await
.expect("the open question was answered")
.unwrap();
assert!(reply.text.contains("\"allow\""), "{reply:?}");
let closed = driver
.until(|event| matches!(event, AppEvent::ChoiceClosed { id, .. } if id == &choice.id))
.await;
assert!(
matches!(&closed, AppEvent::ChoiceClosed { outcome, .. }
if outcome == "Decided on autopilot · yes to the edit to invoice/money.py"),
"{closed:?}"
);
let tests = permission("Bash", json!({"command": "python -m pytest -q"}));
assert!(ask(&spec, tests).await.text.contains("\"allow\""));
let removal = permission("Bash", json!({"command": "rm -rf $BUILD_DIR/"}));
let refused = ask(&spec, removal).await;
assert!(refused.text.contains("\"deny\""), "{refused:?}");
assert!(refused.text.contains("Carry on without it"), "{refused:?}");
let own_files = permission(
"Edit",
json!({"file_path": ".argonauts/config.toml", "old_string": "a", "new_string": "b"}),
);
assert!(ask(&spec, own_files).await.text.contains("\"deny\""));
let ended = driver.until(turn_ended).await;
assert!(
matches!(
conversation(&ended),
Some(ConversationEvent::TurnFinished { .. })
),
"{ended:?}"
);
let events = driver.exit().await;
let asked = events
.iter()
.filter(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Permission))
.count();
assert_eq!(asked, 1, "nothing was asked once autopilot was on");
}
#[tokio::test]
async fn auto_mode_asks_nothing() {
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
driver.agent.push(FakeSession::completed(ANSWER));
driver.submit(TASK).await;
driver.until(turn_ended).await;
let spec = driver.agent.specs().remove(0);
let events = driver.exit().await;
assert!(
spec.asking
.as_ref()
.is_none_or(|asking| asking.asks == Asks::Never),
"{:?}",
spec.asking
);
assert!(
!events
.iter()
.any(|event| matches!(event, AppEvent::Choice { .. })),
);
}
#[tokio::test]
async fn autopilot_asks_nothing() {
let mut driver = TestDriver::start_with(captain_workflow_options(
"[approvals]\nautopilot = true\n[captain]\nasks = \"like_cli\"\n",
))
.await;
driver.agent.push(FakeSession::completed(ANSWER));
driver.submit(TASK).await;
driver.until(turn_ended).await;
let spec = driver.agent.specs().remove(0);
let events = driver.exit().await;
assert!(
spec.asking
.as_ref()
.is_none_or(|asking| asking.asks == Asks::Never),
"{:?}",
spec.asking
);
assert!(
!events
.iter()
.any(|event| matches!(event, AppEvent::Choice { .. })),
);
}
#[tokio::test]
async fn running_out_hands_the_turn_to_the_next_agent() {
let mut options = captain_workflow_options("");
options.flags = vec![r#"fallback.main.order=["codex"]"#.to_owned()];
let mut driver = TestDriver::start_with(options).await;
let codex = driver
.directory
.add(FakeRunner::cli("codex", "openai").then(FakeSession::completed(ANSWER)));
driver.agent.respond(|spec: &SessionSpec| {
std::fs::write(spec.workdir.join("invoice/money.py"), "half\n").unwrap();
FakeSession::ended(EndReason::UsageLimit { resets_at: None })
});
driver.submit(TASK).await;
let ended = driver.until(turn_ended).await;
let events = driver.exit().await;
assert!(matches!(
conversation(&ended),
Some(ConversationEvent::TurnFinished { reply: TurnReply::Answer { markdown }, .. }) if markdown == ANSWER
));
let handed = events.iter().find_map(|event| match conversation(event) {
Some(ConversationEvent::FellBack { from, to, told, .. }) => Some((from, to, told)),
_ => None,
});
let (from, to, told) = handed.expect("a fallback line");
assert_eq!(
(from.agent.as_str(), to.agent.as_str()),
("claude-code", "codex")
);
assert!(told.contains("invoice/money.py changed"), "{told}");
let spec = codex.specs().remove(0);
assert_eq!(spec.access, Access::Direct);
let note: String = spec
.prompt
.iter()
.filter(|part| part.kind == InputPartKind::Fallback)
.map(|part| part.text.clone())
.collect();
assert!(note.contains("invoice/money.py"), "{note}");
let started = events
.iter()
.filter(|event| {
matches!(
conversation(event),
Some(ConversationEvent::TurnStarted { .. })
)
})
.count();
assert_eq!(started, 1, "the turn goes on, it doesn't start again");
}
#[tokio::test]
async fn a_signed_out_cli_falls_back_and_says_how_to_sign_in() {
let mut options = captain_workflow_options("");
options.flags = vec![r#"fallback.main.order=["codex"]"#.to_owned()];
let mut driver = TestDriver::start_with(options).await;
driver
.directory
.add(FakeRunner::cli("codex", "openai").then(FakeSession::completed(ANSWER)));
driver.agent.push(FakeSession::ended(EndReason::Auth {
message: "Login expired · Please run /login".into(),
}));
driver.submit(TASK).await;
driver.until(turn_ended).await;
let events = driver.exit().await;
let fell_back = events.iter().find_map(|event| match conversation(event) {
Some(ConversationEvent::FellBack {
why, signed_out, ..
}) => Some((why, signed_out)),
_ => None,
});
let (why, signed_out) = fell_back.expect("a fallback");
assert_eq!(why, "is signed out (its sign-in expired)");
assert_eq!(
signed_out,
&Some(argonauts_core::app::SignedOut {
why: "its sign-in expired".into(),
login: Some("claude /login".into()),
})
);
}
#[tokio::test]
async fn with_fallback_off_the_turn_ends_and_says_what_changed() {
let mut options = captain_workflow_options("[fallback]\nmode = \"wait\"\n");
options.flags = vec![r#"fallback.main.order=["codex"]"#.to_owned()];
let mut driver = TestDriver::start_with(options).await;
let codex = driver.directory.add(FakeRunner::cli("codex", "openai"));
driver.agent.respond(|spec: &SessionSpec| {
std::fs::write(spec.workdir.join("invoice/money.py"), "half\n").unwrap();
FakeSession::ended(EndReason::UsageLimit { resets_at: None })
});
driver.submit(TASK).await;
driver.until(turn_ended).await;
let stopped = driver
.until(
|event| matches!(event, AppEvent::Notice { text, .. } if text.starts_with("Stopped")),
)
.await;
driver.exit().await;
assert!(codex.specs().is_empty());
let AppEvent::Notice { text, .. } = stopped else {
unreachable!()
};
assert_eq!(
text,
"Stopped · invoice/money.py (+1 -1) changed · /rewind puts it back"
);
}
#[tokio::test]
async fn a_looping_turn_is_stopped_with_a_line_and_no_fallback() {
let mut options = captain_workflow_options("");
options.flags = vec![r#"fallback.main.order=["codex"]"#.to_owned()];
let mut driver = TestDriver::start_with(options).await;
let root = driver.repo.root().to_owned();
let codex = driver
.directory
.add(FakeRunner::cli("codex", "openai").then(FakeSession::completed(ANSWER)));
driver.agent.respond(|spec: &SessionSpec| {
std::fs::write(spec.workdir.join("invoice/money.py"), "half\n").unwrap();
FakeSession::ended(EndReason::Stuck {
watchdog: Watchdog::Repeat,
calls: Some(CallCount {
name: "execute".into(),
times: 12,
}),
})
});
driver.submit(TASK).await;
let ended = driver
.until(|event| {
matches!(
conversation(event),
Some(ConversationEvent::TurnStopped { .. } | ConversationEvent::TurnFailed { .. })
)
})
.await;
let kept = driver
.until(|event| matches!(event, AppEvent::Notice { text, .. } if text.contains("/rewind")))
.await;
let spec = driver.agent.specs().remove(0);
let money = read(&root, "invoice/money.py");
let events = driver.exit().await;
assert_eq!(money, "half\n", "its edit stays");
assert_eq!(spec.limits.repeat_limit, Some(10));
assert_eq!(spec.limits.inactivity_min, Some(15.0));
let Some(ConversationEvent::TurnStopped { why, .. }) = conversation(&ended) else {
panic!("the turn ends stopped, not failed: {ended:?}");
};
assert_eq!(
why,
"Stopped: Opus 5.5 repeated the same execute call 12 times. Its edits stay in your \
checkout."
);
assert!(matches!(
kept,
AppEvent::Notice { text, .. } if text == "invoice/money.py (+1 -1) changed · /rewind puts it back"
));
assert!(codex.specs().is_empty(), "no fallback");
let fell_back = events.iter().any(|event| {
matches!(
conversation(event),
Some(ConversationEvent::FellBack { .. })
)
});
assert!(!fell_back);
}
#[tokio::test]
async fn a_turn_names_the_files_it_left_untracked() {
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
let root = driver.repo.root().to_owned();
std::fs::write(root.join("notes.txt"), "yours\n").unwrap();
driver.agent.respond(|spec: &SessionSpec| {
std::fs::write(spec.workdir.join("invoice/money.py"), "negative = True\n").unwrap();
std::fs::write(spec.workdir.join("test_money_fix.py"), "print(1)\n").unwrap();
FakeSession::completed(ANSWER)
});
driver.submit(TASK).await;
driver.until(turn_ended).await;
let left = driver
.until(|event| matches!(event, AppEvent::Notice { text, .. } if text.starts_with("Left")))
.await;
driver.exit().await;
assert!(matches!(
left,
AppEvent::Notice { text, .. } if text == "Left untracked: test_money_fix.py"
));
}
#[tokio::test]
async fn rewind_puts_back_a_direct_turns_files() {
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
driver.agent.respond(fixing(Duration::ZERO));
let root = driver.repo.root().to_owned();
driver.submit(TASK).await;
driver.until(turn_ended).await;
assert_eq!(read(&root, "invoice/money.py"), "negative = True\n");
driver
.send(UiCommand::Slash {
command: SlashCommand::Rewind { task: None },
})
.await;
let question = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::RewindTurn))
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
assert_eq!(choice.options.len(), 1);
let AnswerAction::Choose { value } = &choice.options[0].action else {
panic!("{:?}", choice.options[0])
};
driver.send(choose(choice.id, value)).await;
let closed = driver
.until(|event| matches!(event, AppEvent::ChoiceClosed { .. }))
.await;
assert!(
matches!(&closed, AppEvent::ChoiceClosed { outcome, .. } if outcome == "Put back invoice/money.py"),
"{closed:?}"
);
assert_eq!(read(&root, "invoice/money.py"), "sign = 1\n");
driver.exit().await;
}
#[tokio::test]
async fn agys_refused_commands_are_offered_after_its_answer() {
let mut driver = TestDriver::start_with(captain_workflow_options(
"crew.main = \"antigravity-cli\"\n[approvals.understand]\nmode = \"supervised\"\n",
))
.await;
let refused =
FakeSession::completed("I couldn't run the tests: running a command needs approval.")
.with_event(tool(
"s2",
"run_command",
json!({"CommandLine": "python -m pytest -q"}),
))
.with_event(RunnerEvent::Agent(AgentEvent::ToolFinished {
id: "s2".into(),
ok: false,
output: Some(
"permission check failed for unsandboxed \"python -m pytest -q\"".into(),
),
parent: None,
}))
.with_cli_session("conv-1");
let agy = driver.directory.add(
FakeRunner::cli("antigravity-cli", "google")
.then(refused)
.then(FakeSession::completed("6 passed.")),
);
driver.submit(TASK).await;
driver.until(turn_ended).await;
let question = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::RefusedCommands))
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
assert_eq!(choice.title, "Let agy run what it was refused?");
assert_eq!(choice.body, ["python -m pytest -q"]);
assert_eq!(
agy.specs()[0].asking.as_ref().map(|a| a.asks),
Some(Asks::LikeCli)
);
driver.send(choose(choice.id, "continue")).await;
driver.until(turn_ended).await;
driver.exit().await;
let resumed = agy.specs().remove(1);
assert_eq!(resumed.access, Access::Direct);
assert_eq!(resumed.resume.as_deref(), Some("conv-1"));
assert!(
resumed
.asking
.as_ref()
.is_none_or(|a| a.asks == Asks::Never)
);
assert_eq!(
resumed.prompt[0].text,
"You may run the commands you were refused."
);
}
fn main_tool(name: &str, arguments: Value) -> Ask {
Ask::Tool {
name: name.to_owned(),
arguments,
}
}
const START_WORKFLOW: &str = "argonauts_start_workflow";
#[tokio::test]
async fn in_captain_the_main_session_starts_a_run_only_when_you_asked_for_one() {
let driver = TestDriver::start_with(captain_workflow_options("")).await;
driver.directory.taking_tools("claude-code");
driver.directory.add(FakeRunner::cli("codex", "openai"));
driver
.directory
.add(FakeRunner::cli("antigravity-cli", "google"));
driver
.agent
.push(FakeSession::completed(ANSWER).with_delay(Duration::from_secs(3)));
let typed = "Fix the rounding in invoice/money.py. Have grok build it and gemini review it, \
and merge without asking me.";
driver.submit(typed).await;
let spec = first_spec(&driver).await;
let asking = spec.asking.as_ref().expect("the turn listens");
assert_eq!(
asking.tools,
[
START_WORKFLOW,
"argonauts_set_goal",
"argonauts_setup",
"argonauts_guide",
"argonauts_lookup"
]
);
let standing = asking.standing.as_deref().expect("standing lines");
assert!(
standing.contains("`argonauts_start_workflow`"),
"{standing}"
);
assert_eq!(spec.prompt[0].text, typed);
let setup = &spec.prompt.last().expect("the setup note").text;
assert!(
setup.contains("Argonauts Code setup: Captain is in use"),
"{setup}"
);
for how_to_run in [
vec![json!({"says": "use the whole crew", "kind": "crew"})],
vec![json!({"says": "the", "kind": "mode", "value": "crew"})],
] {
let unasked = ask(
&spec,
main_tool(
START_WORKFLOW,
json!({
"request": "Delete tests/test_money.py and rewrite the module from scratch",
"how_to_run": how_to_run,
}),
),
)
.await;
assert!(
unasked.text.starts_with("No workflow started."),
"{}",
unasked.text
);
assert!(
unasked.text.contains("What came of each instruction"),
"{}",
unasked.text
);
}
assert!(driver.conductor.starts().is_empty());
let asking = spec.clone();
let asked = tokio::spawn(async move {
let tool = main_tool(
START_WORKFLOW,
json!({
"request": "Fix the rounding in invoice/money.py, where the sign comes late",
"how_to_run": [
{"says": "have grok build it", "kind": "build", "who": "grok"},
{"says": "gemini review it", "kind": "review", "who": "gemini"},
{"says": "merge without asking me", "kind": "merge"},
],
}),
);
ask(&asking, tool).await
});
let run = super::main_session_tests::followed_run(&driver).await;
let started = super::main_session_tests::agent_started(&run, 1);
driver.conductor.push(&run, started).await;
let asked = asked.await.unwrap();
assert!(
asked
.text
.starts_with("A captain run started on this, as the user asked."),
"{asked:?}"
);
let conductor = driver.conductor.clone();
let deadline = tokio::time::Instant::now() + WAIT;
while conductor.starts().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(5)).await;
}
let start = conductor.starts().remove(0);
assert_eq!(
start.request,
format!(
"{typed}\n\nFrom the conversation, as the main agent put it: Fix the rounding in \
invoice/money.py, where the sign comes late"
)
);
assert_eq!(start.mode, RunMode::Captain);
assert!(
start
.captain_or_crew
.as_ref()
.is_some_and(|t| t.as_you_asked())
);
let outcome = |says: &str| {
start
.instructions
.iter()
.find(|instruction| instruction.says == says)
.map(|instruction| instruction.outcome.clone())
.unwrap_or_else(|| panic!("no {says:?} in {:#?}", start.instructions))
};
assert!(matches!(
outcome("gemini review it"),
InstructionOutcome::Followed { ref said, .. } if said == "agy reviews"
));
assert!(matches!(
outcome("have grok build it"),
InstructionOutcome::NotFollowed { ref why } if why.contains("isn't installed")
|| why.starts_with("no agent here is called grok")
));
assert!(matches!(
outcome("merge without asking me"),
InstructionOutcome::NotFollowed { ref why } if why.contains("is a safety setting")
));
assert!(
start
.request_settings
.iter()
.any(|setting| setting == "roles.reviewer.models=[\"antigravity-cli\"]"),
"{:?}",
start.request_settings
);
assert!(
start
.request_settings
.iter()
.any(|setting| setting == "captain.review=true")
);
let again = ask(
&spec,
main_tool(
START_WORKFLOW,
json!({
"request": "",
"how_to_run": [{"says": "gemini review it", "kind": "review", "who": "gemini"}],
}),
),
)
.await;
assert!(
again.text.starts_with("A workflow already started"),
"{again:?}"
);
let unknown = ask(&spec, main_tool("argonauts_nothing", json!({}))).await;
assert!(unknown.is_error, "{unknown:?}");
assert_eq!(driver.conductor.starts().len(), 1);
driver.exit().await;
}
#[tokio::test]
async fn in_captain_a_reviewer_you_name_is_told_to_the_session_that_can_start_a_run() {
let told = |spec: &SessionSpec| {
spec.prompt
.iter()
.any(|part| part.text.contains("own words name who reviews"))
};
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
driver.directory.taking_tools("claude-code");
driver.directory.add(FakeRunner::cli("codex", "openai"));
driver.agent.push(FakeSession::completed(ANSWER));
driver
.submit("Fix the money rounding in invoice/money.py with Codex as the reviewer.")
.await;
let spec = first_spec(&driver).await;
assert_eq!(
spec.access,
Access::Direct,
"the session still gets the turn"
);
assert!(told(&spec), "{:?}", spec.prompt);
driver.until(turn_ended).await;
driver.exit().await;
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
driver.directory.taking_tools("claude-code");
driver.directory.add(FakeRunner::cli("codex", "openai"));
driver.agent.push(FakeSession::completed(ANSWER));
driver
.submit("Fix the money rounding in invoice/money.py.")
.await;
let spec = first_spec(&driver).await;
assert!(!told(&spec), "{:?}", spec.prompt);
driver.until(turn_ended).await;
driver.exit().await;
}
#[tokio::test]
async fn in_captain_a_workflow_with_codex_as_the_reviewer_starts_with_codex_reviewing() {
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
driver.directory.taking_tools("claude-code");
driver.directory.add(FakeRunner::cli("codex", "openai"));
driver
.agent
.push(FakeSession::completed(ANSWER).with_delay(Duration::from_secs(3)));
let typed = "Use a workflow for this, with Codex as the reviewer, and choose everything else \
yourself. Fix the money rounding in invoice/money.py.";
driver.submit(typed).await;
let spec = first_spec(&driver).await;
let asking = spec.clone();
let asked = tokio::spawn(async move {
let tool = main_tool(
START_WORKFLOW,
json!({
"request": "Fix the money rounding in invoice/money.py",
"how_to_run": [
{"says": "Use a workflow for this", "kind": "mode"},
{"says": "with Codex as the reviewer", "kind": "review",
"who": "reviewer", "value": "Codex"},
],
}),
);
ask(&asking, tool).await
});
let run = super::main_session_tests::followed_run(&driver).await;
let started = super::main_session_tests::agent_started(&run, 1);
driver.conductor.push(&run, started).await;
let asked = asked.await.unwrap();
assert!(
asked
.text
.contains("Starting a workflow: Captain, as Claude Code's own, with **")
&& asked.text.contains("Codex** reviewing as you asked; **")
&& asked.text.contains("Claude Code** plans and builds."),
"{asked:?}"
);
let start = driver.conductor.starts().remove(0);
assert_eq!(start.mode, RunMode::Crew);
let settings = &start.request_settings;
for wanted in [
"workflow=\"orchestrator\"",
"crew.orchestrator=\"main\"",
"roles.reviewer.models=[\"codex\"]",
] {
assert!(
settings.iter().any(|setting| setting == wanted),
"{wanted}: {settings:?}"
);
}
let builders = settings
.iter()
.rev()
.find(|setting| setting.starts_with("roles.worker.models="));
assert_eq!(
builders.map(String::as_str),
Some("roles.worker.models=[\"claude-code\"]"),
"{settings:?}"
);
let crew = driver
.until(|event| {
matches!(event, AppEvent::Notice { text, .. } if text.starts_with("Crew for this request"))
})
.await;
let AppEvent::Notice { text, .. } = crew else {
unreachable!()
};
let workers = text.lines().find(|line| line.starts_with("workers"));
assert!(
workers.is_some_and(|line| line.ends_with("your main session")),
"{text}"
);
let reviewers = text.lines().find(|line| line.starts_with("reviewers"));
assert!(
reviewers.is_some_and(|line| line.contains("Codex") && line.ends_with("as you asked")),
"{text}"
);
driver.exit().await;
}
#[tokio::test]
async fn a_captain_the_rules_pick_runs_direct_only_when_its_kind_says_so() {
let small = json!({
"task_type": "bugfix",
"summary": "Fix the rounding",
"files_likely": ["invoice/money.py"],
"file_count": 1,
"risk_labels": [],
"tests_exist": true,
"unknowns": []
});
let change = || {
FakeSession::structured(json!({
"kind": "change",
"answer_markdown": "The sign is applied too late.",
"estimate": small.clone()
}))
.with_cli_session("estimate-1")
};
let styled = |config: &'static str| CliOptions {
prepare: Some(Box::new(move |repo: &argonauts_testkit::TempRepo| {
std::fs::write(repo.global_config(), config).unwrap();
})),
..CliOptions::default()
};
let mut driver =
TestDriver::start_with(styled("[captain_or_crew]\ncaptain_kind = \"direct\"\n")).await;
driver.agent.push(change());
driver.agent.push(FakeSession::completed(ANSWER));
driver.submit(TASK).await;
driver.until(turn_ended).await;
driver.until(turn_ended).await;
let conductor = driver.conductor.clone();
let specs = driver.agent.specs();
let seen = driver.exit().await;
assert!(conductor.starts().is_empty());
assert_eq!(specs[1].access, Access::Direct);
assert_eq!(specs[1].resume.as_deref(), Some("estimate-1"));
assert_eq!(specs[1].prompt[0].text, "Go ahead.");
let started: Vec<(String, bool)> = seen
.iter()
.filter_map(|event| match conversation(event) {
Some(ConversationEvent::TurnStarted {
text, continues, ..
}) => Some((text.clone(), *continues)),
_ => None,
})
.collect();
assert_eq!(started, [(TASK.to_owned(), false), (TASK.to_owned(), true)]);
assert!(seen.iter().any(|event| matches!(
conversation(event),
Some(ConversationEvent::TurnFinished {
reply: TurnReply::Change { captain_or_crew, .. },
..
}) if captain_or_crew.captain == Some(argonauts_core::settings::protocol::CaptainKind::Direct)
)));
let mut driver = TestDriver::start().await;
driver.agent.push(change());
driver.submit(TASK).await;
driver.until(turn_ended).await;
let conductor = driver.conductor.clone();
let deadline = tokio::time::Instant::now() + WAIT;
while conductor.starts().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(conductor.starts()[0].mode, RunMode::Captain);
assert_eq!(driver.agent.specs().len(), 1);
driver.exit().await;
}
#[tokio::test]
async fn a_cli_command_in_a_direct_conversation_works_in_your_checkout() {
use argonauts_core::cli_commands::{CliCommand, CommandKind, CommandSource};
let (mut parts, _) =
crate::controller::test_support::TestCtx::new(captain_workflow_options(""));
parts.agent.push(FakeSession::completed("Wrote CLAUDE.md."));
let mut conversation = super::ConversationState::new(&parts.ctx).unwrap();
let init = CliCommand {
name: "init".into(),
aliases: Vec::new(),
args: String::new(),
help: "Write CLAUDE.md".into(),
kind: CommandKind::Cli,
source: CommandSource::BuiltIn,
};
super::cli_command(&mut parts.ctx, &mut conversation, &init, "", "/init".into()).unwrap();
let deadline = tokio::time::Instant::now() + WAIT;
while parts.agent.specs().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(5)).await;
}
let spec = parts.agent.specs().remove(0);
assert_eq!(spec.access, Access::Direct);
assert_eq!(spec.prompt.len(), 1);
assert_eq!(spec.prompt[0].text, "/init");
}
#[tokio::test]
async fn every_change_with_codex_runs_in_a_checked_worktree() {
let mut driver = TestDriver::start_with(captain_workflow_options(
"crew.main = \"codex\"\n[approvals.understand]\nmode = \"supervised\"\n[captain]\nasks = \"every_change\"\n",
))
.await;
let codex = driver.directory.add(FakeRunner::cli("codex", "openai"));
driver.submit(TASK).await;
let notice = driver
.until(
|event| matches!(event, AppEvent::Notice { text, .. } if text.contains("checked worktree")),
)
.await;
let conductor = driver.conductor.clone();
let deadline = tokio::time::Instant::now() + WAIT;
while conductor.starts().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(5)).await;
}
driver.exit().await;
assert!(matches!(
notice,
AppEvent::Notice { text, .. }
if text == "Codex can't ask before each edit in a headless run, so this runs in a checked worktree."
));
assert_eq!(conductor.starts()[0].mode, RunMode::Captain);
assert!(codex.specs().is_empty());
}
#[tokio::test]
async fn esc_keeps_the_edits_and_says_how_to_put_them_back() {
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
driver.agent.respond(|spec: &SessionSpec| {
std::fs::write(spec.workdir.join("invoice/money.py"), "half\n").unwrap();
FakeSession::until_stopped(vec![tool(
"t1",
"Edit",
json!({"file_path": "invoice/money.py"}),
)])
});
driver.submit(TASK).await;
first_spec(&driver).await;
driver
.until(|event| matches!(conversation(event), Some(ConversationEvent::Agent { .. })))
.await;
driver.send(UiCommand::CancelTurn).await;
driver
.until(|event| {
matches!(
conversation(event),
Some(ConversationEvent::TurnCancelled { .. })
)
})
.await;
let line = driver
.until(|event| matches!(event, AppEvent::Notice { text, .. } if text.contains("/rewind")))
.await;
let root = driver.repo.root().to_owned();
assert_eq!(read(&root, "invoice/money.py"), "half\n");
driver.exit().await;
assert!(matches!(
line,
AppEvent::Notice { text, .. } if text == "invoice/money.py (+1 -1) changed · /rewind puts it back"
));
}
#[tokio::test]
async fn plan_first_in_direct_captain_changes_nothing_until_you_go_ahead() {
for by_command in [true, false] {
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
let root = driver.repo.root().to_owned();
driver.agent.respond(|spec: &SessionSpec| {
if spec.access == Access::Direct {
std::fs::write(spec.workdir.join("invoice/money.py"), "negative = True\n").unwrap();
FakeSession::completed(ANSWER)
} else {
FakeSession::completed("1. Read the sign first.\n2. Apply it once.")
.with_cli_session("plan-1")
}
});
if by_command {
driver
.send(UiCommand::Slash {
command: SlashCommand::Plan {
task: Some(TASK.into()),
},
})
.await;
} else {
driver
.send(UiCommand::Slash {
command: SlashCommand::Plan { task: None },
})
.await;
driver.submit(TASK).await;
}
let question = driver
.until(|event| {
matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::GoAhead)
})
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
assert_eq!(choice.title, "Go ahead with this plan?");
let specs = driver.agent.specs();
assert_eq!(specs.len(), 1, "{by_command}");
assert_eq!(specs[0].access, Access::ReadOnly);
assert!(
specs[0]
.prompt
.iter()
.any(|part| part.text.contains("Plan first is on")),
"{:?}",
specs[0].prompt
);
assert_eq!(read(&root, "invoice/money.py"), "sign = 1\n");
assert!(driver.conductor.starts().is_empty());
driver.send(choose(choice.id, "yes")).await;
driver
.until(|event| {
matches!(
conversation(event),
Some(ConversationEvent::TurnFinished {
reply: TurnReply::Answer { markdown },
..
}) if markdown == ANSWER
)
})
.await;
let specs = driver.agent.specs();
assert_eq!(specs[1].access, Access::Direct);
assert_eq!(specs[1].resume.as_deref(), Some("plan-1"));
assert_eq!(specs[1].prompt[0].text, "Go ahead with this plan.");
assert_eq!(read(&root, "invoice/money.py"), "negative = True\n");
assert!(driver.conductor.starts().is_empty());
driver.exit().await;
}
}
#[tokio::test]
async fn running_out_asks_who_carries_it_on_when_it_should() {
use std::sync::Arc;
use argonauts_core::registry::{ModelEntry, ModelRegistry};
use argonauts_testkit::services::StaticRegistry;
let api_key = || -> Arc<dyn ModelRegistry> {
let registry = StaticRegistry::default();
registry.add(
serde_json::from_value::<ModelEntry>(json!({
"id": "codex",
"provider": "openai",
"kind": "cli",
"access": "api-key",
"cost": { "unit": "usd", "input_per_mtok": 1.0, "output_per_mtok": 4.0 },
}))
.unwrap(),
);
Arc::new(registry)
};
for (config, registry, pick) in [
("[fallback]\nmode = \"ask\"\n", None, "codex"),
("", Some(api_key()), "stop"),
] {
let mut options = captain_workflow_options(config);
options.flags = vec![r#"fallback.main.order=["codex"]"#.to_owned()];
options.registry = registry;
let mut driver = TestDriver::start_with(options).await;
let codex = driver
.directory
.add(FakeRunner::cli("codex", "openai").then(FakeSession::completed(ANSWER)));
driver.agent.respond(|spec: &SessionSpec| {
std::fs::write(spec.workdir.join("invoice/money.py"), "half\n").unwrap();
FakeSession::ended(EndReason::UsageLimit { resets_at: None })
});
driver.submit(TASK).await;
let question = driver
.until(|event| {
matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Fallback)
})
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
assert!(
choice.title.ends_with("Who carries it on?"),
"{}",
choice.title
);
let keys: Vec<char> = choice.options.iter().map(|option| option.key).collect();
assert_eq!(keys, ['1', 's'], "{pick}");
assert!(codex.specs().is_empty(), "{pick}");
if registry_is_paid(&choice) {
assert_eq!(
choice.options[0].detail.as_deref(),
Some("an API key, billed to your account")
);
}
let value = match pick {
"stop" => "stop".to_owned(),
_ => "codex".to_owned(),
};
driver.send(choose(choice.id, &value)).await;
let ended = driver.until(turn_ended).await;
driver.exit().await;
match pick {
"stop" => {
assert!(matches!(
conversation(&ended),
Some(ConversationEvent::TurnFailed { .. })
));
assert!(codex.specs().is_empty());
}
_ => {
assert!(matches!(
conversation(&ended),
Some(ConversationEvent::TurnFinished { reply: TurnReply::Answer { markdown }, .. })
if markdown == ANSWER
));
assert_eq!(codex.specs()[0].access, Access::Direct);
}
}
}
}
fn registry_is_paid(choice: &argonauts_core::app::AppChoice) -> bool {
choice
.options
.first()
.and_then(|option| option.detail.as_deref())
.is_some_and(|detail| detail.contains("API key"))
}
#[tokio::test]
async fn a_shell_command_that_writes_argonauts_own_files_is_put_back_too() {
let options = CliOptions {
prepare: Some(Box::new(|repo: &argonauts_testkit::TempRepo| {
repo.write(".argonauts/config.toml", "workflow = \"captain\"\n")
.unwrap();
repo.write(".argonauts/notes.md", "mine\n").unwrap();
repo.commit_all("start").unwrap();
std::fs::write(
repo.global_config(),
"[captain_or_crew]\nforce = \"captain\"\n[sandbox]\nmode = \"tool\"\n",
)
.unwrap();
})),
..CliOptions::default()
};
let mut driver = TestDriver::start_with(options).await;
let root = driver.repo.root().to_owned();
driver.agent.respond(|spec: &SessionSpec| {
let root = &spec.workdir;
std::fs::write(root.join(".argonauts/config.toml"), "workflow = \"x\"\n").unwrap();
std::fs::write(root.join(".argonauts/notes.md"), "a script's\n").unwrap();
FakeSession::completed(ANSWER).with_event(tool(
"t1",
"Bash",
json!({"command": "cd .argonauts && sed -i '' s/captain/x/ config.toml"}),
))
});
driver.submit("Point the config at x.").await;
driver.until(turn_ended).await;
driver
.until(|event| {
matches!(event, AppEvent::Notice { text, .. }
if text.starts_with("This turn edited .argonauts/config.toml, which only a setup"))
})
.await;
assert_eq!(
read(&root, ".argonauts/config.toml"),
"workflow = \"captain\"\n"
);
assert_eq!(read(&root, ".argonauts/notes.md"), "a script's\n");
driver.exit().await;
}
#[tokio::test]
async fn an_edit_to_argonauts_own_files_goes_back_when_our_sandbox_is_off() {
let options = CliOptions {
prepare: Some(Box::new(|repo: &argonauts_testkit::TempRepo| {
repo.write(".argonauts/notes.md", "mine\n").unwrap();
repo.write("app.py", "print(1)\n").unwrap();
repo.commit_all("start").unwrap();
std::fs::write(
repo.global_config(),
"[captain_or_crew]\nforce = \"captain\"\n[sandbox]\nmode = \"tool\"\n",
)
.unwrap();
})),
..CliOptions::default()
};
let mut driver = TestDriver::start_with(options).await;
let root = driver.repo.root().to_owned();
driver.agent.respond(|spec: &SessionSpec| {
let root = &spec.workdir;
std::fs::write(root.join(".argonauts/notes.md"), "the model's\n").unwrap();
std::fs::write(root.join("app.py"), "print(2)\n").unwrap();
FakeSession::completed(ANSWER)
.with_event(tool(
"t1",
"Write",
json!({"file_path": ".argonauts/notes.md"}),
))
.with_event(changed(".argonauts/notes.md"))
.with_event(tool("t2", "Edit", json!({"file_path": "app.py"})))
.with_event(changed("app.py"))
});
driver.submit("Print 2, and note it.").await;
driver.until(turn_ended).await;
let told = driver
.until(|event| {
matches!(event, AppEvent::Notice { text, .. }
if text.starts_with("This turn edited .argonauts/notes.md, which only a setup changes"))
})
.await;
assert!(matches!(told, AppEvent::Notice { .. }));
assert_eq!(read(&root, ".argonauts/notes.md"), "mine\n");
assert_eq!(read(&root, "app.py"), "print(2)\n");
driver.exit().await;
}
#[tokio::test]
async fn rewind_lists_the_ignored_files_a_turn_wrote_and_leaves_them() {
let options = CliOptions {
prepare: Some(Box::new(|repo: &argonauts_testkit::TempRepo| {
repo.write(".gitignore", ".env\nconfig/local.toml\n")
.unwrap();
repo.write("app.py", "print(1)\n").unwrap();
repo.commit_all("start").unwrap();
repo.write(".env", "TOKEN=yours\n").unwrap();
std::fs::write(
repo.global_config(),
"[captain_or_crew]\nforce = \"captain\"\n",
)
.unwrap();
})),
..CliOptions::default()
};
let mut driver = TestDriver::start_with(options).await;
let root = driver.repo.root().to_owned();
driver.agent.respond(|spec: &SessionSpec| {
let root = &spec.workdir;
std::fs::write(root.join(".env"), "TOKEN=changed-by-agent\n").unwrap();
std::fs::create_dir_all(root.join("config")).unwrap();
std::fs::write(root.join("config/local.toml"), "debug = true\n").unwrap();
std::fs::write(root.join("app.py"), "print(2)\n").unwrap();
let env = root.join(".env").to_string_lossy().into_owned();
FakeSession::completed(ANSWER)
.with_event(tool("t1", "Write", json!({"file_path": env})))
.with_event(changed(&env))
.with_event(tool(
"t2",
"Write",
json!({"file_path": "config/local.toml"}),
))
.with_event(changed("config/local.toml"))
.with_event(tool("t3", "Edit", json!({"file_path": "app.py"})))
.with_event(changed("app.py"))
});
driver.submit("Turn on debug and print 2.").await;
driver.until(turn_ended).await;
driver
.send(UiCommand::Slash {
command: SlashCommand::Rewind { task: None },
})
.await;
let question = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::RewindTurn))
.await;
let AppEvent::Choice { choice } = question else {
unreachable!()
};
assert_eq!(choice.options[0].detail.as_deref(), Some("3 files"));
let AnswerAction::Choose { value } = &choice.options[0].action else {
panic!("{:?}", choice.options[0])
};
driver.send(choose(choice.id, value)).await;
let closed = driver
.until(|event| matches!(event, AppEvent::ChoiceClosed { .. }))
.await;
let AppEvent::ChoiceClosed { outcome, .. } = closed else {
unreachable!()
};
assert_eq!(
outcome,
"Put back app.py · can't put back, as nothing kept how they were: .env, \
config/local.toml"
);
assert_eq!(read(&root, "app.py"), "print(1)\n");
assert_eq!(read(&root, ".env"), "TOKEN=changed-by-agent\n");
assert_eq!(read(&root, "config/local.toml"), "debug = true\n");
driver.exit().await;
}
#[tokio::test]
async fn rewind_names_the_ignored_files_each_cli_wrote() {
let writers = [
"claude-code",
"codex",
"agy write_to_file",
"agy sed_file",
"opencode",
];
let options = CliOptions {
prepare: Some(Box::new(|repo: &argonauts_testkit::TempRepo| {
repo.write(".gitignore", "notes/\n").unwrap();
repo.write("app.py", "print(1)\n").unwrap();
repo.commit_all("start").unwrap();
std::fs::write(
repo.global_config(),
"[captain_or_crew]\nforce = \"captain\"\n",
)
.unwrap();
})),
..CliOptions::default()
};
let mut driver = TestDriver::start_with(options).await;
let root = driver.repo.root().to_owned();
driver.agent.respond(move |spec: &SessionSpec| {
let root = &spec.workdir;
std::fs::write(root.join("app.py"), "print(2)\n").unwrap();
std::fs::create_dir_all(root.join("notes")).unwrap();
let session = FakeSession::completed(ANSWER)
.with_event(tool("t1", "Edit", json!({"file_path": "app.py"})))
.with_event(changed("app.py"));
writers.iter().fold(session, |session, writer| {
let path = root.join(format!("notes/{}.md", writer.replace(' ', "-")));
std::fs::write(&path, "hi\n").unwrap();
written_by(writer, &path)
.into_iter()
.fold(session, |session, event| {
session.with_event(RunnerEvent::Agent(event))
})
})
});
driver.submit("Print 2, and take notes.").await;
driver.until(turn_ended).await;
driver
.send(UiCommand::Slash {
command: SlashCommand::Rewind { task: None },
})
.await;
let AppEvent::Choice { choice } = driver
.until(|event| matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::RewindTurn))
.await
else {
unreachable!()
};
let AnswerAction::Choose { value } = &choice.options[0].action else {
panic!("{:?}", choice.options[0])
};
driver.send(choose(choice.id, value)).await;
let AppEvent::ChoiceClosed { outcome, .. } = driver
.until(|event| matches!(event, AppEvent::ChoiceClosed { .. }))
.await
else {
unreachable!()
};
assert_eq!(
outcome,
"Put back app.py · can't put back, as nothing kept how they were: \
notes/agy-sed_file.md, notes/agy-write_to_file.md, notes/claude-code.md, \
notes/codex.md, notes/opencode.md"
);
assert_eq!(read(&root, "app.py"), "print(1)\n");
driver.exit().await;
}
#[tokio::test]
async fn a_protected_file_a_turn_planted_is_set_aside() {
let mut driver = TestDriver::start_with(captain_workflow_options("")).await;
let root = driver.repo.root().to_owned();
driver.agent.respond(|spec: &SessionSpec| {
std::fs::write(
spec.workdir.join(".mcp.json"),
r#"{"mcpServers": {"x": {"command": "sh"}}}"#,
)
.unwrap();
std::fs::write(spec.workdir.join("invoice/money.py"), "negative = True\n").unwrap();
FakeSession::completed(ANSWER)
});
driver.submit(TASK).await;
let notice = driver
.until(|event| {
matches!(event, AppEvent::Notice { text, .. } if text.starts_with("This turn made .mcp.json"))
})
.await;
let AppEvent::Notice { text, .. } = notice else {
unreachable!()
};
assert!(!root.join(".mcp.json").exists());
assert_eq!(read(&root, "invoice/money.py"), "negative = True\n");
let aside = text
.split(" set aside in ")
.nth(1)
.and_then(|rest| rest.split(" where").next())
.expect("where it went");
assert!(Path::new(aside).join(".mcp.json").is_file(), "{text}");
driver.exit().await;
}
#[tokio::test]
async fn a_question_waiting_on_you_is_no_stall() {
use argonauts_core::event::ErrorCategory;
let stalled = || {
RunnerEvent::Agent(AgentEvent::Error {
category: ErrorCategory::Stalled,
message: "Claude Code has sent nothing for 30 seconds, not even its heartbeat".into(),
retryable: true,
})
};
let shows_a_stall = |events: &[AppEvent]| {
events.iter().any(|event| {
matches!(
conversation(event),
Some(ConversationEvent::Agent {
event: AgentEvent::Error {
category: ErrorCategory::Stalled,
..
},
..
})
)
})
};
for question_open in [true, false] {
let mut driver = TestDriver::start_with(captain_workflow_options(SUPERVISED)).await;
driver.agent.push(
FakeSession::completed(ANSWER)
.with_event(stalled())
.with_delay(Duration::from_millis(800)),
);
driver.submit(TASK).await;
let spec = first_spec(&driver).await;
if question_open {
let edit = Ask::Permission {
tool_name: "Edit".into(),
input: json!({"file_path": "invoice/money.py"}),
tool_use_id: None,
};
tokio::spawn(async move { ask(&spec, edit).await });
driver
.until(|event| {
matches!(event, AppEvent::Choice { choice } if choice.kind == ChoiceKind::Permission)
})
.await;
}
driver.until(turn_ended).await;
let events = driver.exit().await;
assert_eq!(
shows_a_stall(&events),
!question_open,
"question open: {question_open}"
);
}
}
#[tokio::test]
async fn under_the_captain_workflow_workflow_starts_its_run_and_goal_starts_nothing() {
let driver = TestDriver::start_with(captain_workflow_options("")).await;
driver
.send(UiCommand::Slash {
command: SlashCommand::Goal {
text: Some("every test passes".to_owned()),
},
})
.await;
driver
.send(UiCommand::Slash {
command: SlashCommand::WorkflowRun {
task: Some("fix the typo in the README".to_owned()),
},
})
.await;
let conductor = driver.conductor.clone();
let deadline = tokio::time::Instant::now() + WAIT;
while conductor.starts().is_empty() {
assert!(
tokio::time::Instant::now() < deadline,
"/workflow started no run"
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
let starts = conductor.starts();
let [start] = starts.as_slice() else {
panic!("one run, the workflow's: {starts:?}");
};
assert_eq!(start.mode, RunMode::Crew);
assert_eq!(start.request, "fix the typo in the README");
assert!(
start
.overrides
.contains(&"goal.until=\"every test passes\"".to_owned()),
"{:?}",
start.overrides
);
assert!(driver.agent.specs().is_empty(), "no direct turn");
driver.exit().await;
}