use std::path::Path;
use std::process::{Child, Command};
use argonauts_core::api::{ApprovalAnswer, DiffScope, RunState, RunSummary};
use argonauts_core::app::{
AnswerTarget, AppEvent, ChoiceKind, RepoRuns, UiCommand, ViewData, ViewRequest,
};
use argonauts_core::events::agent_status::FallbackPlan;
use argonauts_core::events::approval::{
AnswerAction, ApprovalDecided, ApprovalEvent, ApprovalKind, ApprovalOpened, ApprovalPacket,
FreeText,
};
use argonauts_core::events::run::{Party, RunMode, RunOutcome};
use argonauts_core::events::session::SessionStarted;
use argonauts_core::events::task::{TaskEvent, TryCause};
use argonauts_core::events::{RunEvent, RunEventKind, SessionEvent};
use argonauts_core::ids::RepoId;
use argonauts_core::input::SlashCommand;
use argonauts_core::layout::run_files;
use argonauts_core::names::{ApprovalId, MessageId, ModelRef, RoleName, SessionKey};
use argonauts_core::settings::approvals::{ApprovalMode, DecideWith};
use argonauts_core::settings::fallback::FallbackMode;
use argonauts_core::views::{ContextRow, ContextView};
use argonauts_core::{AgentEvent, NoteLevel, RunId, Spend, TaskId};
use time::OffsetDateTime;
use super::tasks::{fallback_rule, reassign};
use super::views::asks_for_status;
use super::{guidance, mention, take_back};
use crate::FirstView;
use crate::ancestry::Processes;
use crate::client::test_support::{agent, run_started, session_started, usage, write_journal};
use crate::controller::runs::rig::{Rig, event, finished, question, run_id, waiting};
use crate::controller::test_support::{ApprovalCall, Call, CliOptions, TestDriver};
use crate::controller::{Opening, ScopeRequest};
use crate::live_registry::{LiveRunHost, register};
fn attach_to(run: &str, interactive: bool) -> CliOptions {
CliOptions {
interactive,
opening: Opening {
guided_setup: false,
first: FirstView::Attach(run_id(run)),
},
..CliOptions::default()
}
}
async fn barrier(driver: &mut TestDriver) {
driver
.send(UiCommand::Slash {
command: SlashCommand::Status {
action: Default::default(),
},
})
.await;
driver
.until(|event| matches!(event, AppEvent::View { .. } | AppEvent::Notice { .. }))
.await;
}
#[tokio::test]
async fn guidance_ids_are_kept_and_take_back_answers_taken_back() {
let mut rig = Rig::new();
let run = rig.start("Rename the flag").await;
guidance(
&mut rig.ctx,
&mut rig.runs,
run.clone(),
"use --json".into(),
&[],
)
.await
.expect("guidance is queued");
let target = argonauts_core::input::MentionTarget::All;
mention(
&mut rig.ctx,
&mut rig.runs,
run.clone(),
target,
"and docs".into(),
&[],
)
.await
.expect("the mention is queued");
let queued: Vec<MessageId> = rig.runs.queued[&run].iter().map(|m| m.id.clone()).collect();
assert_eq!(queued, [MessageId::numbered(1), MessageId::numbered(2)]);
take_back(&mut rig.ctx, &mut rig.runs, run.clone(), Vec::new())
.await
.expect("take back");
assert!(rig.conductor.calls().contains(&Call::TakeBack {
run: run.clone(),
messages: queued,
}));
rig.until(|event| matches!(event, AppEvent::TakenBack { .. }))
.await;
assert!(
rig.runs.queued[&run].is_empty(),
"nothing is taken back twice"
);
}
#[tokio::test]
async fn esc_on_a_runs_question_tells_the_engine_to_carry_on() {
let mut driver = TestDriver::start().await;
let (run, id) = (run_id("run-005"), ApprovalId::numbered(3));
driver
.send(UiCommand::SetAside {
to: AnswerTarget::Approval {
run: run.clone(),
id: id.clone(),
},
})
.await;
barrier(&mut driver).await;
assert_eq!(
driver.approvals.calls(),
[ApprovalCall::Answer {
run,
answer: ApprovalAnswer {
id,
action: AnswerAction::CarryOn,
text: None,
evidence_sha256: String::new(),
tasks: Vec::new(),
},
}]
);
}
#[tokio::test]
async fn restarting_an_agent_asks_its_run_to_start_that_session_over() {
let mut driver = TestDriver::start().await;
let (run, session) = (run_id("run-005"), SessionKey::numbered(8));
driver
.send(UiCommand::RestartAgent {
run: run.clone(),
session: session.clone(),
})
.await;
barrier(&mut driver).await;
assert!(
driver
.conductor
.calls()
.contains(&Call::RestartSession { run, session })
);
}
#[tokio::test]
async fn an_answer_passes_evidence_sha256_through() {
let mut driver = TestDriver::start().await;
let (run, id) = (run_id("run-005"), ApprovalId::numbered(3));
let evidence = "ab".repeat(32);
let task = TaskId::new("task-002").expect("a task id");
driver
.send(UiCommand::Answer {
to: AnswerTarget::Approval {
run: run.clone(),
id: id.clone(),
},
action: AnswerAction::RequestChanges,
text: Some("smaller diff".to_owned()),
evidence_sha256: Some(evidence.clone()),
tasks: vec![task.clone()],
remember: false,
})
.await;
barrier(&mut driver).await;
assert_eq!(
driver.approvals.calls(),
[ApprovalCall::Answer {
run,
answer: ApprovalAnswer {
id,
action: AnswerAction::RequestChanges,
text: Some("smaller diff".to_owned()),
evidence_sha256: evidence,
tasks: vec![task],
},
}]
);
driver.exit().await;
}
#[tokio::test]
async fn without_approvals_nothing_is_sent() {
let mut driver = TestDriver::start_with(attach_to("run-001", false)).await;
driver
.until(|event| matches!(event, AppEvent::ReplayDone { .. }))
.await;
let commands = [
UiCommand::ApprovalMode {
mode: ApprovalMode::Auto,
},
UiCommand::Answer {
to: AnswerTarget::Approval {
run: run_id("run-001"),
id: ApprovalId::numbered(1),
},
action: AnswerAction::Approve,
text: None,
evidence_sha256: Some("e".repeat(64)),
tasks: Vec::new(),
remember: false,
},
UiCommand::TakeOver {
run: run_id("run-001"),
task: TaskId::new("task-001").expect("a task id"),
},
];
for command in commands {
driver.send(command).await;
let refusal = driver
.until(|event| {
matches!(
event,
AppEvent::Notice {
level: NoteLevel::Warn,
..
}
)
})
.await;
let AppEvent::Notice { text, .. } = refusal else {
unreachable!()
};
assert!(text.contains("can't approve anything"), "{text}");
}
assert!(driver.approvals.calls().is_empty());
driver.exit().await;
}
#[tokio::test]
async fn status_and_every_view_start_no_model_session() {
let mut driver = TestDriver::start_with(attach_to("run-001", true)).await;
driver
.until(|event| matches!(event, AppEvent::ReplayDone { .. }))
.await;
let run = run_id("run-001");
let session = SessionKey::numbered(1);
let task = TaskId::new("task-001").expect("a task id");
let mut commands = vec![
UiCommand::Slash {
command: SlashCommand::Status {
action: Default::default(),
},
},
UiCommand::Slash {
command: SlashCommand::Context {
all: false,
action: Default::default(),
},
},
UiCommand::Slash {
command: SlashCommand::Ask {
question: "Where are we?".to_owned(),
},
},
];
let views = [
ViewRequest::ResumeList,
ViewRequest::Status,
ViewRequest::Context,
ViewRequest::AllRepos,
ViewRequest::Workflow {
run: run.clone(),
workflow: None,
},
ViewRequest::Agent {
run: run.clone(),
session: session.clone(),
},
ViewRequest::Diff {
run: run.clone(),
task,
scope: DiffScope::Task,
},
ViewRequest::Raw { run, session },
];
commands.extend(views.into_iter().map(|view| UiCommand::Request { view }));
for command in commands {
driver.send(command).await;
driver
.until(|event| {
matches!(
event,
AppEvent::View { .. } | AppEvent::Notice { .. } | AppEvent::Hint { .. }
)
})
.await;
}
assert!(driver.agent.specs().is_empty(), "a view started a session");
let asked_status = driver
.conductor
.calls()
.iter()
.filter(|call| matches!(call, Call::Status(_)))
.count();
assert_eq!(
asked_status, 3,
"/status, the status view, and \"where are we\""
);
driver.exit().await;
}
#[tokio::test]
async fn rewind_asks_first_then_sends_it_to_the_run() {
let mut driver = TestDriver::start_with(attach_to("run-001", true)).await;
driver
.until(|event| matches!(event, AppEvent::ReplayDone { .. }))
.await;
let task = TaskId::new("task-002").expect("a task id");
driver
.send(UiCommand::Rewind {
task: task.clone(),
checkpoint: 1,
})
.await;
let AppEvent::Choice { choice } = driver
.until(|event| matches!(event, AppEvent::Choice { .. }))
.await
else {
unreachable!()
};
assert_eq!(choice.kind, ChoiceKind::ConfirmRewind);
assert!(
choice
.body
.iter()
.any(|line| line.contains("task-002-replaced-1"))
);
driver
.send(UiCommand::Answer {
to: AnswerTarget::Choice {
id: choice.id.clone(),
},
action: AnswerAction::Approve,
text: None,
evidence_sha256: None,
tasks: Vec::new(),
remember: false,
})
.await;
let closed = driver
.until(|event| matches!(event, AppEvent::ChoiceClosed { .. }))
.await;
let AppEvent::ChoiceClosed { id, outcome } = closed else {
unreachable!()
};
assert_eq!(id, choice.id);
assert!(
outcome.starts_with("Rewound task-002 to fallback-1.")
&& outcome.contains("argonauts/run-001/task-002-replaced-1"),
"{outcome}"
);
assert!(driver.conductor.calls().contains(&Call::Rewind {
run: run_id("run-001"),
task,
checkpoint: 1,
}));
driver.exit().await;
}
#[tokio::test]
async fn a_spot_check_goes_to_the_run_and_needs_you_at_the_terminal() {
let spot_check = |run: &str| UiCommand::SpotCheck {
run: run_id(run),
id: ApprovalId::numbered(3),
correct: false,
};
let mut driver = TestDriver::start_with(attach_to("run-001", true)).await;
driver
.until(|event| matches!(event, AppEvent::ReplayDone { .. }))
.await;
driver.send(spot_check("run-001")).await;
let marked = driver
.until(|event| matches!(event, AppEvent::Notice { text, .. } if text.starts_with("Marked")))
.await;
let AppEvent::Notice { text, .. } = marked else {
unreachable!()
};
assert!(text.contains("approval-003 of run-001 wrong"), "{text}");
assert!(driver.conductor.calls().contains(&Call::SpotCheck {
run: run_id("run-001"),
id: ApprovalId::numbered(3),
correct: false,
}));
driver.exit().await;
let mut unattended = TestDriver::start_with(attach_to("run-001", false)).await;
unattended
.until(|event| matches!(event, AppEvent::ReplayDone { .. }))
.await;
unattended.send(spot_check("run-001")).await;
unattended
.until(|event| {
matches!(
event,
AppEvent::Notice {
level: NoteLevel::Warn,
..
}
)
})
.await;
let calls = unattended.conductor.calls();
assert!(
!calls
.iter()
.any(|call| matches!(call, Call::SpotCheck { .. })),
"{calls:#?}"
);
unattended.exit().await;
}
#[tokio::test]
async fn running_a_goals_checks_now_goes_to_the_run() {
use argonauts_core::events::goal::GoalAction;
let check_now = GoalAction::CheckNow {
task: Some(TaskId::new("task-002").unwrap()),
};
let mut driver = TestDriver::start_with(attach_to("run-001", true)).await;
driver
.until(|event| matches!(event, AppEvent::ReplayDone { .. }))
.await;
driver
.send(UiCommand::Goal {
run: Some(run_id("run-001")),
action: check_now.clone(),
})
.await;
let said = driver
.until(|event| matches!(event, AppEvent::Notice { text, .. } if text.contains("checks")))
.await;
let AppEvent::Notice { text, .. } = said else {
unreachable!()
};
assert!(
text.starts_with("Running the goal's checks now, beside the try under way."),
"{text}"
);
assert!(driver.conductor.calls().contains(&Call::Goal {
run: run_id("run-001"),
action: check_now,
}));
driver.exit().await;
}
fn fallback_opened(id: &ApprovalId, task: &TaskId, stopped: &ModelRef) -> RunEventKind {
let plan = FallbackPlan {
task: task.clone(),
title: "Redis store".to_owned(),
stopped: stopped.clone(),
reason: "usage limit".to_owned(),
resets_at: None,
work: argonauts_core::DiffStat::default(),
checks_ran: false,
candidates: Vec::new(),
mode: FallbackMode::Ask,
strategy: Default::default(),
race_count: 1,
also_moving: Vec::new(),
};
let opened = ApprovalOpened {
id: id.clone(),
kind: ApprovalKind::Fallback,
title: "Fallback · task-002".to_owned(),
task: Some(task.clone()),
packet: ApprovalPacket::Fallback(plan),
evidence_sha256: "e".repeat(64),
mode: ApprovalMode::Supervised,
decide_with: DecideWith::Rules,
options: Vec::new(),
free_text: FreeText::None,
needs_you: true,
};
RunEventKind::Approval(ApprovalEvent::Opened(Box::new(opened)))
}
#[test]
fn a_remembered_fallback_pick_asks_where_to_save_the_rule() {
let run = run_id("run-001");
let task = TaskId::new("task-002").expect("a task id");
let id = ApprovalId::numbered(4);
let stopped = ModelRef::new("claude-code", Some("opus".to_owned()));
let pick = ModelRef::new("codex", Some("gpt-5".to_owned()));
let mut started = event(
&run,
1,
RunEventKind::Session(SessionEvent::Started(Box::new(
serde_json::from_value::<SessionStarted>(serde_json::json!({
"worker": "claude-code/opus",
"agent_id": "claude-code",
"provider": "anthropic",
"kind": "cli",
"role": "reviewer",
"step": null,
"access": "build",
"lifetime": "task",
"resumed_from": null,
"follow_key": 1,
}))
.expect("a session start"),
))),
);
started.task_id = Some(task.clone());
let decided = ApprovalDecided {
id: id.clone(),
action: AnswerAction::Candidate {
worker: pick.clone(),
},
text: None,
by: Party::User,
evidence_sha256: "e".repeat(64),
auto_preview: None,
tasks: Vec::new(),
};
let events = vec![
started,
event(&run, 2, fallback_opened(&id, &task, &stopped)),
event(
&run,
3,
RunEventKind::Approval(ApprovalEvent::Decided(decided)),
),
];
let expected = ScopeRequest {
key: "fallback.reviewer.replace.claude-code.with".to_owned(),
value: "[\"codex/gpt-5\"]".to_owned(),
command: "fallback".to_owned(),
};
assert_eq!(fallback_rule(&events, &id, None), Some(expected.clone()));
let other = ModelRef::new("antigravity-cli", None);
let answered = fallback_rule(&events, &id, Some(other)).expect("a rule");
assert_eq!(answered.value, "[\"antigravity-cli\"]");
assert_eq!(
fallback_rule(&events[..1], &id, None),
None,
"no fallback question"
);
let worker = fallback_rule(&events[1..], &id, None).expect("a rule");
assert_eq!(worker.key, "fallback.worker.replace.claude-code.with");
}
#[test]
fn where_are_we_is_a_status_question() {
for question in [
"Where are we?",
"status",
"What's the status of task-002",
"how is it going",
] {
assert!(asks_for_status(question), "{question}");
}
for question in [
"Why did the reviewer reject task-002?",
"What does the plan change?",
] {
assert!(!asks_for_status(question), "{question}");
}
}
#[tokio::test]
async fn a_fallback_answer_with_remember_saves_the_rule_where_you_say() {
let mut driver = TestDriver::start_with(attach_to("run-001", true)).await;
driver
.until(|event| matches!(event, AppEvent::ReplayDone { .. }))
.await;
let run = run_id("run-001");
let task = TaskId::new("task-002").expect("a task id");
let id = ApprovalId::numbered(4);
let worker = SessionKey::numbered(1);
let started = session_started(1, &worker, Some(&task), RoleName::Worker, "claude-code");
let stopped = ModelRef::new("claude-code", None);
for event in [
RunEvent {
run_id: run.clone(),
..started
},
event(&run, 2, fallback_opened(&id, &task, &stopped)),
] {
assert!(driver.conductor.push(&run, event).await);
}
driver
.until(|event| matches!(event, AppEvent::Run { event } if event.seq == 2))
.await;
let pick = ModelRef::new("codex", Some("gpt-5".to_owned()));
driver
.send(UiCommand::Answer {
to: AnswerTarget::Approval {
run: run.clone(),
id: id.clone(),
},
action: AnswerAction::Candidate {
worker: pick.clone(),
},
text: None,
evidence_sha256: Some("e".repeat(64)),
tasks: Vec::new(),
remember: true,
})
.await;
let AppEvent::Choice { choice } = driver
.until(|event| matches!(event, AppEvent::Choice { .. }))
.await
else {
unreachable!()
};
assert_eq!(choice.kind, ChoiceKind::SettingScope);
assert_eq!(
choice.body.first().map(String::as_str),
Some("fallback.worker.replace.claude-code.with = [\"codex/gpt-5\"]"),
"{choice:?}"
);
let sent = driver.approvals.calls();
assert!(
matches!(&sent[..], [ApprovalCall::Answer { answer, .. }]
if answer.action == AnswerAction::Candidate { worker: pick.clone() }),
"the pick goes to the engine before the rule is saved: {sent:?}"
);
let project = choice
.options
.iter()
.find(|option| option.key == 'p')
.expect("a project option");
driver
.send(UiCommand::Answer {
to: AnswerTarget::Choice {
id: choice.id.clone(),
},
action: project.action.clone(),
text: None,
evidence_sha256: None,
tasks: Vec::new(),
remember: false,
})
.await;
let changed = driver
.until(|event| matches!(event, AppEvent::SettingChanged { .. }))
.await;
let AppEvent::SettingChanged { key, value, .. } = changed else {
unreachable!()
};
assert_eq!(
(key.as_str(), value.as_str()),
(
"fallback.worker.replace.claude-code.with",
"[\"codex/gpt-5\"]"
)
);
let text = std::fs::read_to_string(driver.env.project_config()).expect("the project file");
let written = toml::Value::Table(text.parse().expect("the project file is TOML"));
let with = ["fallback", "worker", "replace", "claude-code", "with"]
.iter()
.try_fold(&written, |node, part| node.get(part));
assert_eq!(
with.and_then(toml::Value::as_array).map(|picks| picks
.iter()
.filter_map(toml::Value::as_str)
.collect::<Vec<_>>()),
Some(vec!["codex/gpt-5"]),
"{text}"
);
driver.exit().await;
}
fn listed(run: &str, live: bool) -> RunSummary {
RunSummary {
run: run_id(run),
title: format!("{run} title"),
mode: RunMode::Captain,
workflow: "captain".to_owned(),
state: if live {
RunState::Running
} else {
RunState::Waiting
},
stopped_at: String::new(),
updated: OffsetDateTime::UNIX_EPOCH,
spent: Spend::default(),
sessions_to_resume: 0,
waiting: Vec::new(),
live,
remind: false,
name: None,
shape_known: true,
}
}
fn live_session(run: &RunId, seq: u64, key: &SessionKey, window: Option<u64>) -> Vec<RunEvent> {
let task = TaskId::new("task-001").expect("a task id");
let mut events = vec![session_started(
seq,
key,
Some(&task),
RoleName::Worker,
"claude-code",
)];
if let Some(size) = window {
let fill = AgentEvent::ContextFill {
used: size / 10,
size: Some(size),
sent: None,
};
events.push(agent(seq + 1, key, fill));
events.push(usage(seq + 2, key, 1_000, 200, 0.25));
}
events
.into_iter()
.map(|event| RunEvent {
run_id: run.clone(),
..event
})
.collect()
}
fn write_run(rig: &Rig, run: &RunId, events: &[RunEvent]) {
let dir = rig.ctx.env.run_dir(run);
std::fs::create_dir_all(&dir).expect("the run directory");
write_journal(&dir.join(run_files::EVENTS), events);
}
#[tokio::test]
async fn giving_a_task_to_the_worker_it_has_starts_it_over() {
let mut rig = Rig::new();
let run = run_id("run-001");
let task = TaskId::new("task-001").expect("a task id");
let opus: ModelRef = "claude-code/claude-opus-5-5".parse().expect("a model");
let luna: ModelRef = "codex/gpt-5.6-luna".parse().expect("a model");
let mut tried = event(
&run,
1,
RunEventKind::Task(TaskEvent::Try {
try_number: 1,
worker: opus.clone(),
cause: TryCause::First,
told: Vec::new(),
}),
);
tried.task_id = Some(task.clone());
write_run(&rig, &run, &[tried]);
let mut said = Vec::new();
for to in [&opus, &luna] {
reassign(
&mut rig.ctx,
&mut rig.runs,
run.clone(),
task.clone(),
to.clone(),
)
.await
.expect("the run takes it");
let notice = rig
.until(|event| matches!(event, AppEvent::Notice { .. }))
.await;
let AppEvent::Notice { text, .. } = notice else {
unreachable!()
};
said.push(text);
}
assert!(
said[0].starts_with("Starting task-001 over with ") && said[0].contains("a note of it"),
"{said:?}"
);
assert!(said[1].starts_with("Moving task-001 to "), "{said:?}");
let sent: Vec<ModelRef> = rig
.conductor
.calls()
.into_iter()
.filter_map(|call| match call {
Call::Reassign { to, .. } => Some(to),
_ => None,
})
.collect();
assert_eq!(sent, [opus, luna]);
}
async fn context_view(rig: &mut Rig, conversation: &ContextRow) -> ContextView {
super::context(&mut rig.ctx, &mut rig.runs, Some(conversation.clone()))
.await
.expect("/context");
let AppEvent::View { data } = rig
.until(|event| matches!(event, AppEvent::View { .. }))
.await
else {
unreachable!()
};
match *data {
ViewData::Context { view } => view,
other => panic!("{other:?}"),
}
}
#[tokio::test]
async fn context_lists_the_conversation_then_the_runs_sessions_each_capped_by_its_window() {
let mut rig = Rig::with(true, |global| {
std::fs::write(global, "[sessions]\nrotate_at = 0.9\n").expect("the global config");
});
let run = rig.start("Rename the flag").await;
let (worker, other_worker, starting) = (
SessionKey::numbered(1),
SessionKey::numbered(2),
SessionKey::numbered(3),
);
for event in live_session(&run, 1, &worker, Some(200_000)) {
assert!(rig.conductor.push(&run, event).await);
}
rig.settle().await;
let other = run_id("run-002");
let mut other_events = live_session(&other, 1, &other_worker, Some(1_000_000));
other_events.extend(live_session(&other, 4, &starting, None));
write_run(&rig, &other, &other_events);
let not_live = run_id("run-003");
write_run(
&rig,
¬_live,
&live_session(¬_live, 1, &worker, Some(200_000)),
);
rig.conductor.set_runs(vec![
listed("run-001", true),
listed("run-002", true),
listed("run-003", false),
]);
let conversation = ContextRow {
run: None,
session: SessionKey::numbered(9),
label: "conversation".to_owned(),
worker: ModelRef::new("claude-code", None),
used: 3_000,
window: Some(200_000),
cap: None,
tool_calls: 0,
spent: Spend::default(),
rotations: 0,
estimated: false,
counts: false,
categories: Vec::new(),
recorded: None,
ended: None,
answered_for: None,
};
let rows = context_view(&mut rig, &conversation).await.rows;
let shown: Vec<(Option<&str>, &SessionKey, Option<u64>)> = rows
.iter()
.map(|row| (row.run.as_ref().map(RunId::as_str), &row.session, row.cap))
.collect();
assert_eq!(
shown,
[
(None, &conversation.session, None),
(Some("run-001"), &worker, Some(180_000)),
(Some("run-002"), &other_worker, Some(900_000)),
(Some("run-002"), &starting, None),
],
"the conversation, the run on screen, then other live runs, each capped at 0.9 of its window"
);
let said = ContextRow {
counts: true,
..conversation.clone()
};
assert_eq!(
rows[0], said,
"the conversation's row, with what its CLI can say"
);
assert!(rows.iter().all(|row| row.counts && !row.estimated));
assert_eq!((rows[1].used, rows[1].spent.input_tokens), (20_000, 1_000));
crate::controller::runs::follow_conversation(&rig.ctx, &mut rig.runs, Vec::new());
let view = context_view(&mut rig, &conversation).await;
assert!(
view.rows
.iter()
.any(|row| row.run.as_ref().map(RunId::as_str) == Some("run-001")),
"the run going on screen is followed after the switch"
);
assert!(
rig.conductor
.push(&run, finished(&run, 4, RunOutcome::Merged))
.await
);
rig.settle().await;
rig.conductor.set_runs(vec![
listed("run-001", false),
listed("run-002", true),
listed("run-003", false),
]);
let view = context_view(&mut rig, &conversation).await;
let runs: Vec<Option<&str>> = view
.rows
.iter()
.map(|row| row.run.as_ref().map(RunId::as_str))
.collect();
assert_eq!(
runs,
[None, Some("run-001"), Some("run-002"), Some("run-002")],
"the run on screen stays listed once it finishes"
);
let finished: Vec<&str> = view.finished.iter().map(|run| run.run.as_str()).collect();
assert_eq!(finished, ["run-001"]);
crate::controller::runs::follow_conversation(&rig.ctx, &mut rig.runs, Vec::new());
let view = context_view(&mut rig, &conversation).await;
let runs: Vec<Option<&str>> = view
.rows
.iter()
.map(|row| row.run.as_ref().map(RunId::as_str))
.collect();
assert_eq!(
runs,
[None, Some("run-002"), Some("run-002")],
"a finished run leaves the tab with the conversation it finished in"
);
assert!(view.finished.is_empty(), "{:?}", view.finished);
assert!(rig.agent.specs().is_empty(), "/context started a session");
}
#[tokio::test]
async fn a_carried_on_conversation_lists_the_run_it_ended_with() {
let mut rig = Rig::new();
let (earlier, last) = (run_id("run-001"), run_id("run-002"));
let worker = SessionKey::numbered(1);
for run in [&earlier, &last] {
let mut events = live_session(run, 1, &worker, Some(200_000));
events.push(finished(run, 4, RunOutcome::Merged));
write_run(&rig, run, &events);
}
rig.conductor
.set_runs(vec![listed("run-001", false), listed("run-002", false)]);
crate::controller::runs::adopt(&mut rig.runs, [earlier.clone(), last.clone()]);
let conversation = ContextRow {
run: None,
session: SessionKey::numbered(9),
label: "conversation".to_owned(),
worker: ModelRef::new("claude-code", None),
used: 3_000,
window: Some(200_000),
cap: None,
tool_calls: 0,
spent: Spend::default(),
rotations: 0,
estimated: false,
counts: false,
categories: Vec::new(),
recorded: None,
ended: None,
answered_for: None,
};
let view = context_view(&mut rig, &conversation).await;
let runs: Vec<Option<&str>> = view
.rows
.iter()
.map(|row| row.run.as_ref().map(RunId::as_str))
.collect();
assert_eq!(runs, [None, Some("run-002")]);
let finished: Vec<&str> = view.finished.iter().map(|run| run.run.as_str()).collect();
assert_eq!(finished, ["run-002"]);
crate::controller::runs::follow_conversation(&rig.ctx, &mut rig.runs, Vec::new());
let view = context_view(&mut rig, &conversation).await;
assert_eq!(view.rows.len(), 1, "{:?}", view.rows);
assert!(view.finished.is_empty());
}
#[tokio::test]
async fn a_run_sessions_whole_count_comes_from_its_folder() {
use argonauts_core::events::context::{ContextCell, ContextUsage};
let mut rig = Rig::new();
let run = run_id("run-001");
let (counted, never) = (SessionKey::numbered(3), SessionKey::numbered(4));
let dir = argonauts_core::layout::session_dir(&rig.ctx.env.run_dir(&run), &counted);
std::fs::create_dir_all(&dir).expect("the session's folder");
let usage = ContextUsage {
total_tokens: 24_924,
window: 1_000_000,
grid: vec![vec![ContextCell {
category: "Messages".to_owned(),
color: "purple_FOR_SUBAGENTS_ONLY".to_owned(),
filled: true,
tokens: 5_989,
percentage: 1,
fullness: 1.0,
}]],
..ContextUsage::default()
};
std::fs::write(
dir.join(argonauts_core::layout::session_files::CONTEXT),
serde_json::to_vec(&usage).expect("a count"),
)
.expect("the count");
let mut answer = async |session: &SessionKey| {
let request = ViewRequest::SessionContext {
run: run.clone(),
session: session.clone(),
};
super::view(&mut rig.ctx, &mut rig.runs, request)
.await
.expect("the session's count");
let AppEvent::View { data } = rig
.until(|event| matches!(event, AppEvent::View { .. }))
.await
else {
unreachable!()
};
match *data {
ViewData::SessionContext { counted, .. } => counted,
other => panic!("{other:?}"),
}
};
let kept = answer(&counted).await.expect("its count");
assert_eq!(*kept.usage, usage);
assert!(answer(&never).await.is_none());
}
struct FakeRunHost(Child);
impl FakeRunHost {
fn spawn() -> Self {
Self(
Command::new("sleep")
.arg("60")
.spawn()
.expect("sleep starts"),
)
}
fn entry(&self, repo_root: &Path, run: &RunId) -> LiveRunHost {
let pid = self.0.id();
LiveRunHost {
pid,
start_time: Processes::new().start_time(pid).expect("the fallback runs"),
repo_root: repo_root.to_owned(),
run: run.clone(),
sessions: 1,
}
}
}
impl Drop for FakeRunHost {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn waiting_run(rig: &Rig, root: &Path, run: &str, at: OffsetDateTime) -> RunId {
std::fs::create_dir_all(root).expect("the other repo");
let run = run_id(run);
let repo = RepoId::for_repo(root).expect("a repo id");
let dir = rig.ctx.env.paths.run_dir(&repo, &run);
std::fs::create_dir_all(&dir).expect("the run directory");
let approval = ApprovalId::numbered(1);
let events = [
run_started(0, "Rename the loader", None),
question(&run, 1, approval.as_str()),
waiting(&run, 2, vec![approval]),
]
.map(|event| RunEvent {
run_id: run.clone(),
ts: at,
..event
});
write_journal(&dir.join(run_files::EVENTS), &events);
run
}
async fn all_repos(rig: &mut Rig) -> Vec<RepoRuns> {
super::view(&mut rig.ctx, &mut rig.runs, ViewRequest::AllRepos)
.await
.expect("the all repos view");
let AppEvent::View { data } = rig
.until(|event| matches!(event, AppEvent::View { .. }))
.await
else {
unreachable!()
};
match *data {
ViewData::AllRepos { repos } => repos,
other => panic!("{other:?}"),
}
}
#[tokio::test]
async fn all_repos_lists_this_repo_then_live_ones_newest_first() {
let mut rig = Rig::new();
rig.conductor.set_runs(vec![listed("run-001", true)]);
let here = rig.ctx.env.repo_root.clone();
let base = here.parent().expect("the repo's parent").to_owned();
let (older, newer) = (base.join("older"), base.join("newer"));
let at = OffsetDateTime::UNIX_EPOCH + time::Duration::days(20_000);
let older_run = waiting_run(&rig, &older, "run-101", at);
let newer_run = waiting_run(&rig, &newer, "run-102", at + time::Duration::hours(1));
let mut run_hosts: Vec<FakeRunHost> = (0..4).map(|_| FakeRunHost::spawn()).collect();
for entry in [
run_hosts[0].entry(&here, &run_id("run-001")),
run_hosts[1].entry(&older, &older_run),
run_hosts[2].entry(&newer, &newer_run),
run_hosts[3].entry(&base.join("moved-away"), &run_id("run-103")),
] {
register(&rig.ctx.env.paths, &entry).expect("the fallback registers");
}
let repos = all_repos(&mut rig).await;
let names: Vec<String> = repos.iter().map(|repos| repos.repo.clone()).collect();
assert_eq!(
names,
[
rig.ctx.env.display_repo(),
newer.display().to_string(),
older.display().to_string()
],
"this repo once and first, a repo that moved away left out"
);
assert_eq!(repos[0].runs, [listed("run-001", true)]);
for (repo, run) in [(&repos[1], &newer_run), (&repos[2], &older_run)] {
let [summary] = &repo.runs[..] else {
panic!("{repo:?}")
};
assert_eq!(&summary.run, run);
assert_eq!(summary.waiting, [ApprovalId::numbered(1)], "{summary:?}");
}
drop(run_hosts.remove(2));
let names: Vec<String> = all_repos(&mut rig)
.await
.into_iter()
.map(|repos| repos.repo)
.collect();
assert_eq!(
names,
[rig.ctx.env.display_repo(), older.display().to_string()]
);
}
#[tokio::test]
async fn rewind_lists_a_tasks_checkpoints_for_the_picker() {
use argonauts_core::events::task::TaskEvent;
let mut rig = Rig::new();
let run = rig.start("Rename the flag").await;
let (export, docs) = (
TaskId::new("task-001").expect("a task id"),
TaskId::new("task-002").expect("a task id"),
);
let tagged = |seq, task: &TaskId, n| RunEvent {
task_id: Some(task.clone()),
..event(
&run,
seq,
RunEventKind::Task(TaskEvent::CheckpointTagged {
n,
commit: "0123456789abcdef".to_owned(),
from: ModelRef::new("claude-code", None),
to: vec![ModelRef::new("codex", None)],
reason: "usage limit".to_owned(),
}),
)
};
for event in [
tagged(10, &export, 1),
tagged(11, &docs, 1),
tagged(12, &export, 2),
] {
assert!(rig.conductor.push(&run, event).await);
}
rig.settle().await;
super::checkpoints(&mut rig.ctx, &mut rig.runs, Some(export.clone()))
.await
.expect("/rewind task-001");
let AppEvent::View { data } = rig
.until(|event| matches!(event, AppEvent::View { .. }))
.await
else {
unreachable!()
};
let ViewData::Checkpoints {
run: shown,
task,
checkpoints,
} = *data
else {
panic!("/rewind answers with its picker");
};
assert_eq!((shown, task), (run.clone(), Some(export.clone())));
let picked: Vec<(String, u32)> = checkpoints
.iter()
.map(|row| (row.task.to_string(), row.n))
.collect();
assert_eq!(
picked,
[("task-001".to_owned(), 1), ("task-001".to_owned(), 2)]
);
let none = TaskId::new("task-009").expect("a task id");
super::checkpoints(&mut rig.ctx, &mut rig.runs, Some(none))
.await
.expect("/rewind task-009");
let AppEvent::Notice { text, .. } = rig
.until(|event| matches!(event, AppEvent::Notice { .. }))
.await
else {
unreachable!()
};
assert!(
text.contains("task-009 has no fallback checkpoints"),
"{text}"
);
}
#[tokio::test]
async fn follow_up_builds_on_a_merged_run_and_says_why_not_otherwise() {
let mut rig = Rig::new();
let merged = RunSummary {
state: RunState::Merged,
..listed("run-001", false)
};
rig.conductor
.set_runs(vec![listed("run-002", false), merged]);
let follow_up = |run: Option<&str>| (run.map(run_id), "Add a CSV export too".to_owned());
let (run, request) = follow_up(Some("run-002"));
run_follow_up(&mut rig, run, request)
.await
.expect("/follow-up run-002");
let AppEvent::Notice { text, .. } = rig
.until(|event| matches!(event, AppEvent::Notice { .. }))
.await
else {
unreachable!()
};
assert!(text.contains("run-002 isn't merged"), "{text}");
let (run, request) = follow_up(Some("run-404"));
run_follow_up(&mut rig, run, request)
.await
.expect("/follow-up run-404");
let AppEvent::Notice { text, .. } = rig
.until(|event| matches!(event, AppEvent::Notice { .. }))
.await
else {
unreachable!()
};
assert!(text.contains("no run run-404"), "{text}");
assert!(
!rig.conductor
.calls()
.iter()
.any(|call| matches!(call, Call::Continue { .. }))
);
let (run, request) = follow_up(None);
run_follow_up(&mut rig, run, request)
.await
.expect("/follow-up");
assert!(rig.conductor.calls().iter().any(|call| matches!(
call,
Call::Continue { merged, .. } if *merged == merged_id()
)));
}
fn merged_id() -> RunId {
run_id("run-001")
}
async fn run_follow_up(rig: &mut Rig, run: Option<RunId>, request: String) -> super::Handled {
match super::follow_up(&mut rig.ctx, &rig.runs, run, request).await? {
Some(follow_up) => super::continue_run(&mut rig.ctx, &mut rig.runs, follow_up).await,
None => Ok(Vec::new()),
}
}
#[tokio::test]
async fn follow_up_goes_to_the_crew_while_ultracode_is_on() {
for on in [false, true] {
let mut rig = Rig::with(true, |global| {
std::fs::write(
global,
format!("workflow = \"council\"\nultracode = {on}\n"),
)
.unwrap();
});
let merged = RunSummary {
state: RunState::Merged,
..listed("run-001", false)
};
rig.conductor.set_runs(vec![merged]);
run_follow_up(&mut rig, None, "Add a CSV export too".into())
.await
.expect("/follow-up");
let calls = rig.conductor.calls();
let Some(Call::Continue { merged, request }) = calls
.iter()
.find(|call| matches!(call, Call::Continue { .. }))
else {
panic!("no follow-up started: {calls:?}");
};
assert_eq!(*merged, merged_id());
let rule = request
.captain_or_crew
.as_ref()
.map(|captain_or_crew| captain_or_crew.rule.as_str());
if on {
assert_eq!((request.mode, rule), (RunMode::Crew, Some("ultracode")));
} else {
assert_eq!((request.mode, rule), (RunMode::Captain, None));
}
assert!(!request.forced);
}
}
#[tokio::test]
async fn the_workflow_view_before_a_workflow_starts_says_so_plainly() {
let mut rig = Rig::new();
let run = rig.start("Rename the flag").await;
rig.settle().await;
let request = ViewRequest::Workflow {
run: run.clone(),
workflow: None,
};
super::view(&mut rig.ctx, &mut rig.runs, request)
.await
.expect("the workflow view");
let AppEvent::Hint { text } = rig
.until(|event| matches!(event, AppEvent::Hint { .. } | AppEvent::Notice { .. }))
.await
else {
panic!("a key's answer went into the transcript")
};
assert_eq!(text, "This run hasn't started a workflow yet.");
assert!(!text.contains(run.as_str()), "{text}");
}
#[tokio::test]
async fn a_run_of_another_project_is_read_from_that_projects_records() {
let rig = Rig::new();
let run = RunId::new("run-20261020-123036-4e50").unwrap();
let elsewhere = rig
.ctx
.env
.paths
.home()
.join("state/elsewhere-3f9a1c2b4d5e/runs")
.join(run.as_str());
std::fs::create_dir_all(&elsewhere).unwrap();
let line = serde_json::json!({"seq": 0, "ts": "2026-10-20T12:30:36Z", "run_id": run.as_str(),
"source": "test", "kind": {"type": "agent", "data": {"type": "message", "text": "hi", "parent": null}}});
std::fs::write(elsewhere.join("events.jsonl"), format!("{line}\n")).unwrap();
assert!(!rig.ctx.env.run_dir(&run).exists(), "not this project's");
let events = super::views::run_events(&rig.ctx, &rig.runs, &run);
assert_eq!(events.len(), 1, "the other project's log");
}
#[tokio::test]
async fn a_steer_that_isnt_sent_says_so_in_the_transcript() {
let mut rig = Rig::new();
let run = rig.start("Rename the flag").await;
rig.settle().await;
let session = SessionKey::numbered(1);
super::control::steer(
&mut rig.ctx,
&mut rig.runs,
run,
session,
"keep the old name".to_owned(),
false,
)
.await
.expect("the steer is answered");
let AppEvent::Notice { text, .. } = rig
.until(|event| matches!(event, AppEvent::Hint { .. } | AppEvent::Notice { .. }))
.await
else {
panic!("a typed steer's answer went to the notice line")
};
assert_eq!(
text,
"Nothing was sent, since the fake conductor doesn't steer."
);
}
#[test]
fn refusals_from_an_ended_run_read_in_plain_words() {
use argonauts_core::api::ApiError;
let run = RunId::new("run-20261004-043029-d8bb").unwrap();
let merged = super::plain_refusal(&ApiError::Merged(run.clone())).unwrap();
assert_eq!(
merged,
"That run already merged, so its agents are done. To build on its result, type \
/follow-up and what you want next."
);
let gone = super::plain_refusal(&ApiError::NoRun(run.clone())).unwrap();
assert!(!gone.contains(run.as_str()), "{gone}");
assert_eq!(
super::plain_refusal(&ApiError::NotNow("the run isn't running".into())).as_deref(),
Some("The run isn't running.")
);
assert_eq!(
super::plain_refusal(&ApiError::RunHost("socket closed".into())),
None
);
}
#[tokio::test]
async fn stopping_a_merged_run_says_so_plainly() {
let mut rig = Rig::new();
let run = RunId::new("run-20261004-043029-d8bb").unwrap();
rig.conductor.merged(&run);
super::control::stop(&mut rig.ctx, &mut rig.runs, Some(run.clone()))
.await
.expect("a refusal is a notice");
let AppEvent::Notice { text, .. } = rig
.until(|event| matches!(event, AppEvent::Notice { .. }))
.await
else {
unreachable!()
};
assert_eq!(
text,
"That run already merged, so its agents are done. To build on its result, type \
/follow-up and what you want next."
);
}
#[test]
fn failures_read_as_one_plain_line() {
use argonauts_core::api::ApiError;
let refused = ApiError::Refused("task-001 has no session to steer".into());
assert_eq!(
super::failure(&refused, "Couldn't steer task-001"),
"Couldn't steer task-001: task-001 has no session to steer."
);
let down = ApiError::RunHost("socket closed".into());
assert_eq!(
super::failure(&down, "Couldn't pause the run"),
"Couldn't pause the run: its run didn't answer (socket closed)."
);
let run = RunId::new("run-20261004-043029-d8bb").unwrap();
let gone = super::failure(&ApiError::NoRun(run.clone()), "Couldn't resume that run");
assert!(!gone.contains(run.as_str()), "{gone}");
assert!(!gone.contains("refused:"), "{gone}");
}