use super::*;
pub(super) const MAX_SUBAGENT_CONTEXT_BYTES: usize = 256 * 1024;
pub(super) async fn spawn_subagent(
State(state): State<ServerState>,
Path(parent_session_id): Path<String>,
Json(request): Json<SpawnSubagentRequest>,
) -> Result<(StatusCode, Json<SubagentView>), ApiFailure> {
let backend = backend(&state)?.clone();
let parent = {
let snapshot = state.snapshot_rx.borrow();
require_session_record(&snapshot, &parent_session_id)?.clone()
};
if !matches!(parent.harness_kind.as_str(), "claude" | "codex") {
return Err(ApiFailure::conflict(
"only Claude and Codex sessions can spawn sub-agents",
));
}
let initial_prompt = build_subagent_prompt(&request.instructions)?;
if request.task_name.trim().is_empty() {
return Err(ApiFailure::bad_request("task_name cannot be empty"));
}
let selection = resolve_subagent_policy_selection(
&backend,
&parent.profile_id,
&parent.subagents,
request.task_type.unwrap_or_default(),
)
.await?;
let relation = backend
.start_subagent(crate::controller::RegisterSubagentRequest {
parent_session_id: parent_session_id.clone(),
task_name: request.task_name,
profile_id: selection.profile_id,
model: Some(selection.model.clone()),
effort: selection.effort.clone(),
working_directory: request.working_directory.unwrap_or_default(),
initial_prompt,
request_key: mj_core::state::new_session_id().map_err(ApiFailure::from)?,
report_root: None,
})
.await
.map_err(|error| ApiFailure::conflict(format!("sub-agent creation failed: {error:#}")))?;
backend
.start_followup(
relation.child_session_id.clone(),
StartFollowup {
model: Some(selection.model),
effort: selection.effort,
prompt: Some(relation.initial_prompt.clone()),
fast_mode: selection.fast_mode,
},
)
.await?;
let session = await_session_record(&state, &relation.child_session_id).await?;
Ok((
StatusCode::CREATED,
Json(SubagentView {
parent_session_id,
task_name: relation.task_name,
session,
}),
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubagentSelection {
pub profile_id: String,
pub model: String,
pub effort: Option<String>,
pub fast_mode: bool,
}
pub(crate) async fn resolve_subagent_policy_selection(
backend: &Arc<dyn SubagentBackend>,
parent_profile: &str,
policy: &mj_core::subagent::SubagentPolicy,
task_type: mj_core::subagent::SubagentTaskType,
) -> Result<SubagentSelection, ApiFailure> {
if !policy.uses_mjolnir() {
return Err(ApiFailure::conflict(
"this session does not allow Mjolnir sub-agents",
));
}
let config = backend.subagent_config().await?;
let choice = policy
.override_choice(task_type)
.or_else(|| config.choice(task_type))
.ok_or_else(|| ApiFailure::conflict(format!(
"Configure [subagents.{}] or pass --subagent-{} <MODEL> before spawning this Mjolnir sub-agent",
task_type.id(), task_type.id()
)))?;
if choice.effort.as_deref() == Some("adaptive") {
return Err(ApiFailure::bad_request(
"adaptive sub-agent effort is not supported",
));
}
resolve_model_profile_selection_matching(
backend,
None,
&choice.model,
choice
.effort
.as_deref()
.map(EffortRequirement::Exact)
.unwrap_or(EffortRequirement::Default),
None,
ModelProfileCandidateSource::Subagents { parent_profile },
)
.await
.map_err(|error| {
let context = if policy.override_choice(task_type).is_some() {
format!("session --subagent-{} override", task_type.id())
} else {
format!("[subagents.{}]", task_type.id())
};
ApiFailure::conflict(format!("{context}: {}", error.message))
})
}
pub(crate) async fn resolve_session_model_profile_selection(
backend: &Arc<dyn SubagentBackend>,
ranking_anchor: Option<&str>,
model: &str,
effort: Option<&str>,
) -> Result<SubagentSelection, ApiFailure> {
if effort == Some("adaptive") {
return Err(ApiFailure::bad_request(
"adaptive effort is no longer supported",
));
}
let requirement = effort
.map(EffortRequirement::Exact)
.unwrap_or(EffortRequirement::Any);
resolve_model_profile_selection_matching(
backend,
None,
model,
requirement,
None,
ModelProfileCandidateSource::Session { ranking_anchor },
)
.await
}
#[derive(Clone, Copy)]
enum ModelProfileCandidateSource<'a> {
Session { ranking_anchor: Option<&'a str> },
Subagents { parent_profile: &'a str },
}
impl<'a> ModelProfileCandidateSource<'a> {
fn profile_description(self) -> &'static str {
match self {
Self::Session { .. } => "configured",
Self::Subagents { .. } => "eligible",
}
}
fn ranking_anchor(self) -> Option<&'a str> {
match self {
Self::Session { ranking_anchor } => ranking_anchor,
Self::Subagents { parent_profile } => Some(parent_profile),
}
}
}
#[derive(Clone, Copy)]
enum EffortRequirement<'a> {
Any,
Default,
Exact(&'a str),
}
async fn resolve_model_profile_selection_matching(
backend: &Arc<dyn SubagentBackend>,
profile_id: Option<&str>,
model: &str,
requirement: EffortRequirement<'_>,
inherited_effort: Option<&str>,
source: ModelProfileCandidateSource<'_>,
) -> Result<SubagentSelection, ApiFailure> {
let candidates = match source {
ModelProfileCandidateSource::Session { .. } => backend.session_profile_candidates().await?,
ModelProfileCandidateSource::Subagents { parent_profile } => {
backend
.subagent_candidates(parent_profile.to_owned())
.await?
}
};
if matches!(requirement, EffortRequirement::Any) {
let chosen = choose_model_profile(candidates, profile_id, model, source)?;
let efforts = model_efforts(backend, &chosen, model).await?;
let effort = child_effort(model, &efforts, None, inherited_effort)?;
return Ok(selection(chosen.profile_id, model, effort));
}
let SubagentCandidates {
mut offered,
unavailable,
} = candidates;
if let Some(requested) = profile_id {
if let Some((_, reason)) = unavailable.iter().find(|(id, _)| id == requested) {
return Err(ApiFailure::conflict(format!(
"profile {requested:?} is unavailable: {reason}"
)));
}
offered.retain(|candidate| candidate.profile_id == requested);
if offered.is_empty() {
let message = match source {
ModelProfileCandidateSource::Session { .. } => {
format!("profile {requested:?} is not available for a new session")
}
ModelProfileCandidateSource::Subagents { .. } => format!(
"profile {requested:?} is not eligible for sub-agent use from this session"
),
};
return Err(ApiFailure::bad_request(message));
}
} else {
rank_candidates(&mut offered, source.ranking_anchor());
}
let all_offered = offered.clone();
let mut matching_model = false;
let mut offered_any_effort = false;
let mut selection_errors = Vec::new();
for candidate in offered {
if !offers_model(&candidate, model) {
continue;
}
matching_model = true;
let efforts = match model_efforts(backend, &candidate, model).await {
Ok(efforts) => efforts,
Err(error) => {
selection_errors.push(format!("{}: {error:#}", candidate.profile_id));
continue;
}
};
offered_any_effort |= !efforts.is_empty();
let effort = match requirement {
EffortRequirement::Exact(requested)
if efforts.iter().any(|choice| choice.value == requested) =>
{
Some(requested.to_owned())
}
EffortRequirement::Default => None,
EffortRequirement::Exact(_) => {
selection_errors.push(format!(
"{} offers efforts: {}",
candidate.profile_id,
effort_names(&efforts)
));
continue;
}
EffortRequirement::Any => unreachable!("handled above"),
};
return Ok(selection(candidate.profile_id, model, effort));
}
if !matching_model {
return Err(ApiFailure::bad_request(no_profile_offers(
model,
&all_offered,
&unavailable,
source.profile_description(),
)));
}
let required = match requirement {
EffortRequirement::Exact(effort) => format!("effort {effort:?}"),
EffortRequirement::Default => "harness default effort".to_owned(),
EffortRequirement::Any => unreachable!("handled above"),
};
let unavailable_effort = match requirement {
EffortRequirement::Exact(effort) if !offered_any_effort => format!(
"model {model:?} offers no effort choices; requested effort {effort:?} is unavailable. "
),
_ => format!(
"no {} profile offers model {model:?} with {required}. ",
source.profile_description()
),
};
Err(ApiFailure::bad_request(format!(
"{unavailable_effort}{}",
selection_errors
.into_iter()
.chain(
unavailable
.iter()
.map(|(id, reason)| format!("{id}: {reason}")),
)
.collect::<Vec<_>>()
.join("; ")
)))
}
async fn model_efforts(
backend: &Arc<dyn SubagentBackend>,
candidate: &SubagentCandidate,
model: &str,
) -> Result<Vec<mj_core::acp::SessionConfigChoice>, ApiFailure> {
if candidate.choices.model.as_deref() == Some(model) && !candidate.choices.efforts.is_empty() {
Ok(candidate.choices.efforts.clone())
} else {
backend
.profile_config(candidate.profile_id.clone(), Some(model.to_owned()), false)
.await
.map(|choices| choices.efforts)
.map_err(|error| {
ApiFailure::unavailable(format!(
"could not read the efforts profile {:?} offers for model {model:?}: {error:#}",
candidate.profile_id
))
})
}
}
fn selection(profile_id: String, model: &str, effort: Option<String>) -> SubagentSelection {
SubagentSelection {
profile_id,
model: model.to_owned(),
effort,
fast_mode: mj_core::codex_catalog::is_luna_model(model),
}
}
fn choose_model_profile(
candidates: SubagentCandidates,
requested_profile: Option<&str>,
model: &str,
source: ModelProfileCandidateSource<'_>,
) -> Result<SubagentCandidate, ApiFailure> {
match source {
ModelProfileCandidateSource::Subagents { parent_profile } => {
choose_subagent_profile(candidates, requested_profile, parent_profile, model)
}
ModelProfileCandidateSource::Session { ranking_anchor } => {
let SubagentCandidates {
mut offered,
unavailable,
} = candidates;
if let Some(requested) = requested_profile {
if let Some((_, reason)) = unavailable.iter().find(|(id, _)| id == requested) {
return Err(ApiFailure::conflict(format!(
"profile {requested:?} is unavailable: {reason}"
)));
}
let chosen = offered
.into_iter()
.find(|candidate| candidate.profile_id == requested)
.ok_or_else(|| {
ApiFailure::bad_request(format!(
"profile {requested:?} is not available for a new session"
))
})?;
validate_selectors(&chosen.choices, Some(model), None)?;
return Ok(chosen);
}
rank_candidates(&mut offered, ranking_anchor);
match offered
.iter()
.position(|candidate| offers_model(candidate, model))
{
Some(index) => Ok(offered.swap_remove(index)),
None => Err(ApiFailure::bad_request(no_profile_offers(
model,
&offered,
&unavailable,
"configured",
))),
}
}
}
}
fn effort_names(efforts: &[mj_core::acp::SessionConfigChoice]) -> String {
if efforts.is_empty() {
"none".to_owned()
} else {
efforts
.iter()
.map(|choice| choice.value.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
fn child_effort(
model: &str,
offered: &[mj_core::acp::SessionConfigChoice],
requested: Option<&str>,
parent: Option<&str>,
) -> Result<Option<String>, ApiFailure> {
let offers = |effort: &str| offered.iter().any(|choice| choice.value == effort);
match requested {
Some("adaptive") => Err(ApiFailure::bad_request(
"adaptive effort is no longer supported",
)),
Some(effort) if offered.is_empty() => Err(ApiFailure::bad_request(format!(
"model {model:?} offers no effort choices; spawn it without effort, not {effort:?}"
))),
Some(effort) if !offers(effort) => Err(ApiFailure::bad_request(format!(
"model {model:?} does not offer {effort:?} as effort; choices: {}",
offered
.iter()
.map(|choice| choice.value.as_str())
.collect::<Vec<_>>()
.join(", ")
))),
Some(effort) => Ok(Some(effort.to_owned())),
None => Ok(parent
.filter(|effort| *effort != "adaptive" && offers(effort))
.map(str::to_owned)),
}
}
pub(crate) fn choose_subagent_profile(
candidates: SubagentCandidates,
requested_profile: Option<&str>,
parent_profile: &str,
model: &str,
) -> Result<SubagentCandidate, ApiFailure> {
let SubagentCandidates {
mut offered,
unavailable,
} = candidates;
if let Some(requested) = requested_profile {
if let Some((_, reason)) = unavailable.iter().find(|(id, _)| id == requested) {
return Err(ApiFailure::conflict(format!(
"profile {requested:?} is unavailable: {reason}"
)));
}
let chosen = offered
.into_iter()
.find(|candidate| candidate.profile_id == requested)
.ok_or_else(|| {
ApiFailure::bad_request(format!(
"profile {requested:?} is not eligible for sub-agent use from this session"
))
})?;
validate_selectors(&chosen.choices, Some(model), None)?;
return Ok(chosen);
}
rank_candidates(&mut offered, Some(parent_profile));
match offered
.iter()
.position(|candidate| offers_model(candidate, model))
{
Some(index) => Ok(offered.swap_remove(index)),
None => Err(ApiFailure::bad_request(no_profile_offers(
model,
&offered,
&unavailable,
"eligible",
))),
}
}
pub(crate) fn rank_candidates(candidates: &mut [SubagentCandidate], ranking_anchor: Option<&str>) {
candidates.sort_by(|left, right| {
right
.remaining_percent
.cmp(&left.remaining_percent)
.then_with(|| {
let left_is_anchor = ranking_anchor == Some(left.profile_id.as_str());
let right_is_anchor = ranking_anchor == Some(right.profile_id.as_str());
right_is_anchor.cmp(&left_is_anchor)
})
.then_with(|| left.profile_id.cmp(&right.profile_id))
});
}
#[cfg(test)]
pub(crate) fn merge_same_models(
mut candidates: Vec<SubagentCandidate>,
parent_profile: &str,
) -> Vec<SubagentCandidate> {
rank_candidates(&mut candidates, Some(parent_profile));
let mut seen = std::collections::BTreeSet::new();
candidates.retain(|candidate| {
let mut models = candidate
.choices
.models
.iter()
.map(|choice| choice.value.clone())
.collect::<Vec<_>>();
models.sort_unstable();
seen.insert((candidate.harness, models))
});
candidates
}
fn offers_model(candidate: &SubagentCandidate, model: &str) -> bool {
candidate
.choices
.models
.iter()
.any(|choice| choice.value == model)
}
fn no_profile_offers(
model: &str,
offered: &[SubagentCandidate],
unavailable: &[(String, String)],
profile_description: &str,
) -> String {
let mut message = format!("no {profile_description} profile offers model {model:?}.");
if !offered.is_empty() {
let offers = offered
.iter()
.map(|candidate| {
let models = candidate
.choices
.models
.iter()
.map(|choice| choice.value.as_str())
.collect::<Vec<_>>()
.join(", ");
format!("{} ({models})", candidate.profile_id)
})
.collect::<Vec<_>>()
.join("; ");
message.push_str(&format!(" Offered: {offers}."));
}
if !unavailable.is_empty() {
let skipped = unavailable
.iter()
.map(|(id, reason)| format!("{id} ({reason})"))
.collect::<Vec<_>>()
.join("; ");
message.push_str(&format!(" Could not check: {skipped}."));
}
message
}
const SNAPSHOT_CATCH_UP: std::time::Duration = std::time::Duration::from_secs(10);
async fn await_session_record(
state: &ServerState,
session_id: &str,
) -> Result<ApiSession, ApiFailure> {
let mut snapshot_rx = state.snapshot_rx.clone();
let deadline = tokio::time::Instant::now() + SNAPSHOT_CATCH_UP;
loop {
let found = {
let snapshot = snapshot_rx.borrow();
snapshot
.sessions
.iter()
.find(|session| session.id == session_id)
.map(ApiSession::from)
};
if let Some(session) = found {
return Ok(session);
}
if tokio::time::timeout_at(deadline, snapshot_rx.changed())
.await
.is_err()
{
let snapshot = snapshot_rx.borrow();
return Ok(ApiSession::from(require_session_record(
&snapshot, session_id,
)?));
}
}
}
pub(super) async fn list_subagents(
State(state): State<ServerState>,
Path(parent_session_id): Path<String>,
) -> Result<Json<SubagentListResponse>, ApiFailure> {
{
let snapshot = state.snapshot_rx.borrow();
require_session_record(&snapshot, &parent_session_id)?;
}
let records = backend(&state)?
.list_subagents(parent_session_id.clone())
.await?;
let snapshot = state.snapshot_rx.borrow();
let subagents = records
.into_iter()
.map(|record| {
let session = require_session_record(&snapshot, &record.child_session_id)?;
Ok(SubagentView {
parent_session_id: parent_session_id.clone(),
task_name: record.task_name,
session: ApiSession::from(session),
})
})
.collect::<Result<Vec<_>, ApiFailure>>()?;
Ok(Json(SubagentListResponse { subagents }))
}
pub(crate) fn build_subagent_prompt(instructions: &str) -> Result<String, ApiFailure> {
let prompt = instructions.trim();
validate_prompt_text(prompt, false)?;
if prompt.len() > MAX_SUBAGENT_CONTEXT_BYTES {
return Err(ApiFailure::bad_request(format!(
"sub-agent handoff exceeds the {MAX_SUBAGENT_CONTEXT_BYTES}-byte limit"
)));
}
Ok(prompt.to_owned())
}
pub(crate) struct LegacySubagentSourceRange {
pub file: PathBuf,
pub start: u64,
pub end: u64,
}
pub(crate) async fn build_legacy_subagent_prompt(
backend: &Arc<dyn SubagentBackend>,
parent_session_id: &str,
instructions: &str,
context: Option<&str>,
ranges: &[LegacySubagentSourceRange],
) -> Result<String, ApiFailure> {
let mut prompt = instructions.trim().to_owned();
if let Some(context) = context.map(str::trim).filter(|context| !context.is_empty()) {
prompt.push_str("\n\n<parent_context>\n");
prompt.push_str(context);
prompt.push_str("\n</parent_context>");
}
for range in ranges {
if range.file.as_os_str().is_empty()
|| range.file.is_absolute()
|| range
.file
.components()
.any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
{
return Err(ApiFailure::bad_request(format!(
"source path {} must be relative and must not contain '..'",
range.file.display()
)));
}
if range.start == 0 || range.end < range.start {
return Err(ApiFailure::bad_request(format!(
"invalid source range {}:{}-{}; lines are one-based and inclusive",
range.file.display(),
range.start,
range.end
)));
}
let bytes = backend
.read_context_file(parent_session_id.to_owned(), range.file.clone())
.await
.map_err(ApiFailure::from)?;
let text = std::str::from_utf8(&bytes).map_err(|_| {
ApiFailure::bad_request(format!(
"source file {} is not UTF-8 text",
range.file.display()
))
})?;
let lines = text.lines().collect::<Vec<_>>();
if range.end > lines.len() as u64 {
return Err(ApiFailure::bad_request(format!(
"source range {}:{}-{} exceeds its {} lines",
range.file.display(),
range.start,
range.end,
lines.len()
)));
}
prompt.push_str(&format!(
"\n\n--- source {:?}, lines {}-{} (one-based, inclusive) ---\n",
range.file.to_string_lossy(),
range.start,
range.end
));
for (offset, line) in lines[(range.start - 1) as usize..range.end as usize]
.iter()
.enumerate()
{
prompt.push_str(&format!("{:>6} {line}\n", range.start as usize + offset));
}
prompt.push_str("--- end source ---");
if prompt.len() > MAX_SUBAGENT_CONTEXT_BYTES {
return Err(ApiFailure::bad_request(format!(
"sub-agent handoff exceeds the {MAX_SUBAGENT_CONTEXT_BYTES}-byte limit"
)));
}
}
if prompt.len() > MAX_SUBAGENT_CONTEXT_BYTES {
return Err(ApiFailure::bad_request(format!(
"sub-agent handoff exceeds the {MAX_SUBAGENT_CONTEXT_BYTES}-byte limit"
)));
}
Ok(prompt)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_spawn_assignments_use_only_validated_instructions() {
assert_eq!(
build_subagent_prompt(" Read src/lib.rs:1-20 and report.\n").unwrap(),
"Read src/lib.rs:1-20 and report."
);
for text in [" ", " !shell"] {
assert!(build_subagent_prompt(text).is_err());
}
assert!(build_subagent_prompt(&"🦀".repeat(MAX_SUBAGENT_CONTEXT_BYTES / 4)).is_ok());
assert!(build_subagent_prompt(&"🦀".repeat(MAX_SUBAGENT_CONTEXT_BYTES / 4 + 1)).is_err());
for (key, value) in [
("files", serde_json::json!([])),
("context", serde_json::json!("do not lose me")),
] {
let mut request =
serde_json::json!({"task_name":"task", "instructions":"read src/lib.rs"});
request[key] = value;
let error = serde_json::from_value::<SpawnSubagentRequest>(request).unwrap_err();
assert!(error.to_string().contains("unknown field"));
assert!(error.to_string().contains("instructions"));
}
}
#[tokio::test]
async fn accepted_legacy_spawn_preserves_context_and_source_ranges() {
let action: mj_core::subagent::SubagentToolAction = serde_json::from_value(serde_json::json!({
"action":"spawn", "params":{"task_name":"legacy", "instructions":"inspect", "context":"prior findings",
"files":[{"file":"legacy.rs", "ranges":[{"start":2,"end":3}]}]}
})).unwrap();
let mj_core::subagent::SubagentToolAction::Spawn {
instructions,
context,
files,
..
} = action
else {
panic!("spawn expected")
};
let ranges = files
.iter()
.flat_map(|file| {
file.ranges.iter().map(|range| LegacySubagentSourceRange {
file: file.file.clone(),
start: range.start,
end: range.end,
})
})
.collect::<Vec<_>>();
let backend: Arc<dyn SubagentBackend> = Arc::new(FakeSelectionBackend {
discovery_fails: false,
config: Default::default(),
candidates: SubagentCandidates {
offered: vec![],
unavailable: vec![],
},
});
let prompt = build_legacy_subagent_prompt(
&backend,
"parent",
&instructions,
context.as_deref(),
&ranges,
)
.await
.unwrap();
assert!(prompt.starts_with("inspect\n\n<parent_context>\nprior findings"));
assert!(
prompt.contains(" 2 selected\n 3 evidence"),
"{prompt}"
);
assert!(!prompt.contains("unselected"));
let long_instructions = "x".repeat(65_537);
assert_eq!(
build_legacy_subagent_prompt(&backend, "parent", &long_instructions, None, &[])
.await
.unwrap(),
long_instructions
);
}
fn candidate(profile_id: &str, remaining: Option<u8>, models: &[&str]) -> SubagentCandidate {
SubagentCandidate {
profile_id: profile_id.to_owned(),
harness: mj_core::config::HarnessKind::Codex,
choices: mj_core::worker_launch::ProfileConfig {
model: models.first().map(|model| (*model).to_owned()),
models: models
.iter()
.map(|model| mj_core::acp::SessionConfigChoice {
value: (*model).to_owned(),
name: (*model).to_owned(),
description: None,
})
.collect(),
efforts: Vec::new(),
observed_at: 1,
},
remaining_percent: remaining,
}
}
fn ids(candidates: &[SubagentCandidate]) -> Vec<&str> {
candidates
.iter()
.map(|candidate| candidate.profile_id.as_str())
.collect()
}
#[test]
fn ranking_puts_most_quota_first_unknown_last_and_ties_to_the_parent() {
let mut candidates = vec![
candidate("unknown", None, &["luna"]),
candidate("b-other", Some(40), &["luna"]),
candidate("parent", Some(40), &["luna"]),
candidate("a-other", Some(40), &["luna"]),
candidate("high", Some(90), &["luna"]),
candidate("empty", Some(0), &["luna"]),
];
rank_candidates(&mut candidates, Some("parent"));
assert_eq!(
ids(&candidates),
vec!["high", "parent", "a-other", "b-other", "empty", "unknown"]
);
}
#[test]
fn a_pinned_profile_must_be_eligible_and_offer_the_model() {
let candidates = || SubagentCandidates {
offered: vec![candidate("codex", Some(5), &["luna"])],
unavailable: vec![("glm".to_owned(), "not signed in".to_owned())],
};
assert_eq!(
choose_subagent_profile(candidates(), Some("codex"), "codex", "luna")
.unwrap()
.profile_id,
"codex"
);
let not_offered =
choose_subagent_profile(candidates(), Some("codex"), "codex", "sol").unwrap_err();
assert!(
not_offered.message.contains("does not offer \"sol\""),
"{}",
not_offered.message
);
let ineligible =
choose_subagent_profile(candidates(), Some("kimi"), "codex", "luna").unwrap_err();
assert!(
ineligible.message.contains("not eligible"),
"{}",
ineligible.message
);
let broken =
choose_subagent_profile(candidates(), Some("glm"), "codex", "luna").unwrap_err();
assert!(
broken.message.contains("not signed in"),
"{}",
broken.message
);
}
#[test]
fn merging_keeps_the_best_of_each_same_model_group() {
let merged = merge_same_models(
vec![
candidate("codex2", Some(3), &["nova", "luna"]),
candidate("codex4", Some(60), &["luna", "nova"]),
candidate("deepseek", Some(100), &["flash"]),
candidate("codex3", Some(20), &["luna"]),
],
"codex2",
);
assert_eq!(ids(&merged), vec!["deepseek", "codex4", "codex3"]);
}
struct FakeSelectionBackend {
discovery_fails: bool,
config: mj_core::config::SubagentConfig,
candidates: SubagentCandidates,
}
impl SubagentBackend for FakeSelectionBackend {
fn subagent_config(&self) -> BoxFuture<'_, AnyResult<mj_core::config::SubagentConfig>> {
Box::pin(async { Ok(self.config.clone()) })
}
fn profile_config(
&self,
profile: String,
model: Option<String>,
_refresh: bool,
) -> BoxFuture<'_, AnyResult<mj_core::worker_launch::ProfileConfig>> {
Box::pin(async move {
if self.discovery_fails {
anyhow::bail!("model-specific probe failed: expired login");
}
let mut choices = self
.candidates
.offered
.iter()
.find(|candidate| candidate.profile_id == profile)
.unwrap()
.choices
.clone();
if model.as_deref() == Some("no-effort") {
choices.efforts.clear();
}
if model.as_deref() == Some("fixed") && profile != "full-but-wrong-effort" {
choices.efforts = vec![mj_core::acp::SessionConfigChoice {
value: "high".into(),
name: "High".into(),
description: None,
}];
}
Ok(choices)
})
}
fn subagent_candidates(
&self,
_parent_profile: String,
) -> BoxFuture<'_, AnyResult<SubagentCandidates>> {
let candidates = self.candidates.clone();
Box::pin(async move { Ok(candidates) })
}
fn session_handle(
&self,
_session_id: String,
) -> BoxFuture<'_, AnyResult<Option<SessionHandle>>> {
Box::pin(async { Ok(None) })
}
fn prompt(&self, _session_id: String, _text: String) -> BoxFuture<'_, AnyResult<u64>> {
Box::pin(async { anyhow::bail!("not used in this test") })
}
fn turn_state(&self, _session_id: String) -> BoxFuture<'_, AnyResult<Option<TurnState>>> {
Box::pin(async { Ok(None) })
}
fn turn_summary(
&self,
_session_id: String,
_turn: TurnSpan,
) -> BoxFuture<'_, AnyResult<TurnSummary>> {
Box::pin(async { anyhow::bail!("not used in this test") })
}
fn start_followup(
&self,
_session_id: String,
_followup: StartFollowup,
) -> BoxFuture<'_, AnyResult<()>> {
Box::pin(async { anyhow::bail!("not used in this test") })
}
fn start_status(
&self,
_session_id: String,
) -> BoxFuture<'_, AnyResult<Option<StartStatus>>> {
Box::pin(async { Ok(None) })
}
fn transcript(
&self,
_session_id: String,
_after_seq: u64,
_limit: usize,
_roles: Vec<mj_core::transcript::TranscriptRole>,
_finished_only: bool,
) -> BoxFuture<'_, AnyResult<Option<TranscriptPage>>> {
Box::pin(async { Ok(None) })
}
fn diff(
&self,
_session_id: String,
_options: DiffOptions,
) -> BoxFuture<'_, Result<String, ExportError>> {
Box::pin(async { Err(ExportError::Refused("not used in this test".into())) })
}
fn read_context_file(
&self,
session: String,
path: PathBuf,
) -> BoxFuture<'_, Result<Vec<u8>, ExportError>> {
Box::pin(async move {
assert_eq!(session, "parent");
assert_eq!(path, PathBuf::from("legacy.rs"));
Ok(b"unselected\nselected\nevidence\nunselected\n".to_vec())
})
}
fn read_file(
&self,
_session_id: String,
_path: PathBuf,
) -> BoxFuture<'_, Result<Vec<u8>, ExportError>> {
Box::pin(async { Err(ExportError::Refused("not used in this test".into())) })
}
fn push_branch(
&self,
_session_id: String,
_branch: String,
) -> BoxFuture<'_, Result<PushedBranch, ExportError>> {
Box::pin(async { Err(ExportError::Refused("not used in this test".into())) })
}
fn bundle(&self, _session_id: String) -> BoxFuture<'_, Result<BundleExport, ExportError>> {
Box::pin(async { Err(ExportError::Refused("not used in this test".into())) })
}
}
fn efforts(values: &[&str]) -> Vec<mj_core::acp::SessionConfigChoice> {
values
.iter()
.map(|value| mj_core::acp::SessionConfigChoice {
value: (*value).to_owned(),
name: (*value).to_owned(),
description: None,
})
.collect()
}
#[test]
fn a_child_effort_comes_from_the_efforts_its_own_model_offers() {
assert_eq!(
child_effort("haiku", &[], None, Some("high")).unwrap(),
None
);
assert_eq!(
child_effort("sonnet", &efforts(&["low", "high"]), None, Some("high")).unwrap(),
Some("high".into())
);
assert_eq!(
child_effort("sonnet", &efforts(&["low"]), None, Some("high")).unwrap(),
None
);
let none_offered = child_effort("haiku", &[], Some("low"), None).unwrap_err();
assert!(
none_offered.message.contains("offers no effort choices"),
"{}",
none_offered.message
);
let not_offered =
child_effort("sonnet", &efforts(&["low"]), Some("high"), None).unwrap_err();
assert!(
not_offered.message.contains("choices: low"),
"{}",
not_offered.message
);
}
#[tokio::test]
async fn default_effort_spawn_reports_all_failed_model_discoveries() {
use mj_core::config::{SubagentChoice, SubagentConfig};
use mj_core::subagent::{SubagentPolicy, SubagentTaskType};
let choice = SubagentChoice {
model: "no-effort".into(),
effort: None,
};
let backend: Arc<dyn SubagentBackend> = Arc::new(FakeSelectionBackend {
discovery_fails: true,
config: SubagentConfig {
straightforward: Some(choice.clone()),
subtle: Some(choice),
..Default::default()
},
candidates: SubagentCandidates {
offered: vec![
candidate("expired-first", Some(90), &["no-effort"]),
candidate("expired-second", Some(50), &["no-effort"]),
],
unavailable: vec![],
},
});
let error = resolve_subagent_policy_selection(
&backend,
"parent",
&SubagentPolicy::Mjolnir {
straightforward: None,
subtle: None,
},
SubagentTaskType::Straightforward,
)
.await
.unwrap_err();
for detail in [
"[subagents.straightforward]",
"harness default effort",
"no-effort",
"expired-first",
"expired-second",
"expired login",
] {
assert!(error.message.contains(detail), "{}", error.message);
}
}
#[tokio::test]
async fn task_type_spawn_uses_global_choices_and_quota_ranked_eligible_profiles() {
use mj_core::config::{SubagentChoice, SubagentConfig};
use mj_core::subagent::{SubagentPolicy, SubagentTaskType};
let candidates = SubagentCandidates {
offered: vec![
candidate("parent", Some(2), &["fixed", "no-effort"]),
candidate("best", Some(70), &["fixed", "no-effort"]),
candidate("full-but-wrong-effort", Some(100), &["fixed"]),
],
unavailable: vec![],
};
let config = SubagentConfig {
straightforward: Some(SubagentChoice {
model: "no-effort".into(),
effort: None,
}),
subtle: Some(SubagentChoice {
model: "fixed".into(),
effort: Some("high".into()),
}),
..Default::default()
};
let backend: Arc<dyn SubagentBackend> = Arc::new(FakeSelectionBackend {
discovery_fails: false,
config,
candidates: candidates.clone(),
});
for (task_type, model, effort) in [
(SubagentTaskType::Straightforward, "no-effort", None),
(SubagentTaskType::Subtle, "fixed", Some("high")),
] {
let selected = resolve_subagent_policy_selection(
&backend,
"parent",
&SubagentPolicy::Mjolnir {
straightforward: None,
subtle: None,
},
task_type,
)
.await
.unwrap();
assert_eq!(
(
selected.profile_id.as_str(),
selected.model.as_str(),
selected.effort.as_deref()
),
("best", model, effort)
);
}
let unconfigured: Arc<dyn SubagentBackend> = Arc::new(FakeSelectionBackend {
discovery_fails: false,
config: Default::default(),
candidates,
});
let mixed_policy = SubagentPolicy::Mjolnir {
straightforward: None,
subtle: Some(Box::new(SubagentChoice {
model: "fixed".into(),
effort: Some("high".into()),
})),
};
for (task_type, model, effort) in [
(SubagentTaskType::Straightforward, "no-effort", None),
(SubagentTaskType::Subtle, "fixed", Some("high")),
] {
let selected =
resolve_subagent_policy_selection(&backend, "parent", &mixed_policy, task_type)
.await
.unwrap();
assert_eq!(
(
selected.profile_id.as_str(),
selected.model.as_str(),
selected.effort.as_deref()
),
("best", model, effort)
);
}
let error = resolve_subagent_policy_selection(
&unconfigured,
"parent",
&SubagentPolicy::Mjolnir {
straightforward: None,
subtle: None,
},
SubagentTaskType::Subtle,
)
.await
.unwrap_err();
assert!(error.message.contains("[subagents.subtle]"));
assert!(error.message.contains("--subagent-subtle"));
}
}