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

398 lines
14 KiB
Rust

mod common;
use std::sync::Arc;
use common::MockTeamRepo;
use nomifun_api_types::{
TeamAgentRemovedPayload, TeamAgentRenamedPayload, TeamAgentSpawnedPayload, TeamAgentStatusPayload, WebSocketMessage,
};
use nomifun_realtime::EventBroadcaster;
use nomifun_team::events::TeamEventEmitter;
use nomifun_team::prompts::{build_lead_prompt, build_teammate_prompt, build_wake_payload};
use nomifun_team::types::{
MailboxMessage, MailboxMessageType, TaskStatus, TeamAgent, TeamTask, TeammateRole, TeammateStatus,
};
use nomifun_team::{Mailbox, TaskBoard, TeammateManager};
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
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(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
self.events.lock().unwrap().clone()
}
}
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,
}
}
// ===========================================================================
// Test-plan §9: Prompt Templates
// ===========================================================================
fn default_agent_types() -> Vec<(String, String)> {
vec![
("claude".into(), "Claude".into()),
("codex".into(), "Codex".into()),
("gemini".into(), "Gemini".into()),
]
}
// -- LP-1: Lead prompt contains member list ----------------------------------
#[test]
fn lp1_lead_prompt_contains_member_list() {
let members = vec![
make_agent("lead-1", "Lead", TeammateRole::Lead),
make_agent("w1", "Alice", TeammateRole::Teammate),
make_agent("w2", "Bob", TeammateRole::Teammate),
];
let types = default_agent_types();
let prompt = build_lead_prompt("Alpha", &members, &types);
// Nomi bullet format: `- {name} ({backend}, status: {status})`
assert!(prompt.contains("- Lead ("), "lead name missing");
assert!(prompt.contains("- Alice ("), "teammate Alice missing");
assert!(prompt.contains("- Bob ("), "teammate Bob missing");
}
// -- LP-2: Lead prompt contains tool descriptions ----------------------------
#[test]
fn lp2_lead_prompt_contains_tool_descriptions() {
let prompt = build_lead_prompt("Beta", &[], &default_agent_types());
// Nomi lead prompt references the `team_*` coordination tools that the
// leader must use; the MCP layer enumerates them with arguments, so the
// prompt mentions each tool at least once.
let expected_tools = [
"team_send_message",
"team_spawn_agent",
"team_task_create",
"team_task_list",
"team_members",
"team_rename_agent",
"team_shutdown_agent",
"team_list_models",
];
for tool in expected_tools {
assert!(prompt.contains(tool), "missing tool: {tool}");
}
}
// -- LP-3: Lead prompt contains task management guidance ---------------------
#[test]
fn lp3_lead_prompt_contains_task_management_guidance() {
let prompt = build_lead_prompt("Gamma", &[], &default_agent_types());
assert!(
prompt.contains("Break the work into tasks"),
"missing decompose guidance"
);
assert!(prompt.contains("Assign tasks"), "missing assign guidance");
assert!(prompt.contains("dependency"), "missing dependency guidance");
assert!(
prompt.contains("When teammates report back"),
"missing teammate result-review guidance"
);
}
// -- TP-1: Teammate prompt contains execution guidance -----------------------
#[test]
fn tp1_teammate_prompt_contains_execution_guidance() {
let agent = make_agent("w1", "Worker1", TeammateRole::Teammate);
let prompt = build_teammate_prompt(&agent, "Alpha");
assert!(prompt.contains("execute tasks"), "missing execution guidance");
assert!(prompt.contains("team_send_message"), "missing communication tool");
assert!(prompt.contains("team_task_update"), "missing task update tool");
assert!(prompt.contains("shutdown_request"), "missing shutdown protocol");
assert!(prompt.contains("shutdown_approved"), "missing shutdown_approved");
}
// -- TP-2: Teammate prompt contains team name --------------------------------
#[test]
fn tp2_teammate_prompt_contains_team_name() {
let agent = make_agent("w1", "Worker1", TeammateRole::Teammate);
let prompt = build_teammate_prompt(&agent, "Project Falcon");
assert!(prompt.contains("\"Project Falcon\""));
}
// -- WP-1: Wake payload includes unread messages -----------------------------
#[test]
fn wp1_wake_payload_includes_unread_messages() {
let agent = make_agent("lead-1", "Lead", TeammateRole::Lead);
let messages = vec![
MailboxMessage {
id: 1,
team_id: "t1".into(),
to_agent_id: "lead-1".into(),
from_agent_id: "w1".into(),
msg_type: MailboxMessageType::Message,
content: "Feature X is done".into(),
summary: None,
files: None,
read: false,
created_at: 0,
},
MailboxMessage {
id: 2,
team_id: "t1".into(),
to_agent_id: "lead-1".into(),
from_agent_id: "w2".into(),
msg_type: MailboxMessageType::IdleNotification,
content: "idle".into(),
summary: Some("Finished task Y".into()),
files: None,
read: false,
created_at: 0,
},
];
let payload = build_wake_payload(&agent, &[], &messages);
assert!(payload.contains("Feature X is done"));
assert!(payload.contains("`w1`"));
assert!(payload.contains("[message]"));
assert!(payload.contains("`w2`"));
assert!(payload.contains("[idle_notification]"));
assert!(payload.contains("Summary: Finished task Y"));
}
// -- WP-2: Wake payload includes current task list ---------------------------
#[test]
fn wp2_wake_payload_includes_task_list() {
let agent = make_agent("lead-1", "Lead", TeammateRole::Lead);
let tasks = vec![
TeamTask {
id: "aaaaaaaa-1111-2222-3333-444444444444".into(),
team_id: "t1".into(),
subject: "Implement auth".into(),
description: None,
status: TaskStatus::InProgress,
owner: Some("w1".into()),
blocked_by: vec![],
blocks: vec![],
metadata: None,
created_at: 0,
updated_at: 0,
},
TeamTask {
id: "bbbbbbbb-1111-2222-3333-444444444444".into(),
team_id: "t1".into(),
subject: "Write tests".into(),
description: None,
status: TaskStatus::Pending,
owner: Some("w2".into()),
blocked_by: vec!["aaaaaaaa-1111-2222-3333-444444444444".into()],
blocks: vec![],
metadata: None,
created_at: 0,
updated_at: 0,
},
];
let payload = build_wake_payload(&agent, &tasks, &[]);
assert!(payload.contains("Current Task Board"));
assert!(payload.contains("Implement auth"));
assert!(payload.contains("in_progress"));
assert!(payload.contains("Write tests"));
assert!(payload.contains("pending"));
assert!(payload.contains("w1"));
assert!(payload.contains("w2"));
assert!(
payload.contains("aaaaaaaa-1111-2222-3333-444444444444"),
"blocker task ID should appear in blocked_by column"
);
}
// -- WP-3: Wake payload with no messages and no tasks builds normally --------
#[test]
fn wp3_wake_payload_empty_builds_normally() {
let agent = make_agent("w1", "Worker1", TeammateRole::Teammate);
let payload = build_wake_payload(&agent, &[], &[]);
assert!(payload.contains("No new messages"));
assert!(payload.contains("No tasks on the board"));
assert!(payload.contains("**Worker1**"));
assert!(payload.contains("teammate"));
}
// ===========================================================================
// Test-plan §8: WebSocket Event Broadcasting
// ===========================================================================
// -- WE-1: Agent status change event -----------------------------------------
#[tokio::test]
async fn we1_agent_status_change_event() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let bc = Arc::new(RecordingBroadcaster::new());
let agents = vec![
make_agent("lead-1", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let mgr = TeammateManager::new("t1".into(), &agents, mailbox, task_board, bc.clone());
mgr.set_status("w1", TeammateStatus::Working).await.unwrap();
let events = bc.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].name, "team.agent.status");
let payload: TeamAgentStatusPayload = serde_json::from_value(events[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "t1");
assert_eq!(payload.slot_id, "w1");
assert_eq!(payload.status, "working");
}
// -- WE-2: Agent spawned event -----------------------------------------------
#[tokio::test]
async fn we2_agent_spawned_event() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let bc = Arc::new(RecordingBroadcaster::new());
let agents = vec![make_agent("lead-1", "Lead", TeammateRole::Lead)];
let mgr = TeammateManager::new("t1".into(), &agents, mailbox, task_board, bc.clone());
let new_agent = make_agent("w2", "NewWorker", TeammateRole::Teammate);
mgr.add_agent(&new_agent).await;
let spawned: Vec<_> = bc
.events()
.into_iter()
.filter(|e| e.name == "team.agent.spawned")
.collect();
assert_eq!(spawned.len(), 1);
let payload: TeamAgentSpawnedPayload = serde_json::from_value(spawned[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "t1");
assert_eq!(payload.agent.slot_id, "w2");
assert_eq!(payload.agent.name, "NewWorker");
}
// -- WE-3: Agent removed event -----------------------------------------------
#[tokio::test]
async fn we3_agent_removed_event() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let bc = Arc::new(RecordingBroadcaster::new());
let agents = vec![
make_agent("lead-1", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let mgr = TeammateManager::new("t1".into(), &agents, mailbox, task_board, bc.clone());
mgr.remove_agent("w1").await.unwrap();
let removed: Vec<_> = bc
.events()
.into_iter()
.filter(|e| e.name == "team.agent.removed")
.collect();
assert_eq!(removed.len(), 1);
let payload: TeamAgentRemovedPayload = serde_json::from_value(removed[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "t1");
assert_eq!(payload.slot_id, "w1");
}
// -- WE-4: Agent renamed event -----------------------------------------------
#[tokio::test]
async fn we4_agent_renamed_event() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Arc::new(Mailbox::new(repo.clone()));
let task_board = Arc::new(TaskBoard::new(repo));
let bc = Arc::new(RecordingBroadcaster::new());
let agents = vec![
make_agent("lead-1", "Lead", TeammateRole::Lead),
make_agent("w1", "Worker", TeammateRole::Teammate),
];
let mgr = TeammateManager::new("t1".into(), &agents, mailbox, task_board, bc.clone());
mgr.rename_agent("w1", "SuperWorker").await.unwrap();
let renamed: Vec<_> = bc
.events()
.into_iter()
.filter(|e| e.name == "team.agent.renamed")
.collect();
assert_eq!(renamed.len(), 1);
let payload: TeamAgentRenamedPayload = serde_json::from_value(renamed[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "t1");
assert_eq!(payload.slot_id, "w1");
assert_eq!(payload.name, "SuperWorker");
}
// -- Direct TeamEventEmitter test (event payloads use typed structs) ----------
#[test]
fn event_emitter_uses_typed_payloads() {
let bc = Arc::new(RecordingBroadcaster::new());
let emitter = TeamEventEmitter::new("team-x".into(), bc.clone());
let agent = make_agent("s1", "A", TeammateRole::Teammate);
emitter.broadcast_agent_status("s1", TeammateStatus::Thinking);
emitter.broadcast_agent_spawned(&agent);
emitter.broadcast_agent_removed("s1");
emitter.broadcast_agent_renamed("s1", "B");
let events = bc.events();
assert_eq!(events.len(), 4);
let p1: TeamAgentStatusPayload = serde_json::from_value(events[0].data.clone()).unwrap();
assert_eq!(p1.status, "thinking");
let p2: TeamAgentSpawnedPayload = serde_json::from_value(events[1].data.clone()).unwrap();
assert_eq!(p2.agent.slot_id, "s1");
let p3: TeamAgentRemovedPayload = serde_json::from_value(events[2].data.clone()).unwrap();
assert_eq!(p3.slot_id, "s1");
let p4: TeamAgentRenamedPayload = serde_json::from_value(events[3].data.clone()).unwrap();
assert_eq!(p4.name, "B");
}