f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
1310 lines
44 KiB
Rust
1310 lines
44 KiB
Rust
//! End-to-end integration tests for the team communication pipeline.
|
||
//!
|
||
//! These tests verify the full flow:
|
||
//! MCP tool call → mailbox write → wake → send_message → finish → leader notified
|
||
//!
|
||
|
||
// Pre-existing: MutexGuard held across await points is intentional in this
|
||
// test to maintain a short critical section for assertion, then explicitly dropped.
|
||
#![allow(clippy::await_holding_lock)]
|
||
//! Infrastructure used:
|
||
//! - Real in-memory mock repo (same pattern as existing tests)
|
||
//! - Real TCP MCP server (TeamMcpServer)
|
||
//! - Real TeamSession with real Mailbox + TaskBoard
|
||
//! - RecordingAgent: captures send_message calls (mock `IAgentTask` / `IMockAgent`)
|
||
//! - StubTaskManager: pre-populated with RecordingAgent instances
|
||
//!
|
||
//! Scenarios that cannot yet be wired without a live TeamSessionService DB path
|
||
//! are marked #[ignore] with a clear explanation.
|
||
|
||
mod common;
|
||
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
use std::sync::{Arc, Mutex, Weak};
|
||
|
||
use async_trait::async_trait;
|
||
use common::MockTeamRepo;
|
||
use nomifun_ai_agent::agent_task::{AgentInstance, IAgentTask, IMockAgent};
|
||
use nomifun_ai_agent::protocol::events::{AgentStreamEvent, FinishEventData};
|
||
use nomifun_ai_agent::shared_kernel::approval_key;
|
||
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
|
||
use nomifun_api_types::AgentModeResponse;
|
||
use nomifun_api_types::WebSocketMessage;
|
||
use nomifun_common::{AgentKillReason, AgentType, AppError, Confirmation, ConversationStatus, TimestampMs, now_ms};
|
||
use nomifun_db::ITeamRepository;
|
||
use nomifun_realtime::EventBroadcaster;
|
||
use nomifun_team::mcp::protocol::{read_frame, write_frame};
|
||
use nomifun_team::service::TeamSessionService;
|
||
use nomifun_team::{TeamAgent, TeamSession, TeammateRole};
|
||
use serde_json::{Value, json};
|
||
use tokio::net::TcpStream;
|
||
use tokio::sync::broadcast;
|
||
|
||
// ===========================================================================
|
||
// Shared test infrastructure
|
||
// ===========================================================================
|
||
|
||
struct NullBroadcaster;
|
||
impl EventBroadcaster for NullBroadcaster {
|
||
fn broadcast(&self, _msg: WebSocketMessage<Value>) {}
|
||
}
|
||
|
||
/// RecordingBroadcaster captures all WebSocket events for assertion.
|
||
/// Currently unused in e2e_team_flow tests — kept for future scenario expansion.
|
||
#[allow(dead_code)]
|
||
#[derive(Default)]
|
||
struct RecordingBroadcaster {
|
||
events: Mutex<Vec<WebSocketMessage<Value>>>,
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
impl RecordingBroadcaster {
|
||
fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
fn events_named(&self, name: &str) -> Vec<WebSocketMessage<Value>> {
|
||
self.events
|
||
.lock()
|
||
.unwrap()
|
||
.iter()
|
||
.filter(|e| e.name == name)
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
impl EventBroadcaster for RecordingBroadcaster {
|
||
fn broadcast(&self, msg: WebSocketMessage<Value>) {
|
||
self.events.lock().unwrap().push(msg);
|
||
}
|
||
}
|
||
|
||
/// RecordingAgent: captures every send_message call. The broadcast channel
|
||
/// lets tests simulate Finish events by sending AgentStreamEvent::Finish.
|
||
struct RecordingAgent {
|
||
conversation_id: String,
|
||
sent: Arc<Mutex<Vec<SendMessageData>>>,
|
||
event_tx: broadcast::Sender<AgentStreamEvent>,
|
||
fail_with: Option<String>,
|
||
}
|
||
|
||
impl RecordingAgent {
|
||
fn new(conversation_id: &str, sent: Arc<Mutex<Vec<SendMessageData>>>) -> Self {
|
||
let (event_tx, _) = broadcast::channel(16);
|
||
Self {
|
||
conversation_id: conversation_id.to_owned(),
|
||
sent,
|
||
event_tx,
|
||
fail_with: None,
|
||
}
|
||
}
|
||
|
||
/// Create a variant whose send_message always errors.
|
||
/// Reserved for future error-path scenario tests.
|
||
#[allow(dead_code)]
|
||
fn failing(conversation_id: &str, sent: Arc<Mutex<Vec<SendMessageData>>>, error: &str) -> Self {
|
||
let (event_tx, _) = broadcast::channel(16);
|
||
Self {
|
||
conversation_id: conversation_id.to_owned(),
|
||
sent,
|
||
event_tx,
|
||
fail_with: Some(error.to_owned()),
|
||
}
|
||
}
|
||
|
||
/// Subscribe to the agent's event stream so the test can fire Finish/Error.
|
||
#[allow(dead_code)]
|
||
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
|
||
self.event_tx.subscribe()
|
||
}
|
||
|
||
/// Fire a Finish event on the agent's stream (simulates agent completing a turn).
|
||
#[allow(dead_code)]
|
||
fn fire_finish(&self) {
|
||
let _ = self
|
||
.event_tx
|
||
.send(AgentStreamEvent::Finish(FinishEventData { session_id: None, stop_reason: None }));
|
||
}
|
||
}
|
||
|
||
#[async_trait::async_trait]
|
||
impl IAgentTask for RecordingAgent {
|
||
fn agent_type(&self) -> AgentType {
|
||
AgentType::Acp
|
||
}
|
||
fn conversation_id(&self) -> &str {
|
||
&self.conversation_id
|
||
}
|
||
fn workspace(&self) -> &str {
|
||
"/tmp/ws"
|
||
}
|
||
fn status(&self) -> Option<ConversationStatus> {
|
||
None
|
||
}
|
||
fn last_activity_at(&self) -> TimestampMs {
|
||
now_ms()
|
||
}
|
||
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
|
||
self.event_tx.subscribe()
|
||
}
|
||
async fn send_message(&self, data: SendMessageData) -> Result<(), nomifun_ai_agent::AgentSendError> {
|
||
self.sent.lock().unwrap().push(data);
|
||
match &self.fail_with {
|
||
Some(msg) => Err(nomifun_ai_agent::AgentSendError::from_app_error(AppError::Internal(
|
||
msg.clone(),
|
||
))),
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
async fn cancel(&self) -> Result<(), AppError> {
|
||
Ok(())
|
||
}
|
||
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[async_trait::async_trait]
|
||
impl IMockAgent for RecordingAgent {
|
||
fn get_confirmations(&self) -> Vec<Confirmation> {
|
||
Vec::new()
|
||
}
|
||
fn check_approval(&self, action: &str, command_type: Option<&str>) -> bool {
|
||
let _ = approval_key(Some(action), command_type);
|
||
false
|
||
}
|
||
fn confirm(&self, _: &str, _: &str, _: Value, _: bool) -> Result<(), AppError> {
|
||
Ok(())
|
||
}
|
||
async fn mode(&self) -> Result<AgentModeResponse, AppError> {
|
||
Ok(AgentModeResponse {
|
||
mode: "default".to_owned(),
|
||
initialized: false,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// StubTaskManager: allows pre-inserting RecordingAgent handles by conv_id.
|
||
/// Also records kill calls.
|
||
struct StubTaskManager {
|
||
tasks: Mutex<HashMap<String, AgentInstance>>,
|
||
kill_calls: Mutex<Vec<(String, Option<AgentKillReason>)>>,
|
||
}
|
||
|
||
impl StubTaskManager {
|
||
fn new() -> Self {
|
||
Self {
|
||
tasks: Mutex::new(HashMap::new()),
|
||
kill_calls: Mutex::new(Vec::new()),
|
||
}
|
||
}
|
||
|
||
fn insert(&self, conv_id: &str, handle: AgentInstance) {
|
||
self.tasks.lock().unwrap().insert(conv_id.to_owned(), handle);
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
fn kill_calls(&self) -> Vec<(String, Option<AgentKillReason>)> {
|
||
self.kill_calls.lock().unwrap().clone()
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl nomifun_ai_agent::IWorkerTaskManager for StubTaskManager {
|
||
fn get_task(&self, conversation_id: &str) -> Option<AgentInstance> {
|
||
self.tasks.lock().unwrap().get(conversation_id).cloned()
|
||
}
|
||
|
||
async fn get_or_build_task(&self, _: &str, _: BuildTaskOptions) -> Result<AgentInstance, AppError> {
|
||
Err(AppError::Internal(
|
||
"StubTaskManager does not support get_or_build_task".into(),
|
||
))
|
||
}
|
||
fn kill(&self, conversation_id: &str, reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
||
self.kill_calls
|
||
.lock()
|
||
.unwrap()
|
||
.push((conversation_id.to_owned(), reason));
|
||
Ok(())
|
||
}
|
||
fn kill_and_wait(
|
||
&self,
|
||
conversation_id: &str,
|
||
reason: Option<AgentKillReason>,
|
||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||
let _ = self.kill(conversation_id, reason);
|
||
Box::pin(std::future::ready(()))
|
||
}
|
||
fn clear(&self) {}
|
||
fn active_count(&self) -> usize {
|
||
self.tasks.lock().unwrap().len()
|
||
}
|
||
fn collect_idle(&self, _: TimestampMs) -> Vec<String> {
|
||
Vec::new()
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers: extract MCP server info from TeamSession via public API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Get the MCP server port from a TeamSession using the public mcp_stdio_config API.
|
||
fn session_port(session: &TeamSession) -> u16 {
|
||
session.mcp_stdio_config("lead-1").port
|
||
}
|
||
|
||
/// Get the MCP server auth token from a TeamSession using the public mcp_stdio_config API.
|
||
fn session_token(session: &TeamSession) -> String {
|
||
session.mcp_stdio_config("lead-1").token
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MCP protocol helpers (same pattern as e2e_smoke.rs and mcp_server_integration.rs)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async fn tcp_send(stream: &mut TcpStream, req: &Value) {
|
||
let bytes = serde_json::to_vec(req).unwrap();
|
||
write_frame(stream, &bytes).await.unwrap();
|
||
}
|
||
|
||
async fn tcp_recv(stream: &mut TcpStream) -> Value {
|
||
let frame = read_frame(stream).await.unwrap();
|
||
serde_json::from_slice(&frame).unwrap()
|
||
}
|
||
|
||
/// Connect and complete the MCP initialize handshake. Returns an
|
||
/// authenticated, ready-to-use TcpStream.
|
||
async fn mcp_connect(port: u16, auth_token: &str, slot_id: &str) -> TcpStream {
|
||
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
|
||
.await
|
||
.expect("tcp connect to TeamMcpServer");
|
||
let init_req = json!({
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "initialize",
|
||
"params": {
|
||
"auth_token": auth_token,
|
||
"slot_id": slot_id,
|
||
"protocolVersion": "2024-11-05",
|
||
"capabilities": {},
|
||
"clientInfo": { "name": "e2e-test", "version": "0.1" }
|
||
}
|
||
});
|
||
tcp_send(&mut stream, &init_req).await;
|
||
let resp = tcp_recv(&mut stream).await;
|
||
assert!(
|
||
resp["result"]["serverInfo"]["name"].is_string(),
|
||
"initialize failed: {resp}"
|
||
);
|
||
stream
|
||
}
|
||
|
||
/// Send a tools/call and return the full response envelope.
|
||
async fn mcp_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 }
|
||
});
|
||
tcp_send(stream, &req).await;
|
||
tcp_recv(stream).await
|
||
}
|
||
|
||
fn is_mcp_error(resp: &Value) -> bool {
|
||
resp["result"]["isError"].as_bool().unwrap_or(false)
|
||
}
|
||
|
||
fn mcp_text(resp: &Value) -> &str {
|
||
resp["result"]["content"][0]["text"].as_str().unwrap_or("")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Environment builders
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn backend_path() -> Arc<PathBuf> {
|
||
Arc::new(PathBuf::from("/tmp/nomicore-e2e-test"))
|
||
}
|
||
|
||
/// Two-agent team definition: one Lead + one Worker.
|
||
fn two_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,
|
||
},
|
||
]
|
||
}
|
||
|
||
/// Build a TeamSession + shared task manager pre-populated with RecordingAgents.
|
||
///
|
||
/// Returns:
|
||
/// - Arc<TeamSession>
|
||
/// - Arc<StubTaskManager>
|
||
/// - Arc<MockTeamRepo> (for low-level mailbox inspection)
|
||
/// - Arc<Mutex<Vec<SendMessageData>>> (shared sent-messages log)
|
||
async fn setup_session() -> (
|
||
Arc<TeamSession>,
|
||
Arc<StubTaskManager>,
|
||
Arc<MockTeamRepo>,
|
||
Arc<Mutex<Vec<SendMessageData>>>,
|
||
) {
|
||
let repo = Arc::new(MockTeamRepo::new());
|
||
let repo_dyn: Arc<dyn ITeamRepository> = repo.clone();
|
||
let broadcaster: Arc<dyn EventBroadcaster> = Arc::new(NullBroadcaster);
|
||
|
||
let sent: Arc<Mutex<Vec<SendMessageData>>> = Arc::new(Mutex::new(Vec::new()));
|
||
let task_manager = Arc::new(StubTaskManager::new());
|
||
|
||
for agent in two_agents() {
|
||
let handle = AgentInstance::Mock(Arc::new(RecordingAgent::new(&agent.conversation_id, sent.clone())));
|
||
task_manager.insert(&agent.conversation_id, handle);
|
||
}
|
||
|
||
let task_manager_dyn: Arc<dyn nomifun_ai_agent::IWorkerTaskManager> = task_manager.clone();
|
||
|
||
let team = nomifun_team::types::Team {
|
||
id: "e2e-team".into(),
|
||
name: "E2E Team".into(),
|
||
agents: two_agents(),
|
||
lead_agent_id: Some("lead-1".into()),
|
||
created_at: 1000,
|
||
updated_at: 1000,
|
||
};
|
||
|
||
let session = TeamSession::start(
|
||
team,
|
||
repo_dyn,
|
||
broadcaster,
|
||
backend_path(),
|
||
task_manager_dyn,
|
||
"user-e2e".into(),
|
||
Weak::<TeamSessionService>::new(),
|
||
)
|
||
.await
|
||
.expect("TeamSession::start failed");
|
||
|
||
(Arc::new(session), task_manager, repo, sent)
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 1: MCP server starts and tools/list surface is correct
|
||
// ===========================================================================
|
||
|
||
/// Scenario 1a: TeamSession starts, MCP server binds a port, tools are available.
|
||
///
|
||
/// Verifies:
|
||
/// - TeamSession::start succeeds
|
||
/// - MCP TCP server is reachable
|
||
/// - tools/list returns all 10 expected tools
|
||
#[tokio::test]
|
||
async fn s1a_mcp_server_starts_and_tools_available() {
|
||
let (session, _tm, _repo, _sent) = setup_session().await;
|
||
|
||
let port = session_port(&session);
|
||
assert!(port > 0, "MCP server must bind a non-zero port");
|
||
|
||
let token = session_token(&session);
|
||
let mut stream = mcp_connect(port, &token, "lead-1").await;
|
||
|
||
let req = json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" });
|
||
tcp_send(&mut stream, &req).await;
|
||
let resp = tcp_recv(&mut stream).await;
|
||
|
||
let tools = resp["result"]["tools"].as_array().expect("tools array");
|
||
assert_eq!(tools.len(), 10, "expected exactly 10 MCP tools, got {}", tools.len());
|
||
|
||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||
assert!(names.contains(&"team_send_message"), "missing team_send_message");
|
||
assert!(names.contains(&"team_members"), "missing team_members");
|
||
assert!(names.contains(&"team_task_create"), "missing team_task_create");
|
||
|
||
session.stop();
|
||
}
|
||
|
||
/// Scenario 1b: mcp_stdio_config is generated correctly for each agent slot.
|
||
///
|
||
/// Verifies that the stdio config written to conversation.extra contains
|
||
/// the correct port, token, and slot_id values.
|
||
#[tokio::test]
|
||
async fn s1b_mcp_stdio_config_per_agent() {
|
||
let (session, _tm, _repo, _sent) = setup_session().await;
|
||
|
||
let cfg_lead = session.mcp_stdio_config("lead-1");
|
||
assert_eq!(cfg_lead.team_id, "e2e-team");
|
||
assert_eq!(cfg_lead.slot_id, "lead-1");
|
||
assert_eq!(cfg_lead.port, session_port(&session));
|
||
assert!(!cfg_lead.token.is_empty());
|
||
|
||
let cfg_worker = session.mcp_stdio_config("worker-1");
|
||
assert_eq!(cfg_worker.team_id, "e2e-team");
|
||
assert_eq!(cfg_worker.slot_id, "worker-1");
|
||
assert_eq!(cfg_worker.port, cfg_lead.port, "same server port");
|
||
assert_eq!(cfg_worker.token, cfg_lead.token, "same auth token for same session");
|
||
assert_ne!(cfg_worker.slot_id, cfg_lead.slot_id);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 2: MCP team_send_message end-to-end
|
||
// ===========================================================================
|
||
|
||
/// Scenario 2a: Lead calls team_send_message via MCP → mailbox written.
|
||
///
|
||
/// This is the core side-effect test: MCP tool call must persist the message
|
||
/// to the mailbox repo. The wake path (wake_agent_in_session) requires a live
|
||
/// TeamSessionService Weak pointer which is not available in this standalone
|
||
/// TeamSession test environment. Verify mailbox persistence only here;
|
||
/// wake propagation is covered by s9_session_send_message_wakes_lead which
|
||
/// uses the public `send_message` / `send_message_to_agent` API.
|
||
#[tokio::test]
|
||
async fn s2a_mcp_team_send_message_writes_mailbox() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
let port = session_port(&session);
|
||
let token = session_token(&session);
|
||
|
||
let mut stream = mcp_connect(port, &token, "lead-1").await;
|
||
let resp = mcp_call_tool(
|
||
&mut stream,
|
||
10,
|
||
"team_send_message",
|
||
json!({ "to": "worker-1", "message": "e2e test payload" }),
|
||
)
|
||
.await;
|
||
|
||
assert!(!is_mcp_error(&resp), "team_send_message returned error: {resp}");
|
||
assert!(
|
||
mcp_text(&resp).contains("Message sent"),
|
||
"response text must confirm delivery"
|
||
);
|
||
|
||
// Mailbox must have the message (persisted by the MCP handler's execute_action)
|
||
let state = repo.state.lock().unwrap();
|
||
let worker_msgs: Vec<_> = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.to_agent_id == "worker-1" && m.content.contains("e2e test payload"))
|
||
.collect();
|
||
assert!(
|
||
!worker_msgs.is_empty(),
|
||
"mailbox must contain the message for worker-1; repo state: {:?}",
|
||
state.messages
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
/// Scenario 2b: Broadcast team_send_message (to="*") writes to all teammates.
|
||
#[tokio::test]
|
||
async fn s2b_mcp_broadcast_writes_to_all_agents() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
let port = session_port(&session);
|
||
let token = session_token(&session);
|
||
|
||
let mut stream = mcp_connect(port, &token, "lead-1").await;
|
||
let resp = mcp_call_tool(
|
||
&mut stream,
|
||
11,
|
||
"team_send_message",
|
||
json!({ "to": "*", "message": "broadcast msg" }),
|
||
)
|
||
.await;
|
||
|
||
assert!(!is_mcp_error(&resp), "broadcast team_send_message failed: {resp}");
|
||
|
||
// Both lead and worker should have received the broadcast
|
||
let state = repo.state.lock().unwrap();
|
||
let worker_msgs: Vec<_> = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.content.contains("broadcast msg"))
|
||
.collect();
|
||
assert!(
|
||
!worker_msgs.is_empty(),
|
||
"broadcast must write to at least one agent; state.messages={:?}",
|
||
state.messages
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
/// Scenario 2c: team_send_message is not a no-op — the repo actually receives
|
||
/// the row (guards against the "success with no side effect" failure mode).
|
||
#[tokio::test]
|
||
async fn s2c_send_message_side_effect_reaches_repo() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
let port = session_port(&session);
|
||
let token = session_token(&session);
|
||
|
||
// Verify repo is initially empty
|
||
{
|
||
let state = repo.state.lock().unwrap();
|
||
assert!(state.messages.is_empty(), "repo must start empty");
|
||
}
|
||
|
||
let mut stream = mcp_connect(port, &token, "lead-1").await;
|
||
mcp_call_tool(
|
||
&mut stream,
|
||
12,
|
||
"team_send_message",
|
||
json!({ "to": "worker-1", "message": "side-effect check" }),
|
||
)
|
||
.await;
|
||
|
||
// After tool call, repo must have at least one row
|
||
let state = repo.state.lock().unwrap();
|
||
assert!(
|
||
!state.messages.is_empty(),
|
||
"team_send_message must persist at least one message row to repo"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 3: on_agent_finish → mark_idle → IdleNotification → leader re-wake
|
||
// ===========================================================================
|
||
|
||
/// Scenario 3a: Worker finishes → on_agent_finish marks worker idle →
|
||
/// IdleNotification written to lead mailbox → lead is returned as wake target.
|
||
#[tokio::test]
|
||
async fn s3a_on_agent_finish_writes_idle_notification_to_lead() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
|
||
// Set worker to Working (simulates in-flight turn)
|
||
session
|
||
.scheduler()
|
||
.set_status("worker-1", nomifun_team::TeammateStatus::Working)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Simulate worker finishing its turn
|
||
let wake_target = session.on_agent_finish("conv-worker", false).await.unwrap();
|
||
|
||
// Worker should now be idle and lead should be returned as wake target
|
||
assert_eq!(
|
||
wake_target.as_deref(),
|
||
Some("lead-1"),
|
||
"on_agent_finish must return lead-1 as wake target; got {wake_target:?}"
|
||
);
|
||
|
||
let worker_status = session.scheduler().get_status("worker-1").await.unwrap();
|
||
assert_eq!(
|
||
worker_status,
|
||
nomifun_team::TeammateStatus::Idle,
|
||
"worker must be Idle after finish"
|
||
);
|
||
|
||
// IdleNotification must be in lead's mailbox
|
||
let state = repo.state.lock().unwrap();
|
||
let lead_idle: Vec<_> = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.to_agent_id == "lead-1" && m.msg_type == "idle_notification")
|
||
.collect();
|
||
assert!(
|
||
!lead_idle.is_empty(),
|
||
"IdleNotification must be written to lead mailbox after worker finish; got {:?}",
|
||
state.messages
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
/// Scenario 3b: Lead finish does not write IdleNotification to anyone.
|
||
#[tokio::test]
|
||
async fn s3b_lead_finish_does_not_write_idle_notification() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
|
||
session
|
||
.scheduler()
|
||
.set_status("lead-1", nomifun_team::TeammateStatus::Working)
|
||
.await
|
||
.unwrap();
|
||
|
||
let wake_target = session.on_agent_finish("conv-lead", false).await.unwrap();
|
||
|
||
// Lead finish should not produce a wake target
|
||
assert!(wake_target.is_none(), "lead finish must not return a wake target");
|
||
|
||
// No idle_notification should be written
|
||
let state = repo.state.lock().unwrap();
|
||
let idle_notifs: Vec<_> = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.msg_type == "idle_notification")
|
||
.collect();
|
||
assert!(
|
||
idle_notifs.is_empty(),
|
||
"lead finish must not write idle_notification; got {idle_notifs:?}"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
/// Scenario 3c: Full round-trip — worker sends message via MCP → finishes →
|
||
/// lead mailbox has IdleNotification → lead's send_message is called.
|
||
#[tokio::test]
|
||
async fn s3c_finish_triggers_lead_wake_with_idle_notification() {
|
||
let (session, _tm, repo, sent) = setup_session().await;
|
||
|
||
// Write a message to worker's mailbox (so the wake has content to send)
|
||
session
|
||
.mailbox()
|
||
.write(
|
||
"e2e-team",
|
||
"worker-1",
|
||
"lead-1",
|
||
nomifun_team::MailboxMessageType::Message,
|
||
"do the work",
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Set worker Working and drain its mailbox (simulates the wake consuming
|
||
// messages via compute_wake_input before on_agent_finish fires).
|
||
session
|
||
.scheduler()
|
||
.set_status("worker-1", nomifun_team::TeammateStatus::Working)
|
||
.await
|
||
.unwrap();
|
||
let _ = session.mailbox().read_unread("e2e-team", "worker-1").await;
|
||
|
||
// Worker finishes → IdleNotification → lead returned
|
||
let wake_target = session.on_agent_finish("conv-worker", false).await.unwrap();
|
||
assert_eq!(wake_target.as_deref(), Some("lead-1"));
|
||
|
||
// Verify that the IdleNotification is in lead's mailbox (it will be consumed
|
||
// when the lead is re-woken by spawn_finish_subscribers in the real service).
|
||
{
|
||
let state = repo.state.lock().unwrap();
|
||
let lead_msgs: Vec<_> = state.messages.iter().filter(|m| m.to_agent_id == "lead-1").collect();
|
||
assert!(
|
||
!lead_msgs.is_empty(),
|
||
"lead mailbox must have content (idle_notification) after worker finish"
|
||
);
|
||
}
|
||
|
||
// Trigger lead wake via the public send_message_to_agent API:
|
||
// this writes to mailbox + wakes the agent, which causes send_message to fire.
|
||
session
|
||
.send_message_to_agent("lead-1", "wake", None)
|
||
.await
|
||
.expect("send_message_to_agent must succeed");
|
||
|
||
// Allow async propagation
|
||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||
|
||
// Lead's agent send_message must have been called
|
||
let log = sent.lock().unwrap();
|
||
assert!(
|
||
!log.is_empty(),
|
||
"lead's RecordingAgent.send_message must be called after wake; sent log: {log:?}"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 4: add_agent (runtime) + finish propagation
|
||
// ===========================================================================
|
||
|
||
/// Scenario 4: Adding a new agent at runtime (simulating spawn_agent outcome)
|
||
/// then verifying the new agent receives messages and can finish.
|
||
#[tokio::test]
|
||
async fn s4_dynamic_agent_added_then_finish_propagates() {
|
||
let (session, task_manager, repo, sent) = setup_session().await;
|
||
|
||
// Add a new agent at runtime
|
||
let new_agent = TeamAgent {
|
||
slot_id: "helper-1".into(),
|
||
name: "Helper".into(),
|
||
role: TeammateRole::Teammate,
|
||
conversation_id: "conv-helper".into(),
|
||
backend: "acp".into(),
|
||
model: "claude".into(),
|
||
custom_agent_id: None,
|
||
status: None,
|
||
conversation_type: None,
|
||
cli_path: None,
|
||
};
|
||
|
||
// Insert a recording agent for the new conversation
|
||
let handle = AgentInstance::Mock(Arc::new(RecordingAgent::new("conv-helper", sent.clone())));
|
||
task_manager.insert("conv-helper", handle);
|
||
|
||
// Add the agent to the session's scheduler
|
||
session.add_agent(&new_agent).await;
|
||
|
||
// Verify the agent is in the roster
|
||
let agents = session.scheduler().list_agents().await;
|
||
assert_eq!(agents.len(), 3, "expected 3 agents after add_agent");
|
||
assert!(agents.iter().any(|a| a.slot_id == "helper-1"));
|
||
|
||
// Send a welcome message to the new agent
|
||
session
|
||
.mailbox()
|
||
.write(
|
||
"e2e-team",
|
||
"helper-1",
|
||
"lead-1",
|
||
nomifun_team::MailboxMessageType::Message,
|
||
"welcome, helper",
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
// Wake the helper via public send_message_to_agent API
|
||
// (this writes to mailbox AND wakes the agent, consuming the prior mailbox messages)
|
||
session
|
||
.send_message_to_agent("helper-1", "start your task", None)
|
||
.await
|
||
.expect("send_message_to_agent to helper must succeed");
|
||
|
||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||
|
||
// Helper's send_message should have been called
|
||
let log = sent.lock().unwrap();
|
||
assert!(
|
||
!log.is_empty(),
|
||
"helper's RecordingAgent.send_message must be called after send_message_to_agent; log: {log:?}"
|
||
);
|
||
drop(log);
|
||
|
||
// Simulate helper finishing → IdleNotification → lead should be wake target
|
||
let wake_target = session.on_agent_finish("conv-helper", false).await.unwrap();
|
||
assert_eq!(
|
||
wake_target.as_deref(),
|
||
Some("lead-1"),
|
||
"helper finish must return lead as wake target"
|
||
);
|
||
|
||
// Lead mailbox should have IdleNotification from helper
|
||
let state = repo.state.lock().unwrap();
|
||
let lead_notifs: Vec<_> = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.to_agent_id == "lead-1" && m.msg_type == "idle_notification" && m.from_agent_id == "helper-1")
|
||
.collect();
|
||
assert!(
|
||
!lead_notifs.is_empty(),
|
||
"lead must receive idle_notification from helper; msgs={:?}",
|
||
state.messages
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 5: Rapid consecutive messages – dedup window after clear
|
||
// ===========================================================================
|
||
|
||
/// Scenario 5: After a first finish + clear_finalized_turn, a second finish
|
||
/// for the same conversation must also succeed (not be dropped by dedup).
|
||
///
|
||
/// This is the regression test for the "dedup window drops legitimate second
|
||
/// Finish" bug (fix/team-communication-bugs task #5).
|
||
#[tokio::test]
|
||
async fn s5_consecutive_finish_events_after_dedup_clear() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
|
||
// First finish
|
||
session
|
||
.scheduler()
|
||
.set_status("worker-1", nomifun_team::TeammateStatus::Working)
|
||
.await
|
||
.unwrap();
|
||
let first_result = session.on_agent_finish("conv-worker", false).await.unwrap();
|
||
assert_eq!(
|
||
first_result.as_deref(),
|
||
Some("lead-1"),
|
||
"first finish must return wake target"
|
||
);
|
||
|
||
// Simulate what the service's finish_subscribers do: clear the dedup window
|
||
// after a successful wake so the next legitimate finish can proceed.
|
||
session.scheduler().clear_finalized_turn("conv-worker");
|
||
|
||
// Set worker Working again (second turn)
|
||
session
|
||
.scheduler()
|
||
.set_status("worker-1", nomifun_team::TeammateStatus::Working)
|
||
.await
|
||
.unwrap();
|
||
|
||
let second_result = session.on_agent_finish("conv-worker", false).await.unwrap();
|
||
assert_eq!(
|
||
second_result.as_deref(),
|
||
Some("lead-1"),
|
||
"second finish (after dedup clear) must also return wake target; got {second_result:?}"
|
||
);
|
||
|
||
// Lead mailbox should have two IdleNotification entries (one per finish)
|
||
let state = repo.state.lock().unwrap();
|
||
let idle_count = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.to_agent_id == "lead-1" && m.msg_type == "idle_notification")
|
||
.count();
|
||
assert_eq!(
|
||
idle_count, 2,
|
||
"both finish events must produce IdleNotification; got {idle_count}"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
/// Scenario 5b: Within the dedup window, a duplicate finish SHOULD be dropped.
|
||
///
|
||
/// Bug (task #5): `on_agent_finish` calls `clear_finalized_turn` immediately
|
||
/// after `finalize_turn` returns `wake_target.is_some()`. This means the dedup
|
||
/// window is cleared on the first success, allowing the second rapid Finish to
|
||
/// also be processed. The intent was to only clear after the re-woken agent
|
||
/// completes its *next* turn — not immediately.
|
||
///
|
||
/// This test is #[ignore] until the fix is merged: the dedup window must NOT
|
||
/// be cleared immediately after the first success if the second finish arrives
|
||
/// within the 5-second window.
|
||
#[tokio::test]
|
||
#[ignore = "Bug: on_agent_finish clears dedup window immediately, allowing double-processing within window (task #5 fix pending)"]
|
||
async fn s5b_dedup_window_blocks_rapid_duplicate_finish() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
|
||
session
|
||
.scheduler()
|
||
.set_status("worker-1", nomifun_team::TeammateStatus::Working)
|
||
.await
|
||
.unwrap();
|
||
|
||
// First finish — should proceed
|
||
let first = session.on_agent_finish("conv-worker", false).await.unwrap();
|
||
assert!(first.is_some(), "first finish must succeed");
|
||
|
||
// Immediately repeat without clearing — should be dedup'd (returns Ok(None)).
|
||
// NOTE: on_agent_finish checks dedup before any state changes, so the second
|
||
// call should return None even after re-setting status to Working.
|
||
session
|
||
.scheduler()
|
||
.set_status("worker-1", nomifun_team::TeammateStatus::Working)
|
||
.await
|
||
.unwrap();
|
||
|
||
let second = session.on_agent_finish("conv-worker", false).await.unwrap();
|
||
assert!(
|
||
second.is_none(),
|
||
"second finish within dedup window must be dropped (returns None); got {second:?}"
|
||
);
|
||
|
||
// Only one IdleNotification should exist
|
||
let state = repo.state.lock().unwrap();
|
||
let idle_count = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.msg_type == "idle_notification")
|
||
.count();
|
||
assert_eq!(
|
||
idle_count, 1,
|
||
"dedup must prevent double idle_notification; got {idle_count}"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 6: task board operations via MCP
|
||
// ===========================================================================
|
||
|
||
/// Scenario 6: team_task_create via MCP → task board persisted → task visible
|
||
/// in team_task_list.
|
||
#[tokio::test]
|
||
async fn s6_mcp_task_create_and_list() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
let port = session_port(&session);
|
||
let token = session_token(&session);
|
||
|
||
let mut stream = mcp_connect(port, &token, "lead-1").await;
|
||
|
||
// Create a task
|
||
let create_resp = mcp_call_tool(
|
||
&mut stream,
|
||
20,
|
||
"team_task_create",
|
||
json!({ "subject": "E2E Task Alpha" }),
|
||
)
|
||
.await;
|
||
assert!(!is_mcp_error(&create_resp), "team_task_create failed: {create_resp}");
|
||
|
||
// List tasks — must contain the created task
|
||
let list_resp = mcp_call_tool(&mut stream, 21, "team_task_list", json!({})).await;
|
||
assert!(!is_mcp_error(&list_resp), "team_task_list failed: {list_resp}");
|
||
let text = mcp_text(&list_resp);
|
||
let tasks: Vec<Value> = serde_json::from_str(text).expect("task list must be JSON");
|
||
assert!(
|
||
tasks.iter().any(|t| t["subject"] == "E2E Task Alpha"),
|
||
"created task must appear in task list; got {tasks:?}"
|
||
);
|
||
|
||
// Repo-level cross-check: task row reached storage
|
||
let state = repo.state.lock().unwrap();
|
||
assert!(
|
||
state.tasks.iter().any(|t| t.subject == "E2E Task Alpha"),
|
||
"task must be persisted in repo; got {:?}",
|
||
state.tasks
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 7: team_members reflects dynamic roster
|
||
// ===========================================================================
|
||
|
||
/// Scenario 7: After adding an agent at runtime, team_members via MCP
|
||
/// returns the updated roster.
|
||
#[tokio::test]
|
||
async fn s7_team_members_reflects_dynamic_roster() {
|
||
let (session, _tm, _repo, _sent) = setup_session().await;
|
||
let port = session_port(&session);
|
||
let token = session_token(&session);
|
||
|
||
let mut stream = mcp_connect(port, &token, "lead-1").await;
|
||
|
||
// Initially 2 members
|
||
let resp = mcp_call_tool(&mut stream, 30, "team_members", json!({})).await;
|
||
assert!(!is_mcp_error(&resp), "team_members failed");
|
||
let members: Vec<Value> = serde_json::from_str(mcp_text(&resp)).expect("team_members must return JSON array");
|
||
assert_eq!(members.len(), 2, "should start with 2 members");
|
||
|
||
// Add a third agent
|
||
let new_agent = TeamAgent {
|
||
slot_id: "extra-1".into(),
|
||
name: "ExtraAgent".into(),
|
||
role: TeammateRole::Teammate,
|
||
conversation_id: "conv-extra".into(),
|
||
backend: "acp".into(),
|
||
model: "claude".into(),
|
||
custom_agent_id: None,
|
||
status: None,
|
||
conversation_type: None,
|
||
cli_path: None,
|
||
};
|
||
session.add_agent(&new_agent).await;
|
||
|
||
// Now team_members should return 3
|
||
let resp2 = mcp_call_tool(&mut stream, 31, "team_members", json!({})).await;
|
||
assert!(!is_mcp_error(&resp2), "team_members (after add) failed");
|
||
let members2: Vec<Value> = serde_json::from_str(mcp_text(&resp2)).expect("team_members must return JSON array");
|
||
assert_eq!(members2.len(), 3, "roster must include dynamically added agent");
|
||
assert!(
|
||
members2.iter().any(|m| m["name"] == "ExtraAgent"),
|
||
"ExtraAgent must appear in team_members"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 8: Authentication on MCP connection
|
||
// ===========================================================================
|
||
|
||
/// Scenario 8a: Wrong auth token must be rejected.
|
||
#[tokio::test]
|
||
async fn s8a_wrong_auth_token_rejected() {
|
||
let (session, _tm, _repo, _sent) = setup_session().await;
|
||
let port = session_port(&session);
|
||
|
||
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
|
||
.await
|
||
.expect("tcp connect");
|
||
let bad_init = json!({
|
||
"jsonrpc": "2.0",
|
||
"id": 1,
|
||
"method": "initialize",
|
||
"params": {
|
||
"auth_token": "wrong-token",
|
||
"slot_id": "lead-1",
|
||
"protocolVersion": "2024-11-05",
|
||
"capabilities": {},
|
||
"clientInfo": { "name": "attacker", "version": "1" }
|
||
}
|
||
});
|
||
tcp_send(&mut stream, &bad_init).await;
|
||
let resp = tcp_recv(&mut stream).await;
|
||
assert!(
|
||
resp["error"]["message"]
|
||
.as_str()
|
||
.unwrap_or("")
|
||
.contains("Authentication failed"),
|
||
"wrong token must be rejected with Authentication failed; got {resp}"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
/// Scenario 8b: Non-lead slot cannot call team_spawn_agent (role guard).
|
||
#[tokio::test]
|
||
async fn s8b_worker_cannot_call_spawn_agent() {
|
||
let (session, _tm, _repo, _sent) = setup_session().await;
|
||
let port = session_port(&session);
|
||
let token = session_token(&session);
|
||
|
||
let mut stream = mcp_connect(port, &token, "worker-1").await;
|
||
let resp = mcp_call_tool(
|
||
&mut stream,
|
||
40,
|
||
"team_spawn_agent",
|
||
json!({ "name": "Hacker", "backend": "claude" }),
|
||
)
|
||
.await;
|
||
assert!(is_mcp_error(&resp), "worker must not be allowed to call spawn_agent");
|
||
let text = mcp_text(&resp);
|
||
assert!(text.contains("Only Lead"), "error must mention 'Only Lead'; got {text}");
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 9: send_message via TeamSession (not MCP) — direct API path
|
||
// ===========================================================================
|
||
|
||
/// Scenario 9: TeamSession::send_message writes to lead mailbox and
|
||
/// triggers lead's RecordingAgent.send_message.
|
||
#[tokio::test]
|
||
async fn s9_session_send_message_wakes_lead() {
|
||
let (session, _tm, repo, sent) = setup_session().await;
|
||
|
||
session
|
||
.send_message("user input to team", None)
|
||
.await
|
||
.expect("send_message must succeed");
|
||
|
||
// Lead mailbox must have the message
|
||
{
|
||
let state = repo.state.lock().unwrap();
|
||
let lead_msgs: Vec<_> = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.to_agent_id == "lead-1" && m.content == "user input to team")
|
||
.collect();
|
||
assert!(!lead_msgs.is_empty(), "message must be in lead mailbox");
|
||
}
|
||
|
||
// Lead's send_message must be called
|
||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||
let log = sent.lock().unwrap();
|
||
assert!(
|
||
log.iter().any(|d| d.content.contains("user input to team")),
|
||
"lead's RecordingAgent.send_message must be called; log: {log:?}"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 10: Error finish marks agent as Error status
|
||
// ===========================================================================
|
||
|
||
/// Scenario 10: on_agent_finish with is_error=true must preserve Error status.
|
||
///
|
||
/// Bug (task #5 related): Currently `on_agent_finish` sets status to Error,
|
||
/// then calls `finalize_turn` → `mark_idle`, which overwrites Error with Idle.
|
||
/// The correct behavior: Error status should be preserved (not overwritten by
|
||
/// mark_idle). This test is #[ignore] until the Error-status-preservation fix
|
||
/// is merged into fix/team-communication-bugs.
|
||
#[tokio::test]
|
||
#[ignore = "Bug: mark_idle overwrites Error status with Idle (fix pending in fix/team-communication-bugs)"]
|
||
async fn s10_error_finish_sets_agent_status_to_error() {
|
||
let (session, _tm, _repo, _sent) = setup_session().await;
|
||
|
||
session
|
||
.scheduler()
|
||
.set_status("worker-1", nomifun_team::TeammateStatus::Working)
|
||
.await
|
||
.unwrap();
|
||
|
||
let wake_target = session.on_agent_finish("conv-worker", true).await.unwrap();
|
||
assert_eq!(wake_target.as_deref(), Some("lead-1"));
|
||
|
||
let status = session.scheduler().get_status("worker-1").await.unwrap();
|
||
assert_eq!(
|
||
status,
|
||
nomifun_team::TeammateStatus::Error,
|
||
"error finish must preserve Error status (not be overwritten by mark_idle)"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 11: shutdown_approved sentinel interception
|
||
// ===========================================================================
|
||
|
||
/// Scenario 11: Worker sending "shutdown_approved" to lead is intercepted
|
||
/// by the MCP bridge and does not land as a raw string in lead's mailbox.
|
||
#[tokio::test]
|
||
async fn s11_shutdown_approved_interception() {
|
||
let (session, _tm, repo, _sent) = setup_session().await;
|
||
let port = session_port(&session);
|
||
let token = session_token(&session);
|
||
|
||
let mut stream = mcp_connect(port, &token, "worker-1").await;
|
||
let resp = mcp_call_tool(
|
||
&mut stream,
|
||
50,
|
||
"team_send_message",
|
||
json!({ "to": "lead-1", "message": "shutdown_approved" }),
|
||
)
|
||
.await;
|
||
|
||
assert!(!is_mcp_error(&resp), "shutdown_approved must not be a protocol error");
|
||
let text = mcp_text(&resp);
|
||
let payload: Value = serde_json::from_str(text).expect("shutdown_approved response must be JSON");
|
||
assert_eq!(
|
||
payload["status"], "shutdown_approved_received",
|
||
"must return shutdown_approved_received status; got {text}"
|
||
);
|
||
|
||
// The raw sentinel must NOT be in lead's mailbox
|
||
let state = repo.state.lock().unwrap();
|
||
let raw_sentinel: Vec<_> = state
|
||
.messages
|
||
.iter()
|
||
.filter(|m| m.to_agent_id == "lead-1" && m.content == "shutdown_approved")
|
||
.collect();
|
||
assert!(
|
||
raw_sentinel.is_empty(),
|
||
"raw shutdown_approved sentinel must not land in lead mailbox; got {raw_sentinel:?}"
|
||
);
|
||
|
||
session.stop();
|
||
}
|
||
|
||
// ===========================================================================
|
||
// Scenario 12: compute_wake_input — role prompt injection on cold start
|
||
// ===========================================================================
|
||
|
||
/// Scenario 12a: Cold-start lead gets role prompt injected into first_message.
|
||
#[tokio::test]
|
||
async fn s12a_cold_start_lead_gets_role_prompt() {
|
||
let (session, _tm, _repo, _sent) = setup_session().await;
|
||
|
||
session
|
||
.mailbox()
|
||
.write(
|
||
"e2e-team",
|
||
"lead-1",
|
||
"user",
|
||
nomifun_team::MailboxMessageType::Message,
|
||
"kick off",
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let input = session
|
||
.compute_wake_input("lead-1")
|
||
.await
|
||
.unwrap()
|
||
.expect("WakeInput must be Some");
|
||
|
||
assert!(input.should_send, "should_send must be true when mailbox has messages");
|
||
assert!(
|
||
input.first_message.contains("You are the Team Leader"),
|
||
"cold-start lead must get role prompt; got: {}",
|
||
&input.first_message[..input.first_message.len().min(200)]
|
||
);
|
||
assert!(input.first_message.contains("kick off"));
|
||
|
||
session.stop();
|
||
}
|
||
|
||
/// Scenario 12b: Warm lead (role prompt already consumed) does not get role prompt again.
|
||
///
|
||
/// The cold-start flag is a one-shot: `take_needs_role_prompt` returns true
|
||
/// only on the first call. To simulate a "warm" agent, we consume the flag
|
||
/// on a first compute_wake_input call, then assert the second call omits it.
|
||
#[tokio::test]
|
||
async fn s12b_warm_lead_skips_role_prompt() {
|
||
let (session, _tm, _repo, _sent) = setup_session().await;
|
||
|
||
// First: consume the cold-start role-prompt flag by calling compute_wake_input once.
|
||
// The mailbox will be empty so should_send=false, but the flag is consumed.
|
||
session
|
||
.mailbox()
|
||
.write(
|
||
"e2e-team",
|
||
"lead-1",
|
||
"user",
|
||
nomifun_team::MailboxMessageType::Message,
|
||
"initial kick",
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
let first_input = session
|
||
.compute_wake_input("lead-1")
|
||
.await
|
||
.unwrap()
|
||
.expect("first WakeInput");
|
||
assert!(
|
||
first_input.first_message.contains("You are the Team Leader"),
|
||
"first (cold) compute_wake_input must include role prompt"
|
||
);
|
||
// The flag is now consumed (take_needs_role_prompt returned true, set to false).
|
||
|
||
// Second call: write another message and compute again — no role prompt this time.
|
||
session
|
||
.mailbox()
|
||
.write(
|
||
"e2e-team",
|
||
"lead-1",
|
||
"user",
|
||
nomifun_team::MailboxMessageType::Message,
|
||
"follow-up message",
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
|
||
let input = session
|
||
.compute_wake_input("lead-1")
|
||
.await
|
||
.unwrap()
|
||
.expect("second WakeInput must be Some");
|
||
|
||
assert!(
|
||
!input.first_message.contains("You are the Team Leader"),
|
||
"warm lead (flag consumed) must NOT get role prompt on second call; got: {}",
|
||
&input.first_message[..input.first_message.len().min(300)]
|
||
);
|
||
assert!(input.first_message.contains("follow-up message"));
|
||
|
||
session.stop();
|
||
}
|