use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use argonauts_conductor::GitCheck;
use argonauts_conductor::cli_versions::{
CliVersions, Installed, builtins_line, new_command, startup_warnings, unread_line,
version_check,
};
use argonauts_conductor::doctor::update::{
CliUpdate, UPDATE_TIMEOUT, UpdateColumns, UpdateLimits, each_once, update_each,
update_lines_under,
};
use argonauts_core::Adapter;
use argonauts_core::app::{AppEvent, CommandWork};
use argonauts_core::log::NoteLevel;
use argonauts_core::settings::Settings;
use argonauts_core::status_tab::{VERSION_ROW_NAMES, VERSION_ROW_STATES, version_row};
use argonauts_registry::SettingsWarning;
use tokio_util::sync::CancellationToken;
use super::{Ctx, Effect};
use crate::self_update::{Here, OwnRow, Source, how_it_updates, own_row, running_version};
use crate::setup::probe::{self, FoundAgent, LoginState};
pub(crate) fn doctor(ctx: &mut Ctx) -> anyhow::Result<Vec<Effect>> {
let adapters = crate::commands::adapters_here(Some(&ctx.env), ctx.settings.resolved())?;
let settings = ctx.settings.shared();
let out = ctx.out.clone();
let versions = ctx.env.paths.cli_versions();
let prompt_files = super::prompt_templates::doctor_lines(ctx);
let mods = crate::mods::doctor_lines(&ctx.env);
let settings_lines = settings_lines(&ctx.settings.resolved().warnings);
let paths = ctx.env.paths.clone();
let unnamed = super::status_tab::unnamed_accounts(ctx);
tokio::spawn(async move {
let mut lines = doctor_lines(&adapters, &settings, &versions).await;
lines.extend(unnamed);
lines.extend(settings_lines);
lines.extend(prompt_files);
lines.extend(mods);
let import = tokio::task::spawn_blocking(move || crate::overview::doctor_lines(&paths));
lines.extend(import.await.unwrap_or_default());
out.send(AppEvent::Report {
title: "Doctor".to_owned(),
lines,
});
});
Ok(Vec::new())
}
fn settings_lines(warnings: &[SettingsWarning]) -> Vec<String> {
warnings
.iter()
.map(|warning| format!("settings · {warning}"))
.collect()
}
pub(crate) fn stop_command(ctx: &mut Ctx) -> anyhow::Result<Vec<Effect>> {
if let Some(stop) = UpdateAtWork::stop_of(ctx.env.paths.home()) {
stop.cancel();
}
Ok(Vec::new())
}
pub(crate) const UPDATE_GOING: &str = "An update is going. Esc stops it.";
static UPDATES_AT_WORK: Mutex<BTreeMap<PathBuf, CancellationToken>> = Mutex::new(BTreeMap::new());
struct UpdateAtWork {
home: PathBuf,
stop: CancellationToken,
}
impl UpdateAtWork {
fn start(home: &Path) -> Option<Self> {
let mut at_work = UPDATES_AT_WORK
.lock()
.unwrap_or_else(PoisonError::into_inner);
if at_work.contains_key(home) {
return None;
}
let stop = CancellationToken::new();
at_work.insert(home.to_path_buf(), stop.clone());
Some(Self {
home: home.to_path_buf(),
stop,
})
}
fn stop_of(home: &Path) -> Option<CancellationToken> {
UPDATES_AT_WORK
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(home)
.cloned()
}
}
impl Drop for UpdateAtWork {
fn drop(&mut self) {
UPDATES_AT_WORK
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&self.home);
}
}
pub(crate) fn update(ctx: &mut Ctx) -> anyhow::Result<Vec<Effect>> {
if super::cli_updates::a_run_is_going(ctx) {
ctx.notice(NoteLevel::Info, super::cli_updates::UPDATE_WAITS);
return Ok(Vec::new());
}
let Some(at_work) = UpdateAtWork::start(ctx.env.paths.home()) else {
ctx.notice(NoteLevel::Info, UPDATE_GOING);
return Ok(Vec::new());
};
let adapters = crate::commands::adapters_here(Some(&ctx.env), ctx.settings.resolved())?;
let settings = ctx.settings.shared();
let out = ctx.out.clone();
let versions = ctx.env.paths.cli_versions();
let paths = ctx.env.paths.clone();
let here = Source::for_home(&paths).map(|source| Here::this_process(&paths, source));
let working = |work| AppEvent::CommandWorking { work };
out.send(working(Some(CommandWork::Updating)));
tokio::spawn(async move {
let stop = at_work.stop.clone();
let clis = each_once(&adapters, &settings);
let columns = UpdateColumns::of(&clis, &settings)
.await
.with_row(OWN_ROW, &running_version().to_string());
let install = here.as_ref().ok().map(|here| here.install);
let limits = UpdateLimits::within(UPDATE_TIMEOUT);
let rows = std::sync::Mutex::new(Rows::default());
let so_far = || {
let rows = rows
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(lines) = rows.lines(columns) {
out.send(AppEvent::ReportSoFar {
title: UPDATE.to_owned(),
lines,
});
}
};
let own = async {
let row = own_row(here, &settings.updates, &stop).await;
rows.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.own = Some(row);
so_far();
};
let each = update_each(&clis, &settings, limits, &stop, |done| {
rows.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clis = done.to_vec();
so_far();
});
let ((), updates) = tokio::join!(own, each);
let mut rows = rows
.into_inner()
.unwrap_or_else(std::sync::PoisonError::into_inner);
rows.clis = updates;
let mut lines = rows.lines(columns).unwrap_or_default();
lines.extend(install.map(|install| how_it_updates(install, &settings.updates)));
out.send(AppEvent::Report {
title: UPDATE.to_owned(),
lines,
});
if stop.is_cancelled() {
out.send(working(None));
return;
}
drop(at_work);
out.send(working(Some(CommandWork::Checking)));
out.send(AppEvent::Report {
title: "Doctor".to_owned(),
lines: doctor_lines(&adapters, &settings, &versions).await,
});
out.send(working(None));
});
Ok(Vec::new())
}
const OWN_ROW: &str = "Argonauts Code";
#[derive(Default)]
struct Rows {
own: Option<OwnRow>,
clis: Vec<CliUpdate>,
}
impl Rows {
fn lines(&self, columns: UpdateColumns) -> Option<Vec<String>> {
let own = self.own.as_ref()?;
Some(update_lines_under(
(OWN_ROW, &own.version, &own.words),
columns,
&self.clis,
))
}
}
const UPDATE: &str = "Update";
async fn doctor_lines(
adapters: &[Arc<dyn Adapter>],
settings: &Settings,
versions: &Path,
) -> Vec<String> {
let adapters: Vec<Arc<dyn Adapter>> = each_once(adapters, settings)
.into_iter()
.map(|cli| cli.adapter)
.collect();
let adapters = adapters.as_slice();
let (mut agents, git) = check(adapters, settings).await;
let seen = CliVersions::load(versions);
for agent in &mut agents {
with_what_was_seen(agent, adapters, &seen);
}
let new_commands = adapters
.iter()
.map(|adapter| {
let listed = seen.commands(adapter.id());
let count = listed
.iter()
.filter(|command| new_command(adapter.as_ref(), command))
.count();
(adapter.id().to_owned(), count)
})
.collect();
report(&agents, &new_commands, &git)
}
fn with_what_was_seen(agent: &mut FoundAgent, adapters: &[Arc<dyn Adapter>], seen: &CliVersions) {
agent.builtins = seen.builtins(&agent.id);
let Some(adapter) = adapters.iter().find(|adapter| adapter.id() == agent.id) else {
return;
};
if !agent.installed() {
return;
}
agent.check = version_check(
adapter.as_ref(),
Installed {
version: agent.version.as_deref(),
path: agent.path.as_deref(),
flags_whole: agent.problem.is_none(),
},
seen,
);
if let Some(version) = &agent.version {
agent.unread = seen.unread(&agent.id, version);
let missing = seen.startup_missing(&agent.id, version).unwrap_or_default();
agent.lacks = startup_warnings(adapter.as_ref(), version, &missing)
.into_iter()
.map(|(_, warning)| warning)
.collect();
}
}
pub(super) async fn check(
adapters: &[Arc<dyn Adapter>],
settings: &Settings,
) -> (Vec<FoundAgent>, GitCheck) {
let report = argonauts_conductor::doctor(adapters, settings).await;
let mut agents = probe::found_agents(adapters, &report, settings);
for (id, state) in probe::logins(&agents).await {
if let Some(agent) = agents.iter_mut().find(|agent| agent.id == id) {
agent.login = state;
}
}
(agents, report.git)
}
pub(super) fn report(
agents: &[FoundAgent],
new_commands: &BTreeMap<String, usize>,
git: &GitCheck,
) -> Vec<String> {
let mut lines = Vec::new();
for agent in agents {
let mut line = agent_line(agent);
let count = match new_commands.get(&agent.id) {
Some(&count) if agent.installed() => count,
_ => 0,
};
match count {
0 => {}
1 => line.push_str(" · 1 new command read from it"),
count => line.push_str(&format!(" · {count} new commands read from it")),
}
lines.push(line);
if agent.installed() {
lines.extend(agent.problem.iter().map(|problem| format!(" {problem}")));
lines.extend(agent.lacks.iter().map(|lack| format!(" ⚠ {lack}")));
}
lines.extend(unread_line(&agent.unread).map(|line| format!(" {line}")));
lines.extend(builtins_line(&agent.builtins).map(|line| format!(" {line}")));
}
lines.push(match (&git.version, &git.problem) {
(Some(version), _) => format!("git {version}"),
(None, Some(problem)) => format!("git · {problem}"),
(None, None) => "git · not found".to_owned(),
});
lines.push(verdict(agents, git));
if agents
.iter()
.any(|agent| agent.id == "claude-code" && agent.installed())
{
lines.push("Claude Code's own checkup: /checkup".to_owned());
}
lines
}
pub(super) fn agent_line(agent: &FoundAgent) -> String {
if !agent.installed() {
let mut parts = vec![agent.name.clone(), "not installed".to_owned()];
if let Some(install) = &agent.hints.install {
parts.push(format!("install: {install}"));
}
return parts.join(" · ");
}
let mut after = Vec::new();
match agent.login {
LoginState::LoggedIn => after.push("logged in".to_owned()),
LoginState::LoggedOut => after.push(match &agent.hints.login {
Some(login) => format!("not logged in: {login}"),
None => "not logged in".to_owned(),
}),
LoginState::Unknown => {}
}
if !agent.enabled {
after.push(format!("off · /agents add {} turns it on", agent.id));
}
let row = version_row(
&agent.name,
agent.version.as_deref().unwrap_or_default(),
&agent.check,
);
if after.is_empty() {
return row;
}
let after = after.join(" · ");
if agent.check.note().is_some() {
format!("{row} · {after}")
} else {
format!(
"{row:<width$}{after}",
width = VERSION_ROW_NAMES + VERSION_ROW_STATES
)
}
}
fn verdict(agents: &[FoundAgent], git: &GitCheck) -> String {
let ready = agents.iter().filter(|agent| agent.ready()).count();
if git.version.is_none() || ready == 0 {
return "Not ready: Argonauts Code needs git and at least one agent CLI that's installed, on, \
and logged in."
.to_owned();
}
format!("Ready with {ready} of {} agent CLIs.", agents.len())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use argonauts_conductor::AgentStatus;
use argonauts_core::adapter::SetupHints;
use argonauts_core::display_names::DisplayNames;
use argonauts_core::status_tab::{CliVersionCheck, VersionStanding};
use super::*;
fn agent(id: &str, status: AgentStatus, login: LoginState) -> FoundAgent {
let installed = status != AgentStatus::NotInstalled;
FoundAgent {
id: id.to_owned(),
name: DisplayNames::default().cli(id),
version: installed.then(|| "1.0.0".to_owned()),
path: installed.then(|| PathBuf::from(format!("/bin/{id}"))),
status,
enabled: true,
hints: SetupHints {
install: Some(format!("npm i -g {id}")),
login: Some(format!("{id} login")),
login_check: None,
},
login,
newest_tested: None,
unread: Vec::new(),
builtins: Vec::new(),
problem: None,
lacks: Vec::new(),
check: CliVersionCheck {
standing: match status {
AgentStatus::Tested => VersionStanding::Checked,
AgentStatus::Newer => VersionStanding::Newer,
_ => VersionStanding::Unknown,
},
..CliVersionCheck::default()
},
}
}
fn git() -> GitCheck {
GitCheck {
version: Some("2.50.1".to_owned()),
problem: None,
}
}
#[test]
fn each_cli_shows_its_version_and_login() {
let mut off = agent("hermes", AgentStatus::Untested, LoginState::Unknown);
off.enabled = false;
let lines = report(
&[
agent("claude-code", AgentStatus::Tested, LoginState::LoggedIn),
agent("codex", AgentStatus::Tested, LoginState::LoggedOut),
agent("opencode", AgentStatus::NotInstalled, LoginState::Unknown),
off,
],
&BTreeMap::new(),
&git(),
);
assert_eq!(
lines,
vec![
"Claude Code 1.0.0 checked logged in",
"Codex 1.0.0 checked not logged in: codex login",
"OpenCode · not installed · install: npm i -g opencode",
"hermes 1.0.0 unknown version off · /agents add hermes \
turns it on",
"git 2.50.1",
"Ready with 1 of 4 agent CLIs.",
"Claude Code's own checkup: /checkup",
]
);
}
#[test]
fn a_newer_cli_says_so_and_lists_what_argonauts_doesnt_read() {
let mut claude = agent("claude-code", AgentStatus::Newer, LoginState::LoggedIn);
claude.version = Some("2.1.291".to_owned());
claude.check.works = true;
claude.check.checked_with = Some("2.1.289".to_owned());
claude.unread = vec![
("system/turn_summary".to_owned(), 3),
("cost_update".to_owned(), 1),
];
claude.builtins = vec!["cc-plugin-new-thing".to_owned()];
let new_commands = BTreeMap::from([("claude-code".to_owned(), 2)]);
let lines = report(&[claude], &new_commands, &git());
assert_eq!(
lines[..3],
[
"Claude Code 2.1.291 newer than checked · works its flags and start-up \
message match 2.1.289 · logged in · 2 new commands read from it",
" sent 2 kinds of events Argonauts Code doesn't read yet: system/turn_summary, \
cost_update",
" loaded built-in plugins Argonauts Code doesn't know yet: cc-plugin-new-thing, \
which blind roles turn off",
]
);
let codex = report(
&[agent("codex", AgentStatus::Tested, LoginState::LoggedIn)],
&BTreeMap::new(),
&git(),
);
assert!(
!codex.iter().any(|line| line.contains("/checkup")),
"{codex:?}"
);
}
#[tokio::test]
async fn slash_doctor_reports_each_cli_without_holding_the_screen() {
use argonauts_core::app::UiCommand;
use argonauts_core::input::SlashCommand;
use crate::controller::test_support::{CliOptions, TestDriver};
let ids = [
"claude-code",
"codex",
"antigravity-cli",
"opencode",
"opencode-acp",
];
let flags = ids
.iter()
.map(|id| format!(r#"agents.{id}.binary="/nowhere/{id}""#))
.collect();
let mut driver = TestDriver::start_with(CliOptions {
flags,
..CliOptions::default()
})
.await;
driver
.send(UiCommand::Slash {
command: SlashCommand::Doctor,
})
.await;
let event = driver
.until(|event| matches!(event, AppEvent::Report { .. }))
.await;
let AppEvent::Report { title, lines } = event else {
unreachable!()
};
assert_eq!(title, "Doctor");
assert!(
lines[0].starts_with("Claude Code · not installed"),
"{lines:?}"
);
for line in &lines[..ids.len()] {
assert!(line.contains(" · not installed"), "{lines:?}");
}
assert_eq!(lines.len(), ids.len() + 2, "{lines:?}");
assert!(lines[ids.len()].starts_with("git"), "{lines:?}");
assert!(lines[ids.len() + 1].starts_with("Not ready"), "{lines:?}");
driver.exit().await;
}
#[tokio::test]
async fn slash_update_runs_each_updater_then_doctor() {
use argonauts_core::app::UiCommand;
use argonauts_core::input::SlashCommand;
use crate::controller::test_support::{CliOptions, TestDriver};
let dir = tempfile::tempdir().unwrap();
let version = dir.path().join("version");
std::fs::write(&version, "2.1.292 (Claude Code)\n").unwrap();
let claude = dir.path().join("claude");
argonauts_testkit::write_script(
&claude,
&format!(
"#!/bin/sh\ncase \"$1\" in\n --version) cat '{v}' ;;\n update) echo '2.1.295 \
(Claude Code)' > '{v}' ;;\nesac\n",
v = version.display()
),
)
.unwrap();
let mut flags = vec![format!(
r#"agents.claude-code.binary="{}""#,
claude.display()
)];
for id in ["codex", "antigravity-cli", "opencode", "opencode-acp"] {
flags.push(format!(r#"agents.{id}.binary="/nowhere/{id}""#));
}
let mut driver = TestDriver::start_with(CliOptions {
flags,
..CliOptions::default()
})
.await;
driver
.send(UiCommand::Slash {
command: SlashCommand::Update,
})
.await;
let working = |work| move |event: &AppEvent| matches!(event, AppEvent::CommandWorking { work: w } if *w == work);
driver.until(working(Some(CommandWork::Updating))).await;
let so_far = |event: &AppEvent| matches!(event, AppEvent::ReportSoFar { lines, .. } if lines.len() > 1);
let AppEvent::ReportSoFar { title, lines } = driver.until(so_far).await else {
unreachable!()
};
assert_eq!(title, "Update");
assert_eq!(
lines[0],
format!(
"Argonauts Code {:<7} updates are off in tests",
crate::self_update::running_version()
)
);
assert!(
lines[1].starts_with("Claude Code 2.1.292 → 2.1.295"),
"{lines:?}"
);
let report = |event: &AppEvent| matches!(event, AppEvent::Report { .. });
let AppEvent::Report { title, lines } = driver.until(report).await else {
unreachable!()
};
assert_eq!(title, "Update");
assert!(lines[0].starts_with("Argonauts Code "), "{lines:?}");
assert!(
lines[1].starts_with("Claude Code ") && lines[1].ends_with("2.1.292 → 2.1.295"),
"{lines:?}"
);
assert!(lines[2].contains("couldn't update"), "{lines:?}");
driver.until(working(Some(CommandWork::Checking))).await;
let AppEvent::Report { title, lines } = driver.until(report).await else {
unreachable!()
};
assert_eq!(title, "Doctor");
assert!(lines[0].starts_with("Claude Code 2.1.295 "), "{lines:?}");
driver.until(working(None)).await;
driver.exit().await;
}
#[tokio::test]
async fn slash_update_reads_argonauts_codes_own_manifest_first() {
use argonauts_core::app::UiCommand;
use argonauts_core::input::SlashCommand;
use crate::controller::test_support::{CliOptions, TestDriver};
use crate::self_update::test_server::{FakeServer, Signer, manifest};
let mut flags = Vec::new();
for id in [
"claude-code",
"codex",
"antigravity-cli",
"opencode",
"opencode-acp",
] {
flags.push(format!(r#"agents.{id}.binary="/nowhere/{id}""#));
}
let mut driver = TestDriver::start_with(CliOptions {
flags,
..CliOptions::default()
})
.await;
let server = FakeServer::start().await;
let signer = Signer::new();
let mut next = running_version();
next.patch += 1;
server.publish("stable", &manifest(&next.to_string(), 1), &signer);
crate::self_update::testing::serve(
driver.env.paths.home(),
server.source(vec![signer.public()]),
);
driver
.send(UiCommand::Slash {
command: SlashCommand::Update,
})
.await;
let report =
|event: &AppEvent| matches!(event, AppEvent::Report { title, .. } if title == "Update");
let AppEvent::Report { lines, .. } = driver.until(report).await else {
unreachable!()
};
assert_eq!(
lines[0],
format!(
"Argonauts Code {} {next} is out · {}",
running_version(),
crate::self_update::RELEASES
)
);
assert_eq!(
lines.last().unwrap(),
"This copy of Argonauts Code wasn't put here by its installer, so it doesn't replace \
itself."
);
assert!(
server
.asked()
.contains(&"/updates/v1/stable.json".to_owned())
);
driver.exit().await;
}
fn report_rows(lines: Vec<String>) -> Vec<ratatui::text::Line<'static>> {
use argonauts_tui::bridge::Bridge;
use argonauts_tui::{UiOptions, ViewModel};
let mut bridge = Bridge::new(&UiOptions::default());
let mut model = ViewModel::default();
let report = AppEvent::Report {
title: "Update".to_owned(),
lines,
};
bridge.on_event(&mut model, &report, std::time::Instant::now());
let theme = crate::golden::theme();
let rows = model.history.drain_printable(&crate::golden::still(&theme));
let drawn = crate::golden::drawn_rows(&rows);
let first = drawn.iter().position(|row| !row.is_empty()).unwrap_or(0);
let end = drawn
.iter()
.rposition(|row| !row.is_empty())
.map_or(0, |at| at + 1);
rows[first..end].to_vec()
}
#[test]
fn update_panel_is_its_picture() {
use argonauts_conductor::doctor::update::UpdateOutcome;
use argonauts_core::settings::updates::UpdateSettings;
use ratatui::text::{Line, Span};
use crate::self_update::Install;
let cli = |name: &str, version: &str, also: &[&str]| CliUpdate {
name: name.to_owned(),
outcome: UpdateOutcome::UpToDate {
version: version.to_owned(),
},
also_through: also.iter().map(|way| (*way).to_owned()).collect(),
};
let clis = [
cli("Claude Code", "2.1.304", &[]),
cli("Codex", "0.163.0", &[]),
cli("agy", "1.4.3", &[]),
cli("OpenCode", "2.0.32", &["ACP"]),
];
let rows = |words: &str| {
update_lines_under((OWN_ROW, "0.1.0", words), UpdateColumns::default(), &clis)
};
let mut lines = rows("0.1.1 downloaded · takes over at the next start · /release-notes");
lines.push(how_it_updates(Install::Shell, &UpdateSettings::default()));
let mut drawn = report_rows(lines);
let homebrew = report_rows(rows("0.1.1 is out · brew upgrade argonauts-code"));
let hook_off =
homebrew[1]
.spans
.iter()
.fold((Vec::new(), 5usize), |(mut spans, mut skip), span| {
let chars = span.content.chars().count();
if skip >= chars {
skip -= chars;
} else {
let rest: String = span.content.chars().skip(skip).collect();
spans.push(Span::styled(rest, span.style));
skip = 0;
}
(spans, skip)
});
let mut first = vec![Span::raw(" ")];
first.extend(hook_off.0);
drawn.push(Line::from(first));
crate::golden::dump("update_panel", &drawn);
let rows = crate::golden::drawn_rows(&drawn);
assert_eq!(
rows,
crate::golden::picture_rows(include_str!("../../tests/golden/update_panel.txt"))
);
let at = |row: usize, words: &str| {
let start = rows[row][..rows[row].find(words).unwrap()].chars().count();
(row, start..start + words.chars().count())
};
let otherwise = [
at(1, "/release-notes"),
at(7, "brew upgrade argonauts-code"),
];
crate::golden::assert_looks_except(
&crate::golden::theme(),
&drawn,
include_str!("../../tests/golden/update_panel.html"),
|row, column| {
otherwise
.iter()
.any(|(at, columns)| *at == row && columns.contains(&column))
},
);
}
#[tokio::test]
async fn slash_update_stops_on_esc() {
use argonauts_core::app::UiCommand;
use argonauts_core::input::SlashCommand;
use crate::controller::test_support::{CliOptions, TestDriver};
let dir = tempfile::tempdir().unwrap();
let started = dir.path().join("started");
let claude = dir.path().join("claude");
argonauts_testkit::write_script(
&claude,
&format!(
"#!/bin/sh\ncase \"$1\" in\n --version) echo '2.1.292 (Claude Code)' ;;\n \
update) echo started > '{}'; sleep 30 ;;\nesac\n",
started.display()
),
)
.unwrap();
let mut flags = vec![format!(
r#"agents.claude-code.binary="{}""#,
claude.display()
)];
for id in ["codex", "antigravity-cli", "opencode", "opencode-acp"] {
flags.push(format!(r#"agents.{id}.binary="/nowhere/{id}""#));
}
let mut driver = TestDriver::start_with(CliOptions {
flags,
..CliOptions::default()
})
.await;
let update = UiCommand::Slash {
command: SlashCommand::Update,
};
driver.send(update.clone()).await;
while !started.exists() {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
driver.send(update).await;
let notice = |event: &AppEvent| matches!(event, AppEvent::Notice { .. });
let AppEvent::Notice { text, .. } = driver.until(notice).await else {
unreachable!()
};
assert_eq!(text, UPDATE_GOING);
let began = std::time::Instant::now();
driver.send(UiCommand::StopCommand).await;
let report =
|event: &AppEvent| matches!(event, AppEvent::Report { title, .. } if title == "Update");
let AppEvent::Report { lines, .. } = driver.until(report).await else {
unreachable!()
};
assert!(
began.elapsed() < std::time::Duration::from_secs(3),
"{:?}",
began.elapsed()
);
assert!(
lines[1].starts_with("Claude Code ") && lines[1].ends_with("2.1.292 stopped"),
"{lines:?}"
);
driver
.until(|event| matches!(event, AppEvent::CommandWorking { work: None }))
.await;
let events = driver.exit().await;
let doctor = events
.iter()
.any(|event| matches!(event, AppEvent::Report { title, .. } if title == "Doctor"));
assert!(!doctor, "Doctor doesn't follow a stopped update");
}
#[tokio::test]
async fn stopping_with_no_command_at_work_does_nothing() {
use argonauts_core::app::UiCommand;
use crate::controller::test_support::TestDriver;
let driver = TestDriver::start().await;
driver.send(UiCommand::StopCommand).await;
let events = driver.exit().await;
assert!(
!events.iter().any(|event| matches!(
event,
AppEvent::Notice { .. } | AppEvent::CommandWorking { .. }
)),
"{events:#?}"
);
}
#[tokio::test]
async fn slash_update_waits_for_runs_to_end() {
use argonauts_core::app::UiCommand;
use argonauts_core::input::SlashCommand;
use crate::controller::test_support::TestDriver;
use crate::live_registry::{LiveRunHost, register};
let mut driver = TestDriver::start().await;
let (pid, start_time) = crate::ancestry::own_process();
let going = LiveRunHost {
pid,
start_time,
repo_root: "/another/folder".into(),
run: argonauts_core::RunId::new("run-20261010-091503-3fa2").unwrap(),
sessions: 1,
};
register(&driver.env.paths, &going).unwrap();
driver
.send(UiCommand::Slash {
command: SlashCommand::Update,
})
.await;
let notice = |event: &AppEvent| matches!(event, AppEvent::Notice { .. });
let AppEvent::Notice { text, .. } = driver.until(notice).await else {
unreachable!()
};
assert_eq!(text, crate::controller::cli_updates::UPDATE_WAITS);
let events = driver.exit().await;
let reported = events
.iter()
.any(|event| matches!(event, AppEvent::Report { .. }));
assert!(!reported, "nothing was updated or checked");
}
#[test]
fn the_cli_rows_read_as_the_versions_picture_lists_them() {
let row = |id: &str, version: &str, check: CliVersionCheck| {
let mut found = agent(id, AgentStatus::Tested, LoginState::Unknown);
found.version = Some(version.to_owned());
found.check = check;
agent_line(&found)
};
let checked = CliVersionCheck {
standing: VersionStanding::Checked,
works: true,
..CliVersionCheck::default()
};
let rows = [
row(
"claude-code",
"2.1.296",
CliVersionCheck {
newest: Some("2.1.297".to_owned()),
newer_is_out: true,
update_command: Some("claude update".to_owned()),
..checked.clone()
},
),
row(
"codex",
"0.158.0",
CliVersionCheck {
standing: VersionStanding::Older,
oldest_supported: Some("0.160.0".to_owned()),
update_command: Some("brew upgrade codex".to_owned()),
..CliVersionCheck::default()
},
),
row(
"antigravity-cli",
"1.3.3",
CliVersionCheck {
standing: VersionStanding::Newer,
works: true,
checked_with: Some("1.3.1".to_owned()),
..CliVersionCheck::default()
},
),
row(
"opencode",
"1.18.34",
CliVersionCheck {
newest: Some("1.18.34".to_owned()),
..checked
},
),
];
let picture = include_str!("../../../tui/tests/golden/overview/versions.txt");
let drawn: Vec<&str> = picture.lines().skip(2).take(4).collect();
assert_eq!(rows.to_vec(), drawn);
}
#[test]
fn what_a_version_lacks_is_named_under_its_row() {
let mut claude = agent("claude-code", AgentStatus::Newer, LoginState::Unknown);
claude.version = Some("2.1.297".to_owned());
claude.problem = Some("--help lacks flags Argonauts Code passes: --effort".to_owned());
claude.lacks = vec![
"Claude Code 2.1.297 no longer reports its model, so /context can't size its window \
until Argonauts Code is updated"
.to_owned(),
];
let lines = report(&[claude], &BTreeMap::new(), &git());
assert_eq!(
lines[..3],
[
"Claude Code 2.1.297 newer than checked",
" --help lacks flags Argonauts Code passes: --effort",
" ⚠ Claude Code 2.1.297 no longer reports its model, so /context can't size its \
window until Argonauts Code is updated",
]
);
}
#[test]
fn no_ready_cli_or_no_git_is_not_ready() {
let none = report(
&[agent("codex", AgentStatus::Tested, LoginState::LoggedOut)],
&BTreeMap::new(),
&git(),
);
assert!(none.last().unwrap().starts_with("Not ready"), "{none:?}");
let no_git = report(
&[agent("codex", AgentStatus::Tested, LoginState::LoggedIn)],
&BTreeMap::new(),
&GitCheck {
version: None,
problem: Some("git is not installed".to_owned()),
},
);
assert_eq!(no_git[1], "git · git is not installed");
assert!(no_git[2].starts_with("Not ready"), "{no_git:?}");
}
}