Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-team/tests/scheduler_integration.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

575 lines
18 KiB
Rust

mod common;
use std::sync::Arc;
use common::MockTeamRepo;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::EventBroadcaster;
use nomifun_team::{
Mailbox, MailboxMessageType, SchedulerAction, TaskBoard, TeamAgent, TeammateManager, TeammateRole, TeammateStatus,
WAKE_TIMEOUT_MS,
};
// ---------------------------------------------------------------------------
// Test infrastructure
// ---------------------------------------------------------------------------
struct RecordingBroadcaster {
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(vec![]),
}
}
fn events_by_name(&self, name: &str) -> Vec<WebSocketMessage<serde_json::Value>> {
self.events
.lock()
.unwrap()
.iter()
.filter(|e| e.name == name)
.cloned()
.collect()
}
}
impl EventBroadcaster for RecordingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
fn make_agent(slot_id: &str, name: &str, role: TeammateRole) -> TeamAgent {
TeamAgent {
slot_id: slot_id.into(),
name: name.into(),
role,
conversation_id: format!("conv-{slot_id}"),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
}
}
struct TestHarness {
mgr: TeammateManager,
mailbox: Arc<Mailbox>,
task_board: Arc<TaskBoard>,
broadcaster: Arc<RecordingBroadcaster>,
}
fn setup_team(agents: &[TeamAgent]) -> TestHarness {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let broadcaster = Arc::new(RecordingBroadcaster::new());
let mgr = TeammateManager::new(
"team-1".into(),
agents,
mailbox.clone(),
task_board.clone(),
broadcaster.clone(),
);
TestHarness {
mgr,
mailbox,
task_board,
broadcaster,
}
}
// ===========================================================================
// Test-plan §7: Agent 调度引擎
// ===========================================================================
// -- AW-1: Wake idle Agent → status idle→working, payload has tasks+unread --
#[tokio::test]
async fn aw1_wake_idle_agent_transitions_to_working_with_payload() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.task_board
.create_task("team-1", "Task A", None, Some("w1"), &[])
.await
.unwrap();
h.mailbox
.write("team-1", "w1", "lead", MailboxMessageType::Message, "Do it", None)
.await
.unwrap();
let payload = h.mgr.try_wake("w1").await.unwrap();
assert!(payload.is_some());
let p = payload.unwrap();
assert_eq!(p.agent.slot_id, "w1");
assert_eq!(p.tasks.len(), 1);
assert_eq!(p.tasks[0].subject, "Task A");
assert_eq!(p.unread_messages.len(), 1);
assert_eq!(p.unread_messages[0].content, "Do it");
assert_eq!(h.mgr.get_status("w1").await.unwrap(), TeammateStatus::Working);
}
// -- AW-2: Wake → agent completes → finalize_turn → actions executed --------
#[tokio::test]
async fn aw2_wake_complete_finalize_executes_actions() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
h.mgr.set_status("w2", TeammateStatus::Working).await.unwrap();
let actions = vec![
SchedulerAction::TaskCreate {
subject: "Write tests".into(),
description: Some("Unit tests for API".into()),
owner: Some("w1".into()),
blocked_by: vec![],
},
SchedulerAction::SendMessage {
to: "w2".into(),
message: "Please review when done".into(),
},
];
h.mgr.finalize_turn("w1", &actions).await.unwrap();
let tasks = h.task_board.list_tasks("team-1").await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subject, "Write tests");
let w2_msgs = h.mailbox.read_unread("team-1", "w2").await.unwrap();
assert_eq!(w2_msgs.len(), 1);
assert_eq!(w2_msgs[0].content, "Please review when done");
assert_eq!(h.mgr.get_status("w1").await.unwrap(), TeammateStatus::Idle);
}
// -- AW-3: WAKE_TIMEOUT_MS constant is 60000 --------------------------------
#[tokio::test]
async fn aw3_wake_timeout_constant() {
assert_eq!(WAKE_TIMEOUT_MS, 60_000);
}
// -- AW-4: Wake non-idle agent → skip (no duplicate wake) -------------------
#[tokio::test]
async fn aw4_wake_non_idle_agent_skipped() {
let agents = vec![make_agent("w1", "Worker", TeammateRole::Teammate)];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
let payload = h.mgr.try_wake("w1").await.unwrap();
assert!(payload.is_none());
}
// -- DL-1: Lead completes turn → stays idle, no auto-wake -------------------
#[tokio::test]
async fn dl1_lead_finalize_stays_idle_no_auto_wake() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("lead", TeammateStatus::Working).await.unwrap();
let actions = vec![SchedulerAction::SendMessage {
to: "w1".into(),
message: "Go work".into(),
}];
let wake_signal = h.mgr.finalize_turn("lead", &actions).await.unwrap();
assert!(wake_signal.is_none());
assert_eq!(h.mgr.get_status("lead").await.unwrap(), TeammateStatus::Idle);
}
// -- DL-2: All teammates idle → wake leader ---------------------------------
#[tokio::test]
async fn dl2_all_teammates_idle_wakes_leader() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
h.mgr.set_status("w2", TeammateStatus::Working).await.unwrap();
let wake1 = h.mgr.finalize_turn("w1", &[]).await.unwrap();
assert!(wake1.is_none());
let wake2 = h.mgr.finalize_turn("w2", &[]).await.unwrap();
assert_eq!(wake2.as_deref(), Some("lead"));
}
// -- DL-3: Partial teammates idle → leader NOT woken ------------------------
#[tokio::test]
async fn dl3_partial_teammates_idle_no_leader_wake() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
h.mgr.set_status("w2", TeammateStatus::Working).await.unwrap();
let wake = h.mgr.finalize_turn("w1", &[]).await.unwrap();
assert!(wake.is_none());
}
// -- AE-1: send_message action → mailbox + wake target ----------------------
#[tokio::test]
async fn ae1_send_message_action_writes_mailbox() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr
.execute_action(
"lead",
&SchedulerAction::SendMessage {
to: "w1".into(),
message: "Hello worker".into(),
},
)
.await
.unwrap();
let msgs = h.mailbox.read_unread("team-1", "w1").await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "Hello worker");
assert_eq!(msgs[0].from_agent_id, "lead");
}
// -- AE-2: task_create action → task created --------------------------------
#[tokio::test]
async fn ae2_task_create_action() {
let agents = vec![make_agent("lead", "Lead", TeammateRole::Lead)];
let h = setup_team(&agents);
h.mgr
.execute_action(
"lead",
&SchedulerAction::TaskCreate {
subject: "Build feature".into(),
description: Some("With tests".into()),
owner: None,
blocked_by: vec![],
},
)
.await
.unwrap();
let tasks = h.task_board.list_tasks("team-1").await.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].subject, "Build feature");
}
// -- AE-3: idle_notification → agent marked idle + check all-idle -----------
#[tokio::test]
async fn ae3_idle_notification_marks_idle_and_notifies_lead() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
let wake_signal = h
.mgr
.execute_action(
"w1",
&SchedulerAction::IdleNotification {
summary: Some("Done".into()),
},
)
.await
.unwrap();
assert_eq!(h.mgr.get_status("w1").await.unwrap(), TeammateStatus::Idle);
let lead_msgs = h.mailbox.read_unread("team-1", "lead").await.unwrap();
assert_eq!(lead_msgs.len(), 1);
assert_eq!(lead_msgs[0].msg_type, MailboxMessageType::IdleNotification);
assert_eq!(wake_signal.as_deref(), Some("lead"));
}
// -- AE-4: spawn_agent action → logged (actual creation via TeamSession) ----
#[tokio::test]
async fn ae4_spawn_agent_action_logged() {
let agents = vec![make_agent("lead", "Lead", TeammateRole::Lead)];
let h = setup_team(&agents);
let result = h
.mgr
.execute_action(
"lead",
&SchedulerAction::SpawnAgent {
name: "Helper".into(),
role: "teammate".into(),
backend: "claude".into(),
},
)
.await;
assert!(result.is_ok());
}
// ===========================================================================
// Test-plan §8: WebSocket 事件推送
// ===========================================================================
// -- WE-1: Agent status change broadcasts team.agent.status -----------------
#[tokio::test]
async fn we1_status_change_broadcasts_event() {
let agents = vec![make_agent("w1", "Worker", TeammateRole::Teammate)];
let h = setup_team(&agents);
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
let events = h.broadcaster.events_by_name("team.agent.status");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["team_id"], "team-1");
assert_eq!(events[0].data["slot_id"], "w1");
assert_eq!(events[0].data["status"], "working");
}
// -- WE-2: Dynamic agent creation broadcasts team.agent.spawned -------------
#[tokio::test]
async fn we2_add_agent_broadcasts_spawned() {
let agents = vec![make_agent("lead", "Lead", TeammateRole::Lead)];
let h = setup_team(&agents);
let new = make_agent("w1", "Worker", TeammateRole::Teammate);
h.mgr.add_agent(&new).await;
let events = h.broadcaster.events_by_name("team.agent.spawned");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["team_id"], "team-1");
assert!(events[0].data["agent"].is_object());
}
// -- WE-3: Remove agent broadcasts team.agent.removed ----------------------
#[tokio::test]
async fn we3_remove_agent_broadcasts_removed() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr.remove_agent("w1").await.unwrap();
let events = h.broadcaster.events_by_name("team.agent.removed");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["slot_id"], "w1");
}
// -- WE-4: Rename agent broadcasts team.agent.renamed ----------------------
#[tokio::test]
async fn we4_rename_agent_broadcasts_renamed() {
let agents = vec![make_agent("w1", "Worker", TeammateRole::Teammate)];
let h = setup_team(&agents);
h.mgr.rename_agent("w1", "SuperWorker").await.unwrap();
let events = h.broadcaster.events_by_name("team.agent.renamed");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data["slot_id"], "w1");
assert_eq!(events[0].data["name"], "SuperWorker");
}
// ===========================================================================
// Full workflow: Lead delegates → Workers complete → Leader re-woken
// ===========================================================================
#[tokio::test]
async fn full_workflow_lead_delegate_workers_idle_lead_rewake() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
// 1. Wake lead with user message
h.mailbox
.write("team-1", "lead", "user", MailboxMessageType::Message, "Build X", None)
.await
.unwrap();
let payload = h.mgr.try_wake("lead").await.unwrap().unwrap();
assert_eq!(payload.unread_messages.len(), 1);
// 2. Lead delegates work to workers
let lead_actions = vec![
SchedulerAction::TaskCreate {
subject: "Implement X".into(),
description: None,
owner: Some("w1".into()),
blocked_by: vec![],
},
SchedulerAction::SendMessage {
to: "w1".into(),
message: "Implement X".into(),
},
SchedulerAction::SendMessage {
to: "w2".into(),
message: "Write tests for X".into(),
},
SchedulerAction::IdleNotification {
summary: Some("Delegated".into()),
},
];
let wake_signal = h.mgr.finalize_turn("lead", &lead_actions).await.unwrap();
assert!(wake_signal.is_none(), "lead idle → no auto-wake");
// 3. Workers start working
h.mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
h.mgr.set_status("w2", TeammateStatus::Working).await.unwrap();
// 4. Worker1 finishes
let w1_actions = vec![SchedulerAction::IdleNotification {
summary: Some("Implemented X".into()),
}];
let wake = h.mgr.finalize_turn("w1", &w1_actions).await.unwrap();
assert!(wake.is_none(), "w2 still working");
// 5. Worker2 finishes → all idle → leader should be woken
let w2_actions = vec![SchedulerAction::IdleNotification {
summary: Some("Tests written".into()),
}];
let wake = h.mgr.finalize_turn("w2", &w2_actions).await.unwrap();
assert_eq!(wake.as_deref(), Some("lead"), "all teammates idle → wake leader");
// 6. Verify lead has idle notifications from both workers
let lead_msgs = h.mailbox.read_unread("team-1", "lead").await.unwrap();
assert_eq!(lead_msgs.len(), 2);
let summaries: Vec<_> = lead_msgs.iter().map(|m| m.content.as_str()).collect();
assert!(summaries.contains(&"Implemented X"));
assert!(summaries.contains(&"Tests written"));
}
// ===========================================================================
// Shutdown flow: Lead initiates → target receives shutdown_request
// ===========================================================================
#[tokio::test]
async fn shutdown_flow_lead_sends_request_to_target() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr
.execute_action(
"lead",
&SchedulerAction::ShutdownAgent {
slot_id: "w1".into(),
reason: Some("No longer needed".into()),
},
)
.await
.unwrap();
let msgs = h.mailbox.read_unread("team-1", "w1").await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].msg_type, MailboxMessageType::ShutdownRequest);
assert_eq!(msgs[0].content, "No longer needed");
}
#[tokio::test]
async fn shutdown_flow_non_lead_rejected() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
let result = h
.mgr
.execute_action(
"w1",
&SchedulerAction::ShutdownAgent {
slot_id: "w2".into(),
reason: None,
},
)
.await;
assert!(result.is_err());
}
// ===========================================================================
// Broadcast message (to="*") sends to all except sender
// ===========================================================================
#[tokio::test]
async fn broadcast_message_sends_to_all_except_sender() {
let agents = vec![
make_agent("lead", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker1", TeammateRole::Teammate),
make_agent("w2", "Worker2", TeammateRole::Teammate),
];
let h = setup_team(&agents);
h.mgr
.execute_action(
"lead",
&SchedulerAction::SendMessage {
to: "*".into(),
message: "Attention everyone".into(),
},
)
.await
.unwrap();
let w1_msgs = h.mailbox.read_unread("team-1", "w1").await.unwrap();
let w2_msgs = h.mailbox.read_unread("team-1", "w2").await.unwrap();
let lead_msgs = h.mailbox.read_unread("team-1", "lead").await.unwrap();
assert_eq!(w1_msgs.len(), 1);
assert_eq!(w2_msgs.len(), 1);
assert!(lead_msgs.is_empty());
}