use chrono::{DateTime, TimeZone, Utc};
use terraphim_orchestrator::{AgentDefinition, AgentLayer, ScheduleEvent, TimeScheduler};
fn make_agent(name: &str, layer: AgentLayer, schedule: Option<&str>) -> AgentDefinition {
AgentDefinition {
extra_projects: Vec::new(),
name: name.to_string(),
layer,
cli_tool: "echo".to_string(),
task: "test task".to_string(),
model: None,
default_tier: None,
schedule: schedule.map(String::from),
capabilities: vec![],
max_memory_bytes: None,
budget_monthly_cents: None,
provider: None,
persona: None,
terraphim_role: None,
skill_chain: vec![],
sfia_skills: vec![],
fallback_provider: None,
fallback_model: None,
grace_period_secs: None,
max_cpu_seconds: None,
pre_check: None,
gitea_issue: None,
event_only: false,
evolution_enabled: false,
rlm_enabled: None,
bypass_kg_routing: false,
enabled: true,
project: None,
}
}
fn dt(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(year, month, day, hour, min, sec)
.single()
.unwrap()
}
fn should_fire(
schedule: &str,
last_tick_time: DateTime<Utc>,
now: DateTime<Utc>,
last_fire: Option<DateTime<Utc>>,
) -> bool {
use cron::Schedule;
use std::str::FromStr;
let full_expr = format!("0 {} *", schedule);
let schedule = Schedule::from_str(&full_expr).unwrap();
let next_fire = match schedule.after(&last_tick_time).next() {
Some(t) => t,
None => return false,
};
if next_fire > now {
return false;
}
if let Some(lf) = last_fire
&& next_fire <= lf
{
return false;
}
true
}
#[tokio::test]
async fn test_scheduler_fires_at_cron_time() {
let agents = vec![
make_agent("sentinel", AgentLayer::Safety, None),
make_agent("sync", AgentLayer::Core, Some("0 3 * * *")),
make_agent("reviewer", AgentLayer::Growth, None),
];
let mut scheduler = TimeScheduler::new(&agents, Some("0 2 * * *")).unwrap();
let tx = scheduler.event_sender();
let spawn_def = make_agent("sync", AgentLayer::Core, Some("0 3 * * *"));
tx.send(ScheduleEvent::Spawn(Box::new(spawn_def)))
.await
.unwrap();
tx.send(ScheduleEvent::CompoundReview).await.unwrap();
let event1 = scheduler.next_event().await;
match event1 {
ScheduleEvent::Spawn(def) => assert_eq!(def.name, "sync"),
other => panic!("expected Spawn, got {:?}", other),
}
let event2 = scheduler.next_event().await;
match event2 {
ScheduleEvent::CompoundReview => {} other => panic!("expected CompoundReview, got {:?}", other),
}
}
#[tokio::test]
async fn test_scheduler_stop_event() {
let agents = vec![make_agent("sentinel", AgentLayer::Safety, None)];
let mut scheduler = TimeScheduler::new(&agents, None).unwrap();
let tx = scheduler.event_sender();
tx.send(ScheduleEvent::Stop {
agent_name: "sentinel".to_string(),
})
.await
.unwrap();
let event = scheduler.next_event().await;
match event {
ScheduleEvent::Stop { agent_name } => assert_eq!(agent_name, "sentinel"),
other => panic!("expected Stop, got {:?}", other),
}
}
#[test]
fn test_scheduler_layer_partitioning() {
let agents = vec![
make_agent("safety-1", AgentLayer::Safety, None),
make_agent("safety-2", AgentLayer::Safety, None),
make_agent("core-1", AgentLayer::Core, Some("0 1 * * *")),
make_agent("core-2", AgentLayer::Core, Some("0 2 * * *")),
make_agent("growth-1", AgentLayer::Growth, None),
];
let scheduler = TimeScheduler::new(&agents, None).unwrap();
let immediate = scheduler.immediate_agents();
assert_eq!(immediate.len(), 2);
assert!(immediate.iter().all(|a| a.layer == AgentLayer::Safety));
let scheduled = scheduler.scheduled_agents();
assert_eq!(scheduled.len(), 2);
assert!(scheduled.iter().all(|(a, _)| a.layer == AgentLayer::Core));
}
#[test]
fn test_scheduler_skips_disabled_agents() {
let mut disabled = make_agent("disabled-core", AgentLayer::Core, Some("0 3 * * *"));
disabled.enabled = false;
let mut disabled_safety = make_agent("disabled-safety", AgentLayer::Safety, None);
disabled_safety.enabled = false;
let agents = vec![
make_agent("safety-1", AgentLayer::Safety, None),
disabled_safety,
make_agent("core-1", AgentLayer::Core, Some("0 1 * * *")),
disabled,
];
let scheduler = TimeScheduler::new(&agents, None).unwrap();
let immediate = scheduler.immediate_agents();
assert_eq!(immediate.len(), 1);
assert_eq!(immediate[0].name, "safety-1");
let scheduled = scheduler.scheduled_agents();
assert_eq!(scheduled.len(), 1);
assert_eq!(scheduled[0].0.name, "core-1");
}
#[test]
fn test_cron_fires_when_time_matches_schedule() {
let last_tick = dt(2026, 5, 16, 10, 0, 0); let now = dt(2026, 5, 16, 10, 30, 0);
let result = should_fire("30 0-10 * * *", last_tick, now, None);
assert!(
result,
"Agent should fire when tick is before fire time and now >= fire time"
);
}
#[test]
fn test_cron_no_retrigger_same_occurrence() {
let last_tick = dt(2026, 5, 16, 10, 0, 0); let now = dt(2026, 5, 16, 10, 30, 0);
let first_fire = should_fire("30 0-10 * * *", last_tick, now, None);
assert!(first_fire, "First call should fire");
let last_fire = dt(2026, 5, 16, 10, 30, 0);
let second_fire = should_fire("30 0-10 * * *", last_tick, now, Some(last_fire));
assert!(
!second_fire,
"Should NOT fire again within same schedule occurrence"
);
}
#[test]
fn test_cron_fires_next_occurrence() {
let last_tick_first = dt(2026, 5, 16, 9, 0, 0); let now_first = dt(2026, 5, 16, 9, 30, 0);
let first_fire = should_fire("30 1-11 * * *", last_tick_first, now_first, None);
assert!(first_fire, "First occurrence should fire at 9:30");
let last_tick_second = dt(2026, 5, 16, 9, 30, 1); let now_second = dt(2026, 5, 16, 10, 30, 0); let last_fire = dt(2026, 5, 16, 9, 30, 0);
let second_fire = should_fire(
"30 1-11 * * *",
last_tick_second,
now_second,
Some(last_fire),
);
assert!(second_fire, "Should fire at next occurrence (10:30)");
}
#[test]
fn test_cron_skips_already_fired_agent() {
let last_tick = dt(2026, 5, 16, 10, 0, 0);
let now = dt(2026, 5, 16, 10, 30, 0);
let last_fire = dt(2026, 5, 16, 10, 30, 0);
let result = should_fire("30 0-10 * * *", last_tick, now, Some(last_fire));
assert!(
!result,
"Agent should not fire if already fired at this time"
);
}
#[test]
fn test_fire_time_calculation_with_helper() {
let last_tick = dt(2026, 5, 16, 9, 0, 0); let now = dt(2026, 5, 16, 9, 31, 0);
let result = should_fire("30 0-10 * * *", last_tick, now, None);
assert!(result, "Should fire at 9:30 when checked at 9:31");
}
#[test]
fn test_cron_boundary_exactly_at_fire_time() {
let last_tick = dt(2026, 5, 16, 10, 30, 0); let now = dt(2026, 5, 16, 10, 30, 0);
let result = should_fire("30 0-10 * * *", last_tick, now, None);
assert!(
!result,
"Should NOT fire when last_tick and now are exactly at fire time"
);
}
#[test]
fn test_cron_boundary_one_second_before() {
let last_tick = dt(2026, 5, 16, 10, 29, 59); let now = dt(2026, 5, 16, 10, 30, 0);
let result = should_fire("30 0-10 * * *", last_tick, now, None);
assert!(
result,
"Should fire when tick is 1 second before and now is at fire time"
);
}
#[test]
fn test_cron_compound_review_schedule() {
let last_tick = dt(2026, 5, 16, 1, 0, 0); let now = dt(2026, 5, 16, 2, 0, 0);
let result = should_fire("0 2 * * *", last_tick, now, None);
assert!(result, "Compound review should fire at 2:00 AM");
}
#[test]
fn test_cron_compound_review_no_retrigger() {
let last_tick = dt(2026, 5, 16, 1, 0, 0);
let now = dt(2026, 5, 16, 2, 0, 0);
let last_fire = dt(2026, 5, 16, 2, 0, 0);
let result = should_fire("0 2 * * *", last_tick, now, Some(last_fire));
assert!(
!result,
"Compound review should NOT re-trigger at same time"
);
}
#[test]
fn test_cron_multiple_occurrences() {
let last_tick = dt(2026, 5, 16, 1, 0, 0);
let now = dt(2026, 5, 16, 1, 45, 0);
let result = should_fire("45 0-10 * * *", last_tick, now, None);
assert!(result, "Should fire at 1:45");
let now_late = dt(2026, 5, 16, 1, 46, 0);
let last_fire = dt(2026, 5, 16, 1, 45, 0);
let result_no_fire = should_fire("45 0-10 * * *", last_tick, now_late, Some(last_fire));
assert!(
!result_no_fire,
"Should NOT fire again after already firing at 1:45"
);
}
#[test]
fn test_cron_production_schedule_test_guardian() {
let last_tick = dt(2026, 5, 16, 0, 0, 0); let now = dt(2026, 5, 16, 0, 35, 0);
let result = should_fire("35 0-10 * * *", last_tick, now, None);
assert!(result, "test-guardian schedule should fire at 00:35");
}
#[test]
fn test_resolve_mention_rejects_disabled_agent() {
use terraphim_orchestrator::mention;
let mut disabled = make_agent("disabled-agent", AgentLayer::Core, None);
disabled.enabled = false;
let agents = vec![
make_agent("enabled-agent", AgentLayer::Core, None),
disabled,
];
let result = mention::resolve_mention(None, "__legacy__", "disabled-agent", &agents);
assert!(result.is_none(), "Disabled agent should not resolve");
let result = mention::resolve_mention(None, "__legacy__", "enabled-agent", &agents);
assert!(result.is_some(), "Enabled agent should resolve");
assert_eq!(result.unwrap().name, "enabled-agent");
}