use argonauts_core::app::ChoiceKind;
use argonauts_core::model_catalog::display_name_from_id;
use argonauts_core::names::ModelRef;
use super::questions::{Question, SetupView, agent_answers, described, kept_or_new};
use super::steps::{Answers, ORCHESTRATOR};
use crate::choices::{InitAnswer, init_options};
pub(crate) const KEEP: &str = "keep";
const CLI_DEFAULT: &str = "cli-default";
pub(crate) const ORCHESTRATOR_KEY: &str = "roles.orchestrator.models";
pub(crate) const REVIEWER_KEY: &str = "roles.reviewer.models";
const MAIN_AGENT_KEY: &str = "crew.main";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelOption {
pub model: Option<String>,
pub name: String,
pub note: Option<String>,
}
pub(crate) fn who_does_what(view: &SetupView, answers: &Answers) -> Question {
let mut rows = vec![described(
KEEP,
"Keep them all",
"Each role keeps who fills it now.",
)];
rows.extend(view.role_keys.iter().map(|key| InitAnswer {
label: label(key),
value: key.clone(),
detail: Some(now_words(view, answers, key)),
}));
Question {
kind: ChoiceKind::Init,
title: "Who does what".to_owned(),
body: vec![
"Each role the crew fills, and who fills it now. Choose one to change it.".to_owned(),
],
options: init_options(&rows),
}
}
pub(crate) fn agent_question(view: &SetupView, answers: &Answers, key: &str) -> Option<Question> {
let current = worker_for(view, answers, key);
let agents = agent_answers(view, None, current.as_ref());
if agents.is_empty() {
return None;
}
let mut rows = vec![described(KEEP, "Keep it", &now_words(view, answers, key))];
rows.extend(agents);
Some(Question {
kind: ChoiceKind::Init,
title: format!("Who is {}", subject(key).to_lowercase()),
body: Vec::new(),
options: init_options(&rows),
})
}
pub(crate) fn model_question(view: &SetupView, answers: &Answers, key: &str) -> Option<Question> {
let worker = worker_for(view, answers, key)?;
let models = view.models.get(&worker.agent)?;
if models.len() < 2 {
return None;
}
let first = worker.model.as_deref().unwrap_or(CLI_DEFAULT);
let mut rows: Vec<InitAnswer> = models
.iter()
.map(|option| InitAnswer {
label: option.name.clone(),
value: option
.model
.clone()
.unwrap_or_else(|| CLI_DEFAULT.to_owned()),
detail: option.note.clone(),
})
.collect();
rows.sort_by_key(|row| row.value != first);
Some(Question {
kind: ChoiceKind::Init,
title: format!("{}'s model", subject(key)),
body: vec![format!("It runs on {}.", view.agent_name(&worker.agent))],
options: init_options(&rows),
})
}
pub(crate) fn chose_row(value: &str) -> String {
if value == KEEP {
return "Every role keeps who fills it.".to_owned();
}
format!("Choosing who is {}.", subject(value).to_lowercase())
}
pub(crate) fn chose_agent(
view: &SetupView,
answers: &mut Answers,
key: &str,
value: &str,
) -> String {
if value == KEEP {
return format!("{} stays {}.", subject(key), now_words(view, answers, key));
}
let worker = kept_or_new(worker_for(view, answers, key), value);
let line = format!("{} is {}.", subject(key), worker_words(view, &worker));
set_worker(answers, key, worker);
line
}
pub(crate) fn chose_model(
view: &SetupView,
answers: &mut Answers,
key: &str,
value: &str,
) -> String {
let Some(worker) = worker_for(view, answers, key) else {
return String::new();
};
let model = (value != CLI_DEFAULT).then(|| value.to_owned());
let worker = ModelRef::new(worker.agent, model);
let line = format!("{} runs {}.", subject(key), worker_words(view, &worker));
set_worker(answers, key, worker);
line
}
pub(crate) fn worker_for(view: &SetupView, answers: &Answers, key: &str) -> Option<ModelRef> {
match key {
MAIN_AGENT_KEY => {
return answers
.main_agent
.clone()
.or_else(|| view.current_str(key)?.parse().ok());
}
ORCHESTRATOR_KEY if answers.workflow == ORCHESTRATOR && answers.orchestrator.is_some() => {
return answers.orchestrator.clone();
}
_ => {}
}
if let Some(worker) = answers.roles.get(key) {
return Some(worker.clone());
}
if key == REVIEWER_KEY
&& let Some(pick) = answers.reviewers.first()
{
return Some(ModelRef::new(
pick.endpoint.clone(),
Some(pick.model.clone()),
));
}
let named = match view.current_str(key) {
Some(one) => Some(one.to_owned()),
None => view.current_list(key).into_iter().next(),
};
named?.parse().ok()
}
fn set_worker(answers: &mut Answers, key: &str, worker: ModelRef) {
match key {
MAIN_AGENT_KEY => answers.main_agent = Some(worker),
ORCHESTRATOR_KEY if answers.workflow == ORCHESTRATOR => answers.orchestrator = Some(worker),
_ => {
answers.roles.insert(key.to_owned(), worker);
}
}
}
fn now_words(view: &SetupView, answers: &Answers, key: &str) -> String {
match worker_for(view, answers, key) {
Some(worker) => worker_words(view, &worker),
None => match key {
"narrator.model" => "your main agent".to_owned(),
_ => "anyone in your crew".to_owned(),
},
}
}
fn worker_words(view: &SetupView, worker: &ModelRef) -> String {
let agent = view.agent_name(&worker.agent);
match &worker.model {
None => agent,
Some(model) => {
let name = view
.models
.get(&worker.agent)
.and_then(|models| {
models
.iter()
.find(|option| option.model.as_deref() == Some(model))
})
.map_or_else(|| display_name_from_id(model), |option| option.name.clone());
format!("{name} on {agent}")
}
}
}
fn label(key: &str) -> String {
match key {
MAIN_AGENT_KEY => "Main agent".to_owned(),
"narrator.model" => "narrator".to_owned(),
_ => key
.strip_prefix("roles.")
.and_then(|rest| rest.strip_suffix(".models"))
.unwrap_or(key)
.to_owned(),
}
}
fn subject(key: &str) -> String {
match key {
MAIN_AGENT_KEY => "Your main agent".to_owned(),
_ => format!("The {}", label(key)),
}
}
pub(crate) fn toml_value(key: &str, worker: &ModelRef) -> String {
let one = toml::Value::String(worker.to_string());
if key.ends_with(".models") {
toml::Value::Array(vec![one]).to_string()
} else {
one.to_string()
}
}
#[cfg(test)]
mod tests {
use argonauts_conductor::AgentStatus;
use argonauts_core::adapter::SetupHints;
use super::*;
use crate::setup::probe::{FoundAgent, LoginState};
use crate::setup::questions::value_of;
fn codex_view() -> SetupView {
let codex = FoundAgent {
id: "codex".to_owned(),
name: "Codex".to_owned(),
version: Some("0.158.0".to_owned()),
path: Some("/bin/codex".into()),
status: AgentStatus::Tested,
enabled: true,
hints: SetupHints::default(),
login: LoginState::Unknown,
newest_tested: None,
unread: Vec::new(),
builtins: Vec::new(),
problem: None,
lacks: Vec::new(),
check: Default::default(),
};
let mut view = SetupView {
agents: vec![codex],
logins_known: true,
..SetupView::default()
};
view.models.insert(
"codex".to_owned(),
vec![
ModelOption {
model: None,
name: "Default".to_owned(),
note: Some("runs gpt-5.6".to_owned()),
},
ModelOption {
model: Some("gpt-5.6-luna".to_owned()),
name: "GPT-5.6 Luna".to_owned(),
note: None,
},
],
);
view
}
#[test]
fn the_orchestrators_model_is_one_of_its_agents_and_sets_it() {
let view = codex_view();
let mut answers = Answers {
workflow: ORCHESTRATOR.to_owned(),
orchestrator: Some(ModelRef::new("codex", None)),
..Answers::default()
};
let question = model_question(&view, &answers, ORCHESTRATOR_KEY).unwrap();
assert_eq!(question.title, "The orchestrator's model");
let values: Vec<String> = question
.options
.iter()
.filter_map(|option| value_of(&option.action))
.collect();
assert_eq!(values, [CLI_DEFAULT, "gpt-5.6-luna"]);
let line = chose_model(&view, &mut answers, ORCHESTRATOR_KEY, "gpt-5.6-luna");
assert_eq!(line, "The orchestrator runs GPT-5.6 Luna on Codex.");
assert_eq!(
answers.orchestrator,
Some(ModelRef::new("codex", Some("gpt-5.6-luna".to_owned())))
);
let again = model_question(&view, &answers, ORCHESTRATOR_KEY).unwrap();
assert_eq!(
value_of(&again.options[0].action).as_deref(),
Some("gpt-5.6-luna")
);
}
#[test]
fn an_agent_with_only_its_default_asks_no_model() {
let mut view = codex_view();
view.models.get_mut("codex").unwrap().truncate(1);
let answers = Answers {
roles: [(
"roles.reviewer.models".to_owned(),
ModelRef::new("codex", None),
)]
.into(),
..Answers::default()
};
assert!(model_question(&view, &answers, "roles.reviewer.models").is_none());
}
#[test]
fn a_local_reviewer_shows_as_the_reviewer_and_a_later_choice_wins() {
use crate::setup::steps::{ReviewerPick, keys};
let view = codex_view();
let mut answers = Answers {
reviewers: vec![ReviewerPick {
endpoint: "ollama".to_owned(),
base_url: "http://localhost:11434".to_owned(),
model: "qwen3-coder".to_owned(),
}],
..Answers::default()
};
assert_eq!(
worker_for(&view, &answers, REVIEWER_KEY),
Some(ModelRef::new("ollama", Some("qwen3-coder".to_owned())))
);
chose_agent(&view, &mut answers, REVIEWER_KEY, "codex");
let written: Vec<(String, String)> = keys(&answers, true)
.into_iter()
.filter(|(key, _)| key == REVIEWER_KEY)
.collect();
assert_eq!(
written,
[(REVIEWER_KEY.to_owned(), r#"["codex"]"#.to_owned())]
);
}
#[test]
fn rows_read_as_model_names_them() {
assert_eq!(label("crew.main"), "Main agent");
assert_eq!(label("roles.reviewer.models"), "reviewer");
assert_eq!(label("roles.judge.models"), "judge");
assert_eq!(subject("roles.tie-breaker.models"), "The tie-breaker");
assert_eq!(subject("crew.main"), "Your main agent");
}
#[test]
fn a_roles_key_takes_a_list_and_the_others_one_worker() {
let worker = ModelRef::new("codex", Some("gpt-5.6".to_owned()));
assert_eq!(
toml_value("roles.reviewer.models", &worker),
r#"["codex/gpt-5.6"]"#
);
assert_eq!(toml_value("narrator.model", &worker), r#""codex/gpt-5.6""#);
}
}