Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
use nomifun_common::now_ms;
|
||||
use nomifun_db::models::{MailboxMessageRow, TeamAgentRow, TeamRow, TeamTaskRow};
|
||||
use nomifun_db::{DbError, ITeamRepository, UpdateTaskParams, UpdateTeamAgentParams, UpdateTeamParams};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory backing store for [`MockTeamRepo`].
|
||||
///
|
||||
/// Mirrors the post-primary-key-redesign schema: `team_agents` and
|
||||
/// `team_task_deps` are first-class tables (was `teams.agents` /
|
||||
/// `team_tasks.blocked_by`/`blocks` JSON arrays), and `mailbox.id` is an
|
||||
/// autoincrement `i64`.
|
||||
#[derive(Default)]
|
||||
pub struct MockState {
|
||||
pub teams: Vec<TeamRow>,
|
||||
pub team_agents: Vec<TeamAgentRow>,
|
||||
pub messages: Vec<MailboxMessageRow>,
|
||||
pub next_message_id: i64,
|
||||
pub tasks: Vec<TeamTaskRow>,
|
||||
/// Dependency edges: `(blocker_task_id, blocked_task_id)`.
|
||||
pub task_deps: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct MockTeamRepo {
|
||||
pub state: Mutex<MockState>,
|
||||
}
|
||||
|
||||
impl Default for MockTeamRepo {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MockTeamRepo {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Mutex::new(MockState {
|
||||
next_message_id: 1,
|
||||
..MockState::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ITeamRepository for MockTeamRepo {
|
||||
// ── Team CRUD ───────────────────────────────────────────────────
|
||||
|
||||
async fn create_team(&self, row: &TeamRow) -> Result<(), DbError> {
|
||||
self.state.lock().unwrap().teams.push(row.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn list_teams(&self) -> Result<Vec<TeamRow>, DbError> {
|
||||
Ok(self.state.lock().unwrap().teams.clone())
|
||||
}
|
||||
async fn get_team(&self, id: &str) -> Result<Option<TeamRow>, DbError> {
|
||||
Ok(self.state.lock().unwrap().teams.iter().find(|t| t.id == id).cloned())
|
||||
}
|
||||
async fn update_team(&self, id: &str, params: &UpdateTeamParams) -> Result<(), DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let team = state
|
||||
.teams
|
||||
.iter_mut()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| DbError::NotFound(id.to_owned()))?;
|
||||
if let Some(ref name) = params.name {
|
||||
team.name = name.clone();
|
||||
}
|
||||
if let Some(ref lead) = params.lead_agent_id {
|
||||
team.lead_agent_id = Some(lead.clone());
|
||||
}
|
||||
team.updated_at = now_ms();
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_team(&self, id: &str) -> Result<(), DbError> {
|
||||
// Emulate the FK ON DELETE CASCADE chain.
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.teams.retain(|t| t.id != id);
|
||||
state.team_agents.retain(|a| a.team_id != id);
|
||||
state.messages.retain(|m| m.team_id != id);
|
||||
let task_ids: Vec<String> = state
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|t| t.team_id == id)
|
||||
.map(|t| t.id.clone())
|
||||
.collect();
|
||||
state.tasks.retain(|t| t.team_id != id);
|
||||
state
|
||||
.task_deps
|
||||
.retain(|(blocker, blocked)| !task_ids.contains(blocker) && !task_ids.contains(blocked));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Team agents (was teams.agents JSON array) ─────────────────────
|
||||
|
||||
async fn create_team_agent(&self, row: &TeamAgentRow) -> Result<(), DbError> {
|
||||
self.state.lock().unwrap().team_agents.push(row.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn list_team_agents(&self, team_id: &str) -> Result<Vec<TeamAgentRow>, DbError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
let mut agents: Vec<TeamAgentRow> = state
|
||||
.team_agents
|
||||
.iter()
|
||||
.filter(|a| a.team_id == team_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
agents.sort_by(|a, b| a.sort_order.cmp(&b.sort_order).then_with(|| a.slot_id.cmp(&b.slot_id)));
|
||||
Ok(agents)
|
||||
}
|
||||
async fn get_team_agent(&self, slot_id: &str) -> Result<Option<TeamAgentRow>, DbError> {
|
||||
Ok(self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.team_agents
|
||||
.iter()
|
||||
.find(|a| a.slot_id == slot_id)
|
||||
.cloned())
|
||||
}
|
||||
async fn update_team_agent(&self, slot_id: &str, params: &UpdateTeamAgentParams) -> Result<(), DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let agent = state
|
||||
.team_agents
|
||||
.iter_mut()
|
||||
.find(|a| a.slot_id == slot_id)
|
||||
.ok_or_else(|| DbError::NotFound(slot_id.to_owned()))?;
|
||||
if let Some(ref v) = params.name {
|
||||
agent.name = v.clone();
|
||||
}
|
||||
if let Some(ref v) = params.role {
|
||||
agent.role = v.clone();
|
||||
}
|
||||
if let Some(ref v) = params.conversation_id {
|
||||
agent.conversation_id = Some(v.clone());
|
||||
}
|
||||
if let Some(ref v) = params.backend {
|
||||
agent.backend = v.clone();
|
||||
}
|
||||
if let Some(ref v) = params.model {
|
||||
agent.model = v.clone();
|
||||
}
|
||||
if let Some(ref v) = params.custom_agent_id {
|
||||
agent.custom_agent_id = Some(v.clone());
|
||||
}
|
||||
if let Some(ref v) = params.status {
|
||||
agent.status = Some(v.clone());
|
||||
}
|
||||
if let Some(ref v) = params.conversation_type {
|
||||
agent.conversation_type = Some(v.clone());
|
||||
}
|
||||
if let Some(ref v) = params.cli_path {
|
||||
agent.cli_path = Some(v.clone());
|
||||
}
|
||||
if let Some(v) = params.sort_order {
|
||||
agent.sort_order = v;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn rename_team_agent(&self, slot_id: &str, name: &str) -> Result<(), DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let agent = state
|
||||
.team_agents
|
||||
.iter_mut()
|
||||
.find(|a| a.slot_id == slot_id)
|
||||
.ok_or_else(|| DbError::NotFound(slot_id.to_owned()))?;
|
||||
agent.name = name.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_team_agent(&self, slot_id: &str) -> Result<(), DbError> {
|
||||
self.state.lock().unwrap().team_agents.retain(|a| a.slot_id != slot_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Mailbox ─────────────────────────────────────────────────────
|
||||
|
||||
async fn write_message(&self, row: &MailboxMessageRow) -> Result<i64, DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let id = state.next_message_id;
|
||||
state.next_message_id += 1;
|
||||
let mut stored = row.clone();
|
||||
stored.id = id;
|
||||
state.messages.push(stored);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn read_unread_and_mark(&self, team_id: &str, to_agent_id: &str) -> Result<Vec<MailboxMessageRow>, DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut result = vec![];
|
||||
for msg in &mut state.messages {
|
||||
if msg.team_id == team_id && msg.to_agent_id == to_agent_id && !msg.read {
|
||||
msg.read = true;
|
||||
result.push(msg.clone());
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn peek_unread(&self, team_id: &str, to_agent_id: &str) -> Result<Vec<MailboxMessageRow>, DbError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
let result = state
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.team_id == team_id && m.to_agent_id == to_agent_id && !m.read)
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn mark_read_batch(&self, ids: &[i64]) -> Result<(), DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
for msg in &mut state.messages {
|
||||
if ids.contains(&msg.id) {
|
||||
msg.read = true;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_history(
|
||||
&self,
|
||||
team_id: &str,
|
||||
to_agent_id: &str,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<MailboxMessageRow>, DbError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
let iter = state
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.team_id == team_id && m.to_agent_id == to_agent_id);
|
||||
let msgs: Vec<_> = match limit {
|
||||
Some(n) => iter.take(n as usize).cloned().collect(),
|
||||
None => iter.cloned().collect(),
|
||||
};
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────────────
|
||||
|
||||
async fn create_task(&self, row: &TeamTaskRow) -> Result<(), DbError> {
|
||||
self.state.lock().unwrap().tasks.push(row.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_task_by_id(&self, team_id: &str, task_id: &str) -> Result<Option<TeamTaskRow>, DbError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
let found = state
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|t| t.team_id == team_id && t.id == task_id)
|
||||
.cloned();
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
async fn update_task(&self, task_id: &str, params: &UpdateTaskParams) -> Result<(), DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let task = state
|
||||
.tasks
|
||||
.iter_mut()
|
||||
.find(|t| t.id == task_id)
|
||||
.ok_or_else(|| DbError::NotFound(task_id.to_owned()))?;
|
||||
if let Some(ref s) = params.status {
|
||||
task.status = s.clone();
|
||||
}
|
||||
if let Some(ref d) = params.description {
|
||||
task.description = Some(d.clone());
|
||||
}
|
||||
if let Some(ref o) = params.owner {
|
||||
task.owner = Some(o.clone());
|
||||
}
|
||||
if let Some(ref m) = params.metadata {
|
||||
task.metadata = Some(m.clone());
|
||||
}
|
||||
task.updated_at = now_ms();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_tasks(&self, team_id: &str) -> Result<Vec<TeamTaskRow>, DbError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
let tasks = state.tasks.iter().filter(|t| t.team_id == team_id).cloned().collect();
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
// ── Task dependencies (was blocked_by/blocks JSON arrays) ─────────
|
||||
|
||||
async fn add_task_dep(&self, blocker_task_id: &str, blocked_task_id: &str) -> Result<(), DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let edge = (blocker_task_id.to_owned(), blocked_task_id.to_owned());
|
||||
if !state.task_deps.contains(&edge) {
|
||||
state.task_deps.push(edge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_task_dep(&self, blocker_task_id: &str, blocked_task_id: &str) -> Result<(), DbError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state
|
||||
.task_deps
|
||||
.retain(|(blocker, blocked)| !(blocker == blocker_task_id && blocked == blocked_task_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_blockers(&self, task_id: &str) -> Result<Vec<String>, DbError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
Ok(state
|
||||
.task_deps
|
||||
.iter()
|
||||
.filter(|(_, blocked)| blocked == task_id)
|
||||
.map(|(blocker, _)| blocker.clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_blocking(&self, task_id: &str) -> Result<Vec<String>, DbError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
Ok(state
|
||||
.task_deps
|
||||
.iter()
|
||||
.filter(|(blocker, _)| blocker == task_id)
|
||||
.map(|(_, blocked)| blocked.clone())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! End-to-end smoke tests for the team subsystem.
|
||||
//!
|
||||
//! **Purpose:** guard against the "agent claims a tool works but it is an
|
||||
//! empty shell" failure mode by exercising each user-visible capability
|
||||
//! through its real wiring (TCP MCP server, mailbox, task board, scheduler)
|
||||
//! and asserting the observable side effect — not just a success return
|
||||
//! code.
|
||||
//!
|
||||
//! **Scenario status:**
|
||||
//! - scenario 1 (create team → lead → MCP tools available) — `todo!()`,
|
||||
//! `#[ignore]`. Unblocks when `spawn_agent` + MCP wiring lands.
|
||||
//! - scenario 2 (`team_spawn_agent` creates a real session) — `todo!()`,
|
||||
//! `#[ignore]`. Unblocks when `spawn_agent` is implemented
|
||||
//! (see W5-D29a-* modules).
|
||||
//! - scenario 3 (shutdown full protocol) — `todo!()`, `#[ignore]`. Unblocks
|
||||
//! when shutdown_agent / shutdown_approved mailbox wiring lands.
|
||||
//! - scenario 4 (crash → testament → leader wake) — `todo!()`, `#[ignore]`.
|
||||
//! Unblocks when the crash handler is wired into the stream pipeline.
|
||||
//! - scenario 5 (MCP tool execution is not a no-op) — **runs now**. Uses
|
||||
//! only pieces that already exist (mailbox + task board + TeamMcpServer)
|
||||
//! and is the first real e2e guard.
|
||||
//!
|
||||
//! All ignored scenarios must stay compiling so the scaffold itself never
|
||||
//! rots between waves.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::MockTeamRepo;
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use nomifun_team::mcp::protocol::{read_frame, write_frame};
|
||||
use nomifun_team::{Mailbox, TaskBoard, TeamAgent, TeamMcpServer, TeammateManager, TeammateRole};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers — local to this file to avoid touching common/mod.rs and
|
||||
// keep the scaffold self-contained. If more tests start sharing these,
|
||||
// promote to common/e2e_helpers.rs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct NullBroadcaster;
|
||||
impl EventBroadcaster for NullBroadcaster {
|
||||
fn broadcast(&self, _msg: WebSocketMessage<Value>) {}
|
||||
}
|
||||
|
||||
/// Concrete handle returned by [`setup_team_with_lead`]. Holds every piece
|
||||
/// a smoke test might need to assert a side effect.
|
||||
struct SmokeEnv {
|
||||
server: TeamMcpServer,
|
||||
mailbox: Arc<Mailbox>,
|
||||
task_board: Arc<TaskBoard>,
|
||||
repo: Arc<MockTeamRepo>,
|
||||
#[allow(dead_code)]
|
||||
scheduler: Arc<TeammateManager>,
|
||||
team_id: String,
|
||||
lead_slot_id: String,
|
||||
worker_slot_id: String,
|
||||
auth_token: String,
|
||||
}
|
||||
|
||||
/// Build a 2-agent team (lead + worker) wired through a real
|
||||
/// `TeamMcpServer` listening on a random port, against an in-memory mock
|
||||
/// team repo. Does not spin up a `TeamSessionService`, ACP agents, or
|
||||
/// backends — those are exercised in the scenarios that need them.
|
||||
async fn setup_team_with_lead() -> SmokeEnv {
|
||||
let repo = Arc::new(MockTeamRepo::new());
|
||||
let mailbox = Arc::new(Mailbox::new(repo.clone()));
|
||||
let task_board = Arc::new(TaskBoard::new(repo.clone()));
|
||||
let broadcaster: Arc<dyn EventBroadcaster> = Arc::new(NullBroadcaster);
|
||||
|
||||
let team_id = "smoke-team".to_string();
|
||||
let lead_slot_id = "lead-1".to_string();
|
||||
let worker_slot_id = "worker-1".to_string();
|
||||
let agents = vec![
|
||||
TeamAgent {
|
||||
slot_id: lead_slot_id.clone(),
|
||||
name: "Leader".into(),
|
||||
role: TeammateRole::Lead,
|
||||
conversation_id: "conv-lead".into(),
|
||||
backend: "acp".into(),
|
||||
model: "claude".into(),
|
||||
custom_agent_id: None,
|
||||
status: None,
|
||||
conversation_type: None,
|
||||
cli_path: None,
|
||||
},
|
||||
TeamAgent {
|
||||
slot_id: worker_slot_id.clone(),
|
||||
name: "Worker".into(),
|
||||
role: TeammateRole::Teammate,
|
||||
conversation_id: "conv-worker".into(),
|
||||
backend: "acp".into(),
|
||||
model: "claude".into(),
|
||||
custom_agent_id: None,
|
||||
status: None,
|
||||
conversation_type: None,
|
||||
cli_path: None,
|
||||
},
|
||||
];
|
||||
let scheduler = Arc::new(TeammateManager::new(
|
||||
team_id.clone(),
|
||||
&agents,
|
||||
mailbox.clone(),
|
||||
task_board.clone(),
|
||||
broadcaster.clone(),
|
||||
));
|
||||
|
||||
let auth_token = "smoke-token".to_string();
|
||||
let server = TeamMcpServer::start(
|
||||
auth_token.clone(),
|
||||
scheduler.clone(),
|
||||
team_id.clone(),
|
||||
broadcaster,
|
||||
std::sync::Weak::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
SmokeEnv {
|
||||
server,
|
||||
mailbox,
|
||||
task_board,
|
||||
repo,
|
||||
scheduler,
|
||||
team_id,
|
||||
lead_slot_id,
|
||||
worker_slot_id,
|
||||
auth_token,
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the MCP server, perform `initialize` as `slot_id`, and
|
||||
/// return the authenticated stream.
|
||||
async fn mcp_connect(env: &SmokeEnv, slot_id: &str) -> TcpStream {
|
||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", env.server.port()))
|
||||
.await
|
||||
.expect("tcp connect to TeamMcpServer");
|
||||
|
||||
let init_req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"auth_token": env.auth_token,
|
||||
"slot_id": slot_id,
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "smoke-test", "version": "1.0" }
|
||||
}
|
||||
});
|
||||
mcp_send(&mut stream, &init_req).await;
|
||||
let resp = mcp_recv(&mut stream).await;
|
||||
assert!(
|
||||
resp["result"]["serverInfo"]["name"].is_string(),
|
||||
"initialize failed: {resp}"
|
||||
);
|
||||
stream
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC `tools/call` and return the raw response envelope.
|
||||
async fn mcp_call(stream: &mut TcpStream, id: u64, tool: &str, args: Value) -> Value {
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/call",
|
||||
"params": { "name": tool, "arguments": args }
|
||||
});
|
||||
mcp_send(stream, &req).await;
|
||||
mcp_recv(stream).await
|
||||
}
|
||||
|
||||
async fn mcp_send(stream: &mut TcpStream, req: &Value) {
|
||||
let bytes = serde_json::to_vec(req).unwrap();
|
||||
write_frame(stream, &bytes).await.unwrap();
|
||||
}
|
||||
|
||||
async fn mcp_recv(stream: &mut TcpStream) -> Value {
|
||||
let frame = read_frame(stream).await.unwrap();
|
||||
serde_json::from_slice(&frame).unwrap()
|
||||
}
|
||||
|
||||
fn is_error_response(resp: &Value) -> bool {
|
||||
resp["result"]["isError"].as_bool().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Assert the given agent's mailbox contains at least one message whose
|
||||
/// content includes `needle`. Reads the DB-level history so it does not
|
||||
/// mutate the unread flag (unlike `read_unread`).
|
||||
async fn assert_mailbox_contains(mailbox: &Mailbox, team_id: &str, agent_id: &str, needle: &str) {
|
||||
let history = mailbox
|
||||
.get_history(team_id, agent_id, None)
|
||||
.await
|
||||
.expect("mailbox.get_history");
|
||||
assert!(
|
||||
history.iter().any(|m| m.content.contains(needle)),
|
||||
"expected mailbox[{team_id}/{agent_id}] to contain {needle:?}, got {history:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Scenario 1: create team → lead agent exists → MCP tools available
|
||||
// ===========================================================================
|
||||
|
||||
/// User story: "I create a team; its lead is ready and the MCP surface
|
||||
/// that lead will drive is actually wired (not an empty shell)."
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. `TeamSessionService::create_team` with a lead + one worker.
|
||||
/// 2. Assert the returned team has a `lead_agent_id` and two agents.
|
||||
/// 3. Assert `TeamMcpServer` is started for that team (ensure_session).
|
||||
/// 4. `tools/list` returns the full 10-tool surface.
|
||||
/// 5. `team_members` returns both agents.
|
||||
#[tokio::test]
|
||||
#[ignore = "unblocks when TeamSessionService e2e wiring is ready (spawn + ensure_session over real DB)"]
|
||||
async fn smoke_create_team_and_verify_mcp_tools() {
|
||||
todo!("scenario 1: fill once spawn_agent / ensure_session end-to-end is merged");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Scenario 2: team_spawn_agent actually creates a new agent session
|
||||
// ===========================================================================
|
||||
|
||||
/// User story: "The lead calls `team_spawn_agent`; a real new agent shows
|
||||
/// up in the team, has its own conversation row, and has a welcome
|
||||
/// message in its mailbox — not a success return with no side effect."
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Create a team with only a lead.
|
||||
/// 2. Lead calls `team_spawn_agent(name=Helper, role=worker, backend=claude)`.
|
||||
/// 3. `team_members` includes the new Helper.
|
||||
/// 4. Conversation repo has a row for the new agent's conversation_id.
|
||||
/// 5. Helper's mailbox has the welcome / kickoff message.
|
||||
#[tokio::test]
|
||||
#[ignore = "unblocks when W5-D29a-* spawn_agent lands"]
|
||||
async fn smoke_spawn_agent_creates_real_session() {
|
||||
todo!("scenario 2: fill once team_spawn_agent persists agent + conversation + welcome mail");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Scenario 3: shutdown agent — full request/approval protocol
|
||||
// ===========================================================================
|
||||
|
||||
/// User story: "The lead asks a worker to shut down; the worker is
|
||||
/// notified, approves, actually leaves the team, and the WS event is
|
||||
/// broadcast so the UI can refresh."
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Create team, spawn worker.
|
||||
/// 2. Lead MCP-calls `team_shutdown_agent(slot_id=worker)`.
|
||||
/// 3. Worker's mailbox receives a `shutdown_request`.
|
||||
/// 4. Worker replies `shutdown_approved` via `team_send_message`.
|
||||
/// 5. Worker is removed from the team roster.
|
||||
/// 6. `team.agent.removed` WebSocket event is broadcast.
|
||||
#[tokio::test]
|
||||
#[ignore = "unblocks when shutdown_request/approved round-trip is wired (W5-D30a/b/c/d series)"]
|
||||
async fn smoke_shutdown_agent_full_protocol() {
|
||||
todo!("scenario 3: fill once shutdown round-trip + team.agent.removed event are wired");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Scenario 4: agent crash → testament → leader wake
|
||||
// ===========================================================================
|
||||
|
||||
/// User story: "If a worker crashes mid-task, the lead gets a testament
|
||||
/// mailbox message and is woken up to react — no silent failure."
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Create team, spawn worker.
|
||||
/// 2. Inject an Error stream chunk into the worker's agent manager.
|
||||
/// 3. Lead's mailbox receives a crash testament.
|
||||
/// 4. Worker's status transitions to `Error`.
|
||||
/// 5. Lead is woken (wake_lock acquired / wake payload built).
|
||||
#[tokio::test]
|
||||
#[ignore = "unblocks when crash_detection is wired into the stream pipeline with real AcpAgentManager"]
|
||||
async fn smoke_agent_crash_recovery() {
|
||||
todo!("scenario 4: fill once crash detection → testament → wake lead is wired");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Scenario 5: MCP tool execution is not a no-op
|
||||
// ===========================================================================
|
||||
//
|
||||
// This is the anchor scenario that guards against the core failure mode
|
||||
// the user called out: a tool returning `success` with no observable side
|
||||
// effect. It only uses pieces that already exist (mailbox + task board +
|
||||
// TeamMcpServer), so it runs in CI today.
|
||||
|
||||
#[tokio::test]
|
||||
async fn smoke_mcp_tool_execution_not_noop() {
|
||||
let env = setup_team_with_lead().await;
|
||||
let mut stream = mcp_connect(&env, &env.lead_slot_id).await;
|
||||
|
||||
// --- team_send_message → mailbox side effect -------------------------
|
||||
let msg_resp = mcp_call(
|
||||
&mut stream,
|
||||
10,
|
||||
"team_send_message",
|
||||
json!({ "to": env.worker_slot_id, "message": "hello worker" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!is_error_response(&msg_resp),
|
||||
"team_send_message returned error: {msg_resp}"
|
||||
);
|
||||
// Guard against the exact failure mode: success envelope, nothing written.
|
||||
assert_mailbox_contains(&env.mailbox, &env.team_id, &env.worker_slot_id, "hello worker").await;
|
||||
|
||||
// --- team_task_create → task board side effect -----------------------
|
||||
let task_resp = mcp_call(
|
||||
&mut stream,
|
||||
11,
|
||||
"team_task_create",
|
||||
json!({ "subject": "Smoke test subject" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!is_error_response(&task_resp),
|
||||
"team_task_create returned error: {task_resp}"
|
||||
);
|
||||
let tasks = env.task_board.list_tasks(&env.team_id).await.unwrap();
|
||||
assert!(
|
||||
tasks.iter().any(|t| t.subject == "Smoke test subject"),
|
||||
"team_task_create did not persist task, got {tasks:?}"
|
||||
);
|
||||
|
||||
// --- repo-level cross-check: mailbox/task rows actually hit storage --
|
||||
// Even if the service layer lies, the repo-level mock's state is the
|
||||
// ground truth for "did data move through the stack".
|
||||
let repo_state = env.repo.state.lock().unwrap();
|
||||
assert!(!repo_state.messages.is_empty(), "no mailbox rows reached the repo");
|
||||
assert!(!repo_state.tasks.is_empty(), "no task rows reached the repo");
|
||||
drop(repo_state);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
//! Black-box integration tests for `Mailbox` service.
|
||||
//!
|
||||
//! Exercises the service layer against a real SQLite database.
|
||||
//!
|
||||
//! Covers test-plan items:
|
||||
//! - MW-1..MW-3 (write messages: text, idle_notification, shutdown_request)
|
||||
//! - MR-1..MR-4 (atomic read + mark, re-read empty, no messages)
|
||||
//! - MH-1..MH-3 (history query with/without limit)
|
||||
//! - MD-1..MD-2 (delete by team, isolation)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_db::{ITeamRepository, SqliteTeamRepository, init_database_memory};
|
||||
use nomifun_team::{Mailbox, MailboxMessageType};
|
||||
|
||||
/// Returns the `Mailbox` service plus the repo (so cascade-delete tests can
|
||||
/// drop a team) and the owning `Database`. Seeds the `users` row and the two
|
||||
/// teams (`t1`, `t2`) the tests write into — the `mailbox.team_id` FK
|
||||
/// (CASCADE, spec §5.4) now requires the parent team to exist.
|
||||
async fn setup() -> (Mailbox, Arc<dyn ITeamRepository>, nomifun_db::Database) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
|
||||
VALUES ('user_1', 'tester', 'hash', 0, 0)",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
for team_id in ["t1", "t2"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO teams (id, user_id, name, workspace, workspace_mode, agents_version, created_at, updated_at) \
|
||||
VALUES (?, 'user_1', 'Test Team', '/tmp/ws', 'shared', '1.0.0', 0, 0)",
|
||||
)
|
||||
.bind(team_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let repo = Arc::new(SqliteTeamRepository::new(db.pool().clone())) as Arc<dyn ITeamRepository>;
|
||||
(Mailbox::new(repo.clone()), repo, db)
|
||||
}
|
||||
|
||||
// -- MW: Write messages -------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn mw1_write_text_message() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
let msg = mailbox
|
||||
.write("t1", "a1", "user", MailboxMessageType::Message, "hello", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.msg_type, MailboxMessageType::Message);
|
||||
assert_eq!(msg.content, "hello");
|
||||
assert!(!msg.read);
|
||||
// `mailbox.id` is now an autoincrement i64 (spec §2 row 19) returned by
|
||||
// `write_message` via `last_insert_rowid()`.
|
||||
assert!(msg.id > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mw2_write_idle_notification_with_summary() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
let msg = mailbox
|
||||
.write(
|
||||
"t1",
|
||||
"lead",
|
||||
"a1",
|
||||
MailboxMessageType::IdleNotification,
|
||||
"done",
|
||||
Some("Task finished"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.msg_type, MailboxMessageType::IdleNotification);
|
||||
assert_eq!(msg.summary.as_deref(), Some("Task finished"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mw3_write_shutdown_request() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
let msg = mailbox
|
||||
.write(
|
||||
"t1",
|
||||
"a1",
|
||||
"lead",
|
||||
MailboxMessageType::ShutdownRequest,
|
||||
"cleanup done",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.msg_type, MailboxMessageType::ShutdownRequest);
|
||||
}
|
||||
|
||||
// -- MR: Atomic read + mark ---------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn mr1_read_unread_returns_all_and_marks() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
for i in 0..3 {
|
||||
mailbox
|
||||
.write(
|
||||
"t1",
|
||||
"a1",
|
||||
"user",
|
||||
MailboxMessageType::Message,
|
||||
&format!("msg-{i}"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let unread = mailbox.read_unread("t1", "a1").await.unwrap();
|
||||
assert_eq!(unread.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mr2_second_read_returns_empty() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
mailbox
|
||||
.write("t1", "a1", "user", MailboxMessageType::Message, "x", None)
|
||||
.await
|
||||
.unwrap();
|
||||
mailbox.read_unread("t1", "a1").await.unwrap();
|
||||
let second = mailbox.read_unread("t1", "a1").await.unwrap();
|
||||
assert!(second.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mr4_no_unread_messages() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
let unread = mailbox.read_unread("t1", "a1").await.unwrap();
|
||||
assert!(unread.is_empty());
|
||||
}
|
||||
|
||||
// -- MH: History query --------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn mh1_get_history_no_limit() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
for i in 0..5 {
|
||||
mailbox
|
||||
.write("t1", "a1", "user", MailboxMessageType::Message, &format!("m{i}"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
mailbox.read_unread("t1", "a1").await.unwrap();
|
||||
let history = mailbox.get_history("t1", "a1", None).await.unwrap();
|
||||
assert_eq!(history.len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mh2_get_history_with_limit() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
for i in 0..10 {
|
||||
mailbox
|
||||
.write("t1", "a1", "user", MailboxMessageType::Message, &format!("m{i}"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let history = mailbox.get_history("t1", "a1", Some(5)).await.unwrap();
|
||||
assert_eq!(history.len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mh3_empty_history() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
let history = mailbox.get_history("t1", "a1", None).await.unwrap();
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
// -- MD: Delete team cascades to its mailbox ----------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn md1_delete_team_removes_all_messages() {
|
||||
let (mailbox, repo, _db) = setup().await;
|
||||
mailbox
|
||||
.write("t1", "a1", "user", MailboxMessageType::Message, "x", None)
|
||||
.await
|
||||
.unwrap();
|
||||
mailbox
|
||||
.write("t1", "a2", "user", MailboxMessageType::Message, "y", None)
|
||||
.await
|
||||
.unwrap();
|
||||
// The mailbox is purged via FK ON DELETE CASCADE when the team goes away
|
||||
// (spec §5.4) — there is no manual delete_mailbox_by_team.
|
||||
repo.delete_team("t1").await.unwrap();
|
||||
let h1 = mailbox.get_history("t1", "a1", None).await.unwrap();
|
||||
let h2 = mailbox.get_history("t1", "a2", None).await.unwrap();
|
||||
assert!(h1.is_empty());
|
||||
assert!(h2.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn md2_delete_team_does_not_affect_other_teams() {
|
||||
let (mailbox, repo, _db) = setup().await;
|
||||
mailbox
|
||||
.write("t1", "a1", "user", MailboxMessageType::Message, "x", None)
|
||||
.await
|
||||
.unwrap();
|
||||
mailbox
|
||||
.write("t2", "a1", "user", MailboxMessageType::Message, "y", None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.delete_team("t1").await.unwrap();
|
||||
let h2 = mailbox.get_history("t2", "a1", None).await.unwrap();
|
||||
assert_eq!(h2.len(), 1);
|
||||
}
|
||||
|
||||
// -- Agent scope isolation ----------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_unread_scoped_to_target_agent() {
|
||||
let (mailbox, _repo, _db) = setup().await;
|
||||
mailbox
|
||||
.write("t1", "a1", "user", MailboxMessageType::Message, "for-a1", None)
|
||||
.await
|
||||
.unwrap();
|
||||
mailbox
|
||||
.write("t1", "a2", "user", MailboxMessageType::Message, "for-a2", None)
|
||||
.await
|
||||
.unwrap();
|
||||
let a1_msgs = mailbox.read_unread("t1", "a1").await.unwrap();
|
||||
assert_eq!(a1_msgs.len(), 1);
|
||||
assert_eq!(a1_msgs[0].content, "for-a1");
|
||||
let a2_msgs = mailbox.read_unread("t1", "a2").await.unwrap();
|
||||
assert_eq!(a2_msgs.len(), 1);
|
||||
assert_eq!(a2_msgs[0].content, "for-a2");
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use common::MockTeamRepo;
|
||||
use nomifun_api_types::WebSocketMessage;
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use nomifun_team::mcp::protocol::{read_frame, write_frame};
|
||||
use nomifun_team::{Mailbox, TaskBoard, TeamAgent, TeamMcpServer, TeammateManager, TeammateRole};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test infrastructure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct RecordingBroadcaster {
|
||||
events: std::sync::Mutex<Vec<WebSocketMessage<Value>>>,
|
||||
}
|
||||
|
||||
impl RecordingBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: std::sync::Mutex::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&self) -> Vec<WebSocketMessage<Value>> {
|
||||
self.events.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for RecordingBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_agents() -> Vec<TeamAgent> {
|
||||
vec![
|
||||
TeamAgent {
|
||||
slot_id: "lead-1".into(),
|
||||
name: "Leader".into(),
|
||||
role: TeammateRole::Lead,
|
||||
conversation_id: "conv-lead".into(),
|
||||
backend: "acp".into(),
|
||||
model: "claude".into(),
|
||||
custom_agent_id: None,
|
||||
status: None,
|
||||
conversation_type: None,
|
||||
cli_path: None,
|
||||
},
|
||||
TeamAgent {
|
||||
slot_id: "worker-1".into(),
|
||||
name: "Worker".into(),
|
||||
role: TeammateRole::Teammate,
|
||||
conversation_id: "conv-worker".into(),
|
||||
backend: "acp".into(),
|
||||
model: "claude".into(),
|
||||
custom_agent_id: None,
|
||||
status: None,
|
||||
conversation_type: None,
|
||||
cli_path: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
struct TestEnv {
|
||||
server: TeamMcpServer,
|
||||
_repo: Arc<MockTeamRepo>,
|
||||
broadcaster: Arc<RecordingBroadcaster>,
|
||||
}
|
||||
|
||||
async fn setup() -> TestEnv {
|
||||
let repo = Arc::new(MockTeamRepo::new());
|
||||
let mailbox = Arc::new(Mailbox::new(repo.clone()));
|
||||
let task_board = Arc::new(TaskBoard::new(repo.clone()));
|
||||
let recorder = Arc::new(RecordingBroadcaster::new());
|
||||
let broadcaster: Arc<dyn EventBroadcaster> = recorder.clone();
|
||||
let agents = make_agents();
|
||||
let scheduler = Arc::new(TeammateManager::new(
|
||||
"team-1".into(),
|
||||
&agents,
|
||||
mailbox,
|
||||
task_board,
|
||||
broadcaster.clone(),
|
||||
));
|
||||
|
||||
// W5-D29e: standalone MCP server without a live TeamSessionService —
|
||||
// the Weak cannot upgrade, so `team_spawn_agent` will surface the
|
||||
// service-unavailable error. Non-spawn tools still exercise scheduler
|
||||
// flows directly and do not hit this path.
|
||||
let server = TeamMcpServer::start(
|
||||
"test-token-123".into(),
|
||||
scheduler,
|
||||
"team-1".into(),
|
||||
broadcaster,
|
||||
std::sync::Weak::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
TestEnv {
|
||||
server,
|
||||
_repo: repo,
|
||||
broadcaster: recorder,
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_init(port: u16, token: &str, slot_id: &str) -> TcpStream {
|
||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
|
||||
|
||||
let init_req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"auth_token": token,
|
||||
"slot_id": slot_id,
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "test-client", "version": "1.0" }
|
||||
}
|
||||
});
|
||||
send_request(&mut stream, &init_req).await;
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert!(resp["result"]["serverInfo"]["name"].is_string());
|
||||
|
||||
stream
|
||||
}
|
||||
|
||||
async fn send_request(stream: &mut TcpStream, request: &Value) {
|
||||
let data = serde_json::to_vec(request).unwrap();
|
||||
write_frame(stream, &data).await.unwrap();
|
||||
}
|
||||
|
||||
async fn read_response(stream: &mut TcpStream) -> Value {
|
||||
let frame = read_frame(stream).await.unwrap();
|
||||
serde_json::from_slice(&frame).unwrap()
|
||||
}
|
||||
|
||||
async fn call_tool(stream: &mut TcpStream, id: u64, tool: &str, args: Value) -> Value {
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": tool,
|
||||
"arguments": args
|
||||
}
|
||||
});
|
||||
send_request(stream, &req).await;
|
||||
read_response(stream).await
|
||||
}
|
||||
|
||||
fn extract_text(resp: &Value) -> String {
|
||||
resp["result"]["content"][0]["text"].as_str().unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn is_error_response(resp: &Value) -> bool {
|
||||
resp["result"]["isError"].as_bool().unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Connection & Authentication (MC-1, MC-2, MC-3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn mc1_correct_token_connects() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list"
|
||||
});
|
||||
send_request(&mut stream, &req).await;
|
||||
let resp = read_response(&mut stream).await;
|
||||
let tools = resp["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 10);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mc2_wrong_token_rejected() {
|
||||
let env = setup().await;
|
||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", env.server.port()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let init_req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": { "auth_token": "wrong-token", "slot_id": "s1" }
|
||||
});
|
||||
send_request(&mut stream, &init_req).await;
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert!(
|
||||
resp["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Authentication failed")
|
||||
);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mc3_no_token_rejected() {
|
||||
let env = setup().await;
|
||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", env.server.port()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let init_req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {}
|
||||
});
|
||||
send_request(&mut stream, &init_req).await;
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert!(
|
||||
resp["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Authentication failed")
|
||||
);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: tools/list (TTL-1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn tools_list_returns_all_10_tools() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 10,
|
||||
"method": "tools/list"
|
||||
});
|
||||
send_request(&mut stream, &req).await;
|
||||
let resp = read_response(&mut stream).await;
|
||||
let tools = resp["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 10);
|
||||
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"team_send_message"));
|
||||
assert!(names.contains(&"team_spawn_agent"));
|
||||
assert!(names.contains(&"team_task_create"));
|
||||
assert!(names.contains(&"team_task_update"));
|
||||
assert!(names.contains(&"team_task_list"));
|
||||
assert!(names.contains(&"team_members"));
|
||||
assert!(names.contains(&"team_rename_agent"));
|
||||
assert!(names.contains(&"team_shutdown_agent"));
|
||||
assert!(names.contains(&"team_list_models"));
|
||||
assert!(names.contains(&"team_describe_assistant"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: team_send_message (TS-1, TS-2, TS-3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ts1_send_message_to_agent() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "worker-1", "message": "Hello worker"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("worker-1"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ts2_broadcast_message() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "*", "message": "Attention all"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ts3_send_message_to_nonexistent_agent() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "nonexistent", "message": "Hello?"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("No agent matches 'nonexistent'"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ts_shutdown_approved_intercepted() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "lead-1", "message": "shutdown_approved"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
let payload: Value = serde_json::from_str(&text).expect("interception payload is JSON");
|
||||
assert_eq!(payload["status"], "shutdown_approved_received");
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ts_shutdown_rejected_intercepted() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "lead-1", "message": "shutdown_rejected: still finishing task"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert_eq!(text, "shutdown_rejected: still finishing task");
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ts_regular_message_not_intercepted() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "lead-1", "message": "just a normal update"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("lead-1"));
|
||||
assert!(!text.contains("shutdown_approved_received"));
|
||||
assert!(!text.contains("shutdown_rejected_received"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: team_spawn_agent (SP-1, SP-2, SP-3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sp1_lead_spawn_requires_live_session_service() {
|
||||
// W5-D29e: this standalone test env spins up TeamMcpServer with
|
||||
// `Weak::new()` (no live TeamSessionService), so a well-formed Lead
|
||||
// spawn now surfaces the service-unavailable error. Real session-level
|
||||
// spawn success is covered by `tests/e2e_smoke.rs` scenario 2 and by
|
||||
// lib unit tests in `src/session.rs` that wire a TeamSessionService.
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_spawn_agent",
|
||||
json!({"name": "Helper", "role": "worker", "backend": "claude"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(
|
||||
text.contains("Team service not available"),
|
||||
"expected service-unavailable error, got {text:?}"
|
||||
);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sp2_non_whitelisted_backend_rejected() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_spawn_agent",
|
||||
json!({"name": "X", "backend": "malicious"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
// Without a live TeamSessionService the spawn fails at capability check or service access.
|
||||
assert!(
|
||||
text.contains("not allowed") || text.contains("not available"),
|
||||
"unexpected error: {text}"
|
||||
);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sp3_teammate_cannot_spawn() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_spawn_agent",
|
||||
json!({"name": "Helper", "backend": "claude"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("Only Lead"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: team_task_create / team_task_list (TTC-1, TTC-2, TTL-1, TTL-2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttc1_create_basic_task() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_task_create",
|
||||
json!({"subject": "Implement feature X"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("Implement feature X"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttc2_create_task_with_dependency() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
call_tool(&mut stream, 2, "team_task_create", json!({"subject": "Task A"})).await;
|
||||
|
||||
let list_resp = call_tool(&mut stream, 3, "team_task_list", json!({})).await;
|
||||
let tasks: Vec<Value> = serde_json::from_str(&extract_text(&list_resp)).unwrap();
|
||||
let task_a_id = tasks[0]["id"].as_str().unwrap();
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
4,
|
||||
"team_task_create",
|
||||
json!({"subject": "Task B", "blocked_by": [task_a_id]}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
|
||||
let list_resp2 = call_tool(&mut stream, 5, "team_task_list", json!({})).await;
|
||||
let tasks2: Vec<Value> = serde_json::from_str(&extract_text(&list_resp2)).unwrap();
|
||||
assert_eq!(tasks2.len(), 2);
|
||||
|
||||
let task_b = tasks2.iter().find(|t| t["subject"] == "Task B").unwrap();
|
||||
let blocked_by: Vec<String> = serde_json::from_value(task_b["blocked_by"].clone()).unwrap_or_default();
|
||||
assert!(blocked_by.contains(&task_a_id.to_string()));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttl2_task_list_empty() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(&mut stream, 2, "team_task_list", json!({})).await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
let tasks: Vec<Value> = serde_json::from_str(&text).unwrap();
|
||||
assert!(tasks.is_empty());
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttl1_task_list_after_create() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
call_tool(&mut stream, 2, "team_task_create", json!({"subject": "Task A"})).await;
|
||||
|
||||
let resp = call_tool(&mut stream, 3, "team_task_list", json!({})).await;
|
||||
let text = extract_text(&resp);
|
||||
let tasks: Vec<Value> = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0]["subject"], "Task A");
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: team_task_update (TTU-1, TTU-2, TTU-3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttu1_update_task_status() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
call_tool(&mut stream, 2, "team_task_create", json!({"subject": "Task A"})).await;
|
||||
|
||||
let list_resp = call_tool(&mut stream, 3, "team_task_list", json!({})).await;
|
||||
let tasks: Vec<Value> = serde_json::from_str(&extract_text(&list_resp)).unwrap();
|
||||
let task_id = tasks[0]["id"].as_str().unwrap();
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
4,
|
||||
"team_task_update",
|
||||
json!({"task_id": task_id, "status": "completed"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
|
||||
let list_resp2 = call_tool(&mut stream, 5, "team_task_list", json!({})).await;
|
||||
let tasks2: Vec<Value> = serde_json::from_str(&extract_text(&list_resp2)).unwrap();
|
||||
assert_eq!(tasks2[0]["status"], "completed");
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ttu3_update_nonexistent_task() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_task_update",
|
||||
json!({"task_id": "nonexistent-id", "status": "completed"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(is_error_response(&resp));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: team_members (TM-1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn tm1_list_all_members() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(&mut stream, 2, "team_members", json!({})).await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
let members: Vec<Value> = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(members.len(), 2);
|
||||
|
||||
let names: Vec<&str> = members.iter().map(|m| m["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"Leader"));
|
||||
assert!(names.contains(&"Worker"));
|
||||
|
||||
// Regression: cold-start agents (including the lead before its first
|
||||
// wake) must report an explicit `idle` status — never `null` — so MCP
|
||||
// clients do not misread a live teammate as offline.
|
||||
for m in &members {
|
||||
assert_eq!(
|
||||
m["status"].as_str(),
|
||||
Some("idle"),
|
||||
"team_members must report idle status for cold-start agents, got {:?}",
|
||||
m["status"]
|
||||
);
|
||||
}
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: team_rename_agent (TRA-1, TRA-2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn tra1_rename_existing_agent() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_rename_agent",
|
||||
json!({"slot_id": "worker-1", "new_name": "Senior Worker"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("renamed"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tra2_rename_nonexistent_agent() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_rename_agent",
|
||||
json!({"slot_id": "nonexistent", "new_name": "X"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(is_error_response(&resp));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: team_shutdown_agent (TSA-1, TSA-4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn tsa1_lead_sends_shutdown_request() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_shutdown_agent",
|
||||
json!({"slot_id": "worker-1", "reason": "Task complete"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("Shutdown request sent"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tsa4_non_lead_cannot_shutdown() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
|
||||
|
||||
let resp = call_tool(&mut stream, 2, "team_shutdown_agent", json!({"slot_id": "lead-1"})).await;
|
||||
|
||||
assert!(is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("Only Lead"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Unknown method / non-initialize first request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_method_returns_error() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "lead-1").await;
|
||||
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 99,
|
||||
"method": "unknown/method"
|
||||
});
|
||||
send_request(&mut stream, &req).await;
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert!(resp["error"]["code"].as_i64().unwrap() == -32601);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_initialize_first_request_rejected() {
|
||||
let env = setup().await;
|
||||
let mut stream = TcpStream::connect(format!("127.0.0.1:{}", env.server.port()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list"
|
||||
});
|
||||
send_request(&mut stream, &req).await;
|
||||
let resp = read_response(&mut stream).await;
|
||||
assert!(resp["error"]["message"].as_str().unwrap().contains("initialize"));
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Server stop (SS-2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn ss2_stop_server_closes_listener() {
|
||||
let env = setup().await;
|
||||
let port = env.server.port();
|
||||
|
||||
let _stream = connect_and_init(port, "test-token-123", "lead-1").await;
|
||||
env.server.stop();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
let result = TcpStream::connect(format!("127.0.0.1:{port}")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: stdio bridge config (SB-1, SB-3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sb1_bridge_config_generation() {
|
||||
use nomifun_team::{TeamMcpStdioConfig, TeamMcpStdioServerSpec};
|
||||
|
||||
let env = setup().await;
|
||||
let config = TeamMcpStdioConfig {
|
||||
team_id: "team-test".into(),
|
||||
port: env.server.port(),
|
||||
token: env.server.auth_token().to_string(),
|
||||
slot_id: "lead-1".into(),
|
||||
binary_path: "/bin/nomicore".into(),
|
||||
};
|
||||
|
||||
let spec = TeamMcpStdioServerSpec::from_config("/bin/nomicore", &config);
|
||||
let env_map: std::collections::HashMap<_, _> = spec.env.iter().cloned().collect();
|
||||
assert_eq!(env_map[TeamMcpStdioConfig::ENV_PORT], env.server.port().to_string());
|
||||
assert_eq!(env_map[TeamMcpStdioConfig::ENV_TOKEN], "test-token-123");
|
||||
assert_eq!(env_map[TeamMcpStdioConfig::ENV_SLOT_ID], "lead-1");
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sb3_different_agents_get_different_slot_ids() {
|
||||
use nomifun_team::{TeamMcpStdioConfig, TeamMcpStdioServerSpec};
|
||||
|
||||
let env = setup().await;
|
||||
let port = env.server.port();
|
||||
let token = env.server.auth_token().to_string();
|
||||
|
||||
let cfg_lead = TeamMcpStdioConfig {
|
||||
team_id: "t".into(),
|
||||
port,
|
||||
token: token.clone(),
|
||||
slot_id: "lead-1".into(),
|
||||
binary_path: "/b".into(),
|
||||
};
|
||||
let cfg_worker = TeamMcpStdioConfig {
|
||||
team_id: "t".into(),
|
||||
port,
|
||||
token,
|
||||
slot_id: "worker-1".into(),
|
||||
binary_path: "/b".into(),
|
||||
};
|
||||
let spec_lead = TeamMcpStdioServerSpec::from_config("/b", &cfg_lead);
|
||||
let spec_worker = TeamMcpStdioServerSpec::from_config("/b", &cfg_worker);
|
||||
let kv_lead: std::collections::HashMap<_, _> = spec_lead.env.iter().cloned().collect();
|
||||
let kv_worker: std::collections::HashMap<_, _> = spec_worker.env.iter().cloned().collect();
|
||||
|
||||
assert_eq!(
|
||||
kv_lead[TeamMcpStdioConfig::ENV_PORT],
|
||||
kv_worker[TeamMcpStdioConfig::ENV_PORT]
|
||||
);
|
||||
assert_ne!(
|
||||
kv_lead[TeamMcpStdioConfig::ENV_SLOT_ID],
|
||||
kv_worker[TeamMcpStdioConfig::ENV_SLOT_ID]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: mcpStatus broadcast (W5-D31b-1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_status_tcp_ready_is_broadcast_on_successful_bind() {
|
||||
use nomifun_api_types::{TeamMcpPhase, TeamMcpStatusPayload};
|
||||
|
||||
let env = setup().await;
|
||||
let port = env.server.port();
|
||||
|
||||
let events = env.broadcaster.events();
|
||||
let status_events: Vec<_> = events.iter().filter(|e| e.name == "team.mcpStatus").collect();
|
||||
assert_eq!(
|
||||
status_events.len(),
|
||||
1,
|
||||
"expected exactly one team.mcpStatus event after bind, got {}",
|
||||
status_events.len()
|
||||
);
|
||||
|
||||
let payload: TeamMcpStatusPayload = serde_json::from_value(status_events[0].data.clone()).unwrap();
|
||||
assert_eq!(payload.team_id, "team-1");
|
||||
assert_eq!(payload.slot_id, "");
|
||||
assert!(matches!(payload.phase, TeamMcpPhase::TcpReady));
|
||||
assert_eq!(payload.port, Some(port));
|
||||
assert!(payload.server_count.is_none());
|
||||
assert!(payload.error.is_none());
|
||||
|
||||
env.server.stop();
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: W5-D30b — shutdown_rejected detection in team_send_message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn tsr1_shutdown_rejected_notifies_lead_and_preserves_agent() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "lead-1", "message": "shutdown_rejected: still working"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(
|
||||
text.contains("shutdown_rejected"),
|
||||
"response should echo the sentinel, got: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("still working"),
|
||||
"response should echo the reason, got: {text}"
|
||||
);
|
||||
|
||||
// Leader mailbox contains the notification, worker did not receive a
|
||||
// literal copy of the sentinel.
|
||||
let state = env._repo.state.lock().unwrap();
|
||||
let lead_msgs: Vec<_> = state.messages.iter().filter(|m| m.to_agent_id == "lead-1").collect();
|
||||
assert_eq!(lead_msgs.len(), 1, "expected exactly one message to lead");
|
||||
assert_eq!(lead_msgs[0].from_agent_id, "worker-1");
|
||||
assert!(lead_msgs[0].content.contains("Worker"));
|
||||
assert!(lead_msgs[0].content.contains("declined shutdown"));
|
||||
assert!(lead_msgs[0].content.contains("still working"));
|
||||
|
||||
let lead_self_msgs: Vec<_> = state
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.to_agent_id == "lead-1" && m.content == "shutdown_rejected: still working")
|
||||
.collect();
|
||||
assert!(
|
||||
lead_self_msgs.is_empty(),
|
||||
"raw sentinel must not be delivered as a normal message"
|
||||
);
|
||||
drop(state);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tsr2_shutdown_rejected_with_whitespace_reason() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "lead-1", "message": " shutdown_rejected: need more time "}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
|
||||
let state = env._repo.state.lock().unwrap();
|
||||
let lead_msgs: Vec<_> = state.messages.iter().filter(|m| m.to_agent_id == "lead-1").collect();
|
||||
assert_eq!(lead_msgs.len(), 1);
|
||||
// Reason is trimmed before inclusion in the notification.
|
||||
assert!(lead_msgs[0].content.contains("need more time"));
|
||||
assert!(!lead_msgs[0].content.contains(" need more time "));
|
||||
drop(state);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tsr3_send_message_without_sentinel_still_routes_normally() {
|
||||
let env = setup().await;
|
||||
let mut stream = connect_and_init(env.server.port(), "test-token-123", "worker-1").await;
|
||||
|
||||
let resp = call_tool(
|
||||
&mut stream,
|
||||
2,
|
||||
"team_send_message",
|
||||
json!({"to": "lead-1", "message": "regular update"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!is_error_response(&resp));
|
||||
let text = extract_text(&resp);
|
||||
assert!(text.contains("Message sent"));
|
||||
|
||||
// The literal message lands in the lead mailbox unchanged.
|
||||
let state = env._repo.state.lock().unwrap();
|
||||
let lead_msg = state
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.to_agent_id == "lead-1")
|
||||
.expect("message should be delivered");
|
||||
assert_eq!(lead_msg.content, "regular update");
|
||||
drop(state);
|
||||
|
||||
env.server.stop();
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
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());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,338 @@
|
||||
//! Black-box integration tests for `TaskBoard` service.
|
||||
//!
|
||||
//! Exercises the service layer against a real SQLite database.
|
||||
//!
|
||||
//! Covers test-plan items:
|
||||
//! - TK-1..TK-4 (create tasks: no deps, single dep, multi-dep, nonexistent dep)
|
||||
//! - TU-1..TU-5 (update status, description, owner, nonexistent)
|
||||
//! - CU-1..CU-4 (check_unblocks: single, multiple, partial, no downstream)
|
||||
//! - TT-1..TT-3 (list tasks, empty, with deps)
|
||||
//! - DC-4 (blockedBy/blocks bidirectional consistency)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_db::{ITeamRepository, SqliteTeamRepository, init_database_memory};
|
||||
use nomifun_team::{TaskBoard, TaskStatus, TaskUpdate};
|
||||
|
||||
async fn setup() -> (TaskBoard, nomifun_db::Database) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
// Seed the parent user + team: `team_tasks.team_id` now carries a FK
|
||||
// (CASCADE) to `teams(id)` (spec §4.2), so the team must exist before any
|
||||
// task can be inserted.
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
|
||||
VALUES ('user_1', 'tester', 'hash', 0, 0)",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO teams (id, user_id, name, workspace, workspace_mode, agents_version, created_at, updated_at) \
|
||||
VALUES ('t1', 'user_1', 'Test Team', '/tmp/ws', 'shared', '1.0.0', 0, 0)",
|
||||
)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let repo = Arc::new(SqliteTeamRepository::new(db.pool().clone())) as Arc<dyn ITeamRepository>;
|
||||
(TaskBoard::new(repo), db)
|
||||
}
|
||||
|
||||
// -- TK: Create tasks ---------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn tk1_create_task_no_dependencies() {
|
||||
let (board, _db) = setup().await;
|
||||
let task = board
|
||||
.create_task("t1", "Implement feature", None, None, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(task.subject, "Implement feature");
|
||||
assert_eq!(task.status, TaskStatus::Pending);
|
||||
assert!(task.blocked_by.is_empty());
|
||||
assert!(task.blocks.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tk2_create_task_with_single_dependency() {
|
||||
let (board, _db) = setup().await;
|
||||
let task_a = board.create_task("t1", "Task A", None, None, &[]).await.unwrap();
|
||||
let task_b = board
|
||||
.create_task("t1", "Task B", None, None, std::slice::from_ref(&task_a.id))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(task_b.blocked_by, vec![task_a.id.clone()]);
|
||||
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
let a = tasks.iter().find(|t| t.id == task_a.id).unwrap();
|
||||
assert_eq!(a.blocks, vec![task_b.id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tk3_create_task_with_multiple_dependencies() {
|
||||
let (board, _db) = setup().await;
|
||||
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
|
||||
let b = board.create_task("t1", "B", None, None, &[]).await.unwrap();
|
||||
let c = board
|
||||
.create_task("t1", "C", None, None, &[a.id.clone(), b.id.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(c.blocked_by.len(), 2);
|
||||
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
let a_updated = tasks.iter().find(|t| t.id == a.id).unwrap();
|
||||
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
|
||||
assert!(a_updated.blocks.contains(&c.id));
|
||||
assert!(b_updated.blocks.contains(&c.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tk4_create_task_nonexistent_dependency_fails() {
|
||||
let (board, _db) = setup().await;
|
||||
let result = board.create_task("t1", "X", None, None, &["nonexistent".into()]).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- TU: Update tasks ---------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn tu1_update_status_pending_to_in_progress() {
|
||||
let (board, _db) = setup().await;
|
||||
let task = board.create_task("t1", "Work", None, None, &[]).await.unwrap();
|
||||
let updated = board
|
||||
.update_task(
|
||||
"t1",
|
||||
&task.id,
|
||||
&TaskUpdate {
|
||||
status: Some(TaskStatus::InProgress),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.status, TaskStatus::InProgress);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tu2_update_status_to_completed_triggers_unblock() {
|
||||
let (board, _db) = setup().await;
|
||||
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
|
||||
let b = board
|
||||
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
board
|
||||
.update_task(
|
||||
"t1",
|
||||
&a.id,
|
||||
&TaskUpdate {
|
||||
status: Some(TaskStatus::Completed),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
|
||||
assert!(b_updated.blocked_by.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tu3_update_description() {
|
||||
let (board, _db) = setup().await;
|
||||
let task = board.create_task("t1", "Work", None, None, &[]).await.unwrap();
|
||||
let updated = board
|
||||
.update_task(
|
||||
"t1",
|
||||
&task.id,
|
||||
&TaskUpdate {
|
||||
description: Some("Updated description".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.description.as_deref(), Some("Updated description"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tu4_update_owner() {
|
||||
let (board, _db) = setup().await;
|
||||
let task = board.create_task("t1", "Work", None, None, &[]).await.unwrap();
|
||||
let updated = board
|
||||
.update_task(
|
||||
"t1",
|
||||
&task.id,
|
||||
&TaskUpdate {
|
||||
owner: Some("agent-2".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.owner.as_deref(), Some("agent-2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tu5_update_nonexistent_task_fails() {
|
||||
let (board, _db) = setup().await;
|
||||
let result = board.update_task("t1", "nonexistent", &TaskUpdate::default()).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- CU: Check unblocks ------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn cu1_complete_unblocks_single_downstream() {
|
||||
let (board, _db) = setup().await;
|
||||
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
|
||||
let b = board
|
||||
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
board
|
||||
.update_task(
|
||||
"t1",
|
||||
&a.id,
|
||||
&TaskUpdate {
|
||||
status: Some(TaskStatus::Completed),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
|
||||
assert!(b_updated.blocked_by.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cu2_complete_unblocks_multiple_downstream() {
|
||||
let (board, _db) = setup().await;
|
||||
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
|
||||
let b = board
|
||||
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
|
||||
.await
|
||||
.unwrap();
|
||||
let c = board
|
||||
.create_task("t1", "C", None, None, std::slice::from_ref(&a.id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
board
|
||||
.update_task(
|
||||
"t1",
|
||||
&a.id,
|
||||
&TaskUpdate {
|
||||
status: Some(TaskStatus::Completed),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
|
||||
let c_updated = tasks.iter().find(|t| t.id == c.id).unwrap();
|
||||
assert!(b_updated.blocked_by.is_empty());
|
||||
assert!(c_updated.blocked_by.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cu3_partial_unblock_preserves_other_deps() {
|
||||
let (board, _db) = setup().await;
|
||||
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
|
||||
let x = board.create_task("t1", "X", None, None, &[]).await.unwrap();
|
||||
let b = board
|
||||
.create_task("t1", "B", None, None, &[a.id.clone(), x.id.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
board
|
||||
.update_task(
|
||||
"t1",
|
||||
&a.id,
|
||||
&TaskUpdate {
|
||||
status: Some(TaskStatus::Completed),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
let b_updated = tasks.iter().find(|t| t.id == b.id).unwrap();
|
||||
assert_eq!(b_updated.blocked_by, vec![x.id]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cu4_complete_no_downstream_is_noop() {
|
||||
let (board, _db) = setup().await;
|
||||
let task = board.create_task("t1", "Solo", None, None, &[]).await.unwrap();
|
||||
let updated = board
|
||||
.update_task(
|
||||
"t1",
|
||||
&task.id,
|
||||
&TaskUpdate {
|
||||
status: Some(TaskStatus::Completed),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.status, TaskStatus::Completed);
|
||||
}
|
||||
|
||||
// -- TT: List tasks -----------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn tt1_list_all_tasks() {
|
||||
let (board, _db) = setup().await;
|
||||
board.create_task("t1", "A", None, None, &[]).await.unwrap();
|
||||
board.create_task("t1", "B", None, None, &[]).await.unwrap();
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
assert_eq!(tasks.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tt2_list_empty() {
|
||||
let (board, _db) = setup().await;
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
assert!(tasks.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tt3_list_includes_dependency_info() {
|
||||
let (board, _db) = setup().await;
|
||||
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
|
||||
let b = board
|
||||
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
|
||||
.await
|
||||
.unwrap();
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
let b_found = tasks.iter().find(|t| t.id == b.id).unwrap();
|
||||
assert_eq!(b_found.blocked_by, vec![a.id.clone()]);
|
||||
let a_found = tasks.iter().find(|t| t.id == a.id).unwrap();
|
||||
assert!(a_found.blocks.contains(&b.id));
|
||||
}
|
||||
|
||||
// -- DC-4: Bidirectional consistency ------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn dc4_blocked_by_blocks_bidirectional_consistency() {
|
||||
let (board, _db) = setup().await;
|
||||
let a = board.create_task("t1", "A", None, None, &[]).await.unwrap();
|
||||
let b = board
|
||||
.create_task("t1", "B", None, None, std::slice::from_ref(&a.id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks = board.list_tasks("t1").await.unwrap();
|
||||
let a_found = tasks.iter().find(|t| t.id == a.id).unwrap();
|
||||
let b_found = tasks.iter().find(|t| t.id == b.id).unwrap();
|
||||
assert!(a_found.blocks.contains(&b.id));
|
||||
assert!(b_found.blocked_by.contains(&a.id));
|
||||
}
|
||||
Reference in New Issue
Block a user