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

1735 lines
53 KiB
Rust

mod common;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use nomifun_ai_agent::task_manager::AgentFactory;
use nomifun_ai_agent::types::BuildTaskOptions;
use nomifun_ai_agent::{IWorkerTaskManager, WorkerTaskManagerImpl};
use nomifun_api_types::{AddAgentRequest, CreateTeamRequest, TeamAgentInput, WebSocketMessage};
use nomifun_common::{AgentKillReason, AgentType, AppError, PaginatedResult, ProviderWithModel};
use nomifun_db::models::{
AcpSessionRow, AgentMetadataRow, ConversationRow, MessageRow, UpdateAgentHandshakeParams, UpsertAgentMetadataParams,
};
use nomifun_db::{
ConversationFilters, ConversationRowUpdate, CreateAcpSessionParams, DbError, IAcpSessionRepository,
IAgentMetadataRepository, IConversationRepository, IProviderRepository, ITeamRepository, MessageRowUpdate,
MessageSearchRow, PersistedSessionState, SaveRuntimeStateParams, SortOrder,
};
use nomifun_realtime::EventBroadcaster;
use common::MockTeamRepo;
use nomifun_conversation::ConversationService;
use nomifun_team::TeamSessionService;
// ---------------------------------------------------------------------------
// Mock ConversationRepository — minimal impl for TeamSessionService tests
// ---------------------------------------------------------------------------
struct MockConversationRepo {
conversations: std::sync::Mutex<Vec<ConversationRow>>,
}
impl MockConversationRepo {
fn new() -> Self {
Self {
conversations: std::sync::Mutex::new(Vec::new()),
}
}
}
#[async_trait::async_trait]
impl IConversationRepository for MockConversationRepo {
async fn get(&self, id: i64) -> Result<Option<ConversationRow>, DbError> {
let convs = self.conversations.lock().unwrap();
Ok(convs.iter().find(|c| c.id == id).cloned())
}
async fn create(&self, row: &ConversationRow) -> Result<i64, DbError> {
// Mirror SQLite AUTOINCREMENT: the service passes id:0 as a placeholder
// and relies on the repo to mint the real integer PK. Allocate a fresh
// 1-based id so every conversation gets a unique nonzero id.
let mut convs = self.conversations.lock().unwrap();
let new_id = convs.len() as i64 + 1;
let mut stored = row.clone();
stored.id = new_id;
convs.push(stored);
Ok(new_id)
}
async fn update(&self, id: i64, updates: &ConversationRowUpdate) -> Result<(), DbError> {
let mut convs = self.conversations.lock().unwrap();
let conv = convs
.iter_mut()
.find(|c| c.id == id)
.ok_or_else(|| DbError::NotFound(id.to_string()))?;
if let Some(ref extra) = updates.extra {
conv.extra = extra.clone();
}
if let Some(ref name) = updates.name {
conv.name = name.clone();
}
if let Some(pinned) = updates.pinned {
conv.pinned = pinned;
}
if let Some(ref model) = updates.model {
conv.model = model.clone();
}
if let Some(updated_at) = updates.updated_at {
conv.updated_at = updated_at;
}
Ok(())
}
async fn delete(&self, id: i64) -> Result<(), DbError> {
self.conversations.lock().unwrap().retain(|c| c.id != id);
Ok(())
}
async fn list_paginated(
&self,
_user_id: &str,
_filters: &ConversationFilters,
) -> Result<PaginatedResult<ConversationRow>, DbError> {
Ok(PaginatedResult {
items: vec![],
total: 0,
has_more: false,
})
}
async fn find_by_source_and_chat(
&self,
_user_id: &str,
_source: &str,
_chat_id: &str,
_agent_type: &str,
) -> Result<Option<ConversationRow>, DbError> {
Ok(None)
}
async fn list_by_cron_job(&self, _user_id: &str, _cron_job_id: &str) -> Result<Vec<ConversationRow>, DbError> {
Ok(vec![])
}
async fn list_associated(&self, _user_id: &str, _conversation_id: i64) -> Result<Vec<ConversationRow>, DbError> {
Ok(vec![])
}
async fn get_messages(
&self,
_conv_id: i64,
_page: u32,
_page_size: u32,
_order: SortOrder,
) -> Result<PaginatedResult<MessageRow>, DbError> {
Ok(PaginatedResult {
items: vec![],
total: 0,
has_more: false,
})
}
async fn insert_message(&self, _message: &MessageRow) -> Result<(), DbError> {
Ok(())
}
async fn update_message(&self, _id: &str, _updates: &MessageRowUpdate) -> Result<(), DbError> {
Ok(())
}
async fn delete_messages_by_conversation(&self, _conv_id: i64) -> Result<(), DbError> {
Ok(())
}
async fn get_message_by_msg_id(
&self,
_conv_id: i64,
_msg_id: &str,
_msg_type: &str,
) -> Result<Option<MessageRow>, DbError> {
Ok(None)
}
async fn search_messages(
&self,
_user_id: &str,
_keyword: &str,
_page: u32,
_page_size: u32,
) -> Result<PaginatedResult<MessageSearchRow>, DbError> {
Ok(PaginatedResult {
items: vec![],
total: 0,
has_more: false,
})
}
}
// ---------------------------------------------------------------------------
// NullBroadcaster — no-op event broadcaster
// ---------------------------------------------------------------------------
struct NullBroadcaster;
impl EventBroadcaster for NullBroadcaster {
fn broadcast(&self, _msg: WebSocketMessage<serde_json::Value>) {}
}
#[derive(Default)]
struct RecordingBroadcaster {
events: std::sync::Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl RecordingBroadcaster {
fn new() -> Self {
Self::default()
}
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, msg: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(msg);
}
}
// ---------------------------------------------------------------------------
// Team repo: the shared full in-memory `common::MockTeamRepo` now backs teams,
// team_agents, mailbox, tasks, and task_deps as first-class tables (post
// primary-key-redesign), so the previous bespoke `FullMockTeamRepo` wrapper is
// no longer needed — `MockTeamRepo` is used directly.
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
struct StubSkillResolver;
#[async_trait::async_trait]
impl nomifun_conversation::skill_resolver::SkillResolver for StubSkillResolver {
async fn auto_inject_names(&self) -> Vec<String> {
Vec::new()
}
async fn resolve_skills(&self, _names: &[String]) -> Vec<nomifun_conversation::skill_resolver::ResolvedAgentSkill> {
Vec::new()
}
async fn link_workspace_skills(
&self,
_workspace: &std::path::Path,
_rel_dirs: &[&str],
_skills: &[nomifun_conversation::skill_resolver::ResolvedAgentSkill],
) -> usize {
0
}
}
#[derive(Default)]
struct StubAgentMetadataRepo {
rows_by_id: HashMap<String, AgentMetadataRow>,
builtin_by_backend: HashMap<String, AgentMetadataRow>,
}
impl StubAgentMetadataRepo {
fn empty() -> Self {
Self::default()
}
fn with_rows(rows: Vec<AgentMetadataRow>) -> Self {
let mut repo = Self::default();
for row in rows {
if row.agent_source == "builtin"
&& let Some(backend) = row.backend.as_deref()
{
repo.builtin_by_backend.insert(backend.to_owned(), row.clone());
}
repo.rows_by_id.insert(row.id.clone(), row);
}
repo
}
}
#[async_trait::async_trait]
impl IAgentMetadataRepository for StubAgentMetadataRepo {
async fn list_all(&self) -> Result<Vec<AgentMetadataRow>, DbError> {
Ok(self.rows_by_id.values().cloned().collect())
}
async fn get(&self, id: &str) -> Result<Option<AgentMetadataRow>, DbError> {
Ok(self.rows_by_id.get(id).cloned())
}
async fn find_by_source_and_name(
&self,
agent_source: &str,
name: &str,
) -> Result<Option<AgentMetadataRow>, DbError> {
Ok(self
.rows_by_id
.values()
.find(|row| row.agent_source == agent_source && row.name == name)
.cloned())
}
async fn find_builtin_by_backend(&self, backend: &str) -> Result<Option<AgentMetadataRow>, DbError> {
Ok(self.builtin_by_backend.get(backend).cloned())
}
async fn upsert(&self, _params: &UpsertAgentMetadataParams<'_>) -> Result<AgentMetadataRow, DbError> {
Err(DbError::Init("stub".into()))
}
async fn apply_handshake(
&self,
_id: &str,
_params: &UpdateAgentHandshakeParams<'_>,
) -> Result<Option<AgentMetadataRow>, DbError> {
Ok(None)
}
async fn set_enabled(&self, _id: &str, _enabled: bool) -> Result<bool, DbError> {
Ok(false)
}
async fn delete(&self, _id: &str) -> Result<bool, DbError> {
Ok(false)
}
}
struct StubAcpSessionRepo;
#[async_trait::async_trait]
impl IAcpSessionRepository for StubAcpSessionRepo {
async fn get(&self, _conversation_id: i64) -> Result<Option<AcpSessionRow>, DbError> {
Ok(None)
}
async fn create(&self, params: &CreateAcpSessionParams<'_>) -> Result<AcpSessionRow, DbError> {
Ok(AcpSessionRow {
conversation_id: params.conversation_id,
agent_backend: params.agent_backend.to_owned(),
agent_source: params.agent_source.to_owned(),
agent_id: params.agent_id.to_owned(),
session_id: None,
session_status: "created".to_owned(),
session_config: "{}".to_owned(),
last_active_at: None,
suspended_at: None,
})
}
async fn update_session_id(&self, _conversation_id: i64, _session_id: &str) -> Result<bool, DbError> {
Ok(false)
}
async fn clear_session_id(&self, _conversation_id: i64) -> Result<bool, DbError> {
Ok(false)
}
async fn delete(&self, _conversation_id: i64) -> Result<bool, DbError> {
Ok(false)
}
async fn load_runtime_state(&self, _conversation_id: i64) -> Result<Option<PersistedSessionState>, DbError> {
Ok(None)
}
async fn save_runtime_state(
&self,
_conversation_id: i64,
_params: &SaveRuntimeStateParams<'_>,
) -> Result<bool, DbError> {
Ok(false)
}
}
// ---------------------------------------------------------------------------
// Counting task manager — wraps WorkerTaskManagerImpl so tests can assert
// kill / get_or_build_task call counts by conversation id.
// ---------------------------------------------------------------------------
#[derive(Default, Clone)]
struct TaskManagerCalls {
kill: Vec<(String, Option<AgentKillReason>)>,
build: Vec<String>,
}
struct CountingTaskManager {
inner: WorkerTaskManagerImpl,
calls: Mutex<TaskManagerCalls>,
}
impl CountingTaskManager {
fn new(factory: AgentFactory) -> Self {
Self {
inner: WorkerTaskManagerImpl::new(factory),
calls: Mutex::new(TaskManagerCalls::default()),
}
}
fn reset(&self) {
self.inner.clear();
*self.calls.lock().unwrap() = TaskManagerCalls::default();
}
fn snapshot(&self) -> TaskManagerCalls {
self.calls.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl IWorkerTaskManager for CountingTaskManager {
fn get_task(&self, conversation_id: &str) -> Option<nomifun_ai_agent::AgentInstance> {
self.inner.get_task(conversation_id)
}
async fn get_or_build_task(
&self,
conversation_id: &str,
options: BuildTaskOptions,
) -> Result<nomifun_ai_agent::AgentInstance, AppError> {
self.calls.lock().unwrap().build.push(conversation_id.to_owned());
self.inner.get_or_build_task(conversation_id, options).await
}
fn kill(&self, conversation_id: &str, reason: Option<AgentKillReason>) -> Result<(), AppError> {
self.calls
.lock()
.unwrap()
.kill
.push((conversation_id.to_owned(), reason));
self.inner.kill(conversation_id, reason)
}
fn kill_and_wait(
&self,
conversation_id: &str,
reason: Option<AgentKillReason>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
let _ = self.kill(conversation_id, reason);
Box::pin(std::future::ready(()))
}
fn clear(&self) {
self.inner.clear()
}
fn active_count(&self) -> usize {
self.inner.active_count()
}
fn collect_idle(&self, idle_threshold_ms: nomifun_common::TimestampMs) -> Vec<String> {
self.inner.collect_idle(idle_threshold_ms)
}
}
// Minimal stub agent returned by the test factory: ensure_session only
// asks the task manager to kill + rebuild; the returned handle never has
// `send_message` called on it.
mod mock_agent {
use nomifun_ai_agent::agent_task::{IAgentTask, IMockAgent};
use nomifun_ai_agent::protocol::events::AgentStreamEvent;
use nomifun_ai_agent::types::SendMessageData;
use nomifun_common::{AgentKillReason, AgentType, AppError, Confirmation, ConversationStatus, TimestampMs};
use tokio::sync::broadcast;
pub struct MockAgent {
pub conversation_id: String,
pub workspace: String,
pub event_tx: broadcast::Sender<AgentStreamEvent>,
pub confirmations: Vec<Confirmation>,
}
impl MockAgent {
pub fn new(conversation_id: String, workspace: String) -> Self {
Self::with_confirmations(conversation_id, workspace, Vec::new())
}
pub fn with_confirmations(
conversation_id: String,
workspace: String,
confirmations: Vec<Confirmation>,
) -> Self {
let (event_tx, _) = broadcast::channel(16);
Self {
conversation_id,
workspace,
event_tx,
confirmations,
}
}
}
#[async_trait::async_trait]
impl IAgentTask for MockAgent {
fn agent_type(&self) -> AgentType {
AgentType::Acp
}
fn conversation_id(&self) -> &str {
&self.conversation_id
}
fn workspace(&self) -> &str {
&self.workspace
}
fn status(&self) -> Option<ConversationStatus> {
None
}
fn last_activity_at(&self) -> TimestampMs {
0
}
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
self.event_tx.subscribe()
}
async fn send_message(&self, _data: SendMessageData) -> Result<(), nomifun_ai_agent::AgentSendError> {
Ok(())
}
async fn cancel(&self) -> Result<(), AppError> {
Ok(())
}
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
Ok(())
}
}
impl IMockAgent for MockAgent {
fn get_confirmations(&self) -> Vec<Confirmation> {
self.confirmations.clone()
}
}
}
fn success_factory() -> AgentFactory {
use futures_util::FutureExt;
Arc::new(|opts: BuildTaskOptions| {
async move {
Ok(nomifun_ai_agent::AgentInstance::Mock(Arc::new(
mock_agent::MockAgent::new(opts.conversation_id, opts.workspace),
)))
}
.boxed()
})
}
fn confirmations_factory(count: usize) -> AgentFactory {
use futures_util::FutureExt;
use nomifun_common::Confirmation;
Arc::new(move |opts: BuildTaskOptions| {
let confirmations = (0..count)
.map(|idx| Confirmation {
id: format!("tool-{idx}"),
call_id: format!("tool-{idx}"),
title: None,
action: None,
description: format!("Confirm tool {idx}"),
command_type: None,
options: vec![],
})
.collect::<Vec<_>>();
async move {
Ok(nomifun_ai_agent::AgentInstance::Mock(Arc::new(
mock_agent::MockAgent::with_confirmations(opts.conversation_id, opts.workspace, confirmations),
)))
}
.boxed()
})
}
struct EmptyProviderRepo;
#[async_trait::async_trait]
impl IProviderRepository for EmptyProviderRepo {
async fn list(&self) -> Result<Vec<nomifun_db::models::Provider>, DbError> {
Ok(vec![])
}
async fn find_by_id(&self, _id: &str) -> Result<Option<nomifun_db::models::Provider>, DbError> {
Ok(None)
}
async fn create(
&self,
_params: nomifun_db::CreateProviderParams<'_>,
) -> Result<nomifun_db::models::Provider, DbError> {
Err(DbError::NotFound("not implemented".into()))
}
async fn update(
&self,
_id: &str,
_params: nomifun_db::UpdateProviderParams<'_>,
) -> Result<nomifun_db::models::Provider, DbError> {
Err(DbError::NotFound("not implemented".into()))
}
async fn delete(&self, _id: &str) -> Result<(), DbError> {
Err(DbError::NotFound("not implemented".into()))
}
}
fn setup_with_factory(factory: AgentFactory) -> (Arc<TeamSessionService>, Arc<CountingTaskManager>) {
setup_with_factory_and_metadata(factory, Arc::new(StubAgentMetadataRepo::empty()))
}
fn setup_with_factory_and_metadata(
factory: AgentFactory,
agent_metadata_repo: Arc<dyn IAgentMetadataRepository>,
) -> (Arc<TeamSessionService>, Arc<CountingTaskManager>) {
let team_repo: Arc<dyn ITeamRepository> = Arc::new(MockTeamRepo::new());
let conv_repo: Arc<dyn IConversationRepository> = Arc::new(MockConversationRepo::new());
let broadcaster: Arc<dyn EventBroadcaster> = Arc::new(NullBroadcaster);
let acp_session_repo: Arc<dyn IAcpSessionRepository> = Arc::new(StubAcpSessionRepo);
let task_manager = Arc::new(CountingTaskManager::new(factory));
let task_manager_dyn: Arc<dyn IWorkerTaskManager> = task_manager.clone();
let conv_service = ConversationService::new(
std::env::temp_dir(),
broadcaster.clone(),
Arc::new(StubSkillResolver),
task_manager_dyn.clone(),
conv_repo,
agent_metadata_repo.clone(),
acp_session_repo,
);
let backend_binary_path = Arc::new(std::path::PathBuf::from("/tmp/nomicore-test"));
let provider_repo: Arc<dyn IProviderRepository> = Arc::new(EmptyProviderRepo);
let svc = TeamSessionService::new(
team_repo,
agent_metadata_repo,
provider_repo,
conv_service,
broadcaster,
task_manager_dyn,
backend_binary_path,
None,
);
(svc, task_manager)
}
fn setup() -> Arc<TeamSessionService> {
setup_with_factory(success_factory()).0
}
fn setup_with_recording_broadcaster() -> (Arc<TeamSessionService>, Arc<RecordingBroadcaster>) {
let team_repo: Arc<dyn ITeamRepository> = Arc::new(MockTeamRepo::new());
let conv_repo: Arc<dyn IConversationRepository> = Arc::new(MockConversationRepo::new());
let recorder = Arc::new(RecordingBroadcaster::new());
let broadcaster: Arc<dyn EventBroadcaster> = recorder.clone();
let agent_metadata_repo: Arc<dyn IAgentMetadataRepository> = Arc::new(StubAgentMetadataRepo::empty());
let acp_session_repo: Arc<dyn IAcpSessionRepository> = Arc::new(StubAcpSessionRepo);
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(CountingTaskManager::new(success_factory()));
let conv_service = ConversationService::new(
std::env::temp_dir(),
broadcaster.clone(),
Arc::new(StubSkillResolver),
task_manager.clone(),
conv_repo,
agent_metadata_repo.clone(),
acp_session_repo,
);
let backend_binary_path = Arc::new(std::path::PathBuf::from("/tmp/nomicore-test"));
let provider_repo: Arc<dyn IProviderRepository> = Arc::new(EmptyProviderRepo);
let svc = TeamSessionService::new(
team_repo,
agent_metadata_repo,
provider_repo,
conv_service,
broadcaster,
task_manager,
backend_binary_path,
None,
);
(svc, recorder)
}
fn make_agent_metadata_row(id: &str, backend: &str, icon: &str) -> AgentMetadataRow {
AgentMetadataRow {
id: id.to_owned(),
icon: Some(icon.to_owned()),
name: backend.to_owned(),
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: true,
command: None,
args: None,
env: None,
native_skills_dirs: None,
behavior_policy: None,
yolo_id: None,
agent_capabilities: None,
auth_methods: None,
config_options: None,
available_modes: None,
available_models: None,
available_commands: None,
sort_order: 0,
created_at: 0,
updated_at: 0,
}
}
fn setup_with_metadata_rows(rows: Vec<AgentMetadataRow>) -> Arc<TeamSessionService> {
let agent_metadata_repo: Arc<dyn IAgentMetadataRepository> = Arc::new(StubAgentMetadataRepo::with_rows(rows));
setup_with_factory_and_metadata(success_factory(), agent_metadata_repo).0
}
fn two_agent_input() -> Vec<TeamAgentInput> {
vec![
TeamAgentInput {
name: "Lead".into(),
role: "lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
},
TeamAgentInput {
name: "Worker".into(),
role: "teammate".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
},
]
}
fn reset_auto_started_session(svc: &Arc<TeamSessionService>, tm: &Arc<CountingTaskManager>, team_id: &str) {
svc.stop_session(team_id);
tm.reset();
}
// ===========================================================================
// Test: Team CRUD (TC-*, TL-*, TG-*, TD-*, TR-*)
// ===========================================================================
#[tokio::test]
async fn tc1_create_team_with_multiple_agents() {
let svc = setup();
let resp = svc
.create_team(
"user1",
CreateTeamRequest {
name: "Alpha".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
assert_eq!(resp.name, "Alpha");
assert_eq!(resp.agents.len(), 2);
assert_eq!(resp.agents[0].role, "lead");
assert_eq!(resp.agents[1].role, "teammate");
assert!(resp.lead_agent_id.is_some());
assert_eq!(resp.lead_agent_id, Some(resp.agents[0].slot_id.clone()));
}
#[tokio::test]
async fn tc_create_team_uses_custom_agent_id_icon_lookup() {
let svc = setup_with_metadata_rows(vec![make_agent_metadata_row(
"agent_builtin_claude",
"claude",
"/api/assets/logos/ai-major/claude.svg",
)]);
let resp = svc
.create_team(
"user1",
CreateTeamRequest {
name: "Alpha".into(),
agents: vec![TeamAgentInput {
name: "Lead".into(),
role: "lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: Some("agent_builtin_claude".into()),
conversation_id: None,
}],
workspace: None,
},
)
.await
.unwrap();
assert_eq!(
resp.agents[0].icon.as_deref(),
Some("/api/assets/logos/ai-major/claude.svg")
);
}
#[tokio::test]
async fn ta_add_agent_uses_model_fallback_for_acp_backend() {
let svc = setup_with_metadata_rows(vec![make_agent_metadata_row(
"agent_builtin_codex",
"codex",
"/api/assets/logos/tools/coding/codex.svg",
)]);
let team = svc
.create_team(
"user1",
CreateTeamRequest {
name: "Alpha".into(),
agents: vec![TeamAgentInput {
name: "Lead".into(),
role: "lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
}],
workspace: None,
},
)
.await
.unwrap();
let added = svc
.add_agent(
"user1",
&team.id,
AddAgentRequest {
name: "Coder".into(),
role: "teammate".into(),
backend: "acp".into(),
model: "codex".into(),
custom_agent_id: None,
},
)
.await
.unwrap();
assert_eq!(added.icon.as_deref(), Some("/api/assets/logos/tools/coding/codex.svg"));
}
#[tokio::test]
async fn tc2_create_single_agent_team() {
let svc = setup();
let resp = svc
.create_team(
"user1",
CreateTeamRequest {
name: "Solo".into(),
agents: vec![TeamAgentInput {
name: "Lead".into(),
role: "lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
}],
workspace: None,
},
)
.await
.unwrap();
assert_eq!(resp.agents.len(), 1);
assert_eq!(resp.agents[0].role, "lead");
}
#[tokio::test]
async fn tc4_first_agent_is_lead() {
let svc = setup();
let resp = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: vec![
TeamAgentInput {
name: "A".into(),
role: "teammate".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
},
TeamAgentInput {
name: "B".into(),
role: "teammate".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
},
],
workspace: None,
},
)
.await
.unwrap();
assert_eq!(resp.agents[0].role, "lead");
assert_eq!(resp.lead_agent_id, Some(resp.agents[0].slot_id.clone()));
}
#[tokio::test]
async fn tc5_empty_agents_returns_error() {
let svc = setup();
let result = svc
.create_team(
"user1",
CreateTeamRequest {
name: "Empty".into(),
agents: vec![],
workspace: None,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn tc3_each_agent_has_conversation_id() {
let svc = setup();
let resp = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
for agent in &resp.agents {
assert!(agent.conversation_id != 0);
}
assert_ne!(resp.agents[0].conversation_id, resp.agents[1].conversation_id);
}
// -- List teams ---------------------------------------------------------------
#[tokio::test]
async fn tl1_empty_list() {
let svc = setup();
let list = svc.list_teams().await.unwrap();
assert!(list.is_empty());
}
#[tokio::test]
async fn tl2_list_multiple_teams() {
let svc = setup();
svc.create_team(
"user1",
CreateTeamRequest {
name: "A".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.create_team(
"user1",
CreateTeamRequest {
name: "B".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
let list = svc.list_teams().await.unwrap();
assert_eq!(list.len(), 2);
}
#[tokio::test]
async fn tl_list_teams_includes_pending_confirmation_counts_without_rebuilding_tasks() {
let (svc, task_manager) = setup_with_factory(confirmations_factory(2));
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "With Confirmations".into(),
agents: vec![TeamAgentInput {
name: "Lead".into(),
role: "lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
}],
workspace: None,
},
)
.await
.unwrap();
// DTO conversation_id is i64; the task manager is string-keyed (Option A).
let conversation_id = created.agents[0].conversation_id.to_string();
task_manager
.get_or_build_task(
&conversation_id,
BuildTaskOptions {
agent_type: AgentType::Acp,
workspace: "/tmp/ws".into(),
model: ProviderWithModel {
provider_id: "test".into(),
model: "claude".into(),
use_model: None,
},
conversation_id: conversation_id.clone(),
extra: serde_json::json!({}),
},
)
.await
.unwrap();
let before = task_manager.snapshot();
let list = svc.list_teams().await.unwrap();
let after = task_manager.snapshot();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, created.id);
assert_eq!(list[0].agents[0].pending_confirmations, 2);
assert_eq!(after.build, before.build);
}
// -- Get team -----------------------------------------------------------------
#[tokio::test]
async fn tg1_get_existing_team() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "Alpha".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
let got = svc.get_team(&created.id).await.unwrap();
assert_eq!(got.id, created.id);
assert_eq!(got.name, "Alpha");
assert_eq!(got.agents.len(), 2);
}
#[tokio::test]
async fn tg2_get_nonexistent_returns_error() {
let svc = setup();
let result = svc.get_team("nonexistent").await;
assert!(result.is_err());
}
// -- Delete team --------------------------------------------------------------
#[tokio::test]
async fn td1_delete_existing_team() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.remove_team("user1", &created.id).await.unwrap();
let list = svc.list_teams().await.unwrap();
assert!(list.is_empty());
}
#[tokio::test]
async fn td6_delete_nonexistent_returns_error() {
let svc = setup();
let result = svc.remove_team("user1", "nonexistent").await;
assert!(result.is_err());
}
// -- Rename team --------------------------------------------------------------
#[tokio::test]
async fn tr1_rename_existing_team() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "Old".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.rename_team(&created.id, "New Name").await.unwrap();
let got = svc.get_team(&created.id).await.unwrap();
assert_eq!(got.name, "New Name");
}
#[tokio::test]
async fn tr4_rename_nonexistent_returns_error() {
let svc = setup();
let result = svc.rename_team("nonexistent", "X").await;
assert!(result.is_err());
}
// ===========================================================================
// Test: Agent Management (AA-*, AR-*, AN-*)
// ===========================================================================
#[tokio::test]
async fn aa1_add_agent_to_team() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: vec![TeamAgentInput {
name: "Lead".into(),
role: "lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
}],
workspace: None,
},
)
.await
.unwrap();
let agent = svc
.add_agent(
"user1",
&created.id,
AddAgentRequest {
name: "Worker".into(),
role: "teammate".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
},
)
.await
.unwrap();
assert_eq!(agent.name, "Worker");
assert_eq!(agent.role, "teammate");
assert!(agent.conversation_id != 0);
let got = svc.get_team(&created.id).await.unwrap();
assert_eq!(got.agents.len(), 2);
}
#[tokio::test]
async fn aa4_add_agent_to_nonexistent_team() {
let svc = setup();
let result = svc
.add_agent(
"user1",
"nonexistent",
AddAgentRequest {
name: "X".into(),
role: "teammate".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn ar1_remove_agent_from_team() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
let worker_slot = created.agents[1].slot_id.clone();
svc.remove_agent("user1", &created.id, &worker_slot).await.unwrap();
let got = svc.get_team(&created.id).await.unwrap();
assert_eq!(got.agents.len(), 1);
assert!(got.agents.iter().all(|a| a.slot_id != worker_slot));
}
#[tokio::test]
async fn ar4_remove_nonexistent_agent() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
let result = svc.remove_agent("user1", &created.id, "nonexistent").await;
assert!(result.is_err());
}
#[tokio::test]
async fn an1_rename_agent() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
let slot_id = created.agents[1].slot_id.clone();
svc.rename_agent(&created.id, &slot_id, "Senior Worker").await.unwrap();
let got = svc.get_team(&created.id).await.unwrap();
let agent = got.agents.iter().find(|a| a.slot_id == slot_id).unwrap();
assert_eq!(agent.name, "Senior Worker");
}
#[tokio::test]
async fn an3_rename_nonexistent_agent() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
let result = svc.rename_agent(&created.id, "nonexistent", "X").await;
assert!(result.is_err());
}
// ===========================================================================
// Test: Session Management (ES-*, SS-*)
// ===========================================================================
#[tokio::test]
async fn es1_ensure_session_creates_session() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&created.id).await.unwrap();
}
#[tokio::test]
async fn es2_ensure_session_is_idempotent() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&created.id).await.unwrap();
svc.ensure_session(&created.id).await.unwrap();
}
#[tokio::test]
async fn es3_ensure_session_nonexistent_team() {
let svc = setup();
let result = svc.ensure_session("nonexistent").await;
assert!(result.is_err());
}
// -- W5-D31b-2: team.mcpStatus service-layer broadcasts ---------------------
//
// The happy-path assertion (session_injecting → session_ready) would require
// `create_team` to succeed, but on this branch base `create_team` panics at
// conversation creation because `StubAcpSessionRepo::create` returns Err
// (pre-existing baseline break — same root cause `es1_ensure_session_creates_session`
// fails with on `feat/team-wave4-5` HEAD). We therefore only assert the
// `load_failed` broadcast end-to-end here; the remaining phase transitions
// (SessionInjecting / SessionReady / ConfigWriteFailed / SessionError) are
// covered by inline assertions that do not depend on `create_team`.
#[tokio::test]
async fn d31b2_ensure_session_broadcasts_load_failed_for_missing_team() {
let (svc, recorder) = setup_with_recording_broadcaster();
let err = svc.ensure_session("nonexistent-team-xyz").await.unwrap_err();
assert!(matches!(err, nomifun_team::TeamError::TeamNotFound(_)));
let load_failed = recorder
.events_by_name("team.mcpStatus")
.into_iter()
.find(|e| {
e.data
.get("phase")
.and_then(|v| v.as_str())
.map(|s| s == "load_failed")
.unwrap_or(false)
})
.expect("load_failed broadcast expected");
assert_eq!(
load_failed.data.get("team_id").and_then(|v| v.as_str()),
Some("nonexistent-team-xyz")
);
assert!(load_failed.data.get("error").is_some());
}
#[tokio::test]
async fn ss1_stop_session() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&created.id).await.unwrap();
svc.stop_session(&created.id);
}
#[tokio::test]
async fn ss3_stop_session_without_active_is_noop() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.stop_session(&created.id);
}
// ===========================================================================
// Test: Message sending requires active session (SM-*)
// ===========================================================================
#[tokio::test]
async fn sm4_send_message_no_session_returns_error() {
let svc = setup();
let result = svc.send_message("nonexistent", "Hello", None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn sm1_send_message_with_active_session() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&created.id).await.unwrap();
svc.send_message(&created.id, "Hello team", None).await.unwrap();
}
#[tokio::test]
async fn sa_send_message_to_agent_with_active_session() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&created.id).await.unwrap();
let worker_slot = created.agents[1].slot_id.clone();
svc.send_message_to_agent(&created.id, &worker_slot, "Do this", None)
.await
.unwrap();
}
#[tokio::test]
async fn sa3_send_message_to_nonexistent_agent() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&created.id).await.unwrap();
let result = svc
.send_message_to_agent(&created.id, "nonexistent", "Hello", None)
.await;
assert!(result.is_err());
}
// ===========================================================================
// Test: dispose_all
// ===========================================================================
#[tokio::test]
async fn dispose_all_cleans_up_sessions() {
let svc = setup();
let t1 = svc
.create_team(
"user1",
CreateTeamRequest {
name: "A".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
let t2 = svc
.create_team(
"user1",
CreateTeamRequest {
name: "B".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&t1.id).await.unwrap();
svc.ensure_session(&t2.id).await.unwrap();
svc.dispose_all();
// After dispose, sessions are cleaned up.
assert!(svc.get_session_scheduler(&t1.id).is_none());
assert!(svc.get_session_scheduler(&t2.id).is_none());
}
// ===========================================================================
// Test: Delete team stops active session (TD-2 + integration)
// ===========================================================================
#[tokio::test]
async fn td_delete_team_stops_session() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&created.id).await.unwrap();
svc.remove_team("user1", &created.id).await.unwrap();
let result = svc.send_message(&created.id, "Hello", None).await;
assert!(result.is_err());
}
// ===========================================================================
// Test: D9 ensure_session kill + rebuild closed loop
// ===========================================================================
#[tokio::test]
async fn d9_ensure_session_kills_and_rebuilds_every_agent() {
let (svc, tm) = setup_with_factory(success_factory());
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
reset_auto_started_session(&svc, &tm, &created.id);
svc.ensure_session(&created.id).await.unwrap();
// Two agents → kill called 2x and get_or_build_task called 2x, each with
// the corresponding conversation_id. Order is agents-iteration order.
let calls = tm.snapshot();
assert_eq!(calls.kill.len(), 2, "expected 2 kill calls");
assert_eq!(calls.build.len(), 2, "expected 2 build calls");
for (i, agent) in created.agents.iter().enumerate() {
// task manager records ids as Strings; DTO id is i64 (Option A).
assert_eq!(calls.kill[i].0, agent.conversation_id.to_string());
assert_eq!(calls.kill[i].1, Some(AgentKillReason::TeamMcpRebuild));
assert_eq!(calls.build[i], agent.conversation_id.to_string());
}
}
#[tokio::test]
async fn d9_ensure_session_persists_team_mcp_stdio_config() {
// Each agent's conversation.extra must carry a `team_mcp_stdio_config`
// object by the time the factory is called — that is what the rebuilt
// ACP process will read to reach the MCP server.
use futures_util::FutureExt;
let (svc, _tm) = setup_with_factory(Arc::new(|opts: BuildTaskOptions| {
async move {
let extra_has_cfg = opts
.extra
.get("team_mcp_stdio_config")
.and_then(|v| v.as_object())
.is_some_and(|o| o.contains_key("port") && o.contains_key("slot_id"));
assert!(
extra_has_cfg,
"factory called without team_mcp_stdio_config in extra: {:?}",
opts.extra
);
Ok(nomifun_ai_agent::AgentInstance::Mock(Arc::new(
mock_agent::MockAgent::new(opts.conversation_id, opts.workspace),
)))
}
.boxed()
}));
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
svc.ensure_session(&created.id).await.unwrap();
}
#[tokio::test]
async fn d9_ensure_session_is_idempotent() {
let (svc, tm) = setup_with_factory(success_factory());
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
reset_auto_started_session(&svc, &tm, &created.id);
svc.ensure_session(&created.id).await.unwrap();
svc.ensure_session(&created.id).await.unwrap();
// Second call short-circuits — no additional kill/build calls.
let calls = tm.snapshot();
assert_eq!(calls.kill.len(), 2, "second ensure_session must not re-kill");
assert_eq!(calls.build.len(), 2, "second ensure_session must not re-build");
}
#[tokio::test]
async fn d9_ensure_session_rollbacks_when_build_fails() {
// Factory always fails → ensure_session must propagate error and not
// insert into sessions, so send_message afterwards still errors.
use futures_util::FutureExt;
let failing_factory: AgentFactory = Arc::new(|_opts: BuildTaskOptions| {
async move { Err(AppError::Internal("simulated build failure".into())) }.boxed()
});
let (svc, tm) = setup_with_factory(failing_factory);
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
reset_auto_started_session(&svc, &tm, &created.id);
let result = svc.ensure_session(&created.id).await;
assert!(result.is_err(), "ensure_session should propagate build error");
// Rebuild aborts on the first warmup failure, so only the first agent
// is killed/built. No session is inserted, so send_message still errors.
let calls = tm.snapshot();
assert_eq!(calls.kill.len(), 1);
assert_eq!(calls.build.len(), 1);
let send_result = svc.send_message(&created.id, "Hello", None).await;
assert!(
send_result.is_err(),
"session must not be registered after build failure"
);
}
// ===========================================================================
// Test: D11.5 remove_team cascades kill to every agent process
// ===========================================================================
// ===========================================================================
// Test: W4-D23 add_agent_locks — per-team serialization prevents last-writer-
// wins when two tasks race on add_agent.
// ===========================================================================
#[tokio::test]
async fn w4_d23_concurrent_add_agent_preserves_every_insertion() {
// Two concurrent add_agent calls on the same team must both be persisted
// (no silent drop from unsynchronized read-modify-write on the agents
// JSON blob).
let svc = Arc::new(setup());
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: vec![TeamAgentInput {
name: "Lead".into(),
role: "lead".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
conversation_id: None,
}],
workspace: None,
},
)
.await
.unwrap();
let svc_a = svc.clone();
let team_id_a = created.id.clone();
let task_a = tokio::spawn(async move {
svc_a
.add_agent(
"user1",
&team_id_a,
AddAgentRequest {
name: "WorkerA".into(),
role: "teammate".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
},
)
.await
});
let svc_b = svc.clone();
let team_id_b = created.id.clone();
let task_b = tokio::spawn(async move {
svc_b
.add_agent(
"user1",
&team_id_b,
AddAgentRequest {
name: "WorkerB".into(),
role: "teammate".into(),
backend: "acp".into(),
model: "claude".into(),
custom_agent_id: None,
},
)
.await
});
let (a, b) = tokio::join!(task_a, task_b);
a.unwrap().unwrap();
b.unwrap().unwrap();
let got = svc.get_team(&created.id).await.unwrap();
assert_eq!(
got.agents.len(),
3,
"both concurrent add_agent calls must be persisted (1 lead + 2 workers)"
);
let names: std::collections::HashSet<_> = got.agents.iter().map(|a| a.name.clone()).collect();
assert!(names.contains("Lead"));
assert!(names.contains("WorkerA"));
assert!(names.contains("WorkerB"));
}
#[tokio::test]
async fn d115_remove_team_kills_every_agent_process() {
let (svc, tm) = setup_with_factory(success_factory());
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "T".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.unwrap();
reset_auto_started_session(&svc, &tm, &created.id);
// Bring two agents online — after ensure_session, active_count == 2.
svc.ensure_session(&created.id).await.unwrap();
assert_eq!(tm.active_count(), 2, "ensure_session must register 2 live agents");
let before_kill = tm.snapshot().kill.len();
svc.remove_team("user1", &created.id).await.unwrap();
// remove_team must have issued one kill per agent with reason TeamDeleted,
// and the task manager's active_count must drop back to 0.
let calls = tm.snapshot();
let new_kills = &calls.kill[before_kill..];
assert_eq!(
new_kills.len(),
created.agents.len(),
"remove_team must kill every agent once"
);
for (i, agent) in created.agents.iter().enumerate() {
assert_eq!(new_kills[i].0, agent.conversation_id.to_string());
assert_eq!(new_kills[i].1, Some(AgentKillReason::TeamDeleted));
}
assert_eq!(
tm.active_count(),
0,
"every agent worker must be torn down after remove_team"
);
}