use roboticus_agent::action_planner::{PlannedAction, plan};
use roboticus_agent::task_state::{
BehavioralHistory, DeclaredActionState, DecompositionProposal, MemoryConfidence, RosterFit,
RuntimeConstraints, SkillFit, TaskClassification, TaskOperatingState, TaskStateInput, ToolFit,
};
fn base_input() -> TaskStateInput {
TaskStateInput {
user_content: "analyze and report on the current state of the system".into(),
intents: vec!["Execution".into()],
authority: "Creator".into(),
retrieval_metrics: None,
tool_search_stats: None,
mcp_tools_available: false,
taskable_agent_count: 0,
fit_agent_count: 0,
fit_agent_names: vec![],
enabled_skill_count: 5,
matching_skill_count: 0,
missing_skills: vec![],
remaining_budget_tokens: 8000,
provider_breaker_open: false,
inference_mode: "standard".into(),
decomposition_proposal: None,
explicit_specialist_workflow: false,
named_tool_match: false,
recent_response_skeletons: vec![],
recent_user_message_lengths: vec![],
self_echo_fragments: vec![],
declared_action: None,
previous_turn_had_protocol_issues: false,
normalization_retry_streak: 0,
}
}
fn task_state(classification: TaskClassification) -> TaskOperatingState {
TaskOperatingState {
classification,
memory_confidence: MemoryConfidence {
avg_similarity: 0.7,
budget_utilization: 0.5,
retrieval_count: 5,
recall_gap: false,
empty_tiers: vec![],
},
runtime_constraints: RuntimeConstraints {
remaining_budget_tokens: 8000,
budget_pressured: false,
provider_breaker_open: false,
inference_mode: "standard".into(),
},
tool_fit: ToolFit {
available_count: 10,
high_relevance_count: 3,
token_savings: 2000,
mcp_available: false,
},
roster_fit: RosterFit {
taskable_count: 0,
fit_count: 0,
fit_names: vec![],
explicit_workflow: false,
},
skill_fit: SkillFit {
enabled_count: 5,
matching_count: 0,
missing_skills: vec![],
},
behavioral_history: BehavioralHistory {
structural_repetition: false,
repetition_streak: 0,
repeated_pattern: None,
engagement_declining: false,
self_echo_risk: 0.0,
echo_fragment: None,
variation_hint: None,
},
declared_action: DeclaredActionState {
action_declared: false,
action: None,
high_consequence: false,
},
}
}
#[test]
fn planner_selects_return_blocker_when_circuit_breaker_open() {
let mut state = task_state(TaskClassification::Task);
state.runtime_constraints.provider_breaker_open = true;
let mut input = base_input();
input.provider_breaker_open = true;
state.roster_fit.fit_count = 1;
state.roster_fit.fit_names = vec!["sys-specialist".into()];
let execution_plan = plan(&state, &input);
assert_eq!(
execution_plan.selected,
PlannedAction::ReturnBlocker,
"planner must select ReturnBlocker when circuit breaker is open; got {:?}",
execution_plan.selected
);
}
#[test]
fn return_blocker_overrides_delegation_gate() {
let mut state = task_state(TaskClassification::Task);
state.runtime_constraints.provider_breaker_open = true;
state.roster_fit.fit_count = 2;
state.roster_fit.fit_names = vec!["a".into(), "b".into()];
let mut input = base_input();
input.provider_breaker_open = true;
input.fit_agent_count = 2;
input.fit_agent_names = vec!["a".into(), "b".into()];
input.decomposition_proposal = Some(DecompositionProposal {
should_delegate: true,
rationale: "gate says delegate".into(),
utility_margin: 0.8,
});
let execution_plan = plan(&state, &input);
assert_eq!(
execution_plan.selected,
PlannedAction::ReturnBlocker,
"ReturnBlocker must win over delegation gate; got {:?}",
execution_plan.selected
);
let blocker = execution_plan
.candidates
.iter()
.find(|c| c.action == PlannedAction::ReturnBlocker)
.expect("ReturnBlocker must be a candidate");
let delegator = execution_plan
.candidates
.iter()
.find(|c| c.action == PlannedAction::DelegateToSpecialist);
if let Some(delegator) = delegator {
assert!(
blocker.confidence >= delegator.confidence,
"ReturnBlocker confidence ({}) must be >= DelegateToSpecialist confidence ({})",
blocker.confidence,
delegator.confidence
);
}
}
#[test]
fn planner_includes_inspect_memory_on_recall_gap() {
let mut state = task_state(TaskClassification::Task);
state.memory_confidence.recall_gap = true;
state.memory_confidence.avg_similarity = 0.2;
state.memory_confidence.empty_tiers = vec!["semantic".into(), "procedural".into()];
state.roster_fit.fit_count = 0;
state.roster_fit.taskable_count = 0;
let input = base_input();
let execution_plan = plan(&state, &input);
assert!(
execution_plan
.candidates
.iter()
.any(|c| c.action == PlannedAction::InspectMemory),
"InspectMemory must be a candidate when recall_gap=true and avg_similarity=0.2; candidates: {:?}",
execution_plan
.candidates
.iter()
.map(|c| c.action)
.collect::<Vec<_>>()
);
}
#[test]
fn inspect_memory_suppressed_under_budget_pressure() {
let mut state = task_state(TaskClassification::Task);
state.memory_confidence.recall_gap = true;
state.memory_confidence.avg_similarity = 0.2;
state.memory_confidence.empty_tiers = vec!["semantic".into()];
state.runtime_constraints.budget_pressured = true;
let input = base_input();
let execution_plan = plan(&state, &input);
assert!(
!execution_plan
.candidates
.iter()
.any(|c| c.action == PlannedAction::InspectMemory),
"InspectMemory must NOT be a candidate when budget is pressured"
);
}
#[test]
fn inspect_memory_not_triggered_for_conversation() {
let mut state = task_state(TaskClassification::Conversation);
state.memory_confidence.recall_gap = true;
state.memory_confidence.avg_similarity = 0.1;
state.memory_confidence.empty_tiers = vec!["semantic".into()];
let input = base_input();
let execution_plan = plan(&state, &input);
assert_eq!(
execution_plan.selected,
PlannedAction::AnswerDirectly,
"Conversation turns must short-circuit to AnswerDirectly regardless of recall gap"
);
assert!(
!execution_plan
.candidates
.iter()
.any(|c| c.action == PlannedAction::InspectMemory),
"InspectMemory must not appear in conversation candidates"
);
}
#[test]
fn planner_includes_compose_skill_for_missing_skills_with_creator_authority() {
let mut state = task_state(TaskClassification::Task);
state.skill_fit.missing_skills = vec!["dnd-rules".into(), "initiative-order".into()];
let input = base_input();
let execution_plan = plan(&state, &input);
assert!(
execution_plan
.candidates
.iter()
.any(|c| c.action == PlannedAction::ComposeSkill),
"ComposeSkill must be a candidate when missing skills exist; candidates: {:?}",
execution_plan
.candidates
.iter()
.map(|c| c.action)
.collect::<Vec<_>>()
);
}
#[test]
fn compose_skill_requires_creator_authority() {
let mut state = task_state(TaskClassification::Task);
state.skill_fit.missing_skills = vec!["dnd-rules".into()];
let mut input = base_input();
input.authority = "Peer".into();
let execution_plan = plan(&state, &input);
assert!(
!execution_plan
.candidates
.iter()
.any(|c| c.action == PlannedAction::ComposeSkill),
"ComposeSkill must NOT be a candidate for non-Creator authority"
);
}
#[test]
fn compose_skill_not_triggered_without_missing_skills() {
let state = task_state(TaskClassification::Task);
let input = base_input();
let execution_plan = plan(&state, &input);
assert!(
!execution_plan
.candidates
.iter()
.any(|c| c.action == PlannedAction::ComposeSkill),
"ComposeSkill must NOT appear when no skills are missing"
);
}
#[test]
fn planner_centralized_overrides_delegating_gate() {
let state = task_state(TaskClassification::Task);
let mut input = base_input();
input.decomposition_proposal = Some(DecompositionProposal {
should_delegate: true,
rationale: "gate says delegate for cost reasons".into(),
utility_margin: 0.6,
});
input.fit_agent_count = 0;
input.fit_agent_names = vec![];
let execution_plan = plan(&state, &input);
assert_ne!(
execution_plan.selected,
PlannedAction::DelegateToSpecialist,
"planner must NOT select DelegateToSpecialist when no agents fit, \
even if gate recommended delegation"
);
assert_eq!(
execution_plan.selected,
PlannedAction::ContinueCentralized,
"planner must fall back to ContinueCentralized when no fit agents exist"
);
}
#[test]
fn planner_delegates_when_gate_recommends_and_fit_exists() {
let mut state = task_state(TaskClassification::Task);
state.roster_fit.fit_count = 1;
state.roster_fit.fit_names = vec!["analysis-specialist".into()];
state.roster_fit.explicit_workflow = true;
let mut input = base_input();
input.fit_agent_count = 1;
input.fit_agent_names = vec!["analysis-specialist".into()];
input.decomposition_proposal = Some(DecompositionProposal {
should_delegate: true,
rationale: "high utility margin for delegation".into(),
utility_margin: 0.75,
});
let execution_plan = plan(&state, &input);
assert!(
execution_plan
.candidates
.iter()
.any(|c| c.action == PlannedAction::DelegateToSpecialist),
"DelegateToSpecialist must be a candidate when gate recommends + fit agents exist"
);
}
#[test]
fn gate_delegation_signal_without_fit_agents_falls_back_to_centralized() {
let state = task_state(TaskClassification::Task);
let mut input = base_input();
input.decomposition_proposal = Some(DecompositionProposal {
should_delegate: true,
rationale: "marginal complexity signal".into(),
utility_margin: 0.3,
});
input.fit_agent_count = 0;
input.fit_agent_names = vec![];
input.explicit_specialist_workflow = false;
let execution_plan = plan(&state, &input);
assert_eq!(
execution_plan.selected,
PlannedAction::ContinueCentralized,
"gate signal without fit agents MUST NOT trigger delegation; got {:?}\ncandidates: {:?}",
execution_plan.selected,
execution_plan
.candidates
.iter()
.map(|c| (&c.action, c.confidence))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn build_task_state_input_reflects_open_circuit_breaker() {
use crate::api::routes::agent::intent_registry::IntentRegistry;
use crate::api::routes::agent::pipeline::build_task_state_input_for_test;
let state = crate::api::routes::tests::test_state();
{
let mut llm = state.llm.write().await;
let cfg = state.config.read().await;
let primary = cfg.models.primary.clone();
drop(cfg);
let provider = primary.split('/').next().unwrap_or(&primary).to_string();
llm.breakers.force_open(&provider);
}
let msg = "Please execute the quarterly system health audit and generate a detailed report with metrics";
let intents = IntentRegistry::default_registry().classify(msg);
let input = build_task_state_input_for_test(
&state,
"test-session",
msg,
&intents,
roboticus_core::InputAuthority::Creator,
None,
"standard",
)
.await;
assert!(
input.provider_breaker_open,
"build_task_state_input must read live circuit breaker state; \
expected provider_breaker_open=true after force_open()"
);
let task_state = roboticus_agent::task_state::synthesize(&input);
assert!(
input.provider_breaker_open,
"provider_breaker_open must be true when circuit breaker is forced open"
);
let execution_plan = roboticus_agent::action_planner::plan(&task_state, &input);
if task_state.classification == roboticus_agent::task_state::TaskClassification::Task {
assert_eq!(
execution_plan.selected,
PlannedAction::ReturnBlocker,
"Task turn with open circuit breaker must select ReturnBlocker; got {:?}",
execution_plan.selected
);
}
}
#[tokio::test]
async fn build_task_state_input_populates_missing_skills() {
use crate::api::routes::agent::intent_registry::IntentRegistry;
use crate::api::routes::agent::pipeline::build_task_state_input_for_test;
let state = crate::api::routes::tests::test_state();
let intents =
IntentRegistry::default_registry().classify("apply xyzzy-combat-rules to this encounter");
let input = build_task_state_input_for_test(
&state,
"test-session",
"apply xyzzy-combat-rules to this encounter",
&intents,
roboticus_core::InputAuthority::Creator,
None,
"standard",
)
.await;
let _ = input.missing_skills; let _ = input.matching_skill_count; }
#[tokio::test]
async fn build_task_state_input_mcp_false_when_no_servers() {
use crate::api::routes::agent::intent_registry::IntentRegistry;
use crate::api::routes::agent::pipeline::build_task_state_input_for_test;
let state = crate::api::routes::tests::test_state();
let intents = IntentRegistry::default_registry().classify("do something");
let input = build_task_state_input_for_test(
&state,
"test-session",
"do something",
&intents,
roboticus_core::InputAuthority::Creator,
None,
"standard",
)
.await;
assert!(
!input.mcp_tools_available,
"mcp_tools_available must be false when no MCP tools are registered"
);
}