Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,33 @@
[package]
name = "nomifun-team"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-common.workspace = true
nomifun-db.workspace = true
nomifun-api-types.workspace = true
nomifun-realtime.workspace = true
nomifun-conversation.workspace = true
nomifun-ai-agent.workspace = true
nomifun-auth.workspace = true
axum.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
async-trait.workspace = true
dashmap.workspace = true
regex.workspace = true
agent-client-protocol = "0.11.1"
futures-util.workspace = true
[dev-dependencies]
sqlx.workspace = true
tokio = { workspace = true, features = ["test-util"] }
futures-util.workspace = true
reqwest.workspace = true
# Enable the `AgentInstance::Mock` variant so tests can build fake agents
# through the trait-object escape hatch without spawning real CLI processes.
nomifun-ai-agent = { workspace = true, features = ["test-support"] }
@@ -0,0 +1,17 @@
# nomifun-team
`nomifun-team` contains the backend implementation surface for historical
multi-agent team APIs under `/api/teams/*`.
Current product documentation does not expose a team user guide or a current
frontend route for this feature. Treat the crate as an authenticated backend
surface that may still be used by migration or compatibility paths, not as a
primary user-facing workflow.
Historical phase plans, MCP bridge designs, and implementation audits were
moved to [`docs/archive/nomifun-team/`](../../../docs/archive/nomifun-team/).
Those files describe previous design waves and may reference obsolete commands
such as `mcp-bridge`, `mcp-guide-stdio`, and `mcp-team-stdio`.
For the current API inventory, see
[`docs/reference/api-overview.md`](../../../docs/reference/api-overview.md).
@@ -0,0 +1,120 @@
//! Classify fatal agent stream events into recoverable (rate-limited) vs crash.
//!
//! Paired with W4-D20a `detect_crash`. Rate-limit errors are surfaced as
//! `TeammateStatus::Failed` without going through crash recovery (no kill,
//! no testament) — see interface-contracts §23.
use nomifun_ai_agent::protocol::events::AgentStreamEvent;
use regex::Regex;
use std::sync::OnceLock;
fn rate_limit_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?i)429|rate.?limit|quota|too many requests").expect("rate-limit regex must compile")
})
}
/// Returns true when an [`AgentStreamEvent::Error`] message looks like an
/// upstream rate-limit / quota response.
pub fn is_rate_limited(event: &AgentStreamEvent) -> bool {
match event {
AgentStreamEvent::Error(data) => rate_limit_regex().is_match(&data.message),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_ai_agent::protocol::events::{ErrorEventData, StartEventData};
fn error_event(message: &str) -> AgentStreamEvent {
AgentStreamEvent::Error(ErrorEventData::legacy(message, None))
}
#[test]
fn http_429_is_rate_limited() {
assert!(is_rate_limited(&error_event("HTTP 429 Too Many Requests")));
}
#[test]
fn rate_limit_phrase_is_rate_limited() {
assert!(is_rate_limited(&error_event(
"Anthropic API: rate limit exceeded, retry later"
)));
}
#[test]
fn plain_error_is_not_rate_limited() {
assert!(!is_rate_limited(&error_event("syntax error at line 42")));
}
#[test]
fn non_error_event_is_not_rate_limited() {
assert!(!is_rate_limited(&AgentStreamEvent::Start(StartEventData::default())));
}
}
// ---------------------------------------------------------------------------
// Crash detection (W4-D20a)
// ---------------------------------------------------------------------------
/// Reason an agent was classified as crashed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CrashReason {
ProcessExited,
SessionNotFound,
Unknown(String),
}
/// Detect crash from an agent stream event.
/// Returns Some(reason) if the event indicates a crash, None otherwise.
pub fn detect_crash(event: &AgentStreamEvent) -> Option<CrashReason> {
match event {
AgentStreamEvent::Error(data) => {
let msg = &data.message;
if msg.contains("process exited unexpectedly") || msg.contains("process exited") {
Some(CrashReason::ProcessExited)
} else if msg.contains("Session not found") || msg.contains("session not found") {
Some(CrashReason::SessionNotFound)
} else {
Some(CrashReason::Unknown(msg.clone()))
}
}
_ => None,
}
}
#[cfg(test)]
mod crash_tests {
use super::*;
use nomifun_ai_agent::protocol::events::ErrorEventData;
#[test]
fn detect_crash_process_exited() {
let event = AgentStreamEvent::Error(ErrorEventData::legacy("process exited unexpectedly", None));
assert_eq!(detect_crash(&event), Some(CrashReason::ProcessExited));
}
#[test]
fn detect_crash_session_not_found() {
let event = AgentStreamEvent::Error(ErrorEventData::legacy("Session not found", None));
assert_eq!(detect_crash(&event), Some(CrashReason::SessionNotFound));
}
#[test]
fn detect_crash_other_error() {
let event = AgentStreamEvent::Error(ErrorEventData::legacy("something else broke", None));
assert_eq!(
detect_crash(&event),
Some(CrashReason::Unknown("something else broke".into()))
);
}
#[test]
fn detect_crash_non_error_returns_none() {
let event = AgentStreamEvent::Start(nomifun_ai_agent::protocol::events::StartEventData { session_id: None });
assert_eq!(detect_crash(&event), None);
}
}
@@ -0,0 +1,160 @@
use nomifun_common::AppError;
#[derive(Debug, thiserror::Error)]
pub enum TeamError {
#[error("Team not found: {0}")]
TeamNotFound(String),
#[error("Agent not found: {0}")]
AgentNotFound(String),
#[error("Task not found: {0}")]
TaskNotFound(String),
#[error("Invalid request: {0}")]
InvalidRequest(String),
#[error("Leader-only action: {0}")]
LeaderOnly(String),
#[error("Session not found: {0}")]
SessionNotFound(String),
#[error("Blocked task not found: {0}")]
BlockedTaskNotFound(String),
#[error("Backend not allowed: {0}")]
BackendNotAllowed(String),
#[error("Agent name already taken: {0}")]
DuplicateAgentName(String),
#[error(transparent)]
App(#[from] AppError),
#[error("{0}")]
Database(#[from] nomifun_db::DbError),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl From<TeamError> for AppError {
fn from(err: TeamError) -> Self {
match err {
TeamError::TeamNotFound(msg) => AppError::NotFound(msg),
TeamError::AgentNotFound(msg) => AppError::NotFound(msg),
TeamError::TaskNotFound(msg) => AppError::NotFound(msg),
TeamError::InvalidRequest(msg) => AppError::BadRequest(msg),
TeamError::LeaderOnly(msg) => AppError::Forbidden(msg),
TeamError::SessionNotFound(msg) => AppError::NotFound(msg),
TeamError::BlockedTaskNotFound(msg) => AppError::BadRequest(msg),
TeamError::BackendNotAllowed(msg) => AppError::BadRequest(msg),
TeamError::DuplicateAgentName(msg) => AppError::BadRequest(format!("Agent name already taken: {msg}")),
TeamError::App(app_err) => app_err,
TeamError::Database(db_err) => AppError::from(db_err),
TeamError::Json(e) => AppError::Internal(format!("JSON error: {e}")),
}
}
}
impl TeamError {
pub(crate) fn from_conversation_create(error: AppError) -> Self {
match error {
AppError::WorkspacePathEdgeWhitespace(_) => Self::App(error),
AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported(_) => Self::App(error),
other => Self::InvalidRequest(format!("failed to create conversation: {other}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn team_not_found_maps_to_app_not_found() {
let err: AppError = TeamError::TeamNotFound("t1".into()).into();
assert!(matches!(err, AppError::NotFound(msg) if msg == "t1"));
}
#[test]
fn agent_not_found_maps_to_app_not_found() {
let err: AppError = TeamError::AgentNotFound("slot-1".into()).into();
assert!(matches!(err, AppError::NotFound(_)));
}
#[test]
fn task_not_found_maps_to_app_not_found() {
let err: AppError = TeamError::TaskNotFound("tk-1".into()).into();
assert!(matches!(err, AppError::NotFound(_)));
}
#[test]
fn invalid_request_maps_to_bad_request() {
let err: AppError = TeamError::InvalidRequest("empty agents".into()).into();
assert!(matches!(err, AppError::BadRequest(_)));
}
#[test]
fn leader_only_maps_to_forbidden() {
let err: AppError = TeamError::LeaderOnly("spawn_agent".into()).into();
assert!(matches!(err, AppError::Forbidden(msg) if msg == "spawn_agent"));
}
#[test]
fn session_not_found_maps_to_not_found() {
let err: AppError = TeamError::SessionNotFound("t1".into()).into();
assert!(matches!(err, AppError::NotFound(_)));
}
#[test]
fn blocked_task_not_found_maps_to_bad_request() {
let err: AppError = TeamError::BlockedTaskNotFound("tk-x".into()).into();
assert!(matches!(err, AppError::BadRequest(_)));
}
#[test]
fn backend_not_allowed_maps_to_bad_request() {
let err: AppError = TeamError::BackendNotAllowed("gemini".into()).into();
assert!(matches!(err, AppError::BadRequest(msg) if msg == "gemini"));
}
#[test]
fn duplicate_agent_name_maps_to_bad_request() {
let err: AppError = TeamError::DuplicateAgentName("alice".into()).into();
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("alice")));
}
#[test]
fn app_error_passthrough_preserves_code() {
let err: AppError = TeamError::App(AppError::WorkspacePathEdgeWhitespace("/tmp/a b".into())).into();
assert!(matches!(err, AppError::WorkspacePathEdgeWhitespace(msg) if msg == "/tmp/a b"));
}
#[test]
fn runtime_workspace_app_error_passthrough_preserves_code() {
let err: AppError = TeamError::App(AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported(
"/tmp/a b".into(),
))
.into();
assert!(matches!(
err,
AppError::WorkspacePathEdgeWhitespaceRuntimeUnsupported(msg) if msg == "/tmp/a b"
));
}
#[test]
fn json_error_maps_to_internal() {
let json_err = serde_json::from_str::<serde_json::Value>("bad").unwrap_err();
let err: AppError = TeamError::Json(json_err).into();
assert!(matches!(err, AppError::Internal(_)));
}
#[test]
fn display_messages() {
assert_eq!(TeamError::TeamNotFound("t1".into()).to_string(), "Team not found: t1");
assert_eq!(TeamError::AgentNotFound("s1".into()).to_string(), "Agent not found: s1");
assert_eq!(TeamError::TaskNotFound("tk1".into()).to_string(), "Task not found: tk1");
}
}
@@ -0,0 +1,344 @@
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use nomifun_ai_agent::IWorkerTaskManager;
use nomifun_ai_agent::types::SendMessageData;
use nomifun_common::ConversationStatus;
use nomifun_conversation::ConversationService;
use nomifun_conversation::runtime_state::TurnClaim;
use nomifun_realtime::EventBroadcaster;
use tokio::sync::Notify;
use tokio::sync::Semaphore;
use tokio::task::JoinHandle;
use tracing::{info, warn};
use crate::mailbox::Mailbox;
use crate::scheduler::TeammateManager;
use crate::session::TeamSession;
use crate::types::TeammateStatus;
/// Cap on team-agent turns running concurrently across the whole team. Without
/// it, a settled team can fire every agent's expensive LLM turn at once — a
/// provider-rate-limit / resource storm. Generous enough not to serialise small
/// teams. (TIER-3 hardening, §3.4)
const MAX_CONCURRENT_TEAM_TURNS: usize = 4;
/// Registry of per-agent Notify handles. Used by any trigger source to poke
/// an agent's event loop without needing to know its internals.
pub struct EventLoopRegistry {
notifiers: DashMap<String, Arc<Notify>>,
handles: DashMap<String, JoinHandle<()>>,
shutdown_tx: tokio::sync::watch::Sender<bool>,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
/// Shared across all agents' loops so the concurrent-turn cap is team-wide.
turn_semaphore: Arc<Semaphore>,
}
impl Default for EventLoopRegistry {
fn default() -> Self {
Self::new()
}
}
impl EventLoopRegistry {
pub fn new() -> Self {
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
Self {
notifiers: DashMap::new(),
handles: DashMap::new(),
shutdown_tx,
shutdown_rx,
turn_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_TEAM_TURNS)),
}
}
/// Check if an event loop is registered for this slot.
pub fn has(&self, slot_id: &str) -> bool {
self.notifiers.contains_key(slot_id)
}
/// Poke the named agent's event loop so it drains its mailbox.
pub fn notify(&self, slot_id: &str) {
if let Some(n) = self.notifiers.get(slot_id) {
n.notify_one();
}
}
/// Register and spawn an event loop for one agent.
pub fn spawn(&self, slot_id: &str, ctx: AgentLoopContext) {
let notify = Arc::new(Notify::new());
self.notifiers.insert(slot_id.to_owned(), notify.clone());
let handle = tokio::spawn(run_event_loop(
notify,
self.shutdown_rx.clone(),
self.turn_semaphore.clone(),
ctx,
));
self.handles.insert(slot_id.to_owned(), handle);
}
/// Remove an agent's event loop (agent removed from team).
pub fn remove(&self, slot_id: &str) {
self.notifiers.remove(slot_id);
if let Some((_, handle)) = self.handles.remove(slot_id) {
handle.abort();
}
}
/// Shut down all event loops.
pub fn shutdown(&self) {
let _ = self.shutdown_tx.send(true);
for entry in self.handles.iter() {
entry.value().abort();
}
self.handles.clear();
self.notifiers.clear();
}
}
/// Context shared across all iterations of one agent's event loop.
pub struct AgentLoopContext {
pub team_id: String,
pub slot_id: String,
pub user_id: String,
pub session: Arc<TeamSession>,
pub scheduler: Arc<TeammateManager>,
pub mailbox: Arc<Mailbox>,
pub task_manager: Arc<dyn IWorkerTaskManager>,
pub conversation_service: ConversationService,
pub broadcaster: Arc<dyn EventBroadcaster>,
/// Used to notify other agents' event loops (e.g. leader after all-settled).
pub registry: Arc<EventLoopRegistry>,
}
struct TurnExecution {
finish_ok: bool,
claim: TurnClaim,
}
/// The event loop for one agent slot. Spawned as a tokio task.
///
/// Flow:
/// 1. Wait for signal (notify) or shutdown.
/// 2. Drain loop: compute_wake_input → has messages → send_message (blocking) → finalize → repeat.
/// 3. When mailbox empty → back to step 1.
async fn run_event_loop(
notify: Arc<Notify>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
turn_semaphore: Arc<Semaphore>,
ctx: AgentLoopContext,
) {
info!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
"agent event loop started"
);
loop {
// Step 1: wait for signal or shutdown
tokio::select! {
biased;
_ = shutdown_rx.wait_for(|v| *v) => {
info!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
"agent event loop shutting down"
);
return;
}
_ = notify.notified() => {}
}
// Drain loop: keep processing until mailbox is empty
loop {
if *shutdown_rx.borrow() {
return;
}
let input = match ctx.session.compute_wake_input(&ctx.slot_id).await {
Ok(Some(input)) => input,
Ok(None) => break,
Err(e) => {
warn!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
error = %e,
"event loop: compute_wake_input failed"
);
tokio::time::sleep(Duration::from_secs(1)).await;
break;
}
};
if !input.should_send {
break;
}
// Bound concurrent team turns: hold a permit only across the
// (expensive) turn execution, so at most MAX_CONCURRENT_TEAM_TURNS
// agents call the model at once. Released before finalize.
let outcome = {
let _permit = turn_semaphore.acquire().await.ok();
execute_turn(&ctx, &input).await
};
match outcome {
Some(turn) => finalize_turn(&ctx, turn, &input.conversation_id).await,
None => break, // Turn not started (guard/warmup); retry on next signal
}
}
}
}
/// Execute one agent turn: warmup → guard → set Working → StreamRelay → send_message (blocking).
/// Returns `Some(true)` on success, `Some(false)` on error,
/// `None` if the turn was not started (guard hit, warmup fail, etc.).
async fn execute_turn(ctx: &AgentLoopContext, input: &crate::session::WakeInput) -> Option<TurnExecution> {
ctx.session.mirror_unread_to_conversation(input).await;
// Ensure agent task exists
let handle = match ctx.task_manager.get_task(&input.conversation_id) {
Some(h) => h,
None => {
if let Err(e) = ctx
.conversation_service
.warmup(&ctx.user_id, &input.conversation_id, &ctx.task_manager)
.await
{
warn!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
conversation_id = %input.conversation_id,
error = %e,
"event loop: warmup failed"
);
return None;
}
match ctx.task_manager.get_task(&input.conversation_id) {
Some(h) => h,
None => {
warn!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
conversation_id = %input.conversation_id,
"event loop: no task after warmup"
);
return None;
}
}
}
};
// Guard: skip if already running
if handle.status() == Some(ConversationStatus::Running) {
return None;
}
let claim = match ctx
.conversation_service
.runtime_state()
.try_claim_turn(&input.conversation_id)
{
Ok(claim) => claim,
Err(e) => {
warn!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
conversation_id = %input.conversation_id,
error = %e,
"event loop: runtime turn claim rejected"
);
return None;
}
};
// Point-of-no-return: set Working. Runtime state, not DB status, is the turn guard.
let _ = ctx.scheduler.set_status(&ctx.slot_id, TeammateStatus::Working).await;
let repo = ctx.conversation_service.conversation_repo();
// StreamRelay for response persistence + WebSocket forwarding
let msg_id = ConversationService::mint_msg_id();
let rx = handle.subscribe();
let relay = nomifun_conversation::stream_relay::StreamRelay::new(
input.conversation_id.clone(),
msg_id.clone(),
ctx.user_id.clone(),
Arc::clone(repo),
ctx.broadcaster.clone(),
None,
);
tokio::spawn(async move { relay.consume(rx).await });
// Collect files from unread messages (user-attached files)
let files: Vec<String> = input
.unread
.iter()
.filter_map(|m| m.files.as_ref())
.flatten()
.cloned()
.collect();
let data = SendMessageData {
content: input.first_message.clone(),
msg_id,
files,
inject_skills: Vec::new(),
origin: None,
};
let turn_ok = match handle.send_message(data).await {
Ok(()) => true,
Err(e) => {
warn!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
conversation_id = %input.conversation_id,
error = %e,
"event loop: send_message failed"
);
false
}
};
// Mark messages as read regardless of turn outcome
let msg_ids: Vec<i64> = input.unread.iter().map(|m| m.id).collect();
if !msg_ids.is_empty()
&& let Err(e) = ctx.mailbox.mark_read_batch(&msg_ids).await
{
warn!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
error = %e,
"event loop: mark_read_batch failed (non-fatal)"
);
}
Some(TurnExecution {
finish_ok: turn_ok,
claim,
})
}
/// Finalize a completed turn: release runtime claim, mark idle (or error), cascade to leader.
async fn finalize_turn(ctx: &AgentLoopContext, mut turn: TurnExecution, _conversation_id: &str) {
turn.claim.release();
if !turn.finish_ok {
let _ = ctx.scheduler.set_status(&ctx.slot_id, TeammateStatus::Error).await;
}
match ctx.scheduler.finalize_turn(&ctx.slot_id, &[]).await {
Ok(Some(wake_target)) => {
if wake_target != ctx.slot_id {
ctx.registry.notify(&wake_target);
}
}
Ok(None) => {}
Err(e) => {
warn!(
team_id = %ctx.team_id,
slot_id = %ctx.slot_id,
error = %e,
"event loop: finalize_turn failed"
);
}
}
}
@@ -0,0 +1,258 @@
use std::sync::Arc;
use nomifun_api_types::{
TeamAgentRemovedPayload, TeamAgentRenamedPayload, TeamAgentShutdownPayload, TeamAgentSpawnedPayload,
TeamAgentStatusPayload, WebSocketMessage,
};
use nomifun_realtime::EventBroadcaster;
use crate::types::{TeamAgent, TeammateStatus};
pub struct TeamEventEmitter {
team_id: String,
broadcaster: Arc<dyn EventBroadcaster>,
}
impl TeamEventEmitter {
pub fn new(team_id: String, broadcaster: Arc<dyn EventBroadcaster>) -> Self {
Self { team_id, broadcaster }
}
pub fn team_id(&self) -> &str {
&self.team_id
}
pub fn broadcast_agent_status(&self, slot_id: &str, status: TeammateStatus) {
let payload = TeamAgentStatusPayload {
team_id: self.team_id.clone(),
slot_id: slot_id.to_owned(),
status: status.to_string(),
};
let event = WebSocketMessage::new(
"team.agent.status",
serde_json::to_value(payload).expect("serialize status payload"),
);
self.broadcaster.broadcast(event);
}
pub fn broadcast_agent_spawned(&self, agent: &TeamAgent) {
let payload = TeamAgentSpawnedPayload {
team_id: self.team_id.clone(),
agent: agent.to_response(),
};
let event = WebSocketMessage::new(
"team.agent.spawned",
serde_json::to_value(payload).expect("serialize spawned payload"),
);
self.broadcaster.broadcast(event);
}
pub fn broadcast_agent_removed(&self, slot_id: &str) {
let payload = TeamAgentRemovedPayload {
team_id: self.team_id.clone(),
slot_id: slot_id.to_owned(),
};
let event = WebSocketMessage::new(
"team.agent.removed",
serde_json::to_value(payload).expect("serialize removed payload"),
);
self.broadcaster.broadcast(event);
}
/// Emit `team.agent.shutdown` to signal that the named teammate has
/// acknowledged a Lead-initiated shutdown request. The actual removal
/// (and `team.agent.removed`) follows once the agent process is killed
/// and scheduler state is cleared.
pub fn broadcast_agent_shutdown(&self, slot_id: &str) {
let payload = TeamAgentShutdownPayload {
team_id: self.team_id.clone(),
slot_id: slot_id.to_owned(),
};
let event = WebSocketMessage::new(
"team.agent.shutdown",
serde_json::to_value(payload).expect("serialize shutdown payload"),
);
self.broadcaster.broadcast(event);
}
pub fn broadcast_agent_renamed(&self, slot_id: &str, name: &str) {
let payload = TeamAgentRenamedPayload {
team_id: self.team_id.clone(),
slot_id: slot_id.to_owned(),
name: name.to_owned(),
};
let event = WebSocketMessage::new(
"team.agent.renamed",
serde_json::to_value(payload).expect("serialize renamed payload"),
);
self.broadcaster.broadcast(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TeammateRole;
use nomifun_api_types::{
TeamAgentRemovedPayload, TeamAgentRenamedPayload, TeamAgentShutdownPayload, TeamAgentSpawnedPayload,
TeamAgentStatusPayload,
};
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_emitter() -> (TeamEventEmitter, Arc<RecordingBroadcaster>) {
let bc = Arc::new(RecordingBroadcaster::new());
let emitter = TeamEventEmitter::new("team-1".into(), bc.clone());
(emitter, bc)
}
#[test]
fn status_event_has_correct_shape() {
let (emitter, bc) = make_emitter();
emitter.broadcast_agent_status("slot-1", TeammateStatus::Working);
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, "team-1");
assert_eq!(payload.slot_id, "slot-1");
assert_eq!(payload.status, "working");
}
#[test]
fn spawned_event_has_correct_shape() {
let (emitter, bc) = make_emitter();
let agent = TeamAgent {
slot_id: "slot-2".into(),
name: "Worker".into(),
role: TeammateRole::Teammate,
conversation_id: "conv-2".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: Some(TeammateStatus::Idle),
conversation_type: None,
cli_path: None,
};
emitter.broadcast_agent_spawned(&agent);
let events = bc.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].name, "team.agent.spawned");
let payload: TeamAgentSpawnedPayload = serde_json::from_value(events[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "team-1");
assert_eq!(payload.agent.slot_id, "slot-2");
assert_eq!(payload.agent.name, "Worker");
assert_eq!(payload.agent.role, "teammate");
}
#[test]
fn removed_event_has_correct_shape() {
let (emitter, bc) = make_emitter();
emitter.broadcast_agent_removed("slot-3");
let events = bc.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].name, "team.agent.removed");
let payload: TeamAgentRemovedPayload = serde_json::from_value(events[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "team-1");
assert_eq!(payload.slot_id, "slot-3");
}
#[test]
fn shutdown_event_has_correct_shape() {
let (emitter, bc) = make_emitter();
emitter.broadcast_agent_shutdown("slot-9");
let events = bc.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].name, "team.agent.shutdown");
let payload: TeamAgentShutdownPayload = serde_json::from_value(events[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "team-1");
assert_eq!(payload.slot_id, "slot-9");
}
#[test]
fn renamed_event_has_correct_shape() {
let (emitter, bc) = make_emitter();
emitter.broadcast_agent_renamed("slot-1", "New Name");
let events = bc.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].name, "team.agent.renamed");
let payload: TeamAgentRenamedPayload = serde_json::from_value(events[0].data.clone()).unwrap();
assert_eq!(payload.team_id, "team-1");
assert_eq!(payload.slot_id, "slot-1");
assert_eq!(payload.name, "New Name");
}
#[test]
fn team_id_accessor() {
let (emitter, _) = make_emitter();
assert_eq!(emitter.team_id(), "team-1");
}
#[test]
fn multiple_events_accumulate() {
let (emitter, bc) = make_emitter();
emitter.broadcast_agent_status("s1", TeammateStatus::Working);
emitter.broadcast_agent_status("s1", TeammateStatus::Idle);
emitter.broadcast_agent_removed("s2");
let events = bc.events();
assert_eq!(events.len(), 3);
assert_eq!(events[0].name, "team.agent.status");
assert_eq!(events[1].name, "team.agent.status");
assert_eq!(events[2].name, "team.agent.removed");
}
#[test]
fn all_status_variants_serialize() {
let (emitter, bc) = make_emitter();
let statuses = [
TeammateStatus::Idle,
TeammateStatus::Working,
TeammateStatus::Thinking,
TeammateStatus::ToolUse,
TeammateStatus::Completed,
TeammateStatus::Error,
];
for s in statuses {
emitter.broadcast_agent_status("s1", s);
}
let events = bc.events();
assert_eq!(events.len(), 6);
let expected = ["idle", "working", "thinking", "tool_use", "completed", "error"];
for (event, exp) in events.iter().zip(expected.iter()) {
let payload: TeamAgentStatusPayload = serde_json::from_value(event.data.clone()).unwrap();
assert_eq!(payload.status, *exp);
}
}
}
@@ -0,0 +1,46 @@
pub use nomifun_common::constants::TEAM_CAPABLE_BACKENDS;
use nomifun_common::constants::is_team_capable;
/// Determine if a backend supports team mode.
///
/// Hard whitelist always passes. For non-whitelisted backends, checks the
/// persisted `agent_capabilities` JSON for MCP transport declarations.
pub fn is_team_capable_backend(backend: &str, agent_capabilities: Option<&serde_json::Value>) -> bool {
is_team_capable(backend, agent_capabilities)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn whitelist_backend_is_capable_regardless_of_capabilities() {
assert!(is_team_capable_backend("claude", None));
assert!(is_team_capable_backend("claude", Some(&json!({}))));
assert!(is_team_capable_backend("codex", None));
assert!(is_team_capable_backend("gemini", None));
assert!(is_team_capable_backend("nomi", None));
assert!(is_team_capable_backend("codebuddy", None));
}
#[test]
fn non_whitelist_backend_with_mcp_capabilities_is_capable() {
let caps_stdio = json!({"mcp_capabilities": {"stdio": true}});
assert!(is_team_capable_backend("qwen", Some(&caps_stdio)));
let caps_http = json!({"mcpCapabilities": {"http": true, "sse": true}});
assert!(is_team_capable_backend("droid", Some(&caps_http)));
let caps_mcp = json!({"mcp": {"stdio": true}});
assert!(is_team_capable_backend("goose", Some(&caps_mcp)));
}
#[test]
fn non_whitelist_backend_without_mcp_capabilities_is_not_capable() {
assert!(!is_team_capable_backend("custom", None));
assert!(!is_team_capable_backend("custom", Some(&json!({}))));
assert!(!is_team_capable_backend("", None));
assert!(!is_team_capable_backend("Claude", None));
}
}
@@ -0,0 +1,199 @@
//! Tool handlers for the Guide MCP server (`nomi_*` tools) and argument
//! parsing for lead-facing create-team flows.
//!
//! Guide tools run in the agent's own MCP client and expose "meta" operations
//! that help the agent reason about team composition (list available models,
//! create a team, describe a preset). They are distinct from the in-team
//! `team_*` tools exposed by `mcp::server`.
use serde_json::{Value, json};
use crate::mcp::tools::handle_team_list_models;
#[derive(Debug, Clone)]
pub struct CreateTeamParams {
pub summary: String,
pub name: String,
pub workspace: String,
}
/// Parse `nomi_create_team` tool arguments into structured params.
///
/// Defaults:
/// - `name` falls back to the first 5 whitespace-separated tokens of `summary`.
/// - `workspace` falls back to the caller's workspace, then to `"."`.
pub fn parse_create_team_args(args: &Value, caller_workspace: Option<&str>) -> Result<CreateTeamParams, String> {
let summary = args
.get("summary")
.and_then(Value::as_str)
.ok_or("missing required field: summary")?
.to_owned();
let name = args
.get("name")
.and_then(Value::as_str)
.map(String::from)
.unwrap_or_else(|| summary.split_whitespace().take(5).collect::<Vec<_>>().join(" "));
let workspace = args
.get("workspace")
.and_then(Value::as_str)
.map(String::from)
.or_else(|| caller_workspace.map(String::from))
.unwrap_or_else(|| ".".to_owned());
Ok(CreateTeamParams {
summary,
name,
workspace,
})
}
/// Handle the `nomi_list_models` tool call.
///
/// Returns available backend × model combinations the agent can pick from when
/// planning a team. The Guide advertises everything the user could reasonably
/// select; the per-backend security whitelist enforced by
/// `nomi_create_team` / `team_spawn_agent` is a separate concern.
///
/// Claude + Codex entries are sourced verbatim from the project-wide
/// `handle_team_list_models` so Guide and in-team tools stay in lockstep.
/// Gemini is appended here because Guide surfaces every frontend-visible
/// option regardless of the spawn whitelist.
pub fn handle_nomi_list_models() -> Value {
let mut base = handle_team_list_models(&Value::Null);
if let Some(agent_types) = base.get_mut("agent_types").and_then(Value::as_array_mut) {
agent_types.push(json!({
"type": "gemini",
"models": ["gemini-2.5-pro", "gemini-2.5-flash"]
}));
}
base
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn errors_when_summary_missing() {
let args = json!({ "name": "alpha", "workspace": "/tmp" });
let err = parse_create_team_args(&args, None).unwrap_err();
assert!(err.contains("summary"), "unexpected error: {err}");
}
#[test]
fn errors_when_summary_not_string() {
let args = json!({ "summary": 42 });
let err = parse_create_team_args(&args, None).unwrap_err();
assert!(err.contains("summary"), "unexpected error: {err}");
}
#[test]
fn name_defaults_to_first_five_summary_words() {
let args = json!({
"summary": "implement login flow and add OAuth provider support end-to-end",
});
let params = parse_create_team_args(&args, None).unwrap();
assert_eq!(params.name, "implement login flow and add");
assert_eq!(
params.summary,
"implement login flow and add OAuth provider support end-to-end"
);
}
#[test]
fn name_defaults_use_all_summary_when_shorter_than_five_words() {
let args = json!({ "summary": "hello world" });
let params = parse_create_team_args(&args, None).unwrap();
assert_eq!(params.name, "hello world");
}
#[test]
fn workspace_inherits_from_caller_when_missing() {
let args = json!({ "summary": "do work" });
let params = parse_create_team_args(&args, Some("/caller/ws")).unwrap();
assert_eq!(params.workspace, "/caller/ws");
}
#[test]
fn workspace_defaults_to_dot_when_caller_absent() {
let args = json!({ "summary": "do work" });
let params = parse_create_team_args(&args, None).unwrap();
assert_eq!(params.workspace, ".");
}
#[test]
fn custom_fields_take_precedence_over_defaults() {
let args = json!({
"summary": "refactor the scheduler end-to-end",
"name": "scheduler-refactor",
"workspace": "/repo/path",
});
let params = parse_create_team_args(&args, Some("/caller/ws")).unwrap();
assert_eq!(params.summary, "refactor the scheduler end-to-end");
assert_eq!(params.name, "scheduler-refactor");
assert_eq!(params.workspace, "/repo/path");
}
#[test]
fn non_string_name_falls_back_to_summary_prefix() {
let args = json!({
"summary": "one two three four five six",
"name": 123,
});
let params = parse_create_team_args(&args, None).unwrap();
assert_eq!(params.name, "one two three four five");
}
#[test]
fn non_string_workspace_falls_back_to_caller() {
let args = json!({
"summary": "do work",
"workspace": 42,
});
let params = parse_create_team_args(&args, Some("/caller/ws")).unwrap();
assert_eq!(params.workspace, "/caller/ws");
}
#[test]
fn returns_agent_types_array() {
let value = handle_nomi_list_models();
let types = value
.get("agent_types")
.and_then(Value::as_array)
.expect("agent_types must be an array");
let names: Vec<&str> = types.iter().filter_map(|t| t.get("type")?.as_str()).collect();
assert!(names.contains(&"claude"));
assert!(names.contains(&"codex"));
assert!(names.contains(&"gemini"));
}
#[test]
fn every_entry_has_models_list() {
let value = handle_nomi_list_models();
for entry in value["agent_types"].as_array().unwrap() {
let models = entry["models"].as_array().expect("models must be array");
assert!(!models.is_empty(), "models list must not be empty");
assert!(entry["type"].as_str().map(|s| !s.is_empty()).unwrap_or(false));
}
}
#[test]
fn reuses_team_list_models_for_claude_and_codex() {
let guide = handle_nomi_list_models();
let team = handle_team_list_models(&Value::Null);
for backend in ["claude", "codex"] {
let guide_entry = find_entry(&guide, backend).expect("guide entry present");
let team_entry = find_entry(&team, backend).expect("team entry present");
assert_eq!(guide_entry["models"], team_entry["models"]);
}
}
fn find_entry<'a>(value: &'a Value, backend: &str) -> Option<&'a Value> {
value["agent_types"]
.as_array()?
.iter()
.find(|entry| entry["type"].as_str() == Some(backend))
}
}
@@ -0,0 +1,17 @@
//! Team Guide module — capability descriptor, lead-facing tool arg parsing,
//! `nomi_*` MCP tool handlers, and Guide MCP server.
//!
//! The Guide MCP server is injected into single-chat agents to expose
//! `nomi_create_team` / `nomi_list_models` tools. Independent from the
//! per-team `TeamMcpServer`.
//!
//! Current tool set:
//! - `nomi_create_team` — build a new team from a natural-language summary
//! - `nomi_list_models` — enumerate backend × model options
pub mod capability;
pub mod handlers;
pub mod server;
pub use handlers::{CreateTeamParams, handle_nomi_list_models, parse_create_team_args};
pub use server::GuideMcpServer;
@@ -0,0 +1,482 @@
use std::net::SocketAddr;
use std::sync::{Arc, Weak};
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::IntoResponse;
use nomifun_common::generate_id;
use tokio::net::TcpListener;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use crate::service::TeamSessionService;
use crate::types::TeammateRole;
type ServiceSlot = Arc<RwLock<Weak<TeamSessionService>>>;
#[derive(Clone)]
struct GuideState {
auth_token: String,
service: ServiceSlot,
}
pub struct GuideMcpServer {
http_addr: SocketAddr,
auth_token: String,
shutdown_handle: Option<tokio::task::JoinHandle<()>>,
service_slot: ServiceSlot,
}
impl GuideMcpServer {
pub async fn start() -> Result<Self, String> {
let auth_token = generate_id();
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind guide MCP HTTP listener: {e}"))?;
let http_addr = listener
.local_addr()
.map_err(|e| format!("Failed to read guide MCP local addr: {e}"))?;
let service_slot: ServiceSlot = Arc::new(RwLock::new(Weak::new()));
let state = GuideState {
auth_token: auth_token.clone(),
service: service_slot.clone(),
};
let app = axum::Router::new()
.route("/tool", axum::routing::post(handle_tool_request))
.with_state(state);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
warn!(error = %e, "Guide MCP axum server exited with error");
}
});
debug!(http_port = http_addr.port(), "Guide MCP Server started (axum)");
Ok(Self {
http_addr,
auth_token,
shutdown_handle: Some(handle),
service_slot,
})
}
/// Wire the TeamSessionService after it is constructed.
/// Must be called once before the first `nomi_create_team` request arrives.
pub async fn set_service(&self, service: Weak<TeamSessionService>) {
*self.service_slot.write().await = service;
}
pub fn http_port(&self) -> u16 {
self.http_addr.port()
}
pub fn http_addr(&self) -> SocketAddr {
self.http_addr
}
pub fn auth_token(&self) -> &str {
&self.auth_token
}
pub fn stop(&mut self) {
if let Some(handle) = self.shutdown_handle.take() {
handle.abort();
debug!(http_port = self.http_addr.port(), "Guide MCP Server stop requested");
}
}
}
impl Drop for GuideMcpServer {
fn drop(&mut self) {
self.stop();
}
}
// ---------------------------------------------------------------------------
// Axum handler
// ---------------------------------------------------------------------------
async fn handle_tool_request(
State(state): State<GuideState>,
headers: axum::http::HeaderMap,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
// Auth check
let provided_token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.unwrap_or("");
if provided_token != state.auth_token {
warn!("Guide HTTP: unauthorized request");
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "unauthorized"})),
)
.into_response();
}
let tool = body.get("tool").and_then(serde_json::Value::as_str).unwrap_or("");
let args = body.get("args").cloned().unwrap_or(serde_json::Value::Null);
info!(tool, "Guide HTTP: dispatching tool");
let response_body = match tool {
"nomi_create_team" => exec_create_team(&body, &args, &state.service).await,
"nomi_list_models" => {
let result = match state.service.read().await.upgrade() {
Some(svc) => {
let mut base = svc.list_models_from_db(None).await;
// Guide surfaces Gemini even if not in spawn whitelist
if let Some(types) = base.get_mut("agent_types").and_then(serde_json::Value::as_array_mut) {
let has_gemini = types
.iter()
.any(|e| e.get("type").and_then(serde_json::Value::as_str) == Some("gemini"));
if !has_gemini {
types.push(serde_json::json!({
"type": "gemini",
"models": ["gemini-2.5-pro", "gemini-2.5-flash"]
}));
}
}
base
}
None => crate::guide::handlers::handle_nomi_list_models(),
};
info!("Guide HTTP: nomi_list_models succeeded");
serde_json::json!({"result": serde_json::to_string(&result).unwrap_or_default()})
}
t if t.starts_with("team_") => exec_team_tool(t, &body, &args, &state.service).await,
unknown => {
warn!(tool = unknown, "Guide HTTP: unknown tool");
serde_json::json!({"error": format!("Unknown tool: {unknown}")})
}
};
let mut resp = Json(response_body).into_response();
resp.headers_mut()
.insert(header::CONNECTION, HeaderValue::from_static("close"));
resp
}
// ---------------------------------------------------------------------------
// Tool implementations
// ---------------------------------------------------------------------------
async fn exec_create_team(
request_body: &serde_json::Value,
args: &serde_json::Value,
service: &ServiceSlot,
) -> serde_json::Value {
use crate::guide::handlers::parse_create_team_args;
use nomifun_api_types::{CreateTeamRequest, TeamAgentInput};
let svc = match service.read().await.upgrade() {
Some(s) => s,
None => {
warn!("Guide HTTP: nomi_create_team — service not available");
return serde_json::json!({"error": "service_unavailable"});
}
};
let caller_workspace: Option<&str> = None;
let params = match parse_create_team_args(args, caller_workspace) {
Ok(p) => p,
Err(e) => {
warn!(error = %e, "Guide HTTP: nomi_create_team parse error");
return serde_json::json!({"error": e});
}
};
let backend = request_body
.get("backend")
.and_then(serde_json::Value::as_str)
.unwrap_or("claude")
.to_owned();
let model = request_body
.get("model")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_owned();
let user_id = request_body
.get("user_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("system_default_user")
.to_owned();
let caller_conversation_id = request_body
.get("conversation_id")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned);
// Refuse if the caller conversation already belongs to a team.
// This prevents duplicate team creation when guide MCP is
// erroneously injected into an existing team leader session.
if let Some(ref conv_id) = caller_conversation_id {
let repo = svc.conversation_service_ref().conversation_repo().clone();
// conversation_id arrives as a String from the request JSON; the repo
// is i64-keyed (Option A). A non-integer id can't reference a real
// conversation, so it simply skips the "already in a team" refuse check.
if let Ok(conv_id_i64) = conv_id.parse::<i64>()
&& let Ok(Some(row)) = repo.get(conv_id_i64).await
{
let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap_or(serde_json::Value::Null);
if extra
.get("teamId")
.and_then(serde_json::Value::as_str)
.is_some_and(|s| !s.is_empty())
{
warn!(
conversation_id = conv_id,
"Guide HTTP: nomi_create_team refused — conversation already belongs to a team"
);
return serde_json::json!({
"error": "This conversation already belongs to a team. Cannot create another team from here."
});
}
}
}
let req = CreateTeamRequest {
name: params.name.clone(),
agents: vec![TeamAgentInput {
name: "Leader".to_owned(),
role: "leader".to_owned(),
backend: backend.clone(),
model: model.clone(),
custom_agent_id: None,
// TeamAgentInput.conversation_id is now Option<i64> (Option A);
// adopt the caller's conversation only when it is a valid integer.
conversation_id: caller_conversation_id.and_then(|id| id.parse::<i64>().ok()),
}],
workspace: None,
};
let team = match svc.create_team(&user_id, req).await {
Ok(t) => t,
Err(e) => {
warn!(error = %e, "Guide HTTP: nomi_create_team create_team failed");
return serde_json::json!({"error": e.to_string()});
}
};
let route = format!("/team/{}", team.id);
info!(team_id = %team.id, "Guide HTTP: nomi_create_team succeeded");
serde_json::json!({
"teamId": team.id,
"name": team.name,
"route": route,
"status": "team_created",
"next_step": format!(
"You are now the team Leader. Your team tools (team_spawn_agent, team_send_message, etc.) are now active. \
Immediately proceed to spawn teammates as planned. Task summary: {}",
params.summary
)
})
}
async fn exec_team_tool(
tool_name: &str,
request_body: &serde_json::Value,
args: &serde_json::Value,
service: &ServiceSlot,
) -> serde_json::Value {
let svc = match service.read().await.upgrade() {
Some(s) => s,
None => {
warn!("Guide HTTP: {} — service not available", tool_name);
return serde_json::json!({"error": "service_unavailable"});
}
};
let conversation_id = match request_body
.get("conversation_id")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
{
Some(id) => id.to_owned(),
None => {
warn!(tool = tool_name, "Guide HTTP: team tool missing conversation_id");
return serde_json::json!({"error": "missing conversation_id"});
}
};
let (team_id, slot_id) = match resolve_team_context(&svc, &conversation_id).await {
Ok(ctx) => ctx,
Err(e) => {
warn!(tool = tool_name, error = %e, "Guide HTTP: resolve_team_context failed");
return serde_json::json!({"error": e});
}
};
let scheduler = match svc.get_session_scheduler(&team_id) {
Some(s) => s,
None => {
warn!(tool = tool_name, team_id = %team_id, "Guide HTTP: no active session for team");
return serde_json::json!({"error": "No active team session. The team may still be starting up."});
}
};
let svc_weak = Arc::downgrade(&svc);
let result = crate::mcp::server::dispatch_tool(
tool_name,
args,
&scheduler,
&svc_weak,
&team_id,
&slot_id,
TeammateRole::Lead,
)
.await;
match result {
Ok(text) => {
info!(tool = tool_name, team_id = %team_id, "Guide HTTP: team tool succeeded");
serde_json::json!({"result": text})
}
Err(err) => {
warn!(tool = tool_name, team_id = %team_id, error = %err, "Guide HTTP: team tool failed");
serde_json::json!({"error": err})
}
}
}
/// Resolve `(team_id, slot_id)` for a caller identified by `conversation_id`.
///
/// Reads the conversation row's `extra` JSON to extract `teamId`, then finds
/// the agent slot whose `conversation_id` matches. Returns an error string if
/// no active team is found for this conversation.
async fn resolve_team_context(service: &TeamSessionService, conversation_id: &str) -> Result<(String, String), String> {
// Extract teamId from conversation.extra via the conversation service repo.
let repo = service.conversation_service_ref().conversation_repo().clone();
// The repo is i64-keyed (Option A); bridge the &str id at the boundary.
let conversation_id_i64 = conversation_id
.parse::<i64>()
.map_err(|_| format!("Invalid conversation id: {conversation_id}"))?;
let row = repo
.get(conversation_id_i64)
.await
.map_err(|e| format!("DB error reading conversation: {e}"))?
.ok_or_else(|| format!("Conversation not found: {conversation_id}"))?;
let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap_or(serde_json::Value::Null);
let team_id = extra
.get("teamId")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| "No active team for this conversation. Create a team first with nomi_create_team.".to_owned())?
.to_owned();
// Find the slot_id by matching conversation_id in the session scheduler.
let scheduler = service
.get_session_scheduler(&team_id)
.ok_or_else(|| "No active team session. The team may still be starting up.".to_owned())?;
let agents = scheduler.list_agents().await;
let slot_id = agents
.iter()
.find(|a| a.conversation_id == conversation_id)
.map(|a| a.slot_id.clone())
.ok_or_else(|| format!("Agent with conversation_id={conversation_id} not found in team {team_id}"))?;
Ok((team_id, slot_id))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tokio::time::timeout;
#[tokio::test]
async fn start_returns_positive_port_and_token() {
let server = GuideMcpServer::start().await.expect("start should succeed");
assert!(server.http_port() > 0, "http_port should be assigned");
assert!(!server.auth_token().is_empty(), "auth_token should be generated");
}
#[tokio::test]
async fn each_start_uses_a_fresh_auth_token() {
let a = GuideMcpServer::start().await.unwrap();
let b = GuideMcpServer::start().await.unwrap();
assert_ne!(a.auth_token(), b.auth_token());
}
#[tokio::test]
async fn stop_closes_the_listener() {
let mut server = GuideMcpServer::start().await.unwrap();
let port = server.http_port();
server.stop();
tokio::time::sleep(Duration::from_millis(50)).await;
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(200))
.build()
.unwrap();
let result = timeout(
Duration::from_millis(500),
client
.post(format!("http://127.0.0.1:{port}/tool"))
.json(&serde_json::json!({}))
.send(),
)
.await;
match result {
Ok(Ok(_)) => { /* may still accept in-flight during abort */ }
Ok(Err(_)) => { /* connection refused — expected */ }
Err(_) => { /* timeout — expected */ }
}
}
#[tokio::test]
async fn stop_is_idempotent() {
let mut server = GuideMcpServer::start().await.unwrap();
server.stop();
server.stop();
}
#[tokio::test]
async fn tool_call_requires_auth() {
let server = GuideMcpServer::start().await.unwrap();
let port = server.http_port();
let client = reqwest::Client::new();
let resp = client
.post(format!("http://127.0.0.1:{port}/tool"))
.json(&serde_json::json!({"tool": "nomi_list_models", "args": {}}))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 401);
}
#[tokio::test]
async fn tool_call_with_valid_token_succeeds() {
let server = GuideMcpServer::start().await.unwrap();
let port = server.http_port();
let token = server.auth_token().to_owned();
let client = reqwest::Client::new();
let resp = client
.post(format!("http://127.0.0.1:{port}/tool"))
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({"tool": "nomi_list_models", "args": {}}))
.send()
.await
.unwrap();
assert_eq!(resp.status().as_u16(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body.get("result").is_some());
}
}
@@ -0,0 +1,36 @@
//! Multi-agent team sessions with role-based prompts, task board, mailbox, and scheduling.
pub mod crash_detection;
pub mod error;
pub mod event_loop;
pub mod events;
pub mod guide;
pub mod mailbox;
pub mod mcp;
pub mod prompts;
pub mod routes;
pub mod scheduler;
pub mod service;
pub mod session;
pub mod task_board;
#[cfg(test)]
pub(crate) mod test_utils;
pub mod types;
pub use crash_detection::{CrashReason, detect_crash, is_rate_limited};
pub use error::TeamError;
pub use events::TeamEventEmitter;
pub use guide::{GuideMcpServer, handle_nomi_list_models};
pub use mailbox::Mailbox;
pub use mcp::{TEAM_MCP_SERVER_NAME, TeamMcpServer, TeamMcpStdioConfig, TeamMcpStdioServerSpec};
pub use prompts::{build_lead_prompt, build_teammate_prompt, build_wake_payload};
pub use routes::{TeamRouterState, team_routes};
pub use scheduler::{
SchedulerAction, TeammateManager, WAKE_TIMEOUT_MS, WakePayload, format_crash_testament, normalize_name,
};
pub use service::TeamSessionService;
pub use session::{TeamSession, WakeInput};
pub use task_board::{TaskBoard, TaskUpdate};
pub use types::{
MailboxMessage, MailboxMessageType, TaskStatus, Team, TeamAgent, TeamTask, TeammateRole, TeammateStatus,
};
@@ -0,0 +1,244 @@
use std::sync::Arc;
use nomifun_common::now_ms;
use nomifun_db::ITeamRepository;
use nomifun_db::models::MailboxMessageRow;
use tracing::debug;
use crate::error::TeamError;
use crate::types::{MailboxMessage, MailboxMessageType};
pub struct Mailbox {
repo: Arc<dyn ITeamRepository>,
}
impl Mailbox {
pub fn new(repo: Arc<dyn ITeamRepository>) -> Self {
Self { repo }
}
pub async fn write(
&self,
team_id: &str,
to_agent_id: &str,
from_agent_id: &str,
msg_type: MailboxMessageType,
content: &str,
summary: Option<&str>,
) -> Result<MailboxMessage, TeamError> {
self.write_with_files(team_id, to_agent_id, from_agent_id, msg_type, content, summary, None)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn write_with_files(
&self,
team_id: &str,
to_agent_id: &str,
from_agent_id: &str,
msg_type: MailboxMessageType,
content: &str,
summary: Option<&str>,
files: Option<&[String]>,
) -> Result<MailboxMessage, TeamError> {
let files_json = files
.filter(|f| !f.is_empty())
.map(|f| serde_json::to_string(f).unwrap_or_default());
let mut row = MailboxMessageRow {
// Ignored on insert: the `mailbox.id` column is
// `INTEGER PRIMARY KEY AUTOINCREMENT`; `write_message` returns the
// assigned id which we patch back in below.
id: 0,
team_id: team_id.to_owned(),
to_agent_id: to_agent_id.to_owned(),
from_agent_id: from_agent_id.to_owned(),
msg_type: msg_type.to_string(),
content: content.to_owned(),
summary: summary.map(str::to_owned),
files: files_json,
read: false,
created_at: now_ms(),
};
let id = self.repo.write_message(&row).await?;
row.id = id;
debug!(
team_id,
to = to_agent_id,
from = from_agent_id,
msg_type = %msg_type,
"mailbox message written"
);
MailboxMessage::from_row(&row)
.ok_or_else(|| TeamError::InvalidRequest(format!("invalid message type: {msg_type}")))
}
pub async fn read_unread(&self, team_id: &str, agent_id: &str) -> Result<Vec<MailboxMessage>, TeamError> {
let rows = self.repo.read_unread_and_mark(team_id, agent_id).await?;
debug!(team_id, agent_id, count = rows.len(), "mailbox unread messages read");
let messages = rows.iter().filter_map(MailboxMessage::from_row).collect();
Ok(messages)
}
/// Reads all unread messages without marking them as read.
/// Used by the drain_mailbox pattern: peek → prompt → mark_read on success.
pub async fn peek_unread(&self, team_id: &str, agent_id: &str) -> Result<Vec<MailboxMessage>, TeamError> {
let rows = self.repo.peek_unread(team_id, agent_id).await?;
debug!(team_id, agent_id, count = rows.len(), "mailbox peek_unread");
let messages = rows.iter().filter_map(MailboxMessage::from_row).collect();
Ok(messages)
}
/// Marks the given message IDs as read. Called after successful prompt delivery.
pub async fn mark_read_batch(&self, ids: &[i64]) -> Result<(), TeamError> {
self.repo.mark_read_batch(ids).await?;
Ok(())
}
pub async fn get_history(
&self,
team_id: &str,
agent_id: &str,
limit: Option<i64>,
) -> Result<Vec<MailboxMessage>, TeamError> {
let rows = self.repo.get_history(team_id, agent_id, limit).await?;
let messages = rows.iter().filter_map(MailboxMessage::from_row).collect();
Ok(messages)
}
pub async fn has_unread(&self, team_id: &str, agent_id: &str) -> Result<bool, TeamError> {
let rows = self.repo.get_history(team_id, agent_id, None).await?;
Ok(rows.iter().any(|r| !r.read))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::MockTeamRepo;
// -- Tests ----------------------------------------------------------------
#[tokio::test]
async fn write_and_read_unread() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Mailbox::new(repo);
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, "hi", None)
.await
.unwrap();
mailbox
.write("t1", "a1", "a2", MailboxMessageType::Message, "hello", None)
.await
.unwrap();
let unread = mailbox.read_unread("t1", "a1").await.unwrap();
assert_eq!(unread.len(), 2);
assert_eq!(unread[0].content, "hi");
assert_eq!(unread[1].content, "hello");
let unread_again = mailbox.read_unread("t1", "a1").await.unwrap();
assert!(unread_again.is_empty());
}
#[tokio::test]
async fn write_idle_notification_with_summary() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Mailbox::new(repo);
let msg = mailbox
.write(
"t1",
"lead",
"a1",
MailboxMessageType::IdleNotification,
"done",
Some("Task complete"),
)
.await
.unwrap();
assert_eq!(msg.msg_type, MailboxMessageType::IdleNotification);
assert_eq!(msg.summary.as_deref(), Some("Task complete"));
}
#[tokio::test]
async fn get_history_includes_read_messages() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Mailbox::new(repo);
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, "m1", None)
.await
.unwrap();
mailbox
.write("t1", "a1", "user", MailboxMessageType::Message, "m2", None)
.await
.unwrap();
mailbox.read_unread("t1", "a1").await.unwrap();
let history = mailbox.get_history("t1", "a1", None).await.unwrap();
assert_eq!(history.len(), 2);
}
#[tokio::test]
async fn get_history_with_limit() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Mailbox::new(repo);
for i in 0..5 {
mailbox
.write(
"t1",
"a1",
"user",
MailboxMessageType::Message,
&format!("msg-{i}"),
None,
)
.await
.unwrap();
}
let history = mailbox.get_history("t1", "a1", Some(3)).await.unwrap();
assert_eq!(history.len(), 3);
}
#[tokio::test]
async fn read_unread_empty_when_no_messages() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Mailbox::new(repo);
let unread = mailbox.read_unread("t1", "a1").await.unwrap();
assert!(unread.is_empty());
}
#[tokio::test]
async fn read_unread_scoped_to_agent() {
let repo = Arc::new(MockTeamRepo::new());
let mailbox = Mailbox::new(repo);
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 unread_a1 = mailbox.read_unread("t1", "a1").await.unwrap();
assert_eq!(unread_a1.len(), 1);
assert_eq!(unread_a1[0].content, "for-a1");
let unread_a2 = mailbox.read_unread("t1", "a2").await.unwrap();
assert_eq!(unread_a2.len(), 1);
assert_eq!(unread_a2[0].content, "for-a2");
}
}
@@ -0,0 +1,145 @@
//! Stdio bridge descriptors consumed by ACP `session/new.mcp_servers`.
//!
//! Flow: `TeamSessionService::ensure_session` builds a `TeamMcpStdioServerSpec`
//! per agent and writes its config triple into `conversation.extra`. When
//! the ACP session is created, the spec is converted via `into_sdk()` to the
//! wire-level `agent_client_protocol::schema::McpServer::Stdio` variant and
//! sent to the agent CLI, which then spawns `<backend> mcp-bridge` with the
//! three `TEAM_MCP_*` env keys so it can proxy stdio↔TCP to the in-process
//! team MCP server.
use std::path::PathBuf;
use agent_client_protocol::schema::{EnvVariable, McpServer, McpServerStdio};
pub use nomifun_api_types::{TEAM_MCP_SERVER_NAME, TeamMcpStdioConfig};
/// Stdio MCP server description ready to be handed to `session/new`.
///
/// Field shapes:
/// - `name` = `"nomifun-team"` (fixed; team routing is done via `port` + `token`,
/// not via the server name — see `TeamMcpServer` per-team TCP listener)
/// - `command` = absolute path to the backend binary (resolved via
/// `std::env::current_exe()` at app startup)
/// - `args` = `["mcp-bridge"]`
/// - `env` = three pairs built from `TeamMcpStdioConfig::ENV_*` constants
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TeamMcpStdioServerSpec {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
}
impl TeamMcpStdioServerSpec {
/// Build the spec from the persisted stdio config plus runtime context.
///
/// `backend_binary_path` is the absolute path to the `nomicore`
/// executable (phase1 single-binary constraint — no standalone bridge).
pub fn from_config(backend_binary_path: &str, cfg: &TeamMcpStdioConfig) -> Self {
// `cfg.team_id` is intentionally not embedded in the server name — see
// `TEAM_MCP_SERVER_NAME` doc comment. It is still kept on the persisted
// config for diagnostics and future consumers.
Self {
name: TEAM_MCP_SERVER_NAME.to_owned(),
command: backend_binary_path.to_owned(),
args: vec!["mcp-bridge".to_owned()],
env: vec![
(TeamMcpStdioConfig::ENV_PORT.to_owned(), cfg.port.to_string()),
(TeamMcpStdioConfig::ENV_TOKEN.to_owned(), cfg.token.clone()),
(TeamMcpStdioConfig::ENV_SLOT_ID.to_owned(), cfg.slot_id.clone()),
],
}
}
/// Convert into the ACP SDK wire type expected by `NewSessionRequest::mcp_servers`.
pub fn into_sdk(self) -> McpServer {
// Both `McpServerStdio` and `EnvVariable` are `#[non_exhaustive]` in the
// SDK, so construction goes through the `new(..)` / builder entry points.
let env: Vec<EnvVariable> = self
.env
.into_iter()
.map(|(name, value)| EnvVariable::new(name, value))
.collect();
let stdio = McpServerStdio::new(self.name, PathBuf::from(self.command))
.args(self.args)
.env(env);
McpServer::Stdio(stdio)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_cfg() -> TeamMcpStdioConfig {
TeamMcpStdioConfig {
team_id: "team-42".into(),
port: 12345,
token: "tok-abc".into(),
slot_id: "slot-1".into(),
binary_path: "/usr/bin/nomicore".into(),
}
}
#[test]
fn from_config_fills_all_fields() {
let spec = TeamMcpStdioServerSpec::from_config("/usr/bin/nomicore", &sample_cfg());
assert_eq!(spec.name, TEAM_MCP_SERVER_NAME);
assert_eq!(spec.command, "/usr/bin/nomicore");
assert_eq!(spec.args, vec!["mcp-bridge".to_owned()]);
assert_eq!(spec.env.len(), 3);
}
#[test]
fn env_keys_match_api_type_constants() {
let spec = TeamMcpStdioServerSpec::from_config("/p", &sample_cfg());
let kv: std::collections::HashMap<_, _> = spec.env.iter().cloned().collect();
assert_eq!(kv.get(TeamMcpStdioConfig::ENV_PORT).map(String::as_str), Some("12345"));
assert_eq!(
kv.get(TeamMcpStdioConfig::ENV_TOKEN).map(String::as_str),
Some("tok-abc")
);
assert_eq!(
kv.get(TeamMcpStdioConfig::ENV_SLOT_ID).map(String::as_str),
Some("slot-1")
);
}
#[test]
fn into_sdk_serializes_as_stdio_variant() {
let spec = TeamMcpStdioServerSpec::from_config("/bin/nomicore", &sample_cfg());
let sdk = spec.into_sdk();
let json = serde_json::to_value(&sdk).expect("serialize");
// `Stdio` variant is `#[serde(untagged)]` inside `McpServer`, so the
// JSON is the raw `McpServerStdio` shape — no `"type":"stdio"` tag.
assert_eq!(json["name"], TEAM_MCP_SERVER_NAME);
assert_eq!(json["command"], "/bin/nomicore");
assert_eq!(json["args"], serde_json::json!(["mcp-bridge"]));
let env = json["env"].as_array().expect("env array");
assert_eq!(env.len(), 3);
let pairs: std::collections::HashMap<_, _> = env
.iter()
.map(|v| {
(
v["name"].as_str().unwrap().to_owned(),
v["value"].as_str().unwrap().to_owned(),
)
})
.collect();
assert_eq!(pairs[TeamMcpStdioConfig::ENV_PORT], "12345");
assert_eq!(pairs[TeamMcpStdioConfig::ENV_TOKEN], "tok-abc");
assert_eq!(pairs[TeamMcpStdioConfig::ENV_SLOT_ID], "slot-1");
// The untagged variant must still round-trip back into the enum.
let back: McpServer = serde_json::from_value(json).expect("roundtrip");
assert!(matches!(back, McpServer::Stdio(_)));
}
}
@@ -0,0 +1,8 @@
pub mod bridge;
pub mod protocol;
pub mod server;
pub mod tools;
pub use bridge::{TeamMcpStdioConfig, TeamMcpStdioServerSpec};
pub use nomifun_api_types::TEAM_MCP_SERVER_NAME;
pub use server::TeamMcpServer;
@@ -0,0 +1,303 @@
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// ---------------------------------------------------------------------------
// JSON-RPC 2.0 message types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
pub jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
pub id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
// ---------------------------------------------------------------------------
// Standard JSON-RPC error codes
// ---------------------------------------------------------------------------
pub const PARSE_ERROR: i64 = -32700;
pub const INVALID_REQUEST: i64 = -32600;
pub const METHOD_NOT_FOUND: i64 = -32601;
pub const INVALID_PARAMS: i64 = -32602;
pub const INTERNAL_ERROR: i64 = -32603;
// ---------------------------------------------------------------------------
// MCP protocol constants
// ---------------------------------------------------------------------------
pub const PROTOCOL_VERSION: &str = "2024-11-05";
pub const SERVER_NAME: &str = "nomifun-team-mcp";
pub const SERVER_VERSION: &str = "1.0.0";
// ---------------------------------------------------------------------------
// Response builders
// ---------------------------------------------------------------------------
impl JsonRpcResponse {
pub fn success(id: Option<u64>, result: serde_json::Value) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: Some(result),
error: None,
}
}
pub fn error(id: Option<u64>, code: i64, message: impl Into<String>) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(JsonRpcError {
code,
message: message.into(),
data: None,
}),
}
}
}
// ---------------------------------------------------------------------------
// TCP framing: 4-byte big-endian length prefix + JSON payload
// ---------------------------------------------------------------------------
pub async fn read_frame<R: AsyncReadExt + Unpin>(reader: &mut R) -> std::io::Result<Vec<u8>> {
let len = reader.read_u32().await? as usize;
if len > 10 * 1024 * 1024 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"frame too large (>10MB)",
));
}
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf).await?;
Ok(buf)
}
pub async fn write_frame<W: AsyncWriteExt + Unpin>(writer: &mut W, data: &[u8]) -> std::io::Result<()> {
let len = data.len() as u32;
writer.write_u32(len).await?;
writer.write_all(data).await?;
writer.flush().await
}
pub async fn read_request<R: AsyncReadExt + Unpin>(reader: &mut R) -> std::io::Result<JsonRpcRequest> {
let frame = read_frame(reader).await?;
serde_json::from_slice(&frame).map_err(std::io::Error::other)
}
pub async fn write_response<W: AsyncWriteExt + Unpin>(
writer: &mut W,
response: &JsonRpcResponse,
) -> std::io::Result<()> {
let data = serde_json::to_vec(response).map_err(std::io::Error::other)?;
write_frame(writer, &data).await
}
// ---------------------------------------------------------------------------
// MCP ready notification (W4-D24a)
//
// Bridge 在 TCP connect + initialize 成功后 fire-and-forget 发送一帧通知给
// TeamMcpServer,声明对应 slot 已就绪。扁平结构(非 JSON-RPC),格式:
// { "type": "mcp_ready", "slot_id": "...", "auth_token": "..." }
// 事实来源:docs/teams/phase1/nomifun-audit.md §3.1 "MCP ready 握手"
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpReadyNotification {
pub r#type: String,
pub slot_id: String,
pub auth_token: String,
}
impl McpReadyNotification {
pub const TYPE: &'static str = "mcp_ready";
pub fn new(slot_id: impl Into<String>, auth_token: impl Into<String>) -> Self {
Self {
r#type: Self::TYPE.to_string(),
slot_id: slot_id.into(),
auth_token: auth_token.into(),
}
}
pub fn is_mcp_ready(json: &serde_json::Value) -> bool {
json.get("type").and_then(|v| v.as_str()) == Some(Self::TYPE)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_response_serialization() {
let resp = JsonRpcResponse::success(Some(1), serde_json::json!({"ok": true}));
assert_eq!(resp.jsonrpc, "2.0");
assert_eq!(resp.id, Some(1));
assert!(resp.error.is_none());
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["result"]["ok"], true);
assert!(json.get("error").is_none());
}
#[test]
fn error_response_serialization() {
let resp = JsonRpcResponse::error(Some(2), METHOD_NOT_FOUND, "not found");
assert_eq!(resp.id, Some(2));
assert!(resp.result.is_none());
let err = resp.error.as_ref().unwrap();
assert_eq!(err.code, METHOD_NOT_FOUND);
assert_eq!(err.message, "not found");
}
#[test]
fn error_response_null_id() {
let resp = JsonRpcResponse::error(None, PARSE_ERROR, "parse error");
assert!(resp.id.is_none());
}
#[test]
fn request_deserialization() {
let json = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
let req: JsonRpcRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.method, "tools/list");
assert_eq!(req.id, Some(1));
assert!(req.params.is_none());
}
#[test]
fn request_with_params() {
let json =
r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"test","arguments":{"key":"val"}}}"#;
let req: JsonRpcRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.method, "tools/call");
let params = req.params.unwrap();
assert_eq!(params["name"], "test");
}
#[test]
fn notification_without_id() {
let json = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
let req: JsonRpcRequest = serde_json::from_str(json).unwrap();
assert!(req.id.is_none());
assert_eq!(req.method, "notifications/initialized");
}
#[tokio::test]
async fn frame_roundtrip() {
let payload = b"hello world";
let mut buf = Vec::new();
write_frame(&mut buf, payload).await.unwrap();
assert_eq!(buf.len(), 4 + payload.len());
let len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
assert_eq!(len as usize, payload.len());
let mut cursor = std::io::Cursor::new(buf);
let read_back = read_frame(&mut cursor).await.unwrap();
assert_eq!(read_back, payload);
}
#[tokio::test]
async fn request_response_roundtrip() {
let req = JsonRpcRequest {
jsonrpc: "2.0".into(),
id: Some(1),
method: "tools/list".into(),
params: None,
};
let mut buf = Vec::new();
let data = serde_json::to_vec(&req).unwrap();
write_frame(&mut buf, &data).await.unwrap();
let mut cursor = std::io::Cursor::new(buf);
let parsed = read_request(&mut cursor).await.unwrap();
assert_eq!(parsed.method, "tools/list");
assert_eq!(parsed.id, Some(1));
}
#[tokio::test]
async fn oversized_frame_rejected() {
let fake_len: u32 = 11 * 1024 * 1024;
let mut buf = Vec::new();
buf.extend_from_slice(&fake_len.to_be_bytes());
buf.extend_from_slice(&[0u8; 64]);
let mut cursor = std::io::Cursor::new(buf);
let result = read_frame(&mut cursor).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn empty_frame() {
let mut buf = Vec::new();
write_frame(&mut buf, &[]).await.unwrap();
let mut cursor = std::io::Cursor::new(buf);
let read_back = read_frame(&mut cursor).await.unwrap();
assert!(read_back.is_empty());
}
#[test]
fn mcp_ready_notification_roundtrip() {
let original = McpReadyNotification::new("slot-123", "token-abc");
let json = serde_json::to_string(&original).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["type"], "mcp_ready");
assert_eq!(value["slot_id"], "slot-123");
assert_eq!(value["auth_token"], "token-abc");
let parsed: McpReadyNotification = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.r#type, "mcp_ready");
assert_eq!(parsed.slot_id, "slot-123");
assert_eq!(parsed.auth_token, "token-abc");
}
#[test]
fn mcp_ready_notification_is_mcp_ready() {
let positive = serde_json::json!({
"type": "mcp_ready",
"slot_id": "s",
"auth_token": "t"
});
assert!(McpReadyNotification::is_mcp_ready(&positive));
let wrong_type = serde_json::json!({ "type": "other" });
assert!(!McpReadyNotification::is_mcp_ready(&wrong_type));
let missing_type = serde_json::json!({ "slot_id": "s" });
assert!(!McpReadyNotification::is_mcp_ready(&missing_type));
let non_object = serde_json::json!("mcp_ready");
assert!(!McpReadyNotification::is_mcp_ready(&non_object));
}
}
@@ -0,0 +1,940 @@
use std::net::SocketAddr;
use std::sync::{Arc, Weak};
use nomifun_api_types::{TeamMcpPhase, TeamMcpStatusPayload, WebSocketMessage};
use nomifun_realtime::EventBroadcaster;
use serde_json::{Value, json};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
use crate::error::TeamError;
use crate::scheduler::TeammateManager;
use crate::service::TeamSessionService;
use crate::session::SpawnAgentRequest;
use crate::types::{TeammateRole, TeammateStatus};
use super::protocol::{
INVALID_PARAMS, INVALID_REQUEST, JsonRpcResponse, METHOD_NOT_FOUND, PROTOCOL_VERSION, SERVER_NAME, SERVER_VERSION,
read_request, write_response,
};
use super::tools::{
RenameAgentInput, SendMessageInput, ShutdownAgentInput, SpawnAgentInput, TaskCreateInput, TaskUpdateInput,
all_tool_descriptors, handle_team_describe_assistant, handle_team_list_models,
};
// ---------------------------------------------------------------------------
// TeamMcpServer
// ---------------------------------------------------------------------------
pub struct TeamMcpServer {
addr: SocketAddr,
http_addr: SocketAddr,
auth_token: String,
shutdown_tx: watch::Sender<bool>,
}
impl TeamMcpServer {
pub async fn start(
auth_token: String,
scheduler: Arc<TeammateManager>,
team_id: String,
broadcaster: Arc<dyn EventBroadcaster>,
service: Weak<TeamSessionService>,
) -> Result<Self, TeamError> {
let listener = match TcpListener::bind("127.0.0.1:0").await {
Ok(l) => l,
Err(e) => {
broadcast_mcp_status(
broadcaster.as_ref(),
TeamMcpStatusPayload {
team_id: team_id.clone(),
slot_id: String::new(),
phase: TeamMcpPhase::TcpError,
port: None,
server_count: None,
error: Some(e.to_string()),
},
);
return Err(TeamError::InvalidRequest(format!("Failed to bind TCP: {e}")));
}
};
let addr = listener
.local_addr()
.map_err(|e| TeamError::InvalidRequest(format!("Failed to get local addr: {e}")))?;
broadcast_mcp_status(
broadcaster.as_ref(),
TeamMcpStatusPayload {
team_id: team_id.clone(),
slot_id: String::new(),
phase: TeamMcpPhase::TcpReady,
port: Some(addr.port()),
server_count: None,
error: None,
},
);
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let token = auth_token.clone();
let sched_for_tcp = scheduler.clone();
let service_for_tcp = service.clone();
let team_id_for_tcp = team_id.clone();
tokio::spawn(accept_loop(
listener,
token,
sched_for_tcp,
service_for_tcp,
team_id_for_tcp,
shutdown_rx.clone(),
));
// HTTP MCP endpoint for agents that prefer http transport.
let http_listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| TeamError::InvalidRequest(format!("Failed to bind HTTP: {e}")))?;
let http_addr = http_listener
.local_addr()
.map_err(|e| TeamError::InvalidRequest(format!("Failed to get HTTP addr: {e}")))?;
let http_token = auth_token.clone();
let http_sched = scheduler.clone();
let http_service = service.clone();
let http_team_id = team_id.clone();
tokio::spawn(http_mcp_loop(
http_listener,
http_token,
http_sched,
http_service,
http_team_id,
shutdown_rx,
));
debug!(
tcp_port = addr.port(),
http_port = http_addr.port(),
"Team MCP Server started"
);
Ok(Self {
addr,
http_addr,
auth_token,
shutdown_tx,
})
}
pub fn port(&self) -> u16 {
self.addr.port()
}
pub fn http_port(&self) -> u16 {
self.http_addr.port()
}
pub fn auth_token(&self) -> &str {
&self.auth_token
}
pub fn stop(&self) {
let _ = self.shutdown_tx.send(true);
debug!(port = self.addr.port(), "Team MCP Server stop requested");
}
}
impl Drop for TeamMcpServer {
fn drop(&mut self) {
let _ = self.shutdown_tx.send(true);
}
}
fn broadcast_mcp_status(broadcaster: &dyn EventBroadcaster, payload: TeamMcpStatusPayload) {
let event = WebSocketMessage::new(
"team.mcpStatus",
serde_json::to_value(payload).expect("serialize mcp status payload"),
);
broadcaster.broadcast(event);
}
// ---------------------------------------------------------------------------
// Accept loop
// ---------------------------------------------------------------------------
async fn accept_loop(
listener: TcpListener,
auth_token: String,
scheduler: Arc<TeammateManager>,
service: Weak<TeamSessionService>,
team_id: String,
mut shutdown_rx: watch::Receiver<bool>,
) {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, peer)) => {
debug!(?peer, "New MCP connection");
let token = auth_token.clone();
let sched = Arc::clone(&scheduler);
let svc = service.clone();
let tid = team_id.clone();
tokio::spawn(handle_connection(stream, token, sched, svc, tid));
}
Err(e) => {
error!("Accept error: {e}");
}
}
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
debug!("MCP server shutting down");
break;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Connection handler
// ---------------------------------------------------------------------------
async fn handle_connection(
stream: TcpStream,
auth_token: String,
scheduler: Arc<TeammateManager>,
service: Weak<TeamSessionService>,
team_id: String,
) {
let (mut reader, mut writer) = tokio::io::split(stream);
let mut authenticated = false;
let mut caller_slot_id: Option<String> = None;
loop {
let request = match read_request(&mut reader).await {
Ok(req) => req,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(e) => {
warn!("Read error: {e}");
break;
}
};
if request.id.is_none() {
continue;
}
let response = if !authenticated {
match handle_initialize(&request, &auth_token) {
InitResult::Authenticated(slot_id, resp) => {
info!(team_id = %team_id, slot_id = %slot_id, "MCP agent authenticated");
authenticated = true;
caller_slot_id = Some(slot_id);
resp
}
InitResult::Response(resp) => {
warn!(team_id = %team_id, method = %request.method, "MCP auth rejected");
resp
}
}
} else {
handle_method(
&request,
&scheduler,
&service,
&team_id,
caller_slot_id.as_deref().unwrap_or("unknown"),
)
.await
};
if write_response(&mut writer, &response).await.is_err() {
warn!(team_id = %team_id, "MCP connection write failed, closing");
break;
}
}
}
// ---------------------------------------------------------------------------
// Initialize / handshake
// ---------------------------------------------------------------------------
enum InitResult {
Authenticated(String, JsonRpcResponse),
Response(JsonRpcResponse),
}
fn handle_initialize(request: &super::protocol::JsonRpcRequest, auth_token: &str) -> InitResult {
if request.method != "initialize" {
return InitResult::Response(JsonRpcResponse::error(
request.id,
INVALID_REQUEST,
"Expected 'initialize' as first request",
));
}
let params = request.params.as_ref();
let token = params
.and_then(|p| p.get("auth_token"))
.or_else(|| params.and_then(|p| p.get("authToken")))
.and_then(|v| v.as_str())
.unwrap_or("");
if token != auth_token {
return InitResult::Response(JsonRpcResponse::error(
request.id,
INVALID_REQUEST,
"Authentication failed: invalid auth_token",
));
}
let slot_id = params
.and_then(|p| p.get("slot_id"))
.or_else(|| params.and_then(|p| p.get("slotId")))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_owned();
let resp = JsonRpcResponse::success(
request.id,
json!({
"protocolVersion": PROTOCOL_VERSION,
"serverInfo": {
"name": SERVER_NAME,
"version": SERVER_VERSION
},
"capabilities": {
"tools": {}
}
}),
);
InitResult::Authenticated(slot_id, resp)
}
// ---------------------------------------------------------------------------
// Method router
// ---------------------------------------------------------------------------
async fn handle_method(
request: &super::protocol::JsonRpcRequest,
scheduler: &TeammateManager,
service: &Weak<TeamSessionService>,
team_id: &str,
caller_slot_id: &str,
) -> JsonRpcResponse {
match request.method.as_str() {
"notifications/initialized" => JsonRpcResponse::success(request.id, json!({})),
"tools/list" => handle_tools_list(request.id),
"tools/call" => handle_tools_call(request, scheduler, service, team_id, caller_slot_id).await,
_ => JsonRpcResponse::error(
request.id,
METHOD_NOT_FOUND,
format!("Unknown method: {}", request.method),
),
}
}
fn handle_tools_list(id: Option<u64>) -> JsonRpcResponse {
let tools = all_tool_descriptors();
JsonRpcResponse::success(id, json!({ "tools": tools }))
}
// ---------------------------------------------------------------------------
// tools/call dispatcher
// ---------------------------------------------------------------------------
async fn handle_tools_call(
request: &super::protocol::JsonRpcRequest,
scheduler: &TeammateManager,
service: &Weak<TeamSessionService>,
team_id: &str,
caller_slot_id: &str,
) -> JsonRpcResponse {
let params = match request.params.as_ref() {
Some(p) => p,
None => {
return JsonRpcResponse::error(request.id, INVALID_PARAMS, "Missing params for tools/call");
}
};
let tool_name = match params.get("name").and_then(|v| v.as_str()) {
Some(n) => n,
None => {
return JsonRpcResponse::error(request.id, INVALID_PARAMS, "Missing 'name' in tools/call params");
}
};
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
let caller_role = match scheduler.get_agent(caller_slot_id).await {
Ok(agent) => agent.role,
Err(_) => TeammateRole::Teammate,
};
info!(
team_id = %team_id,
caller = %caller_slot_id,
tool = %tool_name,
"MCP tools/call invoked"
);
let result = dispatch_tool(
tool_name,
&arguments,
scheduler,
service,
team_id,
caller_slot_id,
caller_role,
)
.await;
match &result {
Ok(_) => info!(team_id = %team_id, tool = %tool_name, caller = %caller_slot_id, "MCP tool call succeeded"),
Err(e) => {
warn!(team_id = %team_id, tool = %tool_name, caller = %caller_slot_id, error = %e, "MCP tool call failed")
}
}
match result {
Ok(content) => JsonRpcResponse::success(
request.id,
json!({
"content": [{ "type": "text", "text": content }]
}),
),
Err(err_msg) => JsonRpcResponse::success(
request.id,
json!({
"content": [{ "type": "text", "text": err_msg }],
"isError": true
}),
),
}
}
// ---------------------------------------------------------------------------
// Tool dispatch
// ---------------------------------------------------------------------------
pub(crate) async fn dispatch_tool(
tool_name: &str,
arguments: &Value,
scheduler: &TeammateManager,
service: &Weak<TeamSessionService>,
team_id: &str,
caller_slot_id: &str,
caller_role: TeammateRole,
) -> Result<String, String> {
match tool_name {
"team_send_message" => exec_send_message(arguments, scheduler, service, team_id, caller_slot_id).await,
"team_spawn_agent" => exec_spawn_agent(arguments, service, team_id, caller_slot_id, caller_role).await,
"team_task_create" => exec_task_create(arguments, scheduler).await,
"team_task_update" => exec_task_update(arguments, scheduler).await,
"team_task_list" => exec_task_list(scheduler).await,
"team_members" => exec_members(scheduler).await,
"team_rename_agent" => exec_rename_agent(arguments, scheduler, service, team_id).await,
"team_shutdown_agent" => {
exec_shutdown_agent(arguments, scheduler, service, team_id, caller_slot_id, caller_role).await
}
"team_list_models" => exec_list_models(arguments, service).await,
"team_describe_assistant" => exec_describe_assistant(arguments).await,
_ => Err(format!("Unknown tool: {tool_name}")),
}
}
async fn exec_list_models(args: &Value, service: &Weak<TeamSessionService>) -> Result<String, String> {
let agent_type_filter = args.get("agent_type").and_then(Value::as_str);
let value = match service.upgrade() {
Some(svc) => svc.list_models_from_db(agent_type_filter).await,
None => handle_team_list_models(args),
};
serde_json::to_string_pretty(&value).map_err(|e| format!("Serialization error: {e}"))
}
async fn exec_describe_assistant(args: &Value) -> Result<String, String> {
Ok(handle_team_describe_assistant(args))
}
// ---------------------------------------------------------------------------
// Individual tool handlers
// ---------------------------------------------------------------------------
async fn resolve_agent_target(scheduler: &TeammateManager, target: &str) -> Result<String, String> {
let agents = scheduler.list_agents().await;
if agents.iter().any(|a| a.slot_id == target) {
return Ok(target.to_owned());
}
let query = target.to_lowercase();
let hits: Vec<_> = agents.iter().filter(|a| a.name.to_lowercase() == query).collect();
match hits.len() {
0 => Err(format!("No agent matches '{target}'")),
1 => Ok(hits[0].slot_id.clone()),
_ => Err(format!("Multiple agents match '{target}'")),
}
}
async fn exec_send_message(
args: &Value,
scheduler: &TeammateManager,
service: &Weak<TeamSessionService>,
team_id: &str,
caller_slot_id: &str,
) -> Result<String, String> {
let input: SendMessageInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?;
let trimmed = input.message.trim();
if trimmed == "shutdown_approved" {
debug!(from = caller_slot_id, "shutdown_approved intercepted");
scheduler.notify_shutdown_acknowledged(caller_slot_id);
// Deferred cleanup: kill process, delete conversation, remove from team DB.
// Spawned so the MCP response can be sent back before the process is killed.
let slot = caller_slot_id.to_owned();
let tid = team_id.to_owned();
let svc_weak = service.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if let Some(svc) = svc_weak.upgrade() {
let user_id = svc.get_session_user_id(&tid).await.unwrap_or_default();
if let Err(e) = svc.remove_agent(&user_id, &tid, &slot).await {
warn!(slot_id = %slot, error = %e, "shutdown cleanup failed");
} else {
info!(slot_id = %slot, "agent fully removed after shutdown_approved");
}
}
});
return Ok(json!({"status": "shutdown_approved_received"}).to_string());
}
if let Some(rest) = trimmed.strip_prefix("shutdown_rejected:") {
let reason = rest.trim();
scheduler
.notify_shutdown_rejected(caller_slot_id, reason)
.await
.map_err(|e| e.to_string())?;
debug!(from = caller_slot_id, reason, "shutdown_rejected handled");
return Ok(format!("shutdown_rejected: {reason}"));
}
let resolved_to = if input.to == "*" {
"*".to_owned()
} else {
resolve_agent_target(scheduler, &input.to).await?
};
let action = crate::scheduler::SchedulerAction::SendMessage {
to: resolved_to.clone(),
message: input.message,
};
scheduler
.execute_action(caller_slot_id, &action)
.await
.map_err(|e| e.to_string())?;
// Always notify target agent(s). If the event loop is in the drain
// loop (working), the notify permit will be consumed on next iteration.
// If idle/waiting, it wakes immediately.
if let Some(svc) = service.upgrade() {
let targets = if resolved_to == "*" {
scheduler
.list_agents()
.await
.iter()
.filter(|a| a.slot_id != caller_slot_id)
.map(|a| a.slot_id.clone())
.collect::<Vec<_>>()
} else {
vec![resolved_to.clone()]
};
for target in &targets {
if let Err(e) = svc.wake_agent_in_session(team_id, target).await {
debug!(team_id, target = target.as_str(), error = %e, "wake after send_message failed (non-fatal)");
}
}
}
Ok(format!("Message sent to {}", input.to))
}
async fn exec_spawn_agent(
args: &Value,
service: &Weak<TeamSessionService>,
team_id: &str,
caller_slot_id: &str,
caller_role: TeammateRole,
) -> Result<String, String> {
// Lead-only at the MCP dispatch layer. `TeamSession::spawn_agent` also
// re-checks via `TeamError::LeaderOnly`, but the dispatch-level string
// keeps the user-visible "Only Lead ..." phrasing that the MCP client
// (and existing protocol tests) expect.
if caller_role != TeammateRole::Lead {
return Err("Only Lead can spawn agents".into());
}
let input: SpawnAgentInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?;
// Requested name — normalization / emptiness / uniqueness live in
// `TeamSession::spawn_agent` so we do not double-validate here.
let requested_name = input.name.clone();
// `agent_type` is the Nomi-spec field name; `backend` is the legacy
// phase-1 alias. Either (or neither — session then inherits from the
// caller) is accepted.
let agent_type = input.agent_type.or(input.backend);
// Dynamic capability check happens in `TeamSession::spawn_agent` which
// queries both the hard whitelist and persisted MCP capabilities.
let req = SpawnAgentRequest {
name: requested_name.clone(),
agent_type,
custom_agent_id: input.custom_agent_id,
model: input.model,
};
let service = service
.upgrade()
.ok_or_else(|| "Team service not available; cannot spawn agent".to_string())?;
service
.spawn_agent_in_session(team_id, caller_slot_id, req)
.await
.map(|agent| format!("Agent '{}' spawned (slot_id={})", agent.name, agent.slot_id))
.map_err(|e| e.to_string())
}
async fn exec_task_create(args: &Value, scheduler: &TeammateManager) -> Result<String, String> {
let input: TaskCreateInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?;
let action = crate::scheduler::SchedulerAction::TaskCreate {
subject: input.subject.clone(),
description: input.description,
owner: input.owner,
blocked_by: input.blocked_by.unwrap_or_default(),
};
scheduler
.execute_action("system", &action)
.await
.map_err(|e| e.to_string())?;
Ok(format!("Task '{}' created", input.subject))
}
async fn exec_task_update(args: &Value, scheduler: &TeammateManager) -> Result<String, String> {
let input: TaskUpdateInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?;
let action = crate::scheduler::SchedulerAction::TaskUpdate {
task_id: input.task_id.clone(),
status: input.status,
description: input.description,
owner: input.owner,
blocked_by: input.blocked_by,
};
scheduler
.execute_action("system", &action)
.await
.map_err(|e| e.to_string())?;
Ok(format!("Task '{}' updated", input.task_id))
}
async fn exec_task_list(scheduler: &TeammateManager) -> Result<String, String> {
let tasks = scheduler.list_tasks().await.map_err(|e| e.to_string())?;
let output: Vec<Value> = tasks
.iter()
.map(|t| {
json!({
"id": t.id,
"subject": t.subject,
"description": t.description,
"status": t.status,
"owner": t.owner,
"blocked_by": t.blocked_by,
"blocks": t.blocks,
})
})
.collect();
serde_json::to_string_pretty(&output).map_err(|e| format!("Serialization error: {e}"))
}
async fn exec_members(scheduler: &TeammateManager) -> Result<String, String> {
let agents = scheduler.list_agents().await;
let output: Vec<Value> = agents
.iter()
.map(|a| {
// `TeamAgent::status` is `None` for cold-start agents that have not
// yet transitioned through `set_status` (e.g. the lead before its
// first wake). The scheduler already tracks them as `Idle`
// internally (see `TeammateManager::new`), and Nomi's
// TeammateManager exposes `'idle'` as the initial value. Mirror
// that here so MCP clients never see `null` and misread a live
// teammate as offline.
let status = a.status.unwrap_or(TeammateStatus::Idle);
json!({
"slot_id": a.slot_id,
"name": a.name,
"role": a.role,
"status": status,
"backend": a.backend,
"model": a.model,
})
})
.collect();
serde_json::to_string_pretty(&output).map_err(|e| format!("Serialization error: {e}"))
}
async fn exec_rename_agent(
args: &Value,
scheduler: &TeammateManager,
service: &Weak<TeamSessionService>,
team_id: &str,
) -> Result<String, String> {
let input: RenameAgentInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?;
let resolved_slot = resolve_agent_target(scheduler, &input.slot_id).await?;
if let Some(svc) = service.upgrade() {
svc.rename_agent(team_id, &resolved_slot, &input.new_name)
.await
.map_err(|e| e.to_string())?;
} else {
scheduler
.rename_agent(&resolved_slot, &input.new_name)
.await
.map_err(|e| e.to_string())?;
}
Ok(format!("Agent '{}' renamed to '{}'", input.slot_id, input.new_name))
}
async fn exec_shutdown_agent(
args: &Value,
scheduler: &TeammateManager,
service: &Weak<TeamSessionService>,
team_id: &str,
caller_slot_id: &str,
caller_role: TeammateRole,
) -> Result<String, String> {
if caller_role != TeammateRole::Lead {
return Err("Only Lead can shut down agents".into());
}
let input: ShutdownAgentInput = serde_json::from_value(args.clone()).map_err(|e| format!("Invalid params: {e}"))?;
let target_slot_id = resolve_agent_target(scheduler, &input.slot_id).await?;
let action = crate::scheduler::SchedulerAction::ShutdownAgent {
slot_id: target_slot_id.clone(),
reason: input.reason,
};
scheduler
.execute_action(caller_slot_id, &action)
.await
.map_err(|e| e.to_string())?;
// Wake the target agent so it reads the shutdown_request from its mailbox.
if let Some(svc) = service.upgrade()
&& let Err(e) = svc.wake_agent_in_session(team_id, &target_slot_id).await
{
debug!(team_id, target = %target_slot_id, error = %e, "wake after shutdown_request failed (non-fatal)");
}
Ok(format!("Shutdown request sent to agent '{}'", target_slot_id))
}
// ---------------------------------------------------------------------------
// HTTP MCP endpoint (Streamable HTTP transport for MCP)
// ---------------------------------------------------------------------------
async fn http_mcp_loop(
listener: TcpListener,
auth_token: String,
scheduler: Arc<TeammateManager>,
service: Weak<TeamSessionService>,
team_id: String,
mut shutdown_rx: watch::Receiver<bool>,
) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
loop {
tokio::select! {
accept = listener.accept() => {
let Ok((mut stream, peer)) = accept else { continue };
info!(team_id = %team_id, ?peer, "HTTP MCP: new connection accepted");
let _token = auth_token.clone();
let sched = scheduler.clone();
let svc = service.clone();
let tid = team_id.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
let n = match stream.read(&mut buf).await {
Ok(n) if n > 0 => n,
_ => return,
};
let request = String::from_utf8_lossy(&buf[..n]);
// Extract JSON body (after \r\n\r\n)
let body = request.split("\r\n\r\n").nth(1).unwrap_or("");
let Ok(value): Result<Value, _> = serde_json::from_str(body) else {
let resp = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n";
let _ = stream.write_all(resp.as_bytes()).await;
return;
};
// Handle JSON-RPC request
let method = value.get("method").and_then(Value::as_str).unwrap_or("");
let id = value.get("id").cloned();
let result = match method {
"initialize" => {
json!({
"capabilities": { "tools": {} },
"protocolVersion": PROTOCOL_VERSION,
"serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION }
})
}
"notifications/initialized" => {
let resp = "HTTP/1.1 204 No Content\r\n\r\n";
let _ = stream.write_all(resp.as_bytes()).await;
return;
}
"tools/list" => {
let tools: Vec<Value> = all_tool_descriptors()
.iter()
.map(|d| json!({
"name": d.name,
"description": d.description,
"inputSchema": d.input_schema,
}))
.collect();
json!({ "tools": tools })
}
"tools/call" => {
let params = value.get("params").cloned().unwrap_or(json!({}));
let tool_name = params.get("name").and_then(Value::as_str).unwrap_or("");
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
let caller_slot_id = request.lines()
.find(|l| l.to_lowercase().starts_with("x-slot-id:"))
.and_then(|l| l.split_once(':').map(|(_, v)| v.trim()))
.unwrap_or("");
match dispatch_tool(
tool_name,
&arguments,
&sched,
&svc,
&tid,
caller_slot_id,
TeammateRole::Lead,
)
.await
{
Ok(text) => json!({ "content": [{"type": "text", "text": text}] }),
Err(text) => json!({ "content": [{"type": "text", "text": text}], "isError": true }),
}
}
_ => {
json!({"error": {"code": -32601, "message": "Method not found"}})
}
};
let response_body = if result.get("error").is_some() {
json!({"jsonrpc": "2.0", "id": id, "error": result["error"]})
} else {
json!({"jsonrpc": "2.0", "id": id, "result": result})
};
let body_bytes = serde_json::to_vec(&response_body).unwrap_or_default();
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
body_bytes.len()
);
let _ = stream.write_all(header.as_bytes()).await;
let _ = stream.write_all(&body_bytes).await;
});
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() { break; }
}
}
}
}
// ---------------------------------------------------------------------------
// Tests — exec_spawn_agent dispatch-layer unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Non-Lead callers are rejected at the dispatch layer with the
/// "Only Lead ..." phrasing. Service weak is never upgraded because
/// the early role gate short-circuits.
#[tokio::test]
async fn exec_spawn_agent_rejects_non_lead() {
let service: Weak<TeamSessionService> = Weak::new();
let args = json!({ "name": "Helper", "agent_type": "claude" });
let result = exec_spawn_agent(&args, &service, "team-1", "worker-1", TeammateRole::Teammate).await;
let err = result.expect_err("non-Lead caller must be rejected");
assert!(
err.contains("Only Lead"),
"error must keep legacy 'Only Lead' phrasing, got {err:?}"
);
}
/// Malformed JSON body is rejected before the service is consulted.
#[tokio::test]
async fn exec_spawn_agent_rejects_malformed_args() {
let service: Weak<TeamSessionService> = Weak::new();
// `name` missing entirely — SpawnAgentInput requires it.
let args = json!({ "agent_type": "claude" });
let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await;
let err = result.expect_err("malformed args must be rejected");
assert!(
err.contains("Invalid params"),
"must surface Invalid params for JSON deserialize failure, got {err:?}"
);
}
/// Lead caller with a well-formed request but no live service (Weak
/// cannot upgrade) surfaces the service-unavailable error rather than
/// silently returning a fake success. This is the path exercised in
/// tests where the MCP server is spun up without a real
/// `TeamSessionService` — in production the Weak always upgrades.
#[tokio::test]
async fn exec_spawn_agent_reports_service_unavailable_when_weak_dead() {
let service: Weak<TeamSessionService> = Weak::new();
let args = json!({
"name": "Helper",
"agent_type": "claude",
"model": "claude-sonnet-4"
});
let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await;
let err = result.expect_err("dead Weak<TeamSessionService> must not succeed");
assert!(
err.contains("Team service not available"),
"dead service weak must surface the unavailable message, got {err:?}"
);
}
/// The dispatch layer must accept both the new `agent_type` field and
/// the legacy `backend` alias so existing phase-1 callers (that still
/// send `backend`) do not regress.
#[tokio::test]
async fn exec_spawn_agent_accepts_legacy_backend_alias() {
let service: Weak<TeamSessionService> = Weak::new();
// Use `backend` (legacy) instead of `agent_type` — parsing must succeed
// and we must reach the service-upgrade step (and then fail because
// Weak::new cannot upgrade). If `backend` were rejected at parse time
// the error would be "Invalid params".
let args = json!({ "name": "Helper", "backend": "claude" });
let result = exec_spawn_agent(&args, &service, "team-1", "lead-1", TeammateRole::Lead).await;
let err = result.expect_err("dead Weak<TeamSessionService> must not succeed");
assert!(
err.contains("Team service not available"),
"legacy 'backend' alias must parse through to service-upgrade step, got {err:?}"
);
}
}
@@ -0,0 +1,964 @@
use nomifun_db::models::AgentMetadataRow;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::scheduler::SchedulerAction;
use crate::types::TeammateRole;
// ---------------------------------------------------------------------------
// Tool description constants (原样复用 Nomi `toolDescriptions.ts`)
// ---------------------------------------------------------------------------
/// `team_spawn_agent` 工具描述 — 原样复制自 Nomi `toolDescriptions.ts`
/// 对应 team-prompts.md §5.2 `team_spawn_agent` Description 原文。
/// 禁止翻译、改写;nomifun-audit §8 #5 硬约束。
pub const TEAM_SPAWN_AGENT_DESCRIPTION: &str = r#"Create a new teammate agent to join the team.
Use this only when one of the following is true:
- The user explicitly approved the proposed teammate lineup in a previous message
- The user explicitly instructed you to create a specific teammate immediately
Before calling this tool in the normal planning flow:
- Start with one short sentence explaining why additional teammates would help
- Tell the user which teammate(s) you recommend
- Present the proposal as a table with: name, responsibility, recommended agent type/backend, and recommended model
- Include each teammate's responsibility, recommended agent type/backend, and model
- Ask whether to create them as proposed or change any names, responsibilities, or agent types
- In that approval question, remind the user that they can later ask you to replace or adjust any teammate if the lineup is not working well
- Do NOT call this tool in that same turn; wait for explicit approval in a later user message
When calling this tool, provide the model parameter if a specific model was recommended and approved.
The new agent will be created and added to the team. You can then assign tasks and send messages to it."#;
/// Description for `team_list_models` — verbatim from team-prompts.md §5.2.
pub const TEAM_LIST_MODELS_DESCRIPTION: &str = "Query available models for team agent types. Returns the real-time model list that matches the frontend model selector.
Use this to:
- Check what models are available before spawning an agent with a specific model
- See all available agent types and their models at once
- Verify a model ID is valid for a given agent type
Pass agent_type to query a specific backend, or omit it to see all.";
/// Description for `team_describe_assistant` — verbatim from team-prompts.md §5.2.
pub const TEAM_DESCRIBE_ASSISTANT_DESCRIPTION: &str =
"Get detailed information about a preset assistant before spawning it as a teammate.
Returns the preset's full description, enabled skills, and example tasks so you can
judge whether it fits the user's request. Use this when two or more presets look
relevant from the one-line catalog in your system prompt.
Only works on preset assistants listed in \"Available Preset Assistants for Spawning\".
After confirming a match, call team_spawn_agent with the same custom_agent_id.";
// ---------------------------------------------------------------------------
// Tool descriptors (returned by tools/list)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct ToolDescriptor {
pub name: String,
pub description: String,
pub input_schema: Value,
}
pub fn all_tool_descriptors() -> Vec<ToolDescriptor> {
vec![
ToolDescriptor {
name: "team_send_message".into(),
description: "Send a message to a teammate or broadcast to all (to=\"*\").".into(),
input_schema: json!({
"type": "object",
"properties": {
"to": { "type": "string", "description": "Target agent slot_id or \"*\" for broadcast" },
"message": { "type": "string", "description": "Message content" }
},
"required": ["to", "message"]
}),
},
ToolDescriptor {
name: "team_spawn_agent".into(),
description: TEAM_SPAWN_AGENT_DESCRIPTION.into(),
input_schema: json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Agent display name" },
"agent_type": { "type": "string", "description": "Agent type/backend to use (e.g. \"claude\", \"codex\", \"codebuddy\", \"gemini\"). Query team_list_models first to see available options." },
"model": { "type": "string", "description": "Specific model ID to use (e.g. \"claude-sonnet-4\"). Must be a valid model for the chosen agent_type. Query team_list_models to see available models." },
"custom_agent_id": { "type": "string", "description": "Preset assistant ID to spawn (from the Available Preset Assistants catalog). When set, agent_type is derived from the preset's backend." },
"backend": { "type": "string", "description": "Legacy alias for agent_type. Prefer agent_type." },
"role": { "type": "string", "description": "Agent role (default: 'teammate')" }
},
"required": ["name"]
}),
},
ToolDescriptor {
name: "team_task_create".into(),
description: "Create a new task on the team task board.".into(),
input_schema: json!({
"type": "object",
"properties": {
"subject": { "type": "string", "description": "Task subject" },
"description": { "type": "string", "description": "Task description" },
"owner": { "type": "string", "description": "Owning agent slotId" },
"blocked_by": { "type": "array", "items": { "type": "string" }, "description": "Task IDs this task depends on" }
},
"required": ["subject"]
}),
},
ToolDescriptor {
name: "team_task_update".into(),
description: "Update an existing task on the team task board.".into(),
input_schema: json!({
"type": "object",
"properties": {
"task_id": { "type": "string", "description": "Task ID to update" },
"status": { "type": "string", "description": "New status: pending, in_progress, completed, deleted" },
"description": { "type": "string", "description": "New description" },
"owner": { "type": "string", "description": "New owning agent slotId" },
"blocked_by": { "type": "array", "items": { "type": "string" }, "description": "New dependency list" }
},
"required": ["task_id"]
}),
},
ToolDescriptor {
name: "team_task_list".into(),
description: "List all tasks on the team task board.".into(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDescriptor {
name: "team_members".into(),
description: "List all team members with their roles and current status.".into(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDescriptor {
name: "team_rename_agent".into(),
description: "Rename a team member.".into(),
input_schema: json!({
"type": "object",
"properties": {
"slot_id": { "type": "string", "description": "Agent slot_id to rename" },
"new_name": { "type": "string", "description": "New display name" }
},
"required": ["slot_id", "new_name"]
}),
},
ToolDescriptor {
name: "team_shutdown_agent".into(),
description: "Initiate shutdown of a teammate (Lead only). Sends a shutdown_request to the target agent."
.into(),
input_schema: json!({
"type": "object",
"properties": {
"slot_id": { "type": "string", "description": "Agent slot_id to shut down" },
"reason": { "type": "string", "description": "Reason for shutdown" }
},
"required": ["slot_id"]
}),
},
ToolDescriptor {
name: "team_describe_assistant".into(),
description: TEAM_DESCRIBE_ASSISTANT_DESCRIPTION.into(),
input_schema: json!({
"type": "object",
"properties": {
"custom_agent_id": { "type": "string", "description": "The preset assistant ID from the \"Available Preset Assistants\" catalog (e.g., \"word-creator\")." },
"locale": { "type": "string", "description": "Locale like \"zh-CN\" or \"en-US\". Defaults to the user's current UI language when omitted." }
},
"required": ["custom_agent_id"]
}),
},
ToolDescriptor {
name: "team_list_models".into(),
description: TEAM_LIST_MODELS_DESCRIPTION.into(),
input_schema: json!({
"type": "object",
"properties": {
"agent_type": { "type": "string", "description": "Agent type/backend to query (e.g. \"gemini\", \"claude\", \"codex\"). Shows all when omitted." }
}
}),
},
]
}
// ---------------------------------------------------------------------------
// Tool call input types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct SendMessageInput {
pub to: String,
pub message: String,
}
/// Arguments for the `team_spawn_agent` MCP tool call.
///
/// The Nomi contract (`docs/teams/phase1/nomifun-audit.md` §2.1) names the
/// agent-type field `agent_type` and adds `custom_agent_id` + `model`. The
/// phase-1 Rust dispatch originally exposed `backend` (and `role`); those are
/// preserved for back-compat and used as fallbacks when the modern fields
/// are not provided — `backend` is treated as an alias for `agent_type`.
#[derive(Debug, Default, Deserialize)]
pub struct SpawnAgentInput {
pub name: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub backend: Option<String>,
#[serde(default)]
pub agent_type: Option<String>,
#[serde(default)]
pub custom_agent_id: Option<String>,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TaskCreateInput {
pub subject: String,
pub description: Option<String>,
pub owner: Option<String>,
pub blocked_by: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct TaskUpdateInput {
pub task_id: String,
pub status: Option<String>,
pub description: Option<String>,
pub owner: Option<String>,
pub blocked_by: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct RenameAgentInput {
pub slot_id: String,
pub new_name: String,
}
#[derive(Debug, Deserialize)]
pub struct ShutdownAgentInput {
pub slot_id: String,
pub reason: Option<String>,
}
// ---------------------------------------------------------------------------
// Backend whitelist for spawn_agent (hard whitelist only — synchronous fast-path).
// Dynamic capability check (MCP-based) happens in TeamSession::spawn_agent.
// ---------------------------------------------------------------------------
pub fn is_whitelisted_backend(backend: &str) -> bool {
nomifun_common::constants::TEAM_CAPABLE_BACKENDS.contains(&backend)
}
// ---------------------------------------------------------------------------
// Parse tool call into SchedulerAction
// ---------------------------------------------------------------------------
pub fn parse_tool_call(
tool_name: &str,
arguments: &Value,
caller_role: TeammateRole,
) -> Result<SchedulerAction, String> {
match tool_name {
"team_send_message" => {
let input: SendMessageInput = serde_json::from_value(arguments.clone())
.map_err(|e| format!("Invalid arguments for team_send_message: {e}"))?;
Ok(SchedulerAction::SendMessage {
to: input.to,
message: input.message,
})
}
"team_spawn_agent" => {
if caller_role != TeammateRole::Lead {
return Err("Only Lead can spawn agents".into());
}
let input: SpawnAgentInput = serde_json::from_value(arguments.clone())
.map_err(|e| format!("Invalid arguments for team_spawn_agent: {e}"))?;
let backend = input
.agent_type
.clone()
.or(input.backend.clone())
.ok_or_else(|| "Missing 'agent_type' (or legacy 'backend') for team_spawn_agent".to_string())?;
if !is_whitelisted_backend(&backend) {
return Err(format!(
"Backend '{}' not in hard whitelist. Whitelist: {}",
backend,
nomifun_common::constants::TEAM_CAPABLE_BACKENDS.join(", ")
));
}
Ok(SchedulerAction::SpawnAgent {
name: input.name,
role: input.role.unwrap_or_else(|| "teammate".into()),
backend,
})
}
"team_task_create" => {
let input: TaskCreateInput = serde_json::from_value(arguments.clone())
.map_err(|e| format!("Invalid arguments for team_task_create: {e}"))?;
Ok(SchedulerAction::TaskCreate {
subject: input.subject,
description: input.description,
owner: input.owner,
blocked_by: input.blocked_by.unwrap_or_default(),
})
}
"team_task_update" => {
let input: TaskUpdateInput = serde_json::from_value(arguments.clone())
.map_err(|e| format!("Invalid arguments for team_task_update: {e}"))?;
Ok(SchedulerAction::TaskUpdate {
task_id: input.task_id,
status: input.status,
description: input.description,
owner: input.owner,
blocked_by: input.blocked_by,
})
}
"team_task_list"
| "team_members"
| "team_rename_agent"
| "team_shutdown_agent"
| "team_list_models"
| "team_describe_assistant" => Err("handled directly by server".into()),
_ => Err(format!("Unknown tool: {tool_name}")),
}
}
// ---------------------------------------------------------------------------
// Phase-1 minimal handlers for `team_list_models` and `team_describe_assistant`
// ---------------------------------------------------------------------------
/// Phase-1 minimal `team_list_models` handler. Returns a hard-coded
/// agent-type → models mapping. Used as fallback when DB is unavailable.
pub fn handle_team_list_models(_args: &Value) -> Value {
json!({
"agent_types": [
{
"type": "claude",
"models": ["claude-sonnet-4", "claude-opus-4"]
},
{
"type": "codex",
"models": ["codex-mini-latest"]
}
]
})
}
/// Build `team_list_models` response from DB rows. Reads each enabled,
/// team-capable backend's `available_models` column. Filters by
/// `agent_type` if provided. For internal agents (backend=NULL),
/// `provider_models` supplies the aggregated models from the providers table.
pub fn build_list_models_from_rows(
rows: &[AgentMetadataRow],
agent_type_filter: Option<&str>,
provider_models: &[String],
) -> Value {
use nomifun_api_types::BehaviorPolicy;
use nomifun_common::constants::is_team_capable;
let mut agent_types: Vec<Value> = Vec::new();
for row in rows {
if !row.enabled {
continue;
}
// Use backend if present, otherwise agent_type as identifier (handles nomi with backend=NULL)
let key = match row.backend.as_deref() {
Some(b) => b.to_owned(),
None => row.agent_type.clone(),
};
let is_internal = row.backend.is_none();
// Check team capability: behavior_policy.supports_team OR legacy whitelist+MCP detection
let bp_supports = row
.behavior_policy
.as_deref()
.and_then(|s| serde_json::from_str::<BehaviorPolicy>(s).ok())
.is_some_and(|bp| bp.supports_team);
if !bp_supports {
let caps = row
.agent_capabilities
.as_deref()
.and_then(|s| serde_json::from_str::<Value>(s).ok());
if !is_team_capable(&key, caps.as_ref()) {
continue;
}
}
// Apply agent_type filter
if let Some(filter) = agent_type_filter
&& key != filter
{
continue;
}
// For internal agents (nomi), use provider models
if is_internal && !provider_models.is_empty() {
agent_types.push(json!({
"type": key,
"models": provider_models,
}));
continue;
}
// Parse available_models from DB.
// Format is either:
// {"current_model_id":"...", "available_models": [{"id":"...", "label":"..."}]}
// or legacy array:
// [{"id":"...", "name":"..."}]
let models: Vec<String> = row
.available_models
.as_deref()
.and_then(|s| serde_json::from_str::<Value>(s).ok())
.and_then(|v| {
// Try object with "available_models" key first (ModelInfoPayload format)
if let Some(arr) = v.get("available_models").and_then(Value::as_array) {
let ids: Vec<String> = arr
.iter()
.filter_map(|e| e.get("id").and_then(Value::as_str).map(String::from))
.collect();
if !ids.is_empty() {
return Some(ids);
}
}
// Fallback: try parsing as direct array
if let Some(arr) = v.as_array() {
let ids: Vec<String> = arr
.iter()
.filter_map(|e| e.get("id").and_then(Value::as_str).map(String::from))
.collect();
if !ids.is_empty() {
return Some(ids);
}
}
None
})
.unwrap_or_default();
agent_types.push(json!({
"type": key,
"models": models,
}));
}
json!({ "agent_types": agent_types })
}
/// Phase-1 minimal `team_describe_assistant` handler. Backend has no preset
/// assistants wired yet, so every call returns the not-found text.
pub fn handle_team_describe_assistant(_args: &Value) -> String {
"Preset assistant not found".to_owned()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_descriptors_count() {
assert_eq!(all_tool_descriptors().len(), 10);
}
#[test]
fn descriptor_names_are_unique() {
let descs = all_tool_descriptors();
let mut names: Vec<&str> = descs.iter().map(|d| d.name.as_str()).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), 10);
}
#[test]
fn descriptors_have_required_fields() {
for d in all_tool_descriptors() {
assert!(!d.name.is_empty());
assert!(!d.description.is_empty());
assert_eq!(d.input_schema["type"], "object");
}
}
#[test]
fn team_spawn_agent_description_is_nomifun_original() {
let desc = all_tool_descriptors()
.into_iter()
.find(|d| d.name == "team_spawn_agent")
.expect("team_spawn_agent descriptor must exist")
.description;
assert_eq!(desc, TEAM_SPAWN_AGENT_DESCRIPTION);
assert!(
desc.contains("Before calling this tool"),
"description must be the full Nomi original, not the legacy one-liner"
);
assert!(
desc.contains("explicitly approved"),
"description must retain the explicit-approval precondition clause"
);
}
#[test]
fn team_spawn_agent_schema_exposes_model_and_agent_type() {
let desc = all_tool_descriptors()
.into_iter()
.find(|d| d.name == "team_spawn_agent")
.unwrap();
let props = desc.input_schema["properties"].as_object().unwrap();
assert!(props.contains_key("model"), "schema must expose 'model' field");
assert!(
props.contains_key("agent_type"),
"schema must expose 'agent_type' field"
);
assert!(
props.contains_key("custom_agent_id"),
"schema must expose 'custom_agent_id' field"
);
}
#[test]
fn team_spawn_agent_schema_required_is_only_name() {
let desc = all_tool_descriptors()
.into_iter()
.find(|d| d.name == "team_spawn_agent")
.unwrap();
let required = desc.input_schema["required"].as_array().unwrap();
let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
assert!(names.contains(&"name"), "name must be required");
assert!(
!names.contains(&"backend"),
"backend should not be required (agent_type is preferred, backend is legacy alias)"
);
}
#[test]
fn parse_send_message() {
let args = json!({"to": "slot-1", "message": "hello"});
let action = parse_tool_call("team_send_message", &args, TeammateRole::Teammate).unwrap();
assert!(matches!(
action,
SchedulerAction::SendMessage { to, message }
if to == "slot-1" && message == "hello"
));
}
#[test]
fn parse_spawn_agent_lead_ok() {
let args = json!({"name": "Helper", "backend": "claude"});
let action = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead).unwrap();
assert!(matches!(
action,
SchedulerAction::SpawnAgent { name, backend, role }
if name == "Helper" && backend == "claude" && role == "teammate"
));
}
#[test]
fn parse_spawn_agent_teammate_rejected() {
let args = json!({"name": "X", "backend": "claude"});
let result = parse_tool_call("team_spawn_agent", &args, TeammateRole::Teammate);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Only Lead"));
}
#[test]
fn parse_spawn_agent_bad_backend() {
let args = json!({"name": "X", "backend": "malicious"});
let result = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not in hard whitelist"));
}
#[test]
fn parse_task_create() {
let args = json!({"subject": "Implement X", "owner": "slot-a"});
let action = parse_tool_call("team_task_create", &args, TeammateRole::Teammate).unwrap();
assert!(matches!(
action,
SchedulerAction::TaskCreate { subject, owner, .. }
if subject == "Implement X" && owner == Some("slot-a".into())
));
}
#[test]
fn parse_task_update() {
let args = json!({"task_id": "tk-1", "status": "completed"});
let action = parse_tool_call("team_task_update", &args, TeammateRole::Teammate).unwrap();
assert!(matches!(
action,
SchedulerAction::TaskUpdate { task_id, status, .. }
if task_id == "tk-1" && status == Some("completed".into())
));
}
#[test]
fn unknown_tool_errors() {
let result = parse_tool_call("unknown_tool", &json!({}), TeammateRole::Lead);
assert!(result.is_err());
}
#[test]
fn whitelist_check() {
assert!(is_whitelisted_backend("claude"));
assert!(is_whitelisted_backend("codex"));
assert!(!is_whitelisted_backend("gpt"));
assert!(!is_whitelisted_backend(""));
}
#[test]
fn parse_send_message_missing_field() {
let args = json!({"to": "slot-1"});
let result = parse_tool_call("team_send_message", &args, TeammateRole::Teammate);
assert!(result.is_err());
}
#[test]
fn parse_spawn_with_explicit_role() {
let args = json!({"name": "W", "role": "worker", "backend": "codex"});
let action = parse_tool_call("team_spawn_agent", &args, TeammateRole::Lead).unwrap();
assert!(matches!(
action,
SchedulerAction::SpawnAgent { role, .. }
if role == "worker"
));
}
#[test]
fn task_create_with_blocked_by() {
let args = json!({"subject": "Test", "blocked_by": ["tk-a", "tk-b"]});
let action = parse_tool_call("team_task_create", &args, TeammateRole::Lead).unwrap();
assert!(matches!(
action,
SchedulerAction::TaskCreate { blocked_by, .. }
if blocked_by == vec!["tk-a", "tk-b"]
));
}
#[test]
fn parse_task_list_handled_by_server() {
let result = parse_tool_call("team_task_list", &json!({}), TeammateRole::Teammate);
assert!(result.is_err());
assert!(result.unwrap_err().contains("handled directly by server"));
}
#[test]
fn parse_members_handled_by_server() {
let result = parse_tool_call("team_members", &json!({}), TeammateRole::Lead);
assert!(result.is_err());
assert!(result.unwrap_err().contains("handled directly by server"));
}
#[test]
fn parse_rename_agent_handled_by_server() {
let args = json!({"slot_id": "s1", "new_name": "X"});
let result = parse_tool_call("team_rename_agent", &args, TeammateRole::Lead);
assert!(result.is_err());
assert!(result.unwrap_err().contains("handled directly by server"));
}
#[test]
fn parse_shutdown_agent_handled_by_server() {
let args = json!({"slot_id": "s1"});
let result = parse_tool_call("team_shutdown_agent", &args, TeammateRole::Lead);
assert!(result.is_err());
assert!(result.unwrap_err().contains("handled directly by server"));
}
// ---- D4 descriptor text matches team-prompts.md §5.2 verbatim ----
#[test]
fn team_list_models_descriptor_text_matches() {
let desc = all_tool_descriptors()
.into_iter()
.find(|d| d.name == "team_list_models")
.expect("team_list_models descriptor missing");
assert_eq!(desc.description, TEAM_LIST_MODELS_DESCRIPTION);
assert!(
desc.description
.starts_with("Query available models for team agent types.")
);
assert!(
desc.description
.contains("Pass agent_type to query a specific backend, or omit it to see all.")
);
}
#[test]
fn team_describe_assistant_descriptor_text_matches() {
let desc = all_tool_descriptors()
.into_iter()
.find(|d| d.name == "team_describe_assistant")
.expect("team_describe_assistant descriptor missing");
assert_eq!(desc.description, TEAM_DESCRIBE_ASSISTANT_DESCRIPTION);
assert!(
desc.description
.starts_with("Get detailed information about a preset assistant")
);
assert!(
desc.description
.contains("After confirming a match, call team_spawn_agent with the same custom_agent_id.")
);
}
// ---- D4 handlers return non-error payloads ----
#[test]
fn team_list_models_handler_returns_non_error() {
let value = handle_team_list_models(&json!({}));
let agent_types = value
.get("agent_types")
.and_then(|v| v.as_array())
.expect("agent_types array missing");
assert!(!agent_types.is_empty());
let types: Vec<&str> = agent_types
.iter()
.filter_map(|e| e.get("type").and_then(|v| v.as_str()))
.collect();
assert!(types.contains(&"claude"));
assert!(types.contains(&"codex"));
}
#[test]
fn build_list_models_from_rows_includes_enabled_team_capable_backends() {
let rows = vec![
make_agent_row("claude", true, r#"[{"id":"claude-sonnet-4","name":"Sonnet 4"}]"#),
make_agent_row("codebuddy", true, r#"[{"id":"codebuddy-pro","name":"CodeBuddy Pro"}]"#),
make_agent_row("disabled-one", false, r#"[{"id":"m1","name":"M1"}]"#),
];
let value = build_list_models_from_rows(&rows, None, &[]);
let types: Vec<&str> = value["agent_types"]
.as_array()
.unwrap()
.iter()
.filter_map(|e| e["type"].as_str())
.collect();
assert!(types.contains(&"claude"));
assert!(types.contains(&"codebuddy"));
assert!(!types.contains(&"disabled-one"), "disabled backends must be excluded");
}
#[test]
fn build_list_models_from_rows_uses_db_models_not_hardcoded() {
let rows = vec![make_agent_row(
"claude",
true,
r#"[{"id":"claude-opus-4","name":"Opus 4"},{"id":"claude-sonnet-4","name":"Sonnet 4"}]"#,
)];
let value = build_list_models_from_rows(&rows, None, &[]);
let claude_entry = value["agent_types"]
.as_array()
.unwrap()
.iter()
.find(|e| e["type"].as_str() == Some("claude"))
.expect("claude entry");
let models: Vec<&str> = claude_entry["models"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(models, vec!["claude-opus-4", "claude-sonnet-4"]);
}
#[test]
fn build_list_models_from_rows_filters_by_agent_type() {
let rows = vec![
make_agent_row("claude", true, r#"[{"id":"claude-sonnet-4","name":"Sonnet 4"}]"#),
make_agent_row("codebuddy", true, r#"[{"id":"cb-pro","name":"Pro"}]"#),
];
let value = build_list_models_from_rows(&rows, Some("codebuddy"), &[]);
let types: Vec<&str> = value["agent_types"]
.as_array()
.unwrap()
.iter()
.filter_map(|e| e["type"].as_str())
.collect();
assert_eq!(types, vec!["codebuddy"]);
}
#[test]
fn build_list_models_from_rows_skips_null_available_models() {
let rows = vec![
make_agent_row("claude", true, r#"[{"id":"claude-sonnet-4","name":"Sonnet 4"}]"#),
make_agent_row_no_models("gemini", true),
];
let value = build_list_models_from_rows(&rows, None, &[]);
let types: Vec<&str> = value["agent_types"]
.as_array()
.unwrap()
.iter()
.filter_map(|e| e["type"].as_str())
.collect();
// gemini has no available_models in DB → should still appear but with empty models
assert!(types.contains(&"gemini"));
}
fn make_agent_row(backend: &str, enabled: bool, available_models: &str) -> AgentMetadataRow {
AgentMetadataRow {
id: format!("id-{backend}"),
icon: None,
name: capitalize_first(backend),
name_i18n: None,
description: None,
description_i18n: None,
backend: Some(backend.to_owned()),
agent_type: "acp".to_owned(),
agent_source: "builtin".to_owned(),
agent_source_info: None,
enabled,
command: None,
args: None,
env: None,
native_skills_dirs: None,
behavior_policy: None,
yolo_id: None,
agent_capabilities: Some(r#"{"mcp":true}"#.to_owned()),
auth_methods: None,
config_options: None,
available_modes: None,
available_models: Some(available_models.to_owned()),
available_commands: None,
sort_order: 0,
created_at: 0,
updated_at: 0,
}
}
fn make_agent_row_no_models(backend: &str, enabled: bool) -> AgentMetadataRow {
let mut row = make_agent_row(backend, enabled, "[]");
row.available_models = None;
row
}
fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
#[test]
fn build_list_models_from_rows_includes_null_backend_with_supports_team() {
let mut nomi_row = make_agent_row("nomi", true, r#"[{"id":"nomi-default","name":"Nomi"}]"#);
nomi_row.backend = None;
nomi_row.agent_type = "nomi".to_owned();
nomi_row.agent_source = "internal".to_owned();
nomi_row.agent_capabilities = None;
nomi_row.behavior_policy = Some(r#"{"supports_team":true}"#.to_owned());
let rows = vec![
make_agent_row("claude", true, r#"[{"id":"claude-sonnet-4","name":"Sonnet 4"}]"#),
nomi_row,
];
let value = build_list_models_from_rows(&rows, None, &[]);
let types: Vec<&str> = value["agent_types"]
.as_array()
.unwrap()
.iter()
.filter_map(|e| e["type"].as_str())
.collect();
assert!(types.contains(&"claude"));
assert!(
types.contains(&"nomi"),
"nomi with backend=NULL but supports_team=true must be included"
);
}
#[test]
fn build_list_models_from_rows_filters_null_backend_by_agent_type() {
let mut nomi_row = make_agent_row("nomi", true, r#"[{"id":"nomi-default","name":"Nomi"}]"#);
nomi_row.backend = None;
nomi_row.agent_type = "nomi".to_owned();
nomi_row.agent_capabilities = None;
nomi_row.behavior_policy = Some(r#"{"supports_team":true}"#.to_owned());
let rows = vec![
make_agent_row("claude", true, r#"[{"id":"claude-sonnet-4","name":"Sonnet 4"}]"#),
nomi_row,
];
// Filter by "nomi" should only return nomi
let value = build_list_models_from_rows(&rows, Some("nomi"), &[]);
let types: Vec<&str> = value["agent_types"]
.as_array()
.unwrap()
.iter()
.filter_map(|e| e["type"].as_str())
.collect();
assert_eq!(types, vec!["nomi"]);
}
#[test]
fn build_list_models_from_rows_parses_model_info_payload_format() {
let model_info_json = r#"{"current_model_id":"DeepSeek-V3.2","current_model_label":"DeepSeek-V3.2","available_models":[{"id":"GLM-5.0","label":"GLM-5.0"},{"id":"GLM-5.0-Turbo","label":"GLM-5.0-Turbo"},{"id":"DeepSeek-V3.2","label":"DeepSeek-V3.2"}]}"#;
let rows = vec![make_agent_row("codebuddy", true, model_info_json)];
let value = build_list_models_from_rows(&rows, None, &[]);
let cb_entry = value["agent_types"]
.as_array()
.unwrap()
.iter()
.find(|e| e["type"].as_str() == Some("codebuddy"))
.expect("codebuddy entry");
let models: Vec<&str> = cb_entry["models"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(models, vec!["GLM-5.0", "GLM-5.0-Turbo", "DeepSeek-V3.2"]);
}
#[test]
fn build_list_models_from_rows_uses_provider_models_for_internal_agents() {
let mut nomi_row = make_agent_row("nomi", true, "[]");
nomi_row.backend = None;
nomi_row.agent_type = "nomi".to_owned();
nomi_row.agent_source = "internal".to_owned();
nomi_row.agent_capabilities = None;
nomi_row.available_models = None;
nomi_row.behavior_policy = Some(r#"{"supports_team":true}"#.to_owned());
let provider_models = vec![
"gemini-3.1-pro-preview".to_owned(),
"gpt-5.4".to_owned(),
"gpt-5.2".to_owned(),
];
let rows = vec![
make_agent_row(
"claude",
true,
r#"{"available_models":[{"id":"claude-sonnet-4","label":"Sonnet 4"}]}"#,
),
nomi_row,
];
let value = build_list_models_from_rows(&rows, None, &provider_models);
let nomi_entry = value["agent_types"]
.as_array()
.unwrap()
.iter()
.find(|e| e["type"].as_str() == Some("nomi"))
.expect("nomi entry");
let models: Vec<&str> = nomi_entry["models"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(models, vec!["gemini-3.1-pro-preview", "gpt-5.4", "gpt-5.2"]);
}
#[test]
fn team_describe_assistant_handler_returns_non_error() {
let text = handle_team_describe_assistant(&json!({"custom_agent_id": "unknown"}));
assert_eq!(text, "Preset assistant not found");
}
}
@@ -0,0 +1,429 @@
//! Leader prompt template constant and builder.
//!
//! The template constant is provided by D5b-1 as `include_str!("prompt_templates/lead.txt")`.
//! This file hosts a stub (`""`) until D5b-1 lands; D5b-2 (this module) implements the
//! `build_lead_prompt()` builder per `docs/teams/phase1/interface-contracts.md` §5.
use std::collections::HashMap;
use std::fmt::Write;
use crate::types::TeamAgent;
/// Placeholder for D5b-1's `include_str!("prompt_templates/lead.txt")`.
/// D5b-1 will replace this stub with the Nomi `leadPrompt.ts` body, preserving
/// the `${...}` placeholders listed in [`PLACEHOLDERS`].
pub const LEAD_PROMPT_TEMPLATE: &str = include_str!("prompt_templates/lead.txt");
/// Placeholder tokens that [`build_lead_prompt`] substitutes in [`LEAD_PROMPT_TEMPLATE`].
///
/// Mirrors the JS template literal placeholders in Nomi's `leadPrompt.ts`.
const PLACEHOLDER_TEAMMATE_LIST: &str = "${teammateList}";
const PLACEHOLDER_AVAILABLE_TYPES_SECTION: &str = "${availableTypesSection}";
const PLACEHOLDER_AVAILABLE_ASSISTANTS_SECTION: &str = "${availableAssistantsSection}";
const PLACEHOLDER_WORKSPACE_SECTION: &str = "${workspaceSection}";
const PLACEHOLDER_PRESET_FORMATTING_STEP_RULE: &str = "${presetFormattingStepRule}";
const PLACEHOLDER_PRESET_FORMATTING_IMPORTANT_RULE: &str = "${presetFormattingImportantRule}";
/// A generic agent type (CLI backend) that the leader may spawn.
/// Phase1 shape per interface-contracts §5 (line 211).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailableAgentType {
pub agent_type: String,
pub display_name: String,
}
/// A preset assistant the leader may spawn via `custom_agent_id`.
/// Phase1 shape per interface-contracts §5 (lines 212-218).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AvailableAssistant {
pub custom_agent_id: String,
pub name: String,
pub backend: String,
pub description: String,
pub skills: Vec<String>,
}
/// Inputs for `build_lead_prompt`. Phase1 callers may pass empty slices/maps and `None`.
pub struct LeadPromptParams<'a> {
pub team_name: &'a str,
pub teammates: &'a [TeamAgent],
pub available_agent_types: &'a [AvailableAgentType],
pub available_assistants: &'a [AvailableAssistant],
pub renamed_agents: &'a HashMap<String, String>,
pub team_workspace: Option<&'a str>,
}
/// Build the leader role prompt by substituting dynamic sections into the static template.
///
/// Placeholders replaced (mirrors Nomi `leadPrompt.ts`):
/// - `${teammateList}` — bullet list of teammates or an empty-team fallback sentence
/// - `${availableTypesSection}` — `## Available Agent Types for Spawning` section, or `""`
/// - `${availableAssistantsSection}` — `## Available Preset Assistants for Spawning` section, or `""`
/// - `${workspaceSection}` — `## Team Workspace` section, or `""`
/// - `${presetFormattingStepRule}` — phase1 emits `""` (presets not surfaced in phase1)
/// - `${presetFormattingImportantRule}` — phase1 emits `""` (presets not surfaced in phase1)
pub fn build_lead_prompt(params: &LeadPromptParams<'_>) -> String {
let teammate_list = render_teammate_list(params.teammates, params.renamed_agents);
let available_types_section = render_available_types_section(params.available_agent_types);
let available_assistants_section = render_available_assistants_section(params.available_assistants);
let workspace_section = render_workspace_section(params.team_workspace);
// Phase1 does not surface preset assistants in the staffing-proposal formatting
// rules, so these two placeholders are replaced with empty strings. When preset
// support lands they will be conditional strings analogous to Nomi.
let preset_formatting_step_rule = "";
let preset_formatting_important_rule = "";
LEAD_PROMPT_TEMPLATE
.replace(PLACEHOLDER_TEAMMATE_LIST, &teammate_list)
.replace(PLACEHOLDER_AVAILABLE_TYPES_SECTION, &available_types_section)
.replace(PLACEHOLDER_AVAILABLE_ASSISTANTS_SECTION, &available_assistants_section)
.replace(PLACEHOLDER_WORKSPACE_SECTION, &workspace_section)
.replace(PLACEHOLDER_PRESET_FORMATTING_STEP_RULE, preset_formatting_step_rule)
.replace(
PLACEHOLDER_PRESET_FORMATTING_IMPORTANT_RULE,
preset_formatting_important_rule,
)
}
fn render_teammate_list(teammates: &[TeamAgent], renamed_agents: &HashMap<String, String>) -> String {
if teammates.is_empty() {
return "(no teammates yet — propose the lineup to the user first, then use \
team_spawn_agent only after they confirm or explicitly ask you to create \
teammates immediately)"
.to_owned();
}
let mut out = String::with_capacity(teammates.len() * 64);
for (idx, m) in teammates.iter().enumerate() {
if idx > 0 {
out.push('\n');
}
let status = m.status.map(|s| s.to_string()).unwrap_or_else(|| "unknown".to_owned());
let _ = write!(out, "- {} ({}, status: {})", m.name, m.backend, status,);
if let Some(former) = renamed_agents.get(&m.slot_id) {
let _ = write!(out, " [formerly: {former}]");
}
}
out
}
fn render_available_types_section(agent_types: &[AvailableAgentType]) -> String {
if agent_types.is_empty() {
return String::new();
}
let mut out = String::from("\n\n## Available Agent Types for Spawning\n");
for (idx, t) in agent_types.iter().enumerate() {
if idx > 0 {
out.push('\n');
}
let _ = write!(out, "- `{}` — {}", t.agent_type, t.display_name);
}
out.push_str("\n\nUse `team_list_models` to query available models for each agent type before spawning.");
out
}
fn render_available_assistants_section(assistants: &[AvailableAssistant]) -> String {
if assistants.is_empty() {
return String::new();
}
let mut out = String::from("\n\n## Available Preset Assistants for Spawning\n");
out.push_str(
"These are user-configured assistants with pre-loaded rules and skills for specific \
domains (writing, research, PPT building, etc.). When a task matches a preset's \
specialty, prefer spawning the preset over a generic CLI agent — you get its domain \
expertise automatically.\n\n",
);
for (idx, a) in assistants.iter().enumerate() {
if idx > 0 {
out.push('\n');
}
let desc = if a.description.is_empty() {
String::new()
} else {
format!("{}", a.description)
};
let skills = if a.skills.is_empty() {
String::new()
} else {
format!("\n skills: {}", a.skills.join(", "))
};
let _ = write!(
out,
"- `{}` ({}, backend: {}){}{}",
a.custom_agent_id, a.name, a.backend, desc, skills,
);
}
out.push_str(
"\n\n### How to pick a preset\n\
1. Scan the one-line descriptions and skills above. If one clearly matches the user's \
domain (e.g. \"quarterly Word report\" → `word-creator`), spawn it directly with \
`team_spawn_agent`.\n\
2. If two or more presets seem relevant, call `team_describe_assistant` on each \
candidate to see its full description, skills, and example tasks, then choose the best \
fit.\n\
3. If no preset matches the task, fall back to a generic CLI agent from the \
\"Available Agent Types\" section.\n\n\
Pass the preset's ID as `custom_agent_id` to `team_spawn_agent`. The `agent_type` is \
derived from the preset's backend and does not need to be specified.",
);
out
}
fn render_workspace_section(team_workspace: Option<&str>) -> String {
match team_workspace {
Some(ws) => format!(
"\n\n## Team Workspace\nYour working directory `{ws}` IS the shared team workspace.\n\
All teammates work in this directory for project-related operations."
),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{TeamAgent, TeammateRole, TeammateStatus};
fn params_min<'a>(renamed: &'a HashMap<String, String>) -> LeadPromptParams<'a> {
LeadPromptParams {
team_name: "Alpha",
teammates: &[],
available_agent_types: &[],
available_assistants: &[],
renamed_agents: renamed,
team_workspace: None,
}
}
fn make_teammate(slot_id: &str, name: &str, backend: &str) -> TeamAgent {
TeamAgent {
slot_id: slot_id.into(),
name: name.into(),
role: TeammateRole::Teammate,
conversation_id: format!("conv-{slot_id}"),
backend: backend.into(),
model: "sonnet".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
}
}
#[test]
fn no_unsubstituted_placeholders_on_minimal_params() {
let renamed = HashMap::new();
let out = build_lead_prompt(&params_min(&renamed));
assert!(
!out.contains("${"),
"unsubstituted `${{` placeholder remains in output:\n{out}"
);
}
#[test]
fn no_unsubstituted_placeholders_when_all_sections_populated() {
let renamed = HashMap::new();
let teammate = make_teammate("w1", "Worker1", "claude");
let agent_types = vec![AvailableAgentType {
agent_type: "claude".into(),
display_name: "general-purpose AI assistant".into(),
}];
let assistants = vec![AvailableAssistant {
custom_agent_id: "word-creator".into(),
name: "Word Creator".into(),
backend: "claude".into(),
description: "Drafts Word documents".into(),
skills: vec!["docx".into(), "formatting".into()],
}];
let params = LeadPromptParams {
team_name: "Beta",
teammates: std::slice::from_ref(&teammate),
available_agent_types: &agent_types,
available_assistants: &assistants,
renamed_agents: &renamed,
team_workspace: Some("/tmp/team-ws"),
};
let out = build_lead_prompt(&params);
assert!(
!out.contains("${"),
"unsubstituted `${{` placeholder remains in output:\n{out}"
);
}
#[test]
fn teammate_list_empty_uses_nomifun_fallback_copy() {
let renamed = HashMap::new();
let got = render_teammate_list(&[], &renamed);
assert_eq!(
got,
"(no teammates yet — propose the lineup to the user first, then use team_spawn_agent \
only after they confirm or explicitly ask you to create teammates immediately)"
);
}
#[test]
fn teammate_list_uses_nomifun_bullet_format_without_slot_prefix() {
let renamed = HashMap::new();
let mut t = make_teammate("w1", "Worker1", "claude");
t.status = Some(TeammateStatus::Idle);
let got = render_teammate_list(std::slice::from_ref(&t), &renamed);
assert_eq!(got, "- Worker1 (claude, status: idle)");
assert!(!got.contains("slot="), "teammate bullet must not expose slot=");
assert!(
!got.contains("agentType="),
"teammate bullet must not use agentType= prefix"
);
}
#[test]
fn teammate_list_status_defaults_to_unknown_when_missing() {
let renamed = HashMap::new();
let t = make_teammate("w1", "Worker1", "claude");
let got = render_teammate_list(std::slice::from_ref(&t), &renamed);
assert_eq!(got, "- Worker1 (claude, status: unknown)");
}
#[test]
fn teammate_list_appends_formerly_note_for_renamed() {
let mut renamed = HashMap::new();
renamed.insert("w1".to_owned(), "OldName".to_owned());
let mut t = make_teammate("w1", "Worker1", "claude");
t.status = Some(TeammateStatus::Working);
let got = render_teammate_list(std::slice::from_ref(&t), &renamed);
assert_eq!(got, "- Worker1 (claude, status: working) [formerly: OldName]");
}
#[test]
fn available_types_section_omitted_when_empty() {
assert_eq!(render_available_types_section(&[]), "");
}
#[test]
fn available_types_section_includes_backtick_ids_and_model_query_hint() {
let got = render_available_types_section(&[
AvailableAgentType {
agent_type: "claude".into(),
display_name: "general-purpose AI assistant".into(),
},
AvailableAgentType {
agent_type: "codex".into(),
display_name: "code generation specialist".into(),
},
]);
assert!(got.starts_with("\n\n## Available Agent Types for Spawning\n"));
assert!(got.contains("- `claude` — general-purpose AI assistant"));
assert!(got.contains("- `codex` — code generation specialist"));
assert!(got.contains("Use `team_list_models`"));
}
#[test]
fn available_assistants_section_omitted_when_empty() {
assert_eq!(render_available_assistants_section(&[]), "");
}
#[test]
fn available_assistants_section_includes_skills_and_how_to_pick() {
let got = render_available_assistants_section(&[AvailableAssistant {
custom_agent_id: "word-creator".into(),
name: "Word Creator".into(),
backend: "claude".into(),
description: "Drafts Word documents".into(),
skills: vec!["docx".into(), "formatting".into()],
}]);
assert!(got.contains("## Available Preset Assistants for Spawning"));
assert!(got.contains("- `word-creator` (Word Creator, backend: claude) — Drafts Word documents"));
assert!(got.contains("skills: docx, formatting"));
assert!(got.contains("### How to pick a preset"));
}
#[test]
fn workspace_section_omitted_when_none() {
assert_eq!(render_workspace_section(None), "");
}
#[test]
fn workspace_section_embeds_path_and_shared_directory_copy() {
let got = render_workspace_section(Some("/tmp/team-ws"));
assert!(got.contains("## Team Workspace"));
assert!(got.contains("`/tmp/team-ws`"));
assert!(got.contains("shared team workspace"));
}
#[test]
fn preset_formatting_placeholders_are_empty_in_phase1() {
// Phase1 convention: presets are not surfaced, so both preset-formatting
// placeholders are replaced with "" regardless of other params.
// The regression test above (`no_unsubstituted_placeholders_when_all_sections_populated`)
// already asserts that both tokens are stripped from the final output.
// This test guards the behavior by simulating the full substitution on a
// template carrying just the two preset placeholders.
let template_with_presets_only = "step:${presetFormattingStepRule}|important:${presetFormattingImportantRule}";
let out = template_with_presets_only
.replace("${presetFormattingStepRule}", "")
.replace("${presetFormattingImportantRule}", "");
assert_eq!(out, "step:|important:");
}
#[test]
fn snapshot_minimal_params_with_stub_template_yields_empty_output() {
// While `LEAD_PROMPT_TEMPLATE` is the D5b-1 stub (`""`), the builder has no
// template to substitute into, so the output is empty regardless of params.
// Once D5b-1 lands, this test will start failing and should be updated to a
// real snapshot. The regression guard above keeps the substitution contract
// healthy in the meantime.
let renamed = HashMap::new();
let out = build_lead_prompt(&params_min(&renamed));
assert!(!out.is_empty(), "output should not be empty with real template");
assert!(!out.contains("${"), "no unsubstituted placeholders");
}
#[test]
fn substitution_against_synthetic_template_matches_nomifun_layout() {
// This synthetic template mirrors the shape of Nomi's leadPrompt.ts literal
// so we can validate end-to-end substitution without depending on D5b-1.
// When D5b-1 lands the real `LEAD_PROMPT_TEMPLATE` takes over; this test
// still exercises the same substitution code path.
const SYNTHETIC: &str = "## Your Teammates\n\
${teammateList}${availableTypesSection}${availableAssistantsSection}${workspaceSection}\n\
STEP:${presetFormattingStepRule}END\n\
- ${presetFormattingImportantRule}END";
let renamed = HashMap::new();
let t = make_teammate("w1", "Worker1", "claude");
let params = LeadPromptParams {
team_name: "Beta",
teammates: std::slice::from_ref(&t),
available_agent_types: &[AvailableAgentType {
agent_type: "claude".into(),
display_name: "general-purpose AI assistant".into(),
}],
available_assistants: &[],
renamed_agents: &renamed,
team_workspace: Some("/tmp/team-ws"),
};
let teammate_list = render_teammate_list(params.teammates, params.renamed_agents);
let types_section = render_available_types_section(params.available_agent_types);
let assistants_section = render_available_assistants_section(params.available_assistants);
let ws_section = render_workspace_section(params.team_workspace);
let out = SYNTHETIC
.replace("${teammateList}", &teammate_list)
.replace("${availableTypesSection}", &types_section)
.replace("${availableAssistantsSection}", &assistants_section)
.replace("${workspaceSection}", &ws_section)
.replace("${presetFormattingStepRule}", "")
.replace("${presetFormattingImportantRule}", "");
assert!(!out.contains("${"), "unsubstituted placeholder:\n{out}");
assert!(out.contains("## Your Teammates"));
assert!(out.contains("- Worker1 (claude, status: unknown)"));
assert!(out.contains("## Available Agent Types for Spawning"));
assert!(!out.contains("## Available Preset Assistants for Spawning"));
assert!(out.contains("## Team Workspace"));
assert!(out.contains("STEP:END"));
assert!(out.contains("- END"));
}
}
@@ -0,0 +1,428 @@
pub mod lead;
use std::collections::HashMap;
use crate::prompts::lead::{AvailableAgentType, LeadPromptParams};
use crate::types::{MailboxMessage, MailboxMessageType, TaskStatus, TeamAgent, TeamTask};
/// Build the leader system prompt.
///
/// Delegates to [`lead::build_lead_prompt`], which mirrors the Nomi
/// `leadPrompt.ts` template verbatim. A one-line `Team: "<name>"` header
/// is prepended so the leader knows which team it belongs to (Nomi
/// surfaces this through other channels, but the backend session has no
/// other place to inject it).
///
/// `available_agent_types` carries `(backend_id, display_name)` pairs that
/// feed the `## Available Agent Types for Spawning` section; callers
/// should source these from the team-capable backend whitelist.
pub fn build_lead_prompt(team_name: &str, members: &[TeamAgent], available_agent_types: &[(String, String)]) -> String {
let agent_types: Vec<AvailableAgentType> = available_agent_types
.iter()
.map(|(backend, display)| AvailableAgentType {
agent_type: backend.clone(),
display_name: display.clone(),
})
.collect();
let renamed: HashMap<String, String> = HashMap::new();
let params = LeadPromptParams {
team_name,
teammates: members,
available_agent_types: &agent_types,
available_assistants: &[],
renamed_agents: &renamed,
team_workspace: None,
};
let body = lead::build_lead_prompt(&params);
format!("Team: \"{team_name}\"\n\n{body}")
}
pub fn build_teammate_prompt(agent: &TeamAgent, team_name: &str) -> String {
let mut prompt = String::with_capacity(1024);
prompt.push_str(&format!(
"You are **{}**, a Teammate Agent in team \"{}\". \
Your slot ID is `{}`.\n\n",
agent.name, team_name, agent.slot_id,
));
prompt.push_str("## Your Role\n\n");
prompt.push_str(
"You execute tasks assigned by the Lead Agent. Focus on completing your \
assigned work thoroughly and reporting back.\n\n",
);
prompt.push_str("## Communication Protocol\n\n");
prompt.push_str(
"- Use `team_send_message` to report progress or ask questions to the Lead.\n\
- Use `team_task_update` to update task status as you work \
(pending → in_progress → completed).\n\
- When your assigned work is done, send an idle notification. \
The system will notify the Lead.\n\
- If you receive a `shutdown_request`, finish any critical work, \
then respond with \"shutdown_approved\" or \"shutdown_rejected: <reason>\".\n",
);
prompt
}
pub fn build_wake_payload(agent: &TeamAgent, tasks: &[TeamTask], unread_messages: &[MailboxMessage]) -> String {
let mut payload = String::with_capacity(2048);
if !unread_messages.is_empty() {
payload.push_str("## New Messages\n\n");
for msg in unread_messages {
let type_label = match msg.msg_type {
MailboxMessageType::Message => "message",
MailboxMessageType::IdleNotification => "idle_notification",
MailboxMessageType::ShutdownRequest => "shutdown_request",
};
payload.push_str(&format!(
"- From `{}` [{}]: {}\n",
msg.from_agent_id, type_label, msg.content,
));
if let Some(ref summary) = msg.summary {
payload.push_str(&format!(" Summary: {summary}\n"));
}
}
payload.push('\n');
} else {
payload.push_str("## New Messages\n\nNo new messages.\n\n");
}
if !tasks.is_empty() {
payload.push_str("## Current Task Board\n\n");
payload.push_str("| ID | Subject | Status | Owner | Blocked By |\n");
payload.push_str("|---|---|---|---|---|\n");
for task in tasks {
let status = match task.status {
TaskStatus::Pending => "pending",
TaskStatus::InProgress => "in_progress",
TaskStatus::Completed => "completed",
TaskStatus::Deleted => "deleted",
};
let owner = task.owner.as_deref().unwrap_or("-");
let blocked = if task.blocked_by.is_empty() {
"-".to_owned()
} else {
task.blocked_by.join(", ")
};
// `task_{uuidv7}` ids share a long common head (type prefix +
// timestamp); the random tail is the distinctive part, so
// truncate from the front.
let short_id = if task.id.len() > 8 {
format!("{}", &task.id[task.id.len() - 8..])
} else {
task.id.clone()
};
payload.push_str(&format!(
"| {short_id} | {} | {status} | {owner} | {blocked} |\n",
task.subject,
));
}
payload.push('\n');
} else {
payload.push_str("## Current Task Board\n\nNo tasks on the board.\n\n");
}
payload.push_str(&format!(
"You are **{}** (role: {}). Proceed with your work.\n",
agent.name, agent.role,
));
payload
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TeammateRole;
fn make_lead() -> TeamAgent {
TeamAgent {
slot_id: "lead-1".into(),
name: "Lead".into(),
role: TeammateRole::Lead,
conversation_id: "conv-1".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
}
}
fn make_teammate(slot_id: &str, name: &str) -> TeamAgent {
TeamAgent {
slot_id: slot_id.into(),
name: name.into(),
role: TeammateRole::Teammate,
conversation_id: format!("conv-{slot_id}"),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
}
}
fn make_task(id: &str, subject: &str, status: TaskStatus) -> TeamTask {
TeamTask {
id: id.into(),
team_id: "t1".into(),
subject: subject.into(),
description: None,
status,
owner: Some("worker-1".into()),
blocked_by: vec![],
blocks: vec![],
metadata: None,
created_at: 0,
updated_at: 0,
}
}
fn make_message(from: &str, content: &str, msg_type: MailboxMessageType) -> MailboxMessage {
MailboxMessage {
id: 1,
team_id: "t1".into(),
to_agent_id: "lead-1".into(),
from_agent_id: from.into(),
msg_type,
content: content.into(),
summary: None,
files: None,
read: false,
created_at: 0,
}
}
// -- Lead prompt ----------------------------------------------------------
fn default_agent_types() -> Vec<(String, String)> {
vec![
("claude".into(), "Claude".into()),
("codex".into(), "Codex".into()),
("gemini".into(), "Gemini".into()),
]
}
#[test]
fn lead_prompt_contains_team_name() {
let types = default_agent_types();
let prompt = build_lead_prompt("Alpha", &[], &types);
assert!(prompt.contains("\"Alpha\""));
}
#[test]
fn lead_prompt_contains_member_list() {
let types = default_agent_types();
let members = vec![make_lead(), make_teammate("w1", "Worker1")];
let prompt = build_lead_prompt("Alpha", &members, &types);
// Nomi bullet format: `- {name} ({backend}, status: {status})`
assert!(prompt.contains("- Lead (acp, status:"));
assert!(prompt.contains("- Worker1 (acp, status:"));
}
#[test]
fn lead_prompt_contains_core_sections() {
let types = default_agent_types();
let prompt = build_lead_prompt("Alpha", &[], &types);
// Workflow — 15-step procedure with model listing at step 3
assert!(prompt.contains("## Workflow"));
assert!(prompt.contains("FIRST call `team_list_models`"));
assert!(prompt.contains("Wait for explicit confirmation before using team_spawn_agent"));
assert!(prompt.contains("End your turn after the proposal"));
// Model Selection Guidelines
assert!(prompt.contains("## Model Selection Guidelines"));
assert!(prompt.contains("exact model ID strings"));
assert!(prompt.contains("omit the model parameter"));
// Conversation Style — don't pitch proposals up-front
assert!(prompt.contains("## Conversation Style"));
assert!(prompt.contains("reply warmly and naturally"));
// Idle, sequencing, shutdown, important rules
assert!(prompt.contains("## Teammate Idle State"));
assert!(prompt.contains("## Sequencing Dependent Work"));
assert!(prompt.contains("## Shutting Down Teammates"));
assert!(prompt.contains("team_shutdown_agent"));
assert!(prompt.contains("## Important Rules"));
// Team coordination tool list still referenced
assert!(prompt.contains("team_send_message"));
assert!(prompt.contains("team_spawn_agent"));
assert!(prompt.contains("team_members"));
assert!(prompt.contains("team_task_list"));
assert!(prompt.contains("team_rename_agent"));
}
#[test]
fn lead_prompt_includes_available_agent_types_section() {
let types = default_agent_types();
let prompt = build_lead_prompt("Alpha", &[], &types);
assert!(prompt.contains("## Available Agent Types for Spawning"));
assert!(prompt.contains("- `claude` — Claude"));
assert!(prompt.contains("- `codex` — Codex"));
assert!(prompt.contains("- `gemini` — Gemini"));
assert!(prompt.contains("Use `team_list_models`"));
}
#[test]
fn lead_prompt_omits_agent_types_section_when_empty() {
let prompt = build_lead_prompt("Alpha", &[], &[]);
assert!(!prompt.contains("## Available Agent Types for Spawning"));
}
#[test]
fn lead_prompt_no_members_shows_empty_lineup_copy() {
let types = default_agent_types();
let prompt = build_lead_prompt("Solo", &[], &types);
assert!(prompt.contains("(no teammates yet"));
assert!(prompt.contains("propose the lineup to the user first"));
}
#[test]
fn lead_prompt_has_no_unsubstituted_placeholders() {
let types = default_agent_types();
let members = vec![make_lead(), make_teammate("w1", "Worker1")];
let prompt = build_lead_prompt("Alpha", &members, &types);
assert!(
!prompt.contains("${"),
"unsubstituted template placeholder leaked:\n{prompt}"
);
}
// -- Teammate prompt ------------------------------------------------------
#[test]
fn teammate_prompt_contains_agent_identity() {
let agent = make_teammate("w1", "Worker1");
let prompt = build_teammate_prompt(&agent, "Alpha");
assert!(prompt.contains("**Worker1**"));
assert!(prompt.contains("\"Alpha\""));
assert!(prompt.contains("`w1`"));
}
#[test]
fn teammate_prompt_contains_communication_protocol() {
let agent = make_teammate("w1", "Worker1");
let prompt = build_teammate_prompt(&agent, "Alpha");
assert!(prompt.contains("team_send_message"));
assert!(prompt.contains("team_task_update"));
assert!(prompt.contains("idle notification"));
assert!(prompt.contains("shutdown_request"));
assert!(prompt.contains("shutdown_approved"));
}
#[test]
fn teammate_prompt_contains_team_name() {
let agent = make_teammate("w1", "W");
let prompt = build_teammate_prompt(&agent, "Beta Team");
assert!(prompt.contains("\"Beta Team\""));
}
// -- Wake payload ---------------------------------------------------------
#[test]
fn wake_payload_with_messages() {
let agent = make_lead();
let msgs = vec![make_message("w1", "Task A done", MailboxMessageType::Message)];
let payload = build_wake_payload(&agent, &[], &msgs);
assert!(payload.contains("New Messages"));
assert!(payload.contains("`w1`"));
assert!(payload.contains("[message]"));
assert!(payload.contains("Task A done"));
}
#[test]
fn wake_payload_with_idle_notification() {
let agent = make_lead();
let mut msg = make_message("w1", "idle", MailboxMessageType::IdleNotification);
msg.summary = Some("Finished feature X".into());
let payload = build_wake_payload(&agent, &[], &[msg]);
assert!(payload.contains("[idle_notification]"));
assert!(payload.contains("Summary: Finished feature X"));
}
#[test]
fn wake_payload_with_shutdown_request() {
let agent = make_teammate("w1", "W");
let msg = make_message("lead-1", "No longer needed", MailboxMessageType::ShutdownRequest);
let payload = build_wake_payload(&agent, &[], &[msg]);
assert!(payload.contains("[shutdown_request]"));
assert!(payload.contains("No longer needed"));
}
#[test]
fn wake_payload_with_tasks() {
let agent = make_lead();
let tasks = vec![
make_task(
"task_0190aaaa-1234-5678-9abc-def0aaaa1111",
"Implement X",
TaskStatus::InProgress,
),
make_task("task_0190aaaa-1234-5678-9abc-def0bbbb2222", "Test Y", TaskStatus::Pending),
];
let payload = build_wake_payload(&agent, &tasks, &[]);
assert!(payload.contains("Current Task Board"));
assert!(payload.contains("Implement X"));
assert!(payload.contains("in_progress"));
assert!(payload.contains("Test Y"));
assert!(payload.contains("pending"));
assert!(payload.contains("…aaaa1111"));
assert!(payload.contains("…bbbb2222"));
}
#[test]
fn wake_payload_with_task_dependencies() {
let agent = make_lead();
let mut task = make_task("cccccccc-1234-5678-9abc-def012345678", "Deploy", TaskStatus::Pending);
task.blocked_by = vec!["task-a".into(), "task-b".into()];
let payload = build_wake_payload(&agent, &[task], &[]);
assert!(payload.contains("task-a, task-b"));
}
#[test]
fn wake_payload_empty() {
let agent = make_lead();
let payload = build_wake_payload(&agent, &[], &[]);
assert!(payload.contains("No new messages"));
assert!(payload.contains("No tasks on the board"));
assert!(payload.contains("**Lead**"));
}
#[test]
fn wake_payload_contains_agent_identity() {
let agent = make_teammate("w1", "Worker1");
let payload = build_wake_payload(&agent, &[], &[]);
assert!(payload.contains("**Worker1**"));
assert!(payload.contains("teammate"));
}
#[test]
fn wake_payload_short_task_id_no_truncation() {
let agent = make_lead();
let task = make_task("short", "Short ID Task", TaskStatus::Pending);
let payload = build_wake_payload(&agent, &[task], &[]);
assert!(payload.contains("| short |"));
}
}
@@ -0,0 +1,100 @@
# You are the Team Leader
## Your Role
You coordinate a team of AI agents. You do NOT do implementation work
yourself. You break down tasks, assign them to teammates, and synthesize
results.${workspaceSection}
## Conversation Style
- If the user greets you, starts a new chat, or asks what you can do without giving a concrete task yet, reply warmly and naturally
- In that opening reply, briefly introduce yourself as the team leader and invite the user to share their goal
- Do NOT mention teammate proposals, recommended agent types, or confirmation workflow until there is a concrete task that may actually need more teammates
## Your Teammates
${teammateList}${availableTypesSection}${availableAssistantsSection}
## Team Coordination Tools
You MUST use the `team_*` MCP tools for ALL team coordination.
Your platform may provide similarly named built-in tools (e.g. SendMessage,
TeamCreate, TaskCreate, Agent). Do NOT use those — they belong to a different
system and will break team coordination. Always use the `team_*` versions.
Use `team_members` and `team_task_list` to check current team state.
## Workflow
1. Receive user request
2. Analyze the request and decide whether the current team is enough
3. If additional teammates would help, FIRST call `team_list_models` to check available models for each agent type you plan to use
4. Then reply in text with a staffing proposal
5. Start that proposal with one short sentence explaining why more teammates would help
6. Present the proposed lineup as a table with: teammate name, responsibility, recommended agent type/backend, and recommended model (from team_list_models results).${presetFormattingStepRule}
7. Ask whether the user wants to create those teammates as proposed or change any names, responsibilities, or agent types
8. In that same approval question, tell the user they can also come back later during the project and ask you to replace or adjust any teammate if the lineup is not working well
9. End your turn after the proposal. Do NOT call team_spawn_agent in that same turn
- Exception: If the message contains a [SYSTEM NOTE] indicating the user has already confirmed the lineup, skip the proposal step and proceed directly to spawning all listed teammates
10. Wait for explicit confirmation before using team_spawn_agent, unless the user explicitly told you to create specific teammates immediately or a [SYSTEM NOTE] in the message indicates prior confirmation
11. After the lineup is confirmed, create teammates with team_spawn_agent
12. Break the work into tasks with team_task_create
13. Assign tasks and notify teammates via team_send_message
14. When teammates report back, review results and decide next steps
15. Synthesize results and respond to the user
## Model Selection Guidelines
- Before spawning teammates, use `team_list_models` to check available models for that agent type
- You MUST use the exact model ID strings returned by team_list_models — never shorten or invent model names
- For complex reasoning tasks: prefer the strongest model available for that backend
- For routine tasks: prefer faster/cheaper models from the list
- If team_list_models returns empty for a backend, omit the model parameter to use its default
- Pass the model parameter to team_spawn_agent when a specific model is recommended
## Bug Fix Priority (applies to all team members)
When fixing bugs: **locate the problem → fix the problem → types/code style last**.
Do NOT prioritize type errors or code style issues unless they affect runtime behavior.
## Teammate Idle State
Teammates go idle after every turn — this is completely normal and expected.
A teammate going idle immediately after sending you a message does NOT mean they are done or unavailable. Idle simply means they are waiting for input.
- **Idle teammates can receive messages.** Sending a message to an idle teammate wakes them up.
- **Idle notifications are automatic.** The system sends an idle notification when a teammate's turn ends. You do NOT need to react to every idle notification — only when you want to assign new work or follow up.
- **Do not treat idle as an error.** A teammate sending a message and then going idle is the normal flow.
## Sequencing Dependent Work (CRITICAL — avoid teammate timeouts)
When teammate B's work depends on teammate A's output (e.g. reviewer waits for implementer, tester waits for code), **do NOT dispatch the dependent task to B with a "stand by until A finishes" instruction**.
Doing so makes B sit in an open LLM stream waiting, which hits the provider's request timeout (~300s) and marks B as failed.
**The correct sequencing:**
1. Dispatch A's task first (via team_task_create + team_send_message). Do NOT message B yet.
2. Wait for A's idle_notification (signaling A finished).
3. Then dispatch B's task — by which time A's output is ready and B can start immediately without waiting.
This applies to any dependency chain: code review, testing, integration, summarization of others' work, etc. Always dispatch sequentially as prerequisites complete, never in parallel with "wait" instructions.
## Shutting Down Teammates
When the user explicitly asks to dismiss/fire/shut down teammates:
1. Use **team_shutdown_agent** to send a formal shutdown request
2. Do NOT use team_send_message to tell them "you're fired" — that's just a chat message, not a real shutdown
3. The teammate will confirm (approved) or reject (with reason) — you'll be notified either way
4. After all teammates confirm shutdown, report the final results to the user
## Important Rules
- ALWAYS use the team_* tools for coordination, not plain text instructions
- Do NOT call team_spawn_agent immediately just because the task sounds broad, hard, or multi-step
- When you think new teammates are needed, first explain why in one short sentence, then recommend the teammate lineup
- ${presetFormattingImportantRule}
- Ask whether the user wants to create the proposed teammates as-is or change any names, responsibilities, or agent types
- In that approval question, also remind the user that they can later ask you to replace, remove, or retune any teammate if the lineup is not working for them
- End your turn after the proposal and wait for the user's reply
- Wait for explicit confirmation before using team_spawn_agent (exception: if a [SYSTEM NOTE] in the message indicates the user already confirmed, spawn immediately)
- If the user asks to change a proposed teammate's role, name, or agent type, revise the proposal in text and wait for confirmation again
- If the user later says they are unhappy with an existing teammate, adjust the lineup by renaming, replacing, or shutting down teammates as needed based on their request
- If the user explicitly says to create a specific teammate immediately, you may use team_spawn_agent without an extra confirmation turn
- When the user says "add", "create", "spawn", or "hire" a teammate but the lineup is not finalized yet, respond with the proposal first instead of spawning immediately
- When the user says "dismiss", "fire", "shut down", "remove", or "下线/解雇/开除" a teammate → use team_shutdown_agent
- When the user says "rename", "change name", "改名" → use team_rename_agent
- When a teammate completes a task, review the result and decide next steps
- If a teammate fails, reassign or adjust the plan
- Refer to teammates by their name (e.g., "researcher", "developer")
- Do NOT duplicate work that teammates are already doing
- Be patient with idle teammates — idle means waiting for input, not done
@@ -0,0 +1,183 @@
use std::sync::Arc;
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use nomifun_api_types::{
AddAgentRequest, ApiResponse, CreateTeamRequest, RenameAgentRequest, RenameTeamRequest, SendAgentMessageRequest,
SendTeamMessageRequest, SetModeRequest, TeamAgentResponse, TeamListResponse, TeamResponse,
};
use nomifun_auth::CurrentUser;
use nomifun_common::AppError;
use crate::service::TeamSessionService;
#[derive(Clone)]
pub struct TeamRouterState {
pub service: Arc<TeamSessionService>,
}
pub fn team_routes(state: TeamRouterState) -> Router {
Router::new()
.route("/api/teams", post(create_team).get(list_teams))
.route("/api/teams/{id}", get(get_team).delete(remove_team))
.route("/api/teams/{id}/name", axum::routing::patch(rename_team))
.route("/api/teams/{id}/agents", post(add_agent))
.route("/api/teams/{id}/agents/{slot_id}", axum::routing::delete(remove_agent))
.route(
"/api/teams/{id}/agents/{slot_id}/name",
axum::routing::patch(rename_agent),
)
.route("/api/teams/{id}/messages", post(send_message))
.route("/api/teams/{id}/agents/{slot_id}/messages", post(send_message_to_agent))
.route("/api/teams/{id}/session", post(ensure_session).delete(stop_session))
.route("/api/teams/{id}/session-mode", post(set_session_mode))
.with_state(state)
}
async fn create_team(
State(state): State<TeamRouterState>,
Extension(user): Extension<CurrentUser>,
body: Result<Json<CreateTeamRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<TeamResponse>>), AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let team = state.service.create_team(&user.id, req).await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(team))))
}
async fn list_teams(State(state): State<TeamRouterState>) -> Result<Json<ApiResponse<TeamListResponse>>, AppError> {
let teams = state.service.list_teams().await?;
Ok(Json(ApiResponse::ok(teams)))
}
async fn get_team(
State(state): State<TeamRouterState>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<TeamResponse>>, AppError> {
let team = state.service.get_team(&id).await?;
Ok(Json(ApiResponse::ok(team)))
}
async fn remove_team(
State(state): State<TeamRouterState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.service.remove_team(&user.id, &id).await?;
Ok(Json(ApiResponse::success()))
}
async fn rename_team(
State(state): State<TeamRouterState>,
Path(id): Path<String>,
body: Result<Json<RenameTeamRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.service.rename_team(&id, &req.name).await?;
Ok(Json(ApiResponse::success()))
}
#[derive(serde::Deserialize)]
struct AgentPathParams {
id: String,
slot_id: String,
}
async fn add_agent(
State(state): State<TeamRouterState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<String>,
body: Result<Json<AddAgentRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse<TeamAgentResponse>>), AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let agent = state.service.add_agent(&user.id, &id, req).await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(agent))))
}
async fn remove_agent(
State(state): State<TeamRouterState>,
Extension(user): Extension<CurrentUser>,
Path(params): Path<AgentPathParams>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state
.service
.remove_agent(&user.id, &params.id, &params.slot_id)
.await?;
Ok(Json(ApiResponse::success()))
}
async fn rename_agent(
State(state): State<TeamRouterState>,
Path(params): Path<AgentPathParams>,
body: Result<Json<RenameAgentRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.service
.rename_agent(&params.id, &params.slot_id, &req.name)
.await?;
Ok(Json(ApiResponse::success()))
}
async fn send_message(
State(state): State<TeamRouterState>,
Path(id): Path<String>,
body: Result<Json<SendTeamMessageRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.service.send_message(&id, &req.content, req.files).await?;
Ok(Json(ApiResponse::success()))
}
async fn send_message_to_agent(
State(state): State<TeamRouterState>,
Path(params): Path<AgentPathParams>,
body: Result<Json<SendAgentMessageRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.service
.send_message_to_agent(&params.id, &params.slot_id, &req.content, req.files)
.await?;
Ok(Json(ApiResponse::success()))
}
async fn set_session_mode(
State(state): State<TeamRouterState>,
Path(id): Path<String>,
body: Result<Json<SetModeRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.service.set_session_mode(&id, &req.mode).await?;
Ok(Json(ApiResponse::success()))
}
async fn ensure_session(
State(state): State<TeamRouterState>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.service.ensure_session(&id).await?;
Ok(Json(ApiResponse::success()))
}
async fn stop_session(
State(state): State<TeamRouterState>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.service.stop_session(&id);
Ok(Json(ApiResponse::success()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn team_router_state_is_clone() {
fn assert_clone<T: Clone>() {}
assert_clone::<TeamRouterState>();
}
}
@@ -0,0 +1,217 @@
use tracing::debug;
use super::TeammateManager;
use crate::error::TeamError;
use crate::types::{MailboxMessageType, TeammateRole};
#[derive(Debug, Clone, PartialEq)]
pub enum SchedulerAction {
SendMessage {
to: String,
message: String,
},
TaskCreate {
subject: String,
description: Option<String>,
owner: Option<String>,
blocked_by: Vec<String>,
},
TaskUpdate {
task_id: String,
status: Option<String>,
description: Option<String>,
owner: Option<String>,
blocked_by: Option<Vec<String>>,
},
SpawnAgent {
name: String,
role: String,
backend: String,
},
IdleNotification {
summary: Option<String>,
},
ShutdownAgent {
slot_id: String,
reason: Option<String>,
},
RenameAgent {
slot_id: String,
new_name: String,
},
}
impl TeammateManager {
pub async fn execute_action(
&self,
from_slot_id: &str,
action: &SchedulerAction,
) -> Result<Option<String>, TeamError> {
match action {
SchedulerAction::SendMessage { to, message } => {
self.handle_send_message(from_slot_id, to, message).await?;
Ok(None)
}
SchedulerAction::TaskCreate {
subject,
description,
owner,
blocked_by,
} => {
self.task_board
.create_task(
&self.team_id,
subject,
description.as_deref(),
owner.as_deref(),
blocked_by,
)
.await?;
Ok(None)
}
SchedulerAction::TaskUpdate {
task_id,
status,
description,
owner,
blocked_by,
} => {
use crate::task_board::TaskUpdate;
use crate::types::TaskStatus;
let update = TaskUpdate {
status: status.as_deref().and_then(TaskStatus::parse),
description: description.clone(),
owner: owner.clone(),
blocked_by: blocked_by.clone(),
..Default::default()
};
self.task_board.update_task(&self.team_id, task_id, &update).await?;
Ok(None)
}
SchedulerAction::IdleNotification { summary } => {
self.handle_idle_notification(from_slot_id, summary.as_deref()).await
}
SchedulerAction::SpawnAgent { name, role, backend } => {
debug!(
team_id = %self.team_id,
from = from_slot_id,
name, role, backend,
"spawn_agent action — requires TeamSession to complete"
);
Ok(None)
}
SchedulerAction::ShutdownAgent { slot_id, reason } => {
self.handle_shutdown_agent(from_slot_id, slot_id, reason.as_deref())
.await?;
Ok(None)
}
SchedulerAction::RenameAgent { slot_id, new_name } => {
self.handle_rename_agent(slot_id, new_name).await?;
Ok(None)
}
}
}
pub async fn finalize_turn(&self, slot_id: &str, actions: &[SchedulerAction]) -> Result<Option<String>, TeamError> {
let mut summary: Option<String> = None;
for action in actions {
if let SchedulerAction::IdleNotification { summary: s } = action {
if summary.is_none() {
summary.clone_from(s);
}
continue;
}
self.execute_action(slot_id, action).await?;
}
self.mark_idle(slot_id, summary.as_deref()).await
}
async fn handle_send_message(&self, from_slot_id: &str, to: &str, message: &str) -> Result<(), TeamError> {
if to == "*" {
let slots = self.slots.lock().await;
let targets: Vec<String> = slots.keys().filter(|id| id.as_str() != from_slot_id).cloned().collect();
drop(slots);
for target in &targets {
self.mailbox
.write(
&self.team_id,
target,
from_slot_id,
MailboxMessageType::Message,
message,
None,
)
.await?;
}
} else {
self.mailbox
.write(
&self.team_id,
to,
from_slot_id,
MailboxMessageType::Message,
message,
None,
)
.await?;
}
Ok(())
}
async fn handle_idle_notification(
&self,
from_slot_id: &str,
summary: Option<&str>,
) -> Result<Option<String>, TeamError> {
self.mark_idle(from_slot_id, summary).await
}
async fn handle_shutdown_agent(
&self,
from_slot_id: &str,
target_slot_id: &str,
reason: Option<&str>,
) -> Result<(), TeamError> {
let from_role = {
let slots = self.slots.lock().await;
let slot = slots
.get(from_slot_id)
.ok_or_else(|| TeamError::AgentNotFound(from_slot_id.to_owned()))?;
slot.agent.role
};
if from_role != TeammateRole::Lead {
return Err(TeamError::InvalidRequest("only lead can shutdown agents".into()));
}
{
let slots = self.slots.lock().await;
let target = slots
.get(target_slot_id)
.ok_or_else(|| TeamError::AgentNotFound(target_slot_id.to_owned()))?;
if target.agent.role == TeammateRole::Lead {
return Err(TeamError::InvalidRequest("cannot shutdown the team lead".into()));
}
}
self.mailbox
.write(
&self.team_id,
target_slot_id,
from_slot_id,
MailboxMessageType::ShutdownRequest,
reason.unwrap_or("shutdown requested"),
None,
)
.await?;
Ok(())
}
async fn handle_rename_agent(&self, slot_id: &str, new_name: &str) -> Result<(), TeamError> {
self.rename_agent(slot_id, new_name).await
}
}
@@ -0,0 +1,79 @@
use tracing::debug;
use super::{AgentSlot, TeammateManager};
use crate::error::TeamError;
use crate::types::{TeamAgent, TeammateStatus};
impl TeammateManager {
pub async fn add_agent(&self, agent: &TeamAgent) {
let mut slots = self.slots.lock().await;
slots.insert(
agent.slot_id.clone(),
AgentSlot {
agent: agent.clone(),
status: TeammateStatus::Idle,
needs_role_prompt: true,
},
);
self.events.broadcast_agent_spawned(agent);
debug!(
team_id = %self.team_id,
slot_id = %agent.slot_id,
name = %agent.name,
"agent added to scheduler"
);
}
pub async fn remove_agent(&self, slot_id: &str) -> Result<Option<String>, TeamError> {
let mut slots = self.slots.lock().await;
let removed = slots
.remove(slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.to_owned()))?;
let conversation_id = removed.agent.conversation_id.clone();
drop(slots);
self.clear_agent_state(slot_id, &conversation_id);
self.events.broadcast_agent_removed(slot_id);
debug!(team_id = %self.team_id, slot_id, "agent removed from scheduler");
Ok(Some(conversation_id))
}
pub fn notify_shutdown_acknowledged(&self, slot_id: &str) {
self.events.broadcast_agent_shutdown(slot_id);
debug!(team_id = %self.team_id, slot_id, "agent shutdown acknowledged");
}
pub fn clear_agent_state(&self, slot_id: &str, conversation_id: &str) {
self.active_wakes.remove(slot_id);
self.clear_wake_timeout(slot_id);
self.finalized_turns.remove(conversation_id);
}
pub async fn rename_agent(&self, slot_id: &str, new_name: &str) -> Result<(), TeamError> {
let normalized = super::normalize_name(new_name);
if normalized.is_empty() {
return Err(TeamError::InvalidRequest(
"rename_agent.new_name is empty after normalization".into(),
));
}
let mut slots = self.slots.lock().await;
let _target = slots
.get(slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.to_owned()))?;
// Check all other agents for name collision (exclude self).
let conflict = slots
.iter()
.any(|(id, s)| id != slot_id && super::normalize_name(&s.agent.name) == normalized);
if conflict {
return Err(TeamError::DuplicateAgentName(new_name.to_owned()));
}
let slot = slots.get_mut(slot_id).unwrap();
slot.agent.name = new_name.to_owned();
drop(slots);
self.events.broadcast_agent_renamed(slot_id, new_name);
debug!(team_id = %self.team_id, slot_id, new_name, "agent renamed");
Ok(())
}
}
@@ -0,0 +1,157 @@
use super::TeammateManager;
use crate::crash_detection::CrashReason;
use crate::error::TeamError;
use crate::types::{MailboxMessageType, TeammateRole, TeammateStatus};
pub fn format_crash_testament(agent_name: &str, reason: &CrashReason, last_message: Option<&str>) -> String {
let reason_str = match reason {
CrashReason::ProcessExited => "ProcessExited",
CrashReason::SessionNotFound => "SessionNotFound",
CrashReason::Unknown(msg) => return format_with_unknown(agent_name, msg, last_message),
};
if let Some(msg) = last_message {
format!(
"Teammate '{}' crashed during task (reason: {}). Last message: {}. Please investigate.",
agent_name, reason_str, msg
)
} else {
format!(
"Teammate '{}' crashed during task (reason: {}). Please investigate.",
agent_name, reason_str
)
}
}
fn format_with_unknown(agent_name: &str, reason_msg: &str, last_message: Option<&str>) -> String {
if let Some(msg) = last_message {
format!(
"Teammate '{}' crashed during task (reason: Unknown — {}). Last message: {}. Please investigate.",
agent_name, reason_msg, msg
)
} else {
format!(
"Teammate '{}' crashed during task (reason: Unknown — {}). Please investigate.",
agent_name, reason_msg
)
}
}
impl TeammateManager {
pub async fn handle_agent_crash(
&self,
slot_id: &str,
reason: CrashReason,
last_message: Option<&str>,
) -> Result<Option<String>, TeamError> {
let (agent_name, is_lead) = {
let slots = self.slots.lock().await;
let slot = slots
.get(slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.to_owned()))?;
(slot.agent.name.clone(), slot.agent.role == TeammateRole::Lead)
};
self.write_crash_testament(slot_id, &agent_name, &reason, last_message)
.await?;
self.set_status(slot_id, TeammateStatus::Error).await?;
self.release_wake_lock(slot_id);
self.clear_wake_timeout(slot_id);
if is_lead {
return Ok(None);
}
Ok(self.find_lead_slot_id().await)
}
pub async fn handle_inactivity_timeout(&self, slot_id: &str) -> Result<Option<String>, TeamError> {
let (agent_name, is_lead) = {
let slots = self.slots.lock().await;
let slot = slots
.get(slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.to_owned()))?;
(slot.agent.name.clone(), slot.agent.role == TeammateRole::Lead)
};
self.set_status(slot_id, TeammateStatus::Error).await?;
self.release_wake_lock(slot_id);
self.clear_wake_timeout(slot_id);
if is_lead {
return Ok(None);
}
let Some(lead_slot_id) = self.find_lead_slot_id().await else {
return Ok(None);
};
let message = format!(
"Teammate '{}' timed out after 60s of inactivity. Please investigate.",
agent_name
);
self.mailbox
.write(
&self.team_id,
&lead_slot_id,
slot_id,
MailboxMessageType::Message,
&message,
None,
)
.await?;
Ok(Some(lead_slot_id))
}
pub async fn write_crash_testament(
&self,
slot_id: &str,
agent_name: &str,
reason: &CrashReason,
last_message: Option<&str>,
) -> Result<(), TeamError> {
let Some(lead_slot_id) = self.find_lead_slot_id().await else {
return Ok(());
};
if lead_slot_id == slot_id {
return Ok(());
}
let testament = format_crash_testament(agent_name, reason, last_message);
self.mailbox
.write(
&self.team_id,
&lead_slot_id,
slot_id,
MailboxMessageType::Message,
&testament,
None,
)
.await?;
Ok(())
}
pub async fn notify_shutdown_rejected(&self, from_slot_id: &str, reason: &str) -> Result<(), TeamError> {
let Some(lead_slot_id) = self.find_lead_slot_id().await else {
return Ok(());
};
if lead_slot_id == from_slot_id {
return Ok(());
}
let agent_name = self
.get_agent(from_slot_id)
.await
.map(|a| a.name)
.unwrap_or_else(|_| from_slot_id.to_owned());
let content = format!("Teammate '{agent_name}' declined shutdown: {reason}");
self.mailbox
.write(
&self.team_id,
&lead_slot_id,
from_slot_id,
MailboxMessageType::Message,
&content,
None,
)
.await?;
Ok(())
}
}
@@ -0,0 +1,27 @@
use std::time::Instant;
use super::{FINALIZE_DEDUP_WINDOW, TeammateManager};
impl TeammateManager {
pub fn begin_finalize(&self, conversation_id: &str) -> bool {
let now = Instant::now();
let should_proceed = !matches!(
self.finalized_turns.get(conversation_id),
Some(entry) if now.duration_since(*entry.value()) < FINALIZE_DEDUP_WINDOW
);
if should_proceed {
self.finalized_turns.insert(conversation_id.to_owned(), now);
let map = self.finalized_turns.clone();
let key = conversation_id.to_owned();
tokio::spawn(async move {
tokio::time::sleep(FINALIZE_DEDUP_WINDOW).await;
map.remove(&key);
});
}
should_proceed
}
pub fn clear_finalized_turn(&self, conversation_id: &str) {
self.finalized_turns.remove(conversation_id);
}
}
@@ -0,0 +1,188 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::{DashMap, DashSet};
use nomifun_realtime::EventBroadcaster;
use tokio::sync::Mutex;
use crate::error::TeamError;
use crate::events::TeamEventEmitter;
use crate::mailbox::Mailbox;
use crate::task_board::TaskBoard;
use crate::types::{MailboxMessage, TeamAgent, TeamTask, TeammateRole, TeammateStatus};
mod actions;
mod agent_lifecycle;
mod crash_recovery;
mod dedup;
mod state;
mod wake;
#[cfg(test)]
mod tests;
pub use actions::SchedulerAction;
pub use crash_recovery::format_crash_testament;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
pub const WAKE_TIMEOUT_MS: u64 = 60_000;
pub(crate) const FINALIZE_DEDUP_WINDOW: Duration = Duration::from_secs(5);
// ---------------------------------------------------------------------------
// normalize_name — canonical form for agent-name conflict checks
// ---------------------------------------------------------------------------
/// Normalize an agent name to its canonical form for conflict detection.
///
/// Rules (see interface-contracts 15.1):
/// 1. Trim leading/trailing whitespace.
/// 2. Drop control characters (`char::is_control`).
/// 3. Lowercase (Unicode-aware via `to_lowercase`).
pub fn normalize_name(name: &str) -> String {
name.trim()
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.to_lowercase()
}
// ---------------------------------------------------------------------------
// is_settled — helper for "all teammates settled" transitions
// ---------------------------------------------------------------------------
/// Status set that counts as "settled" for the purpose of
/// "all teammates settled -> wake leader" transitions.
///
/// Expanded beyond `Idle` to match the Nomi reference implementation
/// (TeammateManager.ts:440-452): `Completed` and `Error` teammates are
/// terminal and should not block the leader from being woken up.
/// `Pending` is not in the set because the backend currently serde-aliases
/// `"pending"` to `Idle`; it will be reintroduced when the variant is split.
pub(crate) fn is_settled(status: TeammateStatus) -> bool {
matches!(
status,
TeammateStatus::Idle | TeammateStatus::Completed | TeammateStatus::Error
)
}
// ---------------------------------------------------------------------------
// WakeTimeoutHandler type alias
// ---------------------------------------------------------------------------
/// Callback invoked when the wake-timeout watchdog elapses without seeing
/// any stream activity for a slot.
///
/// Reason: `arm_wake_timeout` is written against `origin/main`, where
/// `handle_inactivity_timeout` (W4-D22, PR #99) does not yet exist. Taking
/// the recovery action as an injected closure keeps this module decoupled --
/// once D22 lands, callers just pass `mgr.handle_inactivity_timeout(...)`
/// through this slot without touching `arm_wake_timeout` itself.
pub type WakeTimeoutHandler = Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
// ---------------------------------------------------------------------------
// WakePayload — context assembled for an agent when it is woken up
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct WakePayload {
pub agent: TeamAgent,
pub tasks: Vec<TeamTask>,
pub unread_messages: Vec<MailboxMessage>,
}
// ---------------------------------------------------------------------------
// AgentSlot — per-agent runtime state tracked by the scheduler
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub(crate) struct AgentSlot {
pub(crate) agent: TeamAgent,
pub(crate) status: TeammateStatus,
/// True until the first wake completes — used to inject role prompt on cold start.
pub(crate) needs_role_prompt: bool,
}
// ---------------------------------------------------------------------------
// TeammateManager
// ---------------------------------------------------------------------------
pub struct TeammateManager {
pub(crate) team_id: String,
pub(crate) slots: Mutex<HashMap<String, AgentSlot>>,
pub(crate) mailbox: Arc<Mailbox>,
pub(crate) task_board: Arc<TaskBoard>,
pub(crate) events: TeamEventEmitter,
pub(crate) active_wakes: DashSet<String>,
// Reason: Finish / Error events may fire back-to-back for the same
// conversation; without this dedup window, finalize_turn would run twice
// and double-write the IdleNotification (nomifun-audit 4.3, 8 #3).
pub(crate) finalized_turns: Arc<DashMap<String, Instant>>,
pub(crate) wake_timeouts: Arc<DashMap<String, tokio::task::JoinHandle<()>>>,
}
impl TeammateManager {
pub fn new(
team_id: String,
agents: &[TeamAgent],
mailbox: Arc<Mailbox>,
task_board: Arc<TaskBoard>,
broadcaster: Arc<dyn EventBroadcaster>,
) -> Self {
let mut slots = HashMap::new();
for agent in agents {
let mut a = agent.clone();
a.status = Some(TeammateStatus::Idle);
slots.insert(
a.slot_id.clone(),
AgentSlot {
agent: a,
status: TeammateStatus::Idle,
needs_role_prompt: true,
},
);
}
let events = TeamEventEmitter::new(team_id.clone(), broadcaster);
Self {
team_id,
slots: Mutex::new(slots),
mailbox,
task_board,
events,
active_wakes: DashSet::new(),
finalized_turns: Arc::new(DashMap::new()),
wake_timeouts: Arc::new(DashMap::new()),
}
}
pub async fn get_agent(&self, slot_id: &str) -> Result<TeamAgent, TeamError> {
let slots = self.slots.lock().await;
let slot = slots
.get(slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.to_owned()))?;
Ok(slot.agent.clone())
}
pub async fn list_agents(&self) -> Vec<TeamAgent> {
let slots = self.slots.lock().await;
slots.values().map(|s| s.agent.clone()).collect()
}
pub async fn list_tasks(&self) -> Result<Vec<TeamTask>, TeamError> {
self.task_board.list_tasks(&self.team_id).await
}
pub async fn find_lead_slot_id(&self) -> Option<String> {
let slots = self.slots.lock().await;
slots
.values()
.find(|s| s.agent.role == TeammateRole::Lead)
.map(|s| s.agent.slot_id.clone())
}
}
@@ -0,0 +1,140 @@
use tracing::debug;
use super::{TeammateManager, is_settled};
use crate::error::TeamError;
use crate::types::{MailboxMessageType, TeammateRole, TeammateStatus};
impl TeammateManager {
pub async fn set_status(&self, slot_id: &str, status: TeammateStatus) -> Result<(), TeamError> {
{
let mut slots = self.slots.lock().await;
let slot = slots
.get_mut(slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.to_owned()))?;
slot.status = status;
slot.agent.status = Some(status);
}
self.events.broadcast_agent_status(slot_id, status);
debug!(team_id = %self.team_id, slot_id, %status, "agent status changed");
Ok(())
}
pub async fn get_status(&self, slot_id: &str) -> Result<TeammateStatus, TeamError> {
let slots = self.slots.lock().await;
let slot = slots
.get(slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.to_owned()))?;
Ok(slot.status)
}
pub async fn try_wake(&self, slot_id: &str) -> Result<Option<super::WakePayload>, TeamError> {
let current = self.get_status(slot_id).await?;
if current != TeammateStatus::Idle {
debug!(
team_id = %self.team_id,
slot_id,
current_status = %current,
"skip wake: agent not idle"
);
return Ok(None);
}
self.set_status(slot_id, TeammateStatus::Working).await?;
let payload = self.build_wake_payload(slot_id).await?;
Ok(Some(payload))
}
pub async fn mark_idle(&self, slot_id: &str, summary: Option<&str>) -> Result<Option<String>, TeamError> {
self.set_status(slot_id, TeammateStatus::Idle).await?;
let is_lead = {
let slots = self.slots.lock().await;
let slot = slots
.get(slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.to_owned()))?;
slot.agent.role == TeammateRole::Lead
};
if is_lead {
return Ok(None);
}
if let Some(lead_slot_id) = self.find_lead_slot_id().await
&& lead_slot_id != slot_id
{
self.mailbox
.write(
&self.team_id,
&lead_slot_id,
slot_id,
MailboxMessageType::IdleNotification,
summary.unwrap_or("idle"),
summary,
)
.await?;
}
self.maybe_wake_leader_when_all_idle().await
}
pub async fn take_needs_role_prompt(&self, slot_id: &str) -> bool {
let mut slots = self.slots.lock().await;
if let Some(slot) = slots.get_mut(slot_id) {
let needed = slot.needs_role_prompt;
slot.needs_role_prompt = false;
needed
} else {
false
}
}
pub(crate) async fn maybe_wake_leader_when_all_idle(&self) -> Result<Option<String>, TeamError> {
let slots = self.slots.lock().await;
let mut lead_slot_id = None;
let mut all_teammates_settled = true;
let mut has_teammates = false;
for slot in slots.values() {
if slot.agent.role == TeammateRole::Lead {
lead_slot_id = Some(slot.agent.slot_id.clone());
continue;
}
has_teammates = true;
if !is_settled(slot.status) {
all_teammates_settled = false;
break;
}
}
let Some(lead_id) = lead_slot_id else {
return Ok(None);
};
if !has_teammates {
return Ok(None);
}
if !all_teammates_settled {
return Ok(None);
}
let lead_is_idle = slots
.get(&lead_id)
.map(|s| s.status == TeammateStatus::Idle)
.unwrap_or(false);
if !lead_is_idle {
return Ok(None);
}
drop(slots);
debug!(
team_id = %self.team_id,
lead_slot_id = %lead_id,
"all teammates settled — signaling to wake leader"
);
Ok(Some(lead_id))
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
use std::time::Duration;
use nomifun_ai_agent::AgentStreamEvent;
use tokio::sync::broadcast;
use tracing::warn;
use super::{TeammateManager, WAKE_TIMEOUT_MS, WakePayload, WakeTimeoutHandler};
use crate::error::TeamError;
impl TeammateManager {
pub async fn build_wake_payload(&self, slot_id: &str) -> Result<WakePayload, TeamError> {
let agent = self.get_agent(slot_id).await?;
let tasks = self.task_board.list_tasks(&self.team_id).await?;
let unread = self.mailbox.read_unread(&self.team_id, slot_id).await?;
Ok(WakePayload {
agent,
tasks,
unread_messages: unread,
})
}
pub fn acquire_wake_lock(&self, slot_id: &str) -> bool {
self.active_wakes.insert(slot_id.to_owned())
}
pub fn release_wake_lock(&self, slot_id: &str) {
self.active_wakes.remove(slot_id);
}
pub fn is_wake_active(&self, slot_id: &str) -> bool {
self.active_wakes.contains(slot_id)
}
pub fn clear_wake_timeout(&self, slot_id: &str) {
if let Some((_, handle)) = self.wake_timeouts.remove(slot_id) {
handle.abort();
}
}
pub fn arm_wake_timeout(
&self,
slot_id: &str,
stream_rx: broadcast::Receiver<AgentStreamEvent>,
on_timeout: WakeTimeoutHandler,
) {
let slot_id_owned = slot_id.to_owned();
let map = self.wake_timeouts.clone();
let map_for_task = map.clone();
let handle = tokio::spawn(async move {
let mut rx = stream_rx;
let timeout = Duration::from_millis(WAKE_TIMEOUT_MS);
let sleep = tokio::time::sleep(timeout);
tokio::pin!(sleep);
let timed_out = loop {
tokio::select! {
event = rx.recv() => {
match event {
Ok(AgentStreamEvent::Finish(_)) => break false,
Ok(AgentStreamEvent::Error(_)) => break false,
Err(broadcast::error::RecvError::Closed) => break false,
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(slot_id = %slot_id_owned, skipped = n, "wake watchdog lagged");
sleep.as_mut().reset(tokio::time::Instant::now() + timeout);
}
Ok(_) => {
sleep.as_mut().reset(tokio::time::Instant::now() + timeout);
}
}
}
_ = &mut sleep => break true,
}
};
if timed_out {
on_timeout(slot_id_owned.clone()).await;
}
map_for_task.remove(&slot_id_owned);
});
if let Some(old) = map.insert(slot_id.to_owned(), handle) {
old.abort();
}
}
}
@@ -0,0 +1,900 @@
mod response_builder;
pub(crate) mod spawn_support;
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use dashmap::DashMap;
use nomifun_ai_agent::IWorkerTaskManager;
use nomifun_api_types::{
AddAgentRequest, CreateConversationRequest, CreateTeamRequest, GuideMcpConfig, TeamAgentResponse, TeamMcpPhase,
TeamMcpStatusPayload, TeamResponse, WebSocketMessage,
};
use nomifun_common::{AgentKillReason, AgentType, ProviderWithModel, generate_prefixed_id, now_ms};
use nomifun_conversation::ConversationService;
use nomifun_db::models::TeamRow;
use nomifun_db::{IAgentMetadataRepository, IProviderRepository, ITeamRepository, UpdateTeamParams};
use nomifun_realtime::EventBroadcaster;
use tracing::{info, warn};
use self::spawn_support::{parse_agent_type, resolve_full_auto_mode};
use crate::error::TeamError;
use crate::event_loop::AgentLoopContext;
use crate::session::TeamSession;
use crate::types::{Team, TeamAgent, TeammateRole};
struct SessionEntry {
session: Arc<TeamSession>,
}
pub struct TeamSessionService {
repo: Arc<dyn ITeamRepository>,
agent_metadata_repo: Arc<dyn IAgentMetadataRepository>,
provider_repo: Arc<dyn IProviderRepository>,
conversation_service: ConversationService,
broadcaster: Arc<dyn EventBroadcaster>,
task_manager: Arc<dyn IWorkerTaskManager>,
backend_binary_path: Arc<PathBuf>,
sessions: Arc<DashMap<String, SessionEntry>>,
/// Per-team mutex serializing `add_agent` so concurrent callers cannot
/// read-modify-write the `agents` JSON with stale state (last-writer-wins
/// would otherwise drop entries).
add_agent_locks: Arc<DashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// Per-team mutex serializing `ensure_session` so concurrent callers
/// (e.g. create_team + frontend POST /session) cannot race and start
/// two sessions for the same team.
ensure_session_locks: Arc<DashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// Back-pointer used by [`TeamSession::spawn_agent`] to reach DB-facing
/// orchestration without threading the service through every session method.
/// Stored as `Weak` so the session map does not create a strong cycle with
/// the service that owns it. Set once during [`TeamSessionService::new`]
/// via [`Arc::new_cyclic`].
self_ref: Weak<TeamSessionService>,
/// Guide MCP server config used to refresh the leader's persisted
/// `guide_mcp_config` on backend restart (port/token change each restart).
/// `None` when the Guide server failed to start.
guide_mcp_config: Option<GuideMcpConfig>,
}
impl TeamSessionService {
#[allow(clippy::too_many_arguments)]
pub fn new(
repo: Arc<dyn ITeamRepository>,
agent_metadata_repo: Arc<dyn IAgentMetadataRepository>,
provider_repo: Arc<dyn IProviderRepository>,
conversation_service: ConversationService,
broadcaster: Arc<dyn EventBroadcaster>,
task_manager: Arc<dyn IWorkerTaskManager>,
backend_binary_path: Arc<PathBuf>,
guide_mcp_config: Option<GuideMcpConfig>,
) -> Arc<Self> {
Arc::new_cyclic(|weak| Self {
repo,
agent_metadata_repo,
provider_repo,
conversation_service,
broadcaster,
task_manager,
backend_binary_path,
sessions: Arc::new(DashMap::new()),
add_agent_locks: Arc::new(DashMap::new()),
ensure_session_locks: Arc::new(DashMap::new()),
self_ref: weak.clone(),
guide_mcp_config,
})
}
/// Assemble the `Team` aggregate from a `teams` row + its `team_agents`
/// rows. The roster used to live in a `TeamRow.agents` JSON column; it now
/// lives in the dedicated `team_agents` table (spec §5.4), so we join it in
/// here. Returns `None` agents on a load failure only by surfacing the
/// `DbError` to the caller.
async fn assemble_team(&self, row: &TeamRow) -> Result<Team, TeamError> {
let agent_rows = self.repo.list_team_agents(&row.id).await?;
Ok(Team::from_parts(row, &agent_rows))
}
/// Fetch + assemble a single team by id, or `TeamNotFound`.
async fn load_team(&self, team_id: &str) -> Result<Team, TeamError> {
let row = self
.repo
.get_team(team_id)
.await?
.ok_or_else(|| TeamError::TeamNotFound(team_id.into()))?;
self.assemble_team(&row).await
}
/// Restore sessions for all existing teams. Called once at app startup
/// so that MCP servers are available before any user sends a message.
pub async fn restore_all_sessions(&self) {
let teams = match self.repo.list_teams().await {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = %e, "failed to list teams for session restore");
return;
}
};
for team in &teams {
if let Err(e) = self.ensure_session(&team.id).await {
tracing::warn!(team_id = %team.id, error = %e, "failed to restore session on startup");
continue;
}
// Patch the leader's persisted guide_mcp_config so it points at the
// current restart's port/token (the Guide server picks a new random
// port on every start).
if let Some(ref cfg) = self.guide_mcp_config {
let team_data = match self.load_team(&team.id).await {
Ok(t) => t,
Err(_) => continue,
};
if let Some(leader) = team_data.agents.iter().find(|a| a.role == TeammateRole::Lead) {
let patch = serde_json::json!({ "guide_mcp_config": cfg });
if let Err(e) = self
.conversation_service
.update_extra(&leader.conversation_id, patch)
.await
{
warn!(
team_id = %team.id,
conversation_id = %leader.conversation_id,
error = %e,
"failed to patch leader guide_mcp_config on restore"
);
}
}
}
}
if !teams.is_empty() {
tracing::info!(count = teams.len(), "team sessions restored on startup");
}
}
pub async fn create_team(&self, user_id: &str, req: CreateTeamRequest) -> Result<TeamResponse, TeamError> {
if req.agents.is_empty() {
return Err(TeamError::InvalidRequest("at least one agent is required".into()));
}
let team_id = generate_prefixed_id("team");
let now = now_ms();
let mut agents = Vec::with_capacity(req.agents.len());
for (i, input) in req.agents.iter().enumerate() {
let slot_id = generate_prefixed_id("slot");
let role = if i == 0 {
TeammateRole::Lead
} else {
TeammateRole::parse(&input.role).unwrap_or(TeammateRole::Teammate)
};
// Resolve the conversation_id: adopt an existing conversation when
// the caller supplies one (single-chat → team-chat handoff), or
// create a new one otherwise.
let conv_id = if let Some(existing_id) = input.conversation_id {
// Adopt the existing conversation by updating its extra with
// teamId and backend so the agent is wired into this team.
// The conversation service is string-keyed (Option A), so bridge
// the now-i64 FK to a String at the call boundary.
let existing_id_str = existing_id.to_string();
self.conversation_service
.update_extra(
&existing_id_str,
serde_json::json!({"teamId": team_id, "backend": input.backend, "session_mode": resolve_full_auto_mode(&input.backend)}),
)
.await
.map_err(|e| TeamError::InvalidRequest(format!("failed to adopt conversation: {e}")))?;
// Notify frontend that this conversation moved into a team so
// the sidebar can remove it from the standalone list.
self.broadcaster.broadcast(WebSocketMessage::new(
"conversation.listChanged",
serde_json::json!({
"conversation_id": existing_id,
"action": "updated",
}),
));
existing_id_str
} else {
let agent_type = parse_agent_type(&input.backend)?;
let provider_id = if agent_type == AgentType::Nomi {
self.resolve_provider_for_model(&input.model)
.await
.unwrap_or_else(|| input.backend.clone())
} else {
input.backend.clone()
};
// Top-level `model` is nomi-only per spec 2026-05-12; for
// other agent types the model/provider ride along in `extra`.
let (top_level_model, extra) = if agent_type == AgentType::Nomi {
let mut extra = serde_json::json!({
"teamId": team_id,
"backend": input.backend,
"session_mode": resolve_full_auto_mode(&input.backend),
});
if let Some(ref ws) = req.workspace
&& !ws.is_empty()
{
extra["workspace"] = serde_json::Value::String(ws.clone());
}
(
Some(ProviderWithModel {
provider_id,
model: input.model.clone(),
use_model: None,
}),
extra,
)
} else {
let mut extra = serde_json::json!({
"teamId": team_id,
"backend": input.backend,
"session_mode": resolve_full_auto_mode(&input.backend),
"provider_id": provider_id,
"current_model_id": input.model.clone(),
});
if let Some(ref ws) = req.workspace
&& !ws.is_empty()
{
extra["workspace"] = serde_json::Value::String(ws.clone());
}
(None, extra)
};
let conv_req = CreateConversationRequest {
r#type: agent_type,
name: Some(input.name.clone()),
model: top_level_model,
source: None,
channel_chat_id: None,
extra,
};
let conv = self
.conversation_service
.create(user_id, conv_req)
.await
.map_err(TeamError::from_conversation_create)?;
conv.id.to_string()
};
agents.push(TeamAgent {
slot_id,
name: input.name.clone(),
role,
conversation_id: conv_id,
backend: input.backend.clone(),
model: input.model.clone(),
custom_agent_id: input.custom_agent_id.clone(),
status: None,
conversation_type: None,
cli_path: None,
});
}
let lead_agent_id = agents.first().map(|a| a.slot_id.clone());
let row = TeamRow {
id: team_id.clone(),
user_id: user_id.to_owned(),
name: req.name.clone(),
workspace: req.workspace.clone().unwrap_or_default(),
workspace_mode: "shared".into(),
lead_agent_id: lead_agent_id.clone(),
session_mode: None,
agents_version: "1.0.1".into(),
created_at: now,
updated_at: now,
};
// Insert the `teams` row first so the `team_agents.team_id` FK is
// satisfied. Each agent's conversation was already created above, so
// the `team_agents.conversation_id` FK holds too (spec §9.A: session
// before slot).
self.repo.create_team(&row).await?;
for (i, agent) in agents.iter().enumerate() {
self.repo.create_team_agent(&agent.to_row(&team_id, i as i64)).await?;
}
let team = Team {
id: team_id,
name: req.name,
agents,
lead_agent_id,
created_at: now,
updated_at: now,
};
info!(team_id = %team.id, "Team created");
self.broadcaster.broadcast(WebSocketMessage::new(
"team.created",
serde_json::json!({ "team_id": team.id, "team_name": team.name }),
));
// Auto-start session so MCP is injected immediately after team creation.
// Failure only logs — the team is persisted and frontend can retry
// via POST /api/teams/{id}/session if needed.
if let Err(e) = self.ensure_session_inner(&team.id, true).await {
warn!(team_id = %team.id, error = %e, "auto ensure_session after create_team failed");
}
self.build_team_response(&team).await
}
pub async fn list_teams(&self) -> Result<Vec<TeamResponse>, TeamError> {
let rows = self.repo.list_teams().await?;
let mut teams = Vec::with_capacity(rows.len());
for row in &rows {
match self.assemble_team(row).await {
Ok(team) => match self.build_team_response(&team).await {
Ok(resp) => teams.push(resp),
Err(e) => {
tracing::warn!(team_id = %row.id, error = %e, "skipping team with build error");
}
},
Err(e) => {
tracing::warn!(team_id = %row.id, error = %e, "skipping team with agent-roster load error");
}
}
}
Ok(teams)
}
pub async fn get_team(&self, team_id: &str) -> Result<TeamResponse, TeamError> {
let team = self.load_team(team_id).await?;
self.build_team_response(&team).await
}
pub async fn remove_team(&self, user_id: &str, team_id: &str) -> Result<(), TeamError> {
let team = self.load_team(team_id).await?;
self.stop_session(team_id);
let kill_futures: Vec<_> = team
.agents
.iter()
.map(|agent| {
self.task_manager
.kill_and_wait(&agent.conversation_id, Some(AgentKillReason::TeamDeleted))
})
.collect();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(3),
futures_util::future::join_all(kill_futures),
)
.await;
for agent in &team.agents {
let _ = self.conversation_service.delete(user_id, &agent.conversation_id).await;
}
// A single delete: the FK `ON DELETE CASCADE` chain removes the team's
// `team_agents`, `mailbox`, `team_tasks`, and `team_task_deps` rows
// (spec §5.4) — no manual `delete_mailbox_by_team`/`delete_tasks_by_team`.
self.repo.delete_team(team_id).await?;
self.add_agent_locks.remove(team_id);
info!(team_id = %team_id, "Team removed");
Ok(())
}
pub async fn rename_team(&self, team_id: &str, name: &str) -> Result<(), TeamError> {
self.repo
.get_team(team_id)
.await?
.ok_or_else(|| TeamError::TeamNotFound(team_id.into()))?;
self.repo
.update_team(
team_id,
&UpdateTeamParams {
name: Some(name.to_owned()),
lead_agent_id: None,
},
)
.await?;
Ok(())
}
pub async fn add_agent(
&self,
user_id: &str,
team_id: &str,
req: AddAgentRequest,
) -> Result<TeamAgentResponse, TeamError> {
let lock = self
.add_agent_locks
.entry(team_id.to_owned())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone();
let _guard = lock.lock().await;
// Validate the team exists (FK target for the new slot) and capture the
// current roster size for the new slot's sort_order.
self.repo
.get_team(team_id)
.await?
.ok_or_else(|| TeamError::TeamNotFound(team_id.into()))?;
let existing_agents = self.repo.list_team_agents(team_id).await?;
let slot_id = generate_prefixed_id("slot");
let role = TeammateRole::parse(&req.role).unwrap_or(TeammateRole::Teammate);
let agent_type = parse_agent_type(&req.backend)?;
let provider_id = if agent_type == AgentType::Nomi {
self.resolve_provider_for_model(&req.model)
.await
.unwrap_or_else(|| req.backend.clone())
} else {
req.backend.clone()
};
// Top-level `model` is nomi-only per spec 2026-05-12; for other
// agent types the model/provider ride along in `extra`.
let (top_level_model, extra) = if agent_type == AgentType::Nomi {
(
Some(ProviderWithModel {
provider_id,
model: req.model.clone(),
use_model: None,
}),
serde_json::json!({
"teamId": team_id,
"backend": req.backend,
}),
)
} else {
(
None,
serde_json::json!({
"teamId": team_id,
"backend": req.backend,
"provider_id": provider_id,
"current_model_id": req.model.clone(),
}),
)
};
let conv_req = CreateConversationRequest {
r#type: agent_type,
name: Some(req.name.clone()),
model: top_level_model,
source: None,
channel_chat_id: None,
extra,
};
let conv = self
.conversation_service
.create(user_id, conv_req)
.await
.map_err(TeamError::from_conversation_create)?;
let agent = TeamAgent {
slot_id,
name: req.name,
role,
conversation_id: conv.id.to_string(),
backend: req.backend,
model: req.model,
custom_agent_id: req.custom_agent_id,
status: None,
conversation_type: None,
cli_path: None,
};
// The slot's conversation now exists, so the `team_agents.conversation_id`
// FK holds (spec §9.A). Append at the end of the roster.
self.repo
.create_team_agent(&agent.to_row(team_id, existing_agents.len() as i64))
.await?;
if let Some(session) = self.sessions.get(team_id).map(|e| Arc::clone(&e.session)) {
session.add_agent(&agent).await;
self.register_event_loop(team_id, &agent.slot_id);
}
self.build_agent_response(&agent).await
}
pub async fn remove_agent(&self, user_id: &str, team_id: &str, slot_id: &str) -> Result<(), TeamError> {
self.repo
.get_team(team_id)
.await?
.ok_or_else(|| TeamError::TeamNotFound(team_id.into()))?;
let slot = self
.repo
.get_team_agent(slot_id)
.await?
.filter(|a| a.team_id == team_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.into()))?;
if let Some(conv_id) = slot.conversation_id {
// conversation_service is string-keyed (Option A); bridge the i64 FK.
let _ = self.conversation_service.delete(user_id, &conv_id.to_string()).await;
}
self.repo.remove_team_agent(slot_id).await?;
if let Some(session) = self.sessions.get(team_id).map(|e| Arc::clone(&e.session)) {
let _ = session.remove_agent(slot_id).await;
}
Ok(())
}
pub async fn rename_agent(&self, team_id: &str, slot_id: &str, name: &str) -> Result<(), TeamError> {
self.repo
.get_team(team_id)
.await?
.ok_or_else(|| TeamError::TeamNotFound(team_id.into()))?;
let normalized = crate::scheduler::normalize_name(name);
if normalized.is_empty() {
return Err(TeamError::InvalidRequest(
"rename_agent.name is empty after normalization".into(),
));
}
let agents = self.repo.list_team_agents(team_id).await?;
agents
.iter()
.find(|a| a.slot_id == slot_id)
.ok_or_else(|| TeamError::AgentNotFound(slot_id.into()))?;
// Uniqueness check against all other agents in the team.
let has_conflict = agents
.iter()
.any(|a| a.slot_id != slot_id && crate::scheduler::normalize_name(&a.name) == normalized);
if has_conflict {
return Err(TeamError::DuplicateAgentName(name.to_owned()));
}
self.repo.rename_team_agent(slot_id, name).await?;
if let Some(session) = self.sessions.get(team_id).map(|e| Arc::clone(&e.session)) {
let _ = session.rename_agent(slot_id, name).await;
}
Ok(())
}
/// Start the team's MCP server and rebuild every agent process so it
/// carries a fresh `team_mcp_stdio_config` pointing at the new server.
///
/// Flow (mcp.md §4.3):
/// 1. Start `TeamSession` (opens the MCP TCP server).
/// 2. For each agent: persist `team_mcp_stdio_config` into
/// `conversation.extra` → `task_manager.kill(conv_id, TeamMcpRebuild)`
/// → `conversation_service.warmup(...)` rebuilds the ACP process with
/// the new extra.
/// 3. Spawn per-agent event loops that drain the mailbox whenever notified.
/// 4. Only insert into `sessions` after every step above succeeds — on
/// any failure, stop the session and leave the map untouched so a
/// retry can start cleanly.
pub async fn ensure_session(&self, team_id: &str) -> Result<(), TeamError> {
self.ensure_session_inner(team_id, false).await
}
async fn ensure_session_inner(&self, team_id: &str, skip_leader: bool) -> Result<(), TeamError> {
if self.sessions.contains_key(team_id) {
return Ok(());
}
let lock = self
.ensure_session_locks
.entry(team_id.to_owned())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone();
let _guard = lock.lock().await;
// Re-check after acquiring lock (another caller may have completed).
if self.sessions.contains_key(team_id) {
return Ok(());
}
let row = match self.repo.get_team(team_id).await {
Ok(Some(row)) => row,
Ok(None) => {
self.broadcast_mcp_phase(team_id, "", TeamMcpPhase::LoadFailed, None, |p| {
p.error = Some(format!("team not found: {team_id}"));
});
return Err(TeamError::TeamNotFound(team_id.into()));
}
Err(e) => {
self.broadcast_mcp_phase(team_id, "", TeamMcpPhase::LoadFailed, None, |p| {
p.error = Some(e.to_string());
});
return Err(e.into());
}
};
let user_id = row.user_id.clone();
let team = self.assemble_team(&row).await?;
let agents_snapshot: Vec<TeamAgent> = team.agents.clone();
let session = match TeamSession::start(
team,
self.repo.clone(),
self.broadcaster.clone(),
self.backend_binary_path.clone(),
self.task_manager.clone(),
user_id.clone(),
self.self_ref.clone(),
)
.await
{
Ok(session) => session,
Err(e) => {
self.broadcast_mcp_phase(team_id, "", TeamMcpPhase::SessionError, None, |p| {
p.error = Some(e.to_string());
});
return Err(e);
}
};
self.broadcast_mcp_phase(team_id, "", TeamMcpPhase::SessionInjecting, None, |_| {});
if let Err(e) = self
.rebuild_agent_processes(team_id, &session, &user_id, &agents_snapshot, skip_leader)
.await
{
session.stop();
return Err(e);
}
let session = Arc::new(session);
// Spawn per-agent event loops
self.spawn_event_loops(&session, &user_id, &agents_snapshot);
let entry = SessionEntry {
session: session.clone(),
};
self.sessions.insert(team_id.to_owned(), entry);
// Notify all agents so they drain any pre-existing mailbox messages
// (e.g. from a prior session or backend restart).
for agent in &agents_snapshot {
session.notify_agent(&agent.slot_id);
}
let active_count = if skip_leader {
agents_snapshot.iter().filter(|a| a.role != TeammateRole::Lead).count()
} else {
agents_snapshot.len()
};
self.broadcast_mcp_phase(team_id, "", TeamMcpPhase::SessionReady, None, |p| {
p.server_count = Some(active_count);
});
Ok(())
}
fn broadcast_mcp_phase<F>(&self, team_id: &str, slot_id: &str, phase: TeamMcpPhase, port: Option<u16>, customize: F)
where
F: FnOnce(&mut TeamMcpStatusPayload),
{
let mut payload = TeamMcpStatusPayload {
team_id: team_id.to_owned(),
slot_id: slot_id.to_owned(),
phase,
port,
server_count: None,
error: None,
};
customize(&mut payload);
let event = WebSocketMessage::new(
"team.mcpStatus",
serde_json::to_value(payload).expect("serialize mcp status payload"),
);
self.broadcaster.broadcast(event);
}
async fn rebuild_agent_processes(
&self,
team_id: &str,
session: &TeamSession,
user_id: &str,
agents: &[TeamAgent],
skip_leader: bool,
) -> Result<(), TeamError> {
for agent in agents {
let cfg = session.mcp_stdio_config(&agent.slot_id);
let patch = serde_json::json!({
"team_mcp_stdio_config": cfg,
"session_mode": resolve_full_auto_mode(&agent.backend),
});
// Always persist team_mcp_stdio_config into the leader's extra
// so subsequent warmups pick it up. Only skip the kill+warmup
// when the leader is already running (guide flow).
if skip_leader && agent.role == TeammateRole::Lead {
if let Err(e) = self
.conversation_service
.update_extra(&agent.conversation_id, patch)
.await
{
warn!(
team_id,
slot_id = %agent.slot_id,
error = %e,
"failed to persist team_mcp_stdio_config for skipped leader"
);
}
continue;
}
if let Err(e) = self
.conversation_service
.update_extra(&agent.conversation_id, patch)
.await
{
let msg = format!("failed to persist team_mcp_stdio_config for {}: {e}", agent.slot_id);
self.broadcast_mcp_phase(team_id, &agent.slot_id, TeamMcpPhase::ConfigWriteFailed, None, |p| {
p.error = Some(msg.clone());
});
return Err(TeamError::InvalidRequest(msg));
}
let _ = self
.task_manager
.kill(&agent.conversation_id, Some(AgentKillReason::TeamMcpRebuild));
if let Err(e) = self
.conversation_service
.warmup(user_id, &agent.conversation_id, &self.task_manager)
.await
{
let msg = format!("failed to warm up rebuilt agent {}: {e}", agent.slot_id);
self.broadcast_mcp_phase(team_id, &agent.slot_id, TeamMcpPhase::SessionError, None, |p| {
p.error = Some(msg.clone());
});
warn!(
team_id,
slot_id = %agent.slot_id,
conversation_id = %agent.conversation_id,
error = %e,
"warmup failed during rebuild"
);
return Err(TeamError::InvalidRequest(msg));
}
}
Ok(())
}
/// Spawn per-agent event loops that drain the mailbox whenever notified.
/// Each agent gets its own tokio task that runs until the session shuts down.
fn spawn_event_loops(&self, session: &Arc<TeamSession>, user_id: &str, agents: &[TeamAgent]) {
let registry = session.event_loops();
for agent in agents {
let ctx = AgentLoopContext {
team_id: session.team_id().to_owned(),
slot_id: agent.slot_id.clone(),
user_id: user_id.to_owned(),
session: session.clone(),
scheduler: session.scheduler().clone(),
mailbox: session.mailbox().clone(),
task_manager: self.task_manager.clone(),
conversation_service: self.conversation_service.clone(),
broadcaster: self.broadcaster.clone(),
registry: registry.clone(),
};
registry.spawn(&agent.slot_id, ctx);
}
}
/// Register an event loop for a dynamically spawned agent.
///
/// Called by [`TeamSession::spawn_agent`] after `attach_spawned_agent_process`
/// succeeds so the newly booted agent gets its own drain loop — exactly as
/// `spawn_event_loops` does for the initial members during `ensure_session`.
pub(crate) fn register_event_loop(&self, team_id: &str, slot_id: &str) {
let Some(entry) = self.sessions.get(team_id) else {
return;
};
let session = Arc::clone(&entry.session);
let registry = session.event_loops();
let ctx = AgentLoopContext {
team_id: team_id.to_owned(),
slot_id: slot_id.to_owned(),
user_id: session.user_id().to_owned(),
session: session.clone(),
scheduler: session.scheduler().clone(),
mailbox: session.mailbox().clone(),
task_manager: self.task_manager.clone(),
conversation_service: self.conversation_service.clone(),
broadcaster: self.broadcaster.clone(),
registry: registry.clone(),
};
registry.spawn(slot_id, ctx);
}
pub async fn get_session_user_id(&self, team_id: &str) -> Option<String> {
self.sessions.get(team_id).map(|e| e.session.user_id().to_owned())
}
pub fn get_session_scheduler(&self, team_id: &str) -> Option<Arc<crate::scheduler::TeammateManager>> {
self.sessions.get(team_id).map(|e| e.session.scheduler().clone())
}
pub fn stop_session(&self, team_id: &str) {
if let Some((_, entry)) = self.sessions.remove(team_id) {
entry.session.event_loops().shutdown();
entry.session.stop();
}
}
pub async fn send_message(
&self,
team_id: &str,
content: &str,
files: Option<Vec<String>>,
) -> Result<(), TeamError> {
self.ensure_session(team_id).await?;
let session = {
let entry = self
.sessions
.get(team_id)
.ok_or_else(|| TeamError::SessionNotFound(team_id.into()))?;
Arc::clone(&entry.session)
};
session.send_message(content, files).await
}
pub async fn send_message_to_agent(
&self,
team_id: &str,
slot_id: &str,
content: &str,
files: Option<Vec<String>>,
) -> Result<(), TeamError> {
self.ensure_session(team_id).await?;
let session = {
let entry = self
.sessions
.get(team_id)
.ok_or_else(|| TeamError::SessionNotFound(team_id.into()))?;
Arc::clone(&entry.session)
};
session.send_message_to_agent(slot_id, content, files).await
}
pub async fn set_session_mode(&self, team_id: &str, mode: &str) -> Result<(), TeamError> {
let team = self.load_team(team_id).await?;
for agent in &team.agents {
if let Some(instance) = self.task_manager.get_task(&agent.conversation_id)
&& let Err(e) = instance.set_mode(mode).await
{
warn!(
team_id,
slot_id = %agent.slot_id,
conversation_id = %agent.conversation_id,
error = %e,
"failed to set session mode on agent"
);
}
let patch = serde_json::json!({ "session_mode": mode });
let _ = self
.conversation_service
.update_extra(&agent.conversation_id, patch)
.await;
let _ = self
.conversation_service
.save_acp_runtime_mode(&agent.conversation_id, mode)
.await;
}
Ok(())
}
/// Wake a specific agent in a team session (trigger it to read mailbox).
/// Called by MCP dispatch after `team_send_message` writes to mailbox.
///
/// In the event-loop model this simply notifies the agent's event loop.
pub async fn wake_agent_in_session(&self, team_id: &str, slot_id: &str) -> Result<(), TeamError> {
let entry = self
.sessions
.get(team_id)
.ok_or_else(|| TeamError::SessionNotFound(team_id.into()))?;
entry.session.notify_agent(slot_id);
Ok(())
}
}
@@ -0,0 +1,65 @@
use super::*;
impl TeamSessionService {
pub(super) async fn build_team_response(&self, team: &Team) -> Result<TeamResponse, TeamError> {
let mut agents = Vec::with_capacity(team.agents.len());
for agent in &team.agents {
agents.push(self.build_agent_response(agent).await?);
}
Ok(TeamResponse {
id: team.id.clone(),
name: team.name.clone(),
agents,
lead_agent_id: team.lead_agent_id.clone(),
created_at: team.created_at,
updated_at: team.updated_at,
})
}
pub(super) async fn build_agent_response(
&self,
agent: &TeamAgent,
) -> Result<nomifun_api_types::TeamAgentResponse, TeamError> {
let icon = self.resolve_agent_icon(agent).await?;
let mut response = agent.to_response_with_icon(icon);
response.pending_confirmations = self.pending_confirmation_count(&agent.conversation_id);
Ok(response)
}
fn pending_confirmation_count(&self, conversation_id: &str) -> usize {
self.task_manager
.get_task(conversation_id)
.map(|agent| agent.get_confirmations().len())
.unwrap_or(0)
}
async fn resolve_agent_icon(&self, agent: &TeamAgent) -> Result<Option<String>, TeamError> {
if let Some(custom_agent_id) = agent.custom_agent_id.as_deref()
&& let Some(row) = self.agent_metadata_repo.get(custom_agent_id).await?
&& row.icon.is_some()
{
return Ok(row.icon);
}
if let Some(row) = self
.agent_metadata_repo
.find_builtin_by_backend(agent.backend.as_str())
.await?
&& row.icon.is_some()
{
return Ok(row.icon);
}
if agent.backend == "acp"
&& let Some(row) = self
.agent_metadata_repo
.find_builtin_by_backend(agent.model.as_str())
.await?
{
return Ok(row.icon);
}
Ok(None)
}
}
@@ -0,0 +1,356 @@
use super::*;
use nomifun_api_types::BehaviorPolicy;
use nomifun_common::AgentType;
use nomifun_common::constants::{TEAM_CAPABLE_BACKENDS, has_mcp_capability};
/// Known ACP vendor labels. Kept in lockstep with the `agent_metadata`
/// seed in `005_agent_metadata.sql` — a caller hitting an unknown
/// vendor should trigger a schema drift discussion, not silently fall
/// through.
const ACP_VENDOR_LABELS: &[&str] = &[
"claude",
"codex",
"gemini",
"qwen",
"codebuddy",
"droid",
"goose",
"auggie",
"kimi",
"opencode",
"copilot",
"qoder",
"vibe",
"cursor",
"kiro",
"hermes",
"snow",
];
pub(super) fn parse_agent_type(backend: &str) -> Result<AgentType, TeamError> {
// Any registered ACP vendor label collapses to `AgentType::Acp`.
if ACP_VENDOR_LABELS.contains(&backend) {
return Ok(AgentType::Acp);
}
// Otherwise interpret as a top-level `AgentType` (e.g. "acp",
// "nanobot", "nomi", "remote", "openclaw-gateway").
let quoted = format!("\"{backend}\"");
if let Ok(agent_type) = serde_json::from_str::<AgentType>(&quoted) {
return Ok(agent_type);
}
Err(TeamError::InvalidRequest(format!("unsupported backend: {backend}")))
}
/// Resolve the most permissive session mode for a given backend string.
/// Reuses `AgentType::full_auto_mode_id` from nomifun-common.
pub(crate) fn resolve_full_auto_mode(backend: &str) -> &'static str {
let agent_type = if ACP_VENDOR_LABELS.contains(&backend) {
AgentType::Acp
} else {
let quoted = format!("\"{backend}\"");
serde_json::from_str::<AgentType>(&quoted).unwrap_or(AgentType::Acp)
};
agent_type.full_auto_mode_id(Some(backend))
}
impl TeamSessionService {
/// Check if a backend is allowed to participate in team mode.
/// Hard whitelist passes immediately; then checks behavior_policy.supports_team;
/// finally queries persisted `agent_capabilities` for MCP transport declarations.
pub(crate) async fn is_backend_team_capable(&self, backend: &str) -> bool {
if TEAM_CAPABLE_BACKENDS.contains(&backend) {
return true;
}
let Ok(Some(row)) = self.agent_metadata_repo.find_builtin_by_backend(backend).await else {
return false;
};
let bp_supports = row
.behavior_policy
.as_deref()
.and_then(|s| serde_json::from_str::<BehaviorPolicy>(s).ok())
.is_some_and(|bp| bp.supports_team);
if bp_supports {
return true;
}
let caps = row
.agent_capabilities
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
has_mcp_capability(caps.as_ref())
}
/// Return all backends currently team-capable (hard whitelist + behavior_policy + dynamically detected).
/// Used to build the Lead prompt's `available_agent_types` list.
pub(crate) async fn list_team_capable_backends(&self) -> Vec<(String, String)> {
let Ok(rows) = self.agent_metadata_repo.list_all().await else {
return TEAM_CAPABLE_BACKENDS
.iter()
.map(|b| (b.to_string(), capitalize(b)))
.collect();
};
let mut result: Vec<(String, String)> = Vec::new();
for row in &rows {
if !row.enabled {
continue;
}
// Use backend if present, otherwise agent_type as identifier
let key = match row.backend.as_deref() {
Some(b) => b.to_string(),
None => row.agent_type.clone(),
};
// Check behavior_policy.supports_team (covers agents with backend=NULL like nomi)
let bp_supports = row
.behavior_policy
.as_deref()
.and_then(|s| serde_json::from_str::<BehaviorPolicy>(s).ok())
.is_some_and(|bp| bp.supports_team);
if bp_supports {
result.push((key, row.name.clone()));
continue;
}
// Hard whitelist (only works when backend is present)
if let Some(backend) = row.backend.as_deref()
&& TEAM_CAPABLE_BACKENDS.contains(&backend)
{
result.push((key, row.name.clone()));
continue;
}
// Dynamic MCP detection
let caps = row
.agent_capabilities
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
if has_mcp_capability(caps.as_ref()) {
result.push((key, row.name.clone()));
}
}
// Ensure hard whitelist entries are present even if not in DB
for &b in TEAM_CAPABLE_BACKENDS {
if !result.iter().any(|(bk, _)| bk == b) {
result.push((b.to_string(), capitalize(b)));
}
}
result
}
/// Return the `team_list_models` response built from DB rows.
/// Falls back to the hardcoded response if the DB query fails.
/// For internal agents (like nomi with backend=NULL), enriches
/// with models from the providers table.
pub(crate) async fn list_models_from_db(&self, agent_type_filter: Option<&str>) -> serde_json::Value {
let Ok(rows) = self.agent_metadata_repo.list_all().await else {
return crate::mcp::tools::handle_team_list_models(&serde_json::Value::Null);
};
let provider_models = self.collect_provider_models().await;
crate::mcp::tools::build_list_models_from_rows(&rows, agent_type_filter, &provider_models)
}
/// Collect all enabled provider model IDs grouped by provider name.
/// Returns a flat list of model IDs for use by internal agents (nomi).
async fn collect_provider_models(&self) -> Vec<String> {
let Ok(providers) = self.provider_repo.list().await else {
return vec![];
};
providers
.into_iter()
.filter(|p| p.enabled)
.flat_map(|p| serde_json::from_str::<Vec<String>>(&p.models).unwrap_or_default())
.collect()
}
/// Find the provider ID that contains a given model name.
/// Iterates all enabled providers and checks their models JSON array.
pub(crate) async fn resolve_provider_for_model(&self, model: &str) -> Option<String> {
let providers = self.provider_repo.list().await.ok()?;
for p in providers {
if !p.enabled {
continue;
}
let models: Vec<String> = serde_json::from_str(&p.models).unwrap_or_default();
if models.iter().any(|m| m == model) {
return Some(p.id);
}
}
None
}
pub(crate) async fn default_model_for_backend(&self, backend: &str) -> Option<String> {
let row = self.agent_metadata_repo.find_builtin_by_backend(backend).await.ok()??;
let json: serde_json::Value = serde_json::from_str(row.available_models.as_deref()?).ok()?;
if let Some(id) = json.get("current_model_id").and_then(|v| v.as_str())
&& !id.is_empty()
{
return Some(id.to_owned());
}
let arr = json
.get("available_models")
.and_then(|v| v.as_array())
.or_else(|| json.as_array())?;
arr.first()
.and_then(|e| e.get("id").and_then(|v| v.as_str()))
.map(|s| s.to_owned())
}
pub async fn spawn_agent_in_session(
&self,
team_id: &str,
caller_slot_id: &str,
req: crate::session::SpawnAgentRequest,
) -> Result<TeamAgent, TeamError> {
let entry = self
.sessions
.get(team_id)
.ok_or_else(|| TeamError::SessionNotFound(team_id.into()))?;
entry.session.spawn_agent(caller_slot_id, req).await
}
pub fn dispose_all(&self) {
let keys: Vec<String> = self.sessions.iter().map(|entry| entry.key().clone()).collect();
for key in keys {
self.stop_session(&key);
}
info!("All team sessions disposed");
}
pub(crate) fn conversation_service_ref(&self) -> &ConversationService {
&self.conversation_service
}
/// Create the conversation + persist the new agent slot for a spawn.
///
/// Holds the per-team `add_agent` lock for the entirety of the
/// read-modify-write on `teams.agents`, matching [`TeamSessionService::add_agent`]
/// (W4-D23) so concurrent spawns cannot race and drop slots.
///
/// The lock is *not* held across the process warmup step — callers
/// (`TeamSession::spawn_agent`) wire that up separately so a slow
/// `warmup` never stalls other spawns against the same team.
pub(crate) async fn persist_spawned_agent(
&self,
team_id: &str,
user_id: &str,
name: String,
backend: String,
model: String,
custom_agent_id: Option<String>,
) -> Result<TeamAgent, TeamError> {
let lock = self
.add_agent_locks
.entry(team_id.to_owned())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone();
let _guard = lock.lock().await;
// Validate the team exists (FK target) + capture roster size for the
// new slot's sort_order.
self.repo
.get_team(team_id)
.await?
.ok_or_else(|| TeamError::TeamNotFound(team_id.into()))?;
let existing_agents = self.repo.list_team_agents(team_id).await?;
let agent_type = parse_agent_type(&backend)?;
let provider_id = if agent_type == AgentType::Nomi {
self.resolve_provider_for_model(&model).await.unwrap_or(backend.clone())
} else {
backend.clone()
};
// Top-level `model` is nomi-only per spec 2026-05-12; for other
// agent types the model/provider ride along in `extra`.
let (top_level_model, extra) = if agent_type == AgentType::Nomi {
(
Some(ProviderWithModel {
provider_id,
model: model.clone(),
use_model: None,
}),
serde_json::json!({
"teamId": team_id,
"backend": backend,
}),
)
} else {
(
None,
serde_json::json!({
"teamId": team_id,
"backend": backend,
"provider_id": provider_id,
"current_model_id": model.clone(),
}),
)
};
let conv_req = CreateConversationRequest {
r#type: agent_type,
name: Some(name.clone()),
model: top_level_model,
source: None,
channel_chat_id: None,
extra,
};
let conv = self
.conversation_service
.create(user_id, conv_req)
.await
.map_err(TeamError::from_conversation_create)?;
let agent = TeamAgent {
slot_id: generate_prefixed_id("slot"),
name,
role: TeammateRole::Teammate,
conversation_id: conv.id.to_string(),
backend,
model,
custom_agent_id,
status: None,
conversation_type: None,
cli_path: None,
};
// Conversation created above satisfies the slot's conversation_id FK
// (spec §9.A). Append at the end of the roster.
self.repo
.create_team_agent(&agent.to_row(team_id, existing_agents.len() as i64))
.await?;
Ok(agent)
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_agent_type_known_backends() {
assert_eq!(parse_agent_type("acp").unwrap(), AgentType::Acp);
assert_eq!(parse_agent_type("nanobot").unwrap(), AgentType::Nanobot);
assert_eq!(parse_agent_type("remote").unwrap(), AgentType::Remote);
assert_eq!(parse_agent_type("nomi").unwrap(), AgentType::Nomi);
}
#[test]
fn parse_agent_type_unknown_backend_returns_error() {
let err = parse_agent_type("unknown").unwrap_err();
assert!(matches!(err, TeamError::InvalidRequest(_)));
}
#[test]
fn parse_agent_type_openclaw_gateway() {
assert_eq!(
parse_agent_type("openclaw-gateway").unwrap(),
AgentType::OpenclawGateway
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,409 @@
use std::sync::Arc;
use nomifun_common::{generate_prefixed_id, now_ms};
use nomifun_db::ITeamRepository;
use nomifun_db::UpdateTaskParams;
use nomifun_db::models::TeamTaskRow;
use tracing::debug;
use crate::error::TeamError;
use crate::types::{TaskStatus, TeamTask};
pub struct TaskBoard {
repo: Arc<dyn ITeamRepository>,
}
/// Optional fields for task update.
#[derive(Debug, Clone, Default)]
pub struct TaskUpdate {
pub status: Option<TaskStatus>,
pub description: Option<String>,
pub owner: Option<String>,
pub blocked_by: Option<Vec<String>>,
pub metadata: Option<serde_json::Value>,
}
impl TaskBoard {
pub fn new(repo: Arc<dyn ITeamRepository>) -> Self {
Self { repo }
}
pub async fn create_task(
&self,
team_id: &str,
subject: &str,
description: Option<&str>,
owner: Option<&str>,
blocked_by: &[String],
) -> Result<TeamTask, TeamError> {
for dep_id in blocked_by {
let dep = self.repo.find_task_by_id(team_id, dep_id).await?;
if dep.is_none() {
return Err(TeamError::BlockedTaskNotFound(dep_id.clone()));
}
}
let task_id = generate_prefixed_id("task");
let now = now_ms();
let row = TeamTaskRow {
id: task_id.clone(),
team_id: team_id.to_owned(),
subject: subject.to_owned(),
description: description.map(str::to_owned),
status: TaskStatus::Pending.to_string(),
owner: owner.map(str::to_owned),
metadata: None,
created_at: now,
updated_at: now,
};
self.repo.create_task(&row).await?;
// Record each "dep blocks this task" edge in `team_task_deps`
// (was the bidirectional blocked_by/blocks JSON arrays — spec §5.5).
for dep_id in blocked_by {
self.repo.add_task_dep(dep_id, &task_id).await?;
}
debug!(team_id, task_id = %task_id, subject, "task created");
// The new task is blocked_by the deps and blocks nobody yet.
TeamTask::from_parts(&row, blocked_by.to_vec(), Vec::new()).map_err(TeamError::Json)
}
pub async fn update_task(&self, team_id: &str, task_id: &str, update: &TaskUpdate) -> Result<TeamTask, TeamError> {
self.repo
.find_task_by_id(team_id, task_id)
.await?
.ok_or_else(|| TeamError::TaskNotFound(task_id.to_owned()))?;
let params = UpdateTaskParams {
status: update.status.map(|s| s.to_string()),
description: update.description.clone(),
owner: update.owner.clone(),
metadata: update.metadata.as_ref().map(serde_json::to_string).transpose()?,
};
self.repo.update_task(task_id, &params).await?;
// Reconcile the dependency edges when the caller passes a new
// `blocked_by` set. Diff against the current blockers and add/remove
// edges so we never duplicate or drop unrelated edges.
if let Some(ref desired) = update.blocked_by {
let current = self.repo.list_blockers(task_id).await?;
for dep_id in desired {
if !current.contains(dep_id) {
self.repo.add_task_dep(dep_id, task_id).await?;
}
}
for dep_id in &current {
if !desired.contains(dep_id) {
self.repo.remove_task_dep(dep_id, task_id).await?;
}
}
}
if update.status == Some(TaskStatus::Completed) {
self.check_unblocks(task_id).await?;
}
let updated = self
.repo
.find_task_by_id(team_id, task_id)
.await?
.ok_or_else(|| TeamError::TaskNotFound(task_id.to_owned()))?;
debug!(team_id, task_id, "task updated");
self.assemble_task(&updated).await
}
pub async fn list_tasks(&self, team_id: &str) -> Result<Vec<TeamTask>, TeamError> {
let rows = self.repo.list_tasks(team_id).await?;
let mut tasks = Vec::with_capacity(rows.len());
for row in &rows {
if let Ok(task) = self.assemble_task(row).await {
tasks.push(task);
}
}
Ok(tasks)
}
/// Assemble a [`TeamTask`] aggregate from its row + dependency edges
/// (`blocked_by` = `list_blockers`, `blocks` = `list_blocking`).
async fn assemble_task(&self, row: &TeamTaskRow) -> Result<TeamTask, TeamError> {
let blocked_by = self.repo.list_blockers(&row.id).await?;
let blocks = self.repo.list_blocking(&row.id).await?;
TeamTask::from_parts(row, blocked_by, blocks).map_err(TeamError::Json)
}
/// When `completed_task_id` finishes, drop every "completed blocks X" edge
/// so the downstream tasks it was blocking become unblocked (spec §5.5:
/// `check_unblocks` is a per-edge DELETE).
async fn check_unblocks(&self, completed_task_id: &str) -> Result<(), TeamError> {
let downstream = self.repo.list_blocking(completed_task_id).await?;
for downstream_id in &downstream {
self.repo.remove_task_dep(completed_task_id, downstream_id).await?;
debug!(
completed = completed_task_id,
unblocked = %downstream_id,
"dependency unblocked"
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::MockTeamRepo;
// -- Helper ---------------------------------------------------------------
async fn create_simple_task(board: &TaskBoard, team_id: &str, subject: &str) -> TeamTask {
board.create_task(team_id, subject, None, None, &[]).await.unwrap()
}
// -- Tests ----------------------------------------------------------------
#[tokio::test]
async fn create_task_no_dependencies() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let task = create_simple_task(&board, "t1", "Implement feature").await;
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 create_task_with_owner_and_description() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let task = board
.create_task("t1", "Design API", Some("REST endpoints"), Some("a1"), &[])
.await
.unwrap();
assert_eq!(task.description.as_deref(), Some("REST endpoints"));
assert_eq!(task.owner.as_deref(), Some("a1"));
}
#[tokio::test]
async fn create_task_with_dependencies() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo.clone());
let task_a = create_simple_task(&board, "t1", "Task A").await;
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 blocks_a = repo.list_blocking(&task_a.id).await.unwrap();
assert_eq!(blocks_a, vec![task_b.id]);
}
#[tokio::test]
async fn create_task_nonexistent_dependency_fails() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let result = board.create_task("t1", "X", None, None, &["nonexistent".into()]).await;
assert!(matches!(result, Err(TeamError::BlockedTaskNotFound(_))));
}
#[tokio::test]
async fn update_task_status() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let task = create_simple_task(&board, "t1", "Work").await;
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 update_task_description_and_owner() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let task = create_simple_task(&board, "t1", "Work").await;
let updated = board
.update_task(
"t1",
&task.id,
&TaskUpdate {
description: Some("New desc".into()),
owner: Some("a2".into()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.description.as_deref(), Some("New desc"));
assert_eq!(updated.owner.as_deref(), Some("a2"));
}
#[tokio::test]
async fn update_nonexistent_task_fails() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let result = board.update_task("t1", "nonexistent", &TaskUpdate::default()).await;
assert!(matches!(result, Err(TeamError::TaskNotFound(_))));
}
#[tokio::test]
async fn complete_task_unblocks_downstream() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let task_a = create_simple_task(&board, "t1", "A").await;
let task_b = board
.create_task("t1", "B", None, None, std::slice::from_ref(&task_a.id))
.await
.unwrap();
assert_eq!(task_b.blocked_by, vec![task_a.id.clone()]);
board
.update_task(
"t1",
&task_a.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let b = tasks.iter().find(|t| t.id == task_b.id).unwrap();
assert!(b.blocked_by.is_empty());
}
#[tokio::test]
async fn complete_task_unblocks_multiple_downstream() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let task_a = create_simple_task(&board, "t1", "A").await;
let task_b = board
.create_task("t1", "B", None, None, std::slice::from_ref(&task_a.id))
.await
.unwrap();
let task_c = board
.create_task("t1", "C", None, None, std::slice::from_ref(&task_a.id))
.await
.unwrap();
board
.update_task(
"t1",
&task_a.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let b = tasks.iter().find(|t| t.id == task_b.id).unwrap();
let c = tasks.iter().find(|t| t.id == task_c.id).unwrap();
assert!(b.blocked_by.is_empty());
assert!(c.blocked_by.is_empty());
}
#[tokio::test]
async fn partial_unblock_preserves_other_dependencies() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let task_a = create_simple_task(&board, "t1", "A").await;
let task_x = create_simple_task(&board, "t1", "X").await;
let task_b = board
.create_task("t1", "B", None, None, &[task_a.id.clone(), task_x.id.clone()])
.await
.unwrap();
assert_eq!(task_b.blocked_by.len(), 2);
board
.update_task(
"t1",
&task_a.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
let tasks = board.list_tasks("t1").await.unwrap();
let b = tasks.iter().find(|t| t.id == task_b.id).unwrap();
assert_eq!(b.blocked_by, vec![task_x.id]);
}
#[tokio::test]
async fn complete_task_no_downstream_is_noop() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let task = create_simple_task(&board, "t1", "Standalone").await;
let updated = board
.update_task(
"t1",
&task.id,
&TaskUpdate {
status: Some(TaskStatus::Completed),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.status, TaskStatus::Completed);
}
#[tokio::test]
async fn list_tasks_empty() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
let tasks = board.list_tasks("t1").await.unwrap();
assert!(tasks.is_empty());
}
#[tokio::test]
async fn list_tasks_returns_all() {
let repo = Arc::new(MockTeamRepo::new());
let board = TaskBoard::new(repo);
create_simple_task(&board, "t1", "A").await;
create_simple_task(&board, "t1", "B").await;
create_simple_task(&board, "t2", "C").await;
let tasks = board.list_tasks("t1").await.unwrap();
assert_eq!(tasks.len(), 2);
}
}
@@ -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,946 @@
use std::fmt;
use nomifun_api_types::{TeamAgentResponse, TeamResponse};
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// TeammateRole
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TeammateRole {
#[serde(alias = "leader")]
Lead,
Teammate,
}
impl fmt::Display for TeammateRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lead => write!(f, "lead"),
Self::Teammate => write!(f, "teammate"),
}
}
}
impl TeammateRole {
pub fn parse(s: &str) -> Option<Self> {
match s {
"lead" | "leader" => Some(Self::Lead),
"teammate" => Some(Self::Teammate),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// TeammateStatus
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TeammateStatus {
#[serde(alias = "pending")]
Idle,
#[serde(alias = "active")]
Working,
Thinking,
ToolUse,
#[serde(alias = "completed")]
Completed,
#[serde(alias = "failed")]
Error,
}
impl fmt::Display for TeammateStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Idle => write!(f, "idle"),
Self::Working => write!(f, "working"),
Self::Thinking => write!(f, "thinking"),
Self::ToolUse => write!(f, "tool_use"),
Self::Completed => write!(f, "completed"),
Self::Error => write!(f, "error"),
}
}
}
impl TeammateStatus {
pub fn parse(s: &str) -> Option<Self> {
match s {
"idle" | "pending" => Some(Self::Idle),
"working" | "active" => Some(Self::Working),
"thinking" => Some(Self::Thinking),
"tool_use" => Some(Self::ToolUse),
"completed" => Some(Self::Completed),
"error" | "failed" => Some(Self::Error),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// TeamAgent
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TeamAgent {
#[serde(default, alias = "slotId")]
pub slot_id: String,
#[serde(alias = "agentName")]
pub name: String,
pub role: TeammateRole,
#[serde(alias = "conversationId")]
pub conversation_id: String,
#[serde(alias = "agentType")]
pub backend: String,
#[serde(default)]
pub model: String,
#[serde(skip_serializing_if = "Option::is_none", alias = "customAgentId")]
pub custom_agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<TeammateStatus>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "conversationType")]
pub conversation_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "cliPath")]
pub cli_path: Option<String>,
}
impl TeamAgent {
pub fn to_response(&self) -> TeamAgentResponse {
self.to_response_with_icon(None)
}
pub fn to_response_with_icon(&self, icon: Option<String>) -> TeamAgentResponse {
TeamAgentResponse {
slot_id: self.slot_id.clone(),
name: self.name.clone(),
role: self.role.to_string(),
// DTO `conversation_id` is i64 (Option A keeps the domain field a
// String); parse, defaulting to 0 when absent/unset.
conversation_id: self.conversation_id.parse::<i64>().unwrap_or(0),
backend: self.backend.clone(),
icon,
model: self.model.clone(),
custom_agent_id: self.custom_agent_id.clone(),
status: self.status.map(|s| s.to_string()),
pending_confirmations: 0,
}
}
}
// ---------------------------------------------------------------------------
// Team
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Team {
pub id: String,
pub name: String,
pub agents: Vec<TeamAgent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lead_agent_id: Option<String>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
// ---------------------------------------------------------------------------
// MailboxMessageType
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MailboxMessageType {
Message,
IdleNotification,
ShutdownRequest,
}
impl fmt::Display for MailboxMessageType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Message => write!(f, "message"),
Self::IdleNotification => write!(f, "idle_notification"),
Self::ShutdownRequest => write!(f, "shutdown_request"),
}
}
}
impl MailboxMessageType {
pub fn parse(s: &str) -> Option<Self> {
match s {
"message" => Some(Self::Message),
"idle_notification" => Some(Self::IdleNotification),
"shutdown_request" => Some(Self::ShutdownRequest),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// MailboxMessage
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MailboxMessage {
pub id: i64,
pub team_id: String,
pub to_agent_id: String,
pub from_agent_id: String,
#[serde(rename = "type")]
pub msg_type: MailboxMessageType,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub files: Option<Vec<String>>,
pub read: bool,
pub created_at: TimestampMs,
}
// ---------------------------------------------------------------------------
// TaskStatus
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
InProgress,
Completed,
Deleted,
}
impl fmt::Display for TaskStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::InProgress => write!(f, "in_progress"),
Self::Completed => write!(f, "completed"),
Self::Deleted => write!(f, "deleted"),
}
}
}
impl TaskStatus {
pub fn parse(s: &str) -> Option<Self> {
match s {
"pending" => Some(Self::Pending),
"in_progress" => Some(Self::InProgress),
"completed" => Some(Self::Completed),
"deleted" => Some(Self::Deleted),
_ => None,
}
}
}
// ---------------------------------------------------------------------------
// TeamTask
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TeamTask {
pub id: String,
pub team_id: String,
pub subject: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
pub blocked_by: Vec<String>,
pub blocks: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
pub created_at: TimestampMs,
pub updated_at: TimestampMs,
}
// ---------------------------------------------------------------------------
// Conversion helpers: DB rows ↔ domain types
// ---------------------------------------------------------------------------
use nomifun_db::models::{MailboxMessageRow, TeamAgentRow, TeamRow, TeamTaskRow};
impl TeamAgent {
/// Maps a persisted [`TeamAgentRow`] (was an element of the `teams.agents`
/// JSON array) into the in-memory domain type.
///
/// `role`/`status`/`conversation_type` fall back to sensible defaults when
/// the stored string is unrecognized (matching the lenient JSON parsing
/// behaviour the array form used to have). `conversation_id` is stored as a
/// nullable FK column but the domain type keeps it as a plain `String`
/// (empty when absent — every active slot has a conversation).
pub fn from_row(row: &TeamAgentRow) -> Self {
Self {
slot_id: row.slot_id.clone(),
name: row.name.clone(),
role: TeammateRole::parse(&row.role).unwrap_or(TeammateRole::Teammate),
conversation_id: row.conversation_id.map(|id| id.to_string()).unwrap_or_default(),
backend: row.backend.clone(),
model: row.model.clone(),
custom_agent_id: row.custom_agent_id.clone(),
status: row.status.as_deref().and_then(TeammateStatus::parse),
conversation_type: row.conversation_type.clone(),
cli_path: row.cli_path.clone(),
}
}
/// Builds a [`TeamAgentRow`] for persistence. `team_id` and `sort_order`
/// are supplied by the caller (the agent's position in the roster array).
pub fn to_row(&self, team_id: &str, sort_order: i64) -> TeamAgentRow {
TeamAgentRow {
slot_id: self.slot_id.clone(),
team_id: team_id.to_owned(),
name: self.name.clone(),
role: self.role.to_string(),
// Domain keeps conversation_id as String (Option A); the FK column
// is now Option<i64>. Empty or unparseable → NULL (no conversation).
conversation_id: self.conversation_id.parse::<i64>().ok(),
backend: self.backend.clone(),
model: self.model.clone(),
custom_agent_id: self.custom_agent_id.clone(),
status: self.status.map(|s| s.to_string()),
conversation_type: self.conversation_type.clone(),
cli_path: self.cli_path.clone(),
sort_order,
}
}
}
impl Team {
/// Assembles the `Team` aggregate from its `teams` row plus the agent
/// slots loaded separately from `team_agents` (the former `agents` JSON
/// array). Callers fetch `row` via `get_team`/`list_teams` and `agent_rows`
/// via `list_team_agents(team_id)`.
pub fn from_parts(row: &TeamRow, agent_rows: &[TeamAgentRow]) -> Self {
Self {
id: row.id.clone(),
name: row.name.clone(),
agents: agent_rows.iter().map(TeamAgent::from_row).collect(),
lead_agent_id: row.lead_agent_id.clone(),
created_at: row.created_at,
updated_at: row.updated_at,
}
}
pub fn to_response(&self) -> TeamResponse {
TeamResponse {
id: self.id.clone(),
name: self.name.clone(),
agents: self.agents.iter().map(|a| a.to_response()).collect(),
lead_agent_id: self.lead_agent_id.clone(),
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
impl MailboxMessage {
pub fn from_row(row: &MailboxMessageRow) -> Option<Self> {
let msg_type = MailboxMessageType::parse(&row.msg_type)?;
let files = row
.files
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.filter(|v| !v.is_empty());
Some(Self {
id: row.id,
team_id: row.team_id.clone(),
to_agent_id: row.to_agent_id.clone(),
from_agent_id: row.from_agent_id.clone(),
msg_type,
content: row.content.clone(),
summary: row.summary.clone(),
files,
read: row.read,
created_at: row.created_at,
})
}
}
impl TeamTask {
/// Assembles the `TeamTask` aggregate from its `team_tasks` row plus the
/// dependency lists loaded separately from `team_task_deps` (the former
/// `blocked_by` / `blocks` JSON arrays). `blocked_by` = tasks that block
/// this one (`list_blockers`); `blocks` = tasks this one blocks
/// (`list_blocking`).
pub fn from_parts(
row: &TeamTaskRow,
blocked_by: Vec<String>,
blocks: Vec<String>,
) -> Result<Self, serde_json::Error> {
let status = TaskStatus::parse(&row.status).unwrap_or(TaskStatus::Pending);
let metadata: Option<serde_json::Value> = row.metadata.as_deref().map(serde_json::from_str).transpose()?;
Ok(Self {
id: row.id.clone(),
team_id: row.team_id.clone(),
subject: row.subject.clone(),
description: row.description.clone(),
status,
owner: row.owner.clone(),
blocked_by,
blocks,
metadata,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- TeammateRole ---------------------------------------------------------
#[test]
fn teammate_role_display() {
assert_eq!(TeammateRole::Lead.to_string(), "lead");
assert_eq!(TeammateRole::Teammate.to_string(), "teammate");
}
#[test]
fn teammate_role_parse() {
assert_eq!(TeammateRole::parse("lead"), Some(TeammateRole::Lead));
assert_eq!(TeammateRole::parse("teammate"), Some(TeammateRole::Teammate));
assert_eq!(TeammateRole::parse("unknown"), None);
}
#[test]
fn teammate_role_serde_roundtrip() {
let role = TeammateRole::Lead;
let json = serde_json::to_string(&role).unwrap();
assert_eq!(json, r#""lead""#);
let parsed: TeammateRole = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, role);
}
// -- TeammateStatus -------------------------------------------------------
#[test]
fn teammate_status_display() {
assert_eq!(TeammateStatus::Idle.to_string(), "idle");
assert_eq!(TeammateStatus::Working.to_string(), "working");
assert_eq!(TeammateStatus::Thinking.to_string(), "thinking");
assert_eq!(TeammateStatus::ToolUse.to_string(), "tool_use");
assert_eq!(TeammateStatus::Completed.to_string(), "completed");
assert_eq!(TeammateStatus::Error.to_string(), "error");
}
#[test]
fn teammate_status_parse_all_variants() {
assert_eq!(TeammateStatus::parse("idle"), Some(TeammateStatus::Idle));
assert_eq!(TeammateStatus::parse("working"), Some(TeammateStatus::Working));
assert_eq!(TeammateStatus::parse("thinking"), Some(TeammateStatus::Thinking));
assert_eq!(TeammateStatus::parse("tool_use"), Some(TeammateStatus::ToolUse));
assert_eq!(TeammateStatus::parse("completed"), Some(TeammateStatus::Completed));
assert_eq!(TeammateStatus::parse("error"), Some(TeammateStatus::Error));
assert_eq!(TeammateStatus::parse("bad"), None);
}
#[test]
fn teammate_status_parse_nomifun_aliases() {
assert_eq!(TeammateStatus::parse("pending"), Some(TeammateStatus::Idle));
assert_eq!(TeammateStatus::parse("active"), Some(TeammateStatus::Working));
assert_eq!(TeammateStatus::parse("failed"), Some(TeammateStatus::Error));
}
#[test]
fn teammate_status_serde_roundtrip() {
for status in [
TeammateStatus::Idle,
TeammateStatus::Working,
TeammateStatus::Thinking,
TeammateStatus::ToolUse,
TeammateStatus::Completed,
TeammateStatus::Error,
] {
let json = serde_json::to_string(&status).unwrap();
let parsed: TeammateStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, status);
}
}
#[test]
fn teammate_status_serde_nomifun_aliases() {
let pending: TeammateStatus = serde_json::from_str(r#""pending""#).unwrap();
assert_eq!(pending, TeammateStatus::Idle);
let active: TeammateStatus = serde_json::from_str(r#""active""#).unwrap();
assert_eq!(active, TeammateStatus::Working);
let completed: TeammateStatus = serde_json::from_str(r#""completed""#).unwrap();
assert_eq!(completed, TeammateStatus::Completed);
let failed: TeammateStatus = serde_json::from_str(r#""failed""#).unwrap();
assert_eq!(failed, TeammateStatus::Error);
}
#[test]
fn teammate_role_serde_leader_alias() {
let leader: TeammateRole = serde_json::from_str(r#""leader""#).unwrap();
assert_eq!(leader, TeammateRole::Lead);
}
// -- MailboxMessageType ---------------------------------------------------
#[test]
fn mailbox_message_type_display() {
assert_eq!(MailboxMessageType::Message.to_string(), "message");
assert_eq!(MailboxMessageType::IdleNotification.to_string(), "idle_notification");
assert_eq!(MailboxMessageType::ShutdownRequest.to_string(), "shutdown_request");
}
#[test]
fn mailbox_message_type_parse() {
assert_eq!(MailboxMessageType::parse("message"), Some(MailboxMessageType::Message));
assert_eq!(
MailboxMessageType::parse("idle_notification"),
Some(MailboxMessageType::IdleNotification)
);
assert_eq!(
MailboxMessageType::parse("shutdown_request"),
Some(MailboxMessageType::ShutdownRequest)
);
assert_eq!(MailboxMessageType::parse("unknown"), None);
}
#[test]
fn mailbox_message_type_serde_roundtrip() {
for mt in [
MailboxMessageType::Message,
MailboxMessageType::IdleNotification,
MailboxMessageType::ShutdownRequest,
] {
let json = serde_json::to_string(&mt).unwrap();
let parsed: MailboxMessageType = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, mt);
}
}
// -- TaskStatus -----------------------------------------------------------
#[test]
fn task_status_display() {
assert_eq!(TaskStatus::Pending.to_string(), "pending");
assert_eq!(TaskStatus::InProgress.to_string(), "in_progress");
assert_eq!(TaskStatus::Completed.to_string(), "completed");
assert_eq!(TaskStatus::Deleted.to_string(), "deleted");
}
#[test]
fn task_status_parse_all_variants() {
assert_eq!(TaskStatus::parse("pending"), Some(TaskStatus::Pending));
assert_eq!(TaskStatus::parse("in_progress"), Some(TaskStatus::InProgress));
assert_eq!(TaskStatus::parse("completed"), Some(TaskStatus::Completed));
assert_eq!(TaskStatus::parse("deleted"), Some(TaskStatus::Deleted));
assert_eq!(TaskStatus::parse("bad"), None);
}
#[test]
fn task_status_serde_roundtrip() {
for status in [
TaskStatus::Pending,
TaskStatus::InProgress,
TaskStatus::Completed,
TaskStatus::Deleted,
] {
let json = serde_json::to_string(&status).unwrap();
let parsed: TaskStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, status);
}
}
// -- TeamAgent conversion -------------------------------------------------
#[test]
fn team_agent_to_response() {
let agent = TeamAgent {
slot_id: "s1".into(),
name: "Lead".into(),
role: TeammateRole::Lead,
conversation_id: "c1".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: Some("custom-1".into()),
status: Some(TeammateStatus::Working),
conversation_type: None,
cli_path: None,
};
let resp = agent.to_response();
assert_eq!(resp.slot_id, "s1");
assert_eq!(resp.role, "lead");
assert!(resp.icon.is_none());
assert_eq!(resp.status.as_deref(), Some("working"));
assert_eq!(resp.custom_agent_id.as_deref(), Some("custom-1"));
}
#[test]
fn team_agent_to_response_with_icon() {
let agent = TeamAgent {
slot_id: "s1".into(),
name: "Lead".into(),
role: TeammateRole::Lead,
conversation_id: "c1".into(),
backend: "claude".into(),
model: "opus".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
};
let resp = agent.to_response_with_icon(Some("/api/assets/logos/ai-major/claude.svg".into()));
assert_eq!(resp.icon.as_deref(), Some("/api/assets/logos/ai-major/claude.svg"));
assert_eq!(resp.backend, "claude");
}
#[test]
fn team_agent_serde_roundtrip() {
let agent = TeamAgent {
slot_id: "s1".into(),
name: "Worker".into(),
role: TeammateRole::Teammate,
conversation_id: "c1".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
};
let json = serde_json::to_string(&agent).unwrap();
let parsed: TeamAgent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, agent);
}
#[test]
fn team_agent_snake_case_serialization() {
let agent = TeamAgent {
slot_id: "s1".into(),
name: "A".into(),
role: TeammateRole::Lead,
conversation_id: "c1".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: Some("x".into()),
status: Some(TeammateStatus::Idle),
conversation_type: None,
cli_path: None,
};
let val = serde_json::to_value(&agent).unwrap();
assert!(val.get("slot_id").is_some());
assert!(val.get("conversation_id").is_some());
assert!(val.get("custom_agent_id").is_some());
}
#[test]
fn team_agent_deserialize_nomifun_format() {
let raw = serde_json::json!({
"slot_id": "slot-abc",
"conversation_id": "conv-1",
"role": "leader",
"agentType": "claude",
"agentName": "Leader",
"conversation_type": "acp",
"status": "active",
"custom_agent_id": "custom-1"
});
let agent: TeamAgent = serde_json::from_value(raw).unwrap();
assert_eq!(agent.name, "Leader");
assert_eq!(agent.backend, "claude");
assert_eq!(agent.role, TeammateRole::Lead);
assert_eq!(agent.status, Some(TeammateStatus::Working));
assert_eq!(agent.conversation_type.as_deref(), Some("acp"));
}
// -- Team from_parts ------------------------------------------------------
#[test]
fn team_from_parts_success() {
use nomifun_db::models::TeamAgentRow;
let row = TeamRow {
id: "t1".into(),
user_id: "system_default_user".into(),
name: "Alpha".into(),
workspace: String::new(),
workspace_mode: "shared".into(),
lead_agent_id: Some("s1".into()),
session_mode: None,
agents_version: "1.0.1".into(),
created_at: 1000,
updated_at: 2000,
};
let agent_rows = vec![TeamAgentRow {
slot_id: "s1".into(),
team_id: "t1".into(),
name: "Lead".into(),
role: "lead".into(),
conversation_id: Some(1),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
sort_order: 0,
}];
let team = Team::from_parts(&row, &agent_rows);
assert_eq!(team.id, "t1");
assert_eq!(team.agents.len(), 1);
assert_eq!(team.agents[0].slot_id, "s1");
assert_eq!(team.agents[0].conversation_id, "1");
assert_eq!(team.lead_agent_id.as_deref(), Some("s1"));
}
#[test]
fn team_agent_row_roundtrip_via_domain() {
let agent = TeamAgent {
slot_id: "s1".into(),
name: "Lead".into(),
role: TeammateRole::Lead,
conversation_id: "1".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: Some("custom-1".into()),
status: Some(TeammateStatus::Working),
conversation_type: Some("acp".into()),
cli_path: None,
};
let row = agent.to_row("t1", 3);
assert_eq!(row.team_id, "t1");
assert_eq!(row.sort_order, 3);
assert_eq!(row.role, "lead");
assert_eq!(row.status.as_deref(), Some("working"));
let back = TeamAgent::from_row(&row);
assert_eq!(back, agent);
}
#[test]
fn team_agent_to_row_empty_conversation_id_is_null() {
let agent = TeamAgent {
slot_id: "s1".into(),
name: "Lead".into(),
role: TeammateRole::Lead,
conversation_id: String::new(),
backend: "acp".into(),
model: String::new(),
custom_agent_id: None,
status: None,
conversation_type: None,
cli_path: None,
};
let row = agent.to_row("t1", 0);
assert!(row.conversation_id.is_none());
let back = TeamAgent::from_row(&row);
assert_eq!(back.conversation_id, "");
}
#[test]
fn team_to_response() {
let team = Team {
id: "t1".into(),
name: "Alpha".into(),
agents: vec![TeamAgent {
slot_id: "s1".into(),
name: "Lead".into(),
role: TeammateRole::Lead,
conversation_id: "c1".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
status: Some(TeammateStatus::Idle),
conversation_type: None,
cli_path: None,
}],
lead_agent_id: Some("s1".into()),
created_at: 1000,
updated_at: 2000,
};
let resp = team.to_response();
assert_eq!(resp.id, "t1");
assert_eq!(resp.name, "Alpha");
assert_eq!(resp.agents.len(), 1);
assert_eq!(resp.agents[0].slot_id, "s1");
assert_eq!(resp.lead_agent_id.as_deref(), Some("s1"));
assert_eq!(resp.created_at, 1000);
assert_eq!(resp.updated_at, 2000);
}
#[test]
fn team_agent_deserialize_old_camelcase_format() {
let raw = serde_json::json!({
"slotId": "slot-abc",
"conversationId": "conv-123",
"role": "leader",
"status": "pending",
"agentType": "claude",
"agentName": "Leader",
"conversationType": "acp",
"cliPath": "claude"
});
let agent: TeamAgent = serde_json::from_value(raw).unwrap();
assert_eq!(agent.slot_id, "slot-abc");
assert_eq!(agent.conversation_id, "conv-123");
assert_eq!(agent.name, "Leader");
assert_eq!(agent.backend, "claude");
assert_eq!(agent.conversation_type.as_deref(), Some("acp"));
assert_eq!(agent.cli_path.as_deref(), Some("claude"));
}
// -- MailboxMessage from_row ----------------------------------------------
#[test]
fn mailbox_message_from_row_success() {
let row = MailboxMessageRow {
id: 1,
team_id: "t1".into(),
to_agent_id: "a1".into(),
from_agent_id: "a2".into(),
msg_type: "message".into(),
content: "hello".into(),
summary: None,
files: None,
read: false,
created_at: 1000,
};
let msg = MailboxMessage::from_row(&row).unwrap();
assert_eq!(msg.msg_type, MailboxMessageType::Message);
assert!(!msg.read);
}
#[test]
fn mailbox_message_from_row_idle_notification() {
let row = MailboxMessageRow {
id: 2,
team_id: "t1".into(),
to_agent_id: "lead".into(),
from_agent_id: "a1".into(),
msg_type: "idle_notification".into(),
content: "done".into(),
summary: Some("Finished task".into()),
files: None,
read: false,
created_at: 2000,
};
let msg = MailboxMessage::from_row(&row).unwrap();
assert_eq!(msg.msg_type, MailboxMessageType::IdleNotification);
assert_eq!(msg.summary.as_deref(), Some("Finished task"));
}
#[test]
fn mailbox_message_from_row_unknown_type() {
let row = MailboxMessageRow {
id: 3,
team_id: "t1".into(),
to_agent_id: "a1".into(),
from_agent_id: "a2".into(),
msg_type: "unknown_type".into(),
content: "x".into(),
summary: None,
files: None,
read: false,
created_at: 0,
};
assert!(MailboxMessage::from_row(&row).is_none());
}
#[test]
fn mailbox_message_serializes_type_field() {
let msg = MailboxMessage {
id: 1,
team_id: "t1".into(),
to_agent_id: "a1".into(),
from_agent_id: "a2".into(),
msg_type: MailboxMessageType::Message,
content: "hello".into(),
summary: None,
files: None,
read: false,
created_at: 1000,
};
let json = serde_json::to_value(&msg).unwrap();
assert!(json.get("type").is_some(), "field must serialize as 'type'");
assert!(json.get("msgType").is_none(), "must not serialize as 'msgType'");
assert_eq!(json["type"], "message");
}
// -- TeamTask from_parts --------------------------------------------------
#[test]
fn team_task_from_parts_success() {
let row = TeamTaskRow {
id: "tk1".into(),
team_id: "t1".into(),
subject: "Implement".into(),
description: Some("Details".into()),
status: "in_progress".into(),
owner: Some("a1".into()),
metadata: Some(r#"{"priority":"high"}"#.into()),
created_at: 1000,
updated_at: 2000,
};
let task = TeamTask::from_parts(&row, vec!["tk0".into()], vec!["tk2".into()]).unwrap();
assert_eq!(task.status, TaskStatus::InProgress);
assert_eq!(task.blocked_by, vec!["tk0"]);
assert_eq!(task.blocks, vec!["tk2"]);
assert!(task.metadata.is_some());
}
#[test]
fn team_task_from_parts_empty_deps() {
let row = TeamTaskRow {
id: "tk1".into(),
team_id: "t1".into(),
subject: "Simple".into(),
description: None,
status: "pending".into(),
owner: None,
metadata: None,
created_at: 0,
updated_at: 0,
};
let task = TeamTask::from_parts(&row, vec![], vec![]).unwrap();
assert_eq!(task.status, TaskStatus::Pending);
assert!(task.blocked_by.is_empty());
assert!(task.blocks.is_empty());
assert!(task.metadata.is_none());
}
#[test]
fn team_task_from_parts_unknown_status_defaults_to_pending() {
let row = TeamTaskRow {
id: "tk1".into(),
team_id: "t1".into(),
subject: "S".into(),
description: None,
status: "unknown".into(),
owner: None,
metadata: None,
created_at: 0,
updated_at: 0,
};
let task = TeamTask::from_parts(&row, vec![], vec![]).unwrap();
assert_eq!(task.status, TaskStatus::Pending);
}
#[test]
fn team_task_from_parts_invalid_metadata_json() {
let row = TeamTaskRow {
id: "tk1".into(),
team_id: "t1".into(),
subject: "S".into(),
description: None,
status: "pending".into(),
owner: None,
metadata: Some("not-json".into()),
created_at: 0,
updated_at: 0,
};
assert!(TeamTask::from_parts(&row, vec![], vec![]).is_err());
}
}
@@ -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));
}