use std::path::PathBuf;
use argonauts_core::events::run::{BriefFrom, ForkFrom, StartsFrom};
use argonauts_core::names::{ModelRef, RoleName};
use argonauts_core::settings::Settings;
use argonauts_core::settings::crew::OrchestratorContext;
use super::ConversationState;
use crate::controller::Ctx;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MainSession {
pub worker: ModelRef,
pub cli_session: String,
pub folder: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Start {
Fork(MainSession),
Brief(Brief),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Brief {
Chosen,
OtherCli { orchestrator: String, main: String },
NoFork { cli: String },
NoSession,
}
impl Brief {
fn why(&self, cli: impl Fn(&str) -> String) -> Option<String> {
match self {
Self::Chosen | Self::NoSession => None,
Self::OtherCli { orchestrator, main } => Some(format!(
"{} can't carry a {} conversation",
cli(orchestrator),
cli(main)
)),
Self::NoFork { cli: id } => Some(format!("{} can't copy a conversation", cli(id))),
}
}
}
pub(crate) fn start(ctx: &Ctx, conversation: &ConversationState, settings: &Settings) -> Start {
let Some(orchestrator) = orchestrator(ctx, settings) else {
return Start::Brief(Brief::NoSession);
};
start_on(ctx, conversation, settings, &orchestrator)
}
pub(crate) fn start_on(
ctx: &Ctx,
conversation: &ConversationState,
settings: &Settings,
orchestrator: &ModelRef,
) -> Start {
if settings.crew.orchestrator_context == OrchestratorContext::Brief {
return Start::Brief(Brief::Chosen);
}
let Some(session) = &conversation.session else {
return Start::Brief(Brief::NoSession);
};
if session.worker.agent != orchestrator.agent {
return Start::Brief(Brief::OtherCli {
orchestrator: orchestrator.agent.clone(),
main: session.worker.agent.clone(),
});
}
let forks = ctx
.services
.workers
.get(orchestrator)
.is_ok_and(|runner| runner.capabilities().fork);
if !forks {
return Start::Brief(Brief::NoFork {
cli: orchestrator.agent.clone(),
});
}
Start::Fork(MainSession {
worker: session.worker.clone(),
cli_session: session.cli_session.clone(),
folder: conversation.folder(ctx).to_path_buf(),
})
}
fn orchestrator(ctx: &Ctx, settings: &Settings) -> Option<ModelRef> {
let models = argonauts_crew::roles::candidates(settings, RoleName::Orchestrator);
let available = ctx.services.workers.available();
models
.iter()
.find(|model| available.contains(model))
.or(models.first())
.cloned()
}
impl Start {
pub(crate) fn words(&self, cli: impl Fn(&str) -> String) -> String {
const BRIEF: &str = "starting from the brief";
match self {
Self::Fork(_) => "carrying this conversation".to_owned(),
Self::Brief(brief) => match brief.why(cli) {
Some(why) => format!("{BRIEF}, since {why}"),
None => BRIEF.to_owned(),
},
}
}
pub(crate) fn starts_from(
&self,
main: &ModelRef,
tokens: Option<u64>,
cli: impl Fn(&str) -> String,
) -> StartsFrom {
match self {
Self::Fork(session) => StartsFrom::Fork(ForkFrom {
worker: session.worker.clone(),
cli_session: session.cli_session.clone(),
folder: session.folder.clone(),
tokens,
}),
Self::Brief(brief) => StartsFrom::Brief(BriefFrom {
written_by: main.clone(),
why: brief.why(cli),
}),
}
}
pub(crate) fn forks(&self) -> bool {
matches!(self, Self::Fork(_))
}
}
#[cfg(test)]
mod tests {
use argonauts_core::display_names::DisplayNames;
use argonauts_core::session_runner::{RunnerCapabilities, SessionRunner as _};
use argonauts_testkit::FakeRunner;
use super::*;
use crate::controller::test_support::{CliOptions, TestCtx};
use crate::conversation_store::ConversationSession;
fn model(text: &str) -> ModelRef {
text.parse().unwrap()
}
#[test]
fn only_the_main_sessions_own_cli_with_a_fork_copies_it() {
let (parts, _) = TestCtx::new(CliOptions::default());
let runner = FakeRunner::cli("claude-code/opus-5-5", "anthropic");
let capabilities = RunnerCapabilities {
fork: true,
..runner.capabilities()
};
parts.directory.add(runner.with_capabilities(capabilities));
let mut conversation = ConversationState::new(&parts.ctx).unwrap();
let settings = Settings::default();
let opus = model("claude-code/opus-5-5");
let start = |conversation: &ConversationState, settings: &Settings, worker: &str| {
start_on(&parts.ctx, conversation, settings, &model(worker))
};
assert_eq!(
start(&conversation, &settings, "claude-code/opus-5-5"),
Start::Brief(Brief::NoSession)
);
conversation.session = Some(ConversationSession {
worker: opus.clone(),
cli_session: "main-session-1".to_owned(),
fork: false,
});
assert_eq!(
start(&conversation, &settings, "claude-code/opus-5-5"),
Start::Fork(MainSession {
worker: opus,
cli_session: "main-session-1".to_owned(),
folder: parts.repo.root().to_path_buf(),
})
);
assert_eq!(
start(&conversation, &settings, "antigravity-cli"),
Start::Brief(Brief::OtherCli {
orchestrator: "antigravity-cli".to_owned(),
main: "claude-code".to_owned(),
})
);
assert_eq!(
start(&conversation, &settings, "claude-code/sonnet-5"),
Start::Brief(Brief::NoFork {
cli: "claude-code".to_owned()
})
);
let mut brief = Settings::default();
brief.crew.orchestrator_context = OrchestratorContext::Brief;
assert_eq!(
start(&conversation, &brief, "claude-code/opus-5-5"),
Start::Brief(Brief::Chosen)
);
}
#[test]
fn each_start_says_what_the_orchestrator_begins_from() {
let names = DisplayNames::default();
let cli = |id: &str| names.cli(id);
let fork = Start::Fork(MainSession {
worker: "claude-code/opus-5-5".parse().unwrap(),
cli_session: "11111111-2222-4333-8444-555555555555".to_owned(),
folder: PathBuf::from("/repo"),
});
assert_eq!(fork.words(cli), "carrying this conversation");
assert!(fork.forks());
let other = Start::Brief(Brief::OtherCli {
orchestrator: "antigravity-cli".to_owned(),
main: "claude-code".to_owned(),
});
assert_eq!(
other.words(cli),
"starting from the brief, since agy can't carry a Claude Code conversation"
);
assert!(!other.forks());
assert_eq!(
Start::Brief(Brief::Chosen).words(cli),
"starting from the brief"
);
assert_eq!(
Start::Brief(Brief::NoFork {
cli: "antigravity-cli".to_owned()
})
.words(cli),
"starting from the brief, since agy can't copy a conversation"
);
}
#[test]
fn each_start_is_kept_with_the_run_for_its_views() {
let names = DisplayNames::default();
let cli = |id: &str| names.cli(id);
let opus: ModelRef = "claude-code/opus-5-5".parse().unwrap();
let session = MainSession {
worker: opus.clone(),
cli_session: "main-1".to_owned(),
folder: PathBuf::from("/repo"),
};
assert_eq!(
Start::Fork(session).starts_from(&opus, Some(48_200), cli),
StartsFrom::Fork(ForkFrom {
worker: opus.clone(),
cli_session: "main-1".to_owned(),
folder: PathBuf::from("/repo"),
tokens: Some(48_200),
})
);
let other = Start::Brief(Brief::OtherCli {
orchestrator: "antigravity-cli".to_owned(),
main: "claude-code".to_owned(),
});
assert_eq!(
other.starts_from(&opus, Some(48_200), cli),
StartsFrom::Brief(BriefFrom {
written_by: opus.clone(),
why: Some("agy can't carry a Claude Code conversation".to_owned()),
})
);
assert_eq!(
Start::Brief(Brief::Chosen).starts_from(&opus, None, cli),
StartsFrom::Brief(BriefFrom {
written_by: opus,
why: None,
})
);
}
}