"""Tests for _execute_team_collab in chat.py (U6). Tests cover: - @team prefix triggers team collaboration (returns True) - Non-@team input does not trigger (returns False) - @team without task content sends error - Team events are relayed to WebSocket via emit_team_event - final_answer is sent after execution - User message and final result are persisted to session history - Team is dissolved after execution - Execution failure sends error and dissolves team - @team and @board do not interfere with each other """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from agentkit.experts.config import ExpertConfig, ExpertTemplate from agentkit.experts.registry import ExpertTemplateRegistry from agentkit.server.routes.chat import _execute_team_collab from agentkit.session.manager import SessionManager from agentkit.session.models import MessageRole from agentkit.session.store import InMemorySessionStore # ── 辅助函数 ────────────────────────────────────────────── def _make_expert_template( name: str, persona: str = "测试专家", is_lead: bool = False, ) -> ExpertTemplate: """创建测试用 ExpertTemplate""" config = ExpertConfig( name=name, agent_type="expert", persona=persona, thinking_style="analytical", bound_skills=[], is_lead=is_lead, task_mode="llm_generate", prompt={"identity": persona}, ) return ExpertTemplate( name=name, config=config, is_builtin=True, description=f"{name} 模板", ) def _make_registry_with_dev_team() -> ExpertTemplateRegistry: """创建包含 dev_team 模板和成员模板的注册中心""" registry = ExpertTemplateRegistry() registry.register(_make_expert_template("tech_lead", persona="技术负责人")) registry.register(_make_expert_template("frontend_engineer", persona="前端工程师")) registry.register(_make_expert_template("backend_engineer", persona="后端工程师")) # dev_team 模板(bound_skills 存储成员列表) registry.register( ExpertTemplate( name="dev_team", config=ExpertConfig( name="dev_team", agent_type="expert", persona="编程团队", thinking_style="流水线", bound_skills=["tech_lead", "frontend_engineer", "backend_engineer"], task_mode="llm_generate", prompt={"identity": "Dev Team"}, ), is_builtin=True, description="编程团队模板", ) ) return registry class FakeWebSocket: """Minimal WebSocket fake for testing.""" def __init__(self) -> None: self.sent: list[dict] = [] self.app = MagicMock() self.app.state.agent_pool = MagicMock() self.app.state.expert_template_registry = None # Will be set per-test async def send_json(self, data: dict) -> None: self.sent.append(data) def _make_mock_team() -> MagicMock: """创建 mock ExpertTeam 实例""" mock_team = MagicMock() mock_team.team_channel = "team:test" mock_team.handoff_transport = MagicMock() mock_team.handoff_transport.register_handler = MagicMock() mock_team.handoff_transport._handlers = {} mock_team.create_team = AsyncMock() mock_team.dissolve = AsyncMock() return mock_team def _make_mock_orchestrator(result: dict) -> MagicMock: """创建 mock TeamOrchestrator 实例""" mock_orch = MagicMock() mock_orch.execute = AsyncMock(return_value=result) return mock_orch @pytest.fixture async def session_manager() -> SessionManager: sm = SessionManager(store=InMemorySessionStore()) session = await sm.create_session(agent_name="test-agent") sm._test_session_id = session.session_id return sm @pytest.fixture def websocket() -> FakeWebSocket: ws = FakeWebSocket() ws.app.state.expert_template_registry = _make_registry_with_dev_team() return ws @pytest.fixture def mock_orchestrator_result() -> dict: """Mock result from TeamOrchestrator.execute()""" return { "status": "completed", "result": {"content": "## 团队最终结果\n\n用户登录功能已实现"}, "phase_results": { "phase-1": {"content": "规划完成", "phase_name": "规划"}, "phase-2": {"content": "前端实现完成", "phase_name": "前端"}, }, "plan": MagicMock(), } # ── 路由匹配测试 ────────────────────────────────────────── class TestTeamCollabRouting: """_execute_team_collab 路由匹配测试""" @pytest.mark.asyncio async def test_team_prefix_triggers_collab( self, websocket, session_manager, mock_orchestrator_result ): """@team 前缀触发团队协作,返回 True""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team_cls.return_value = _make_mock_team() mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) result = await _execute_team_collab( websocket, session_id, "@team 开发用户登录功能", session_manager ) assert result is True @pytest.mark.asyncio async def test_non_team_input_does_not_trigger(self, websocket, session_manager): """非 @team 输入不触发,返回 False""" session_id = session_manager._test_session_id result = await _execute_team_collab( websocket, session_id, "普通问题", session_manager ) assert result is False @pytest.mark.asyncio async def test_board_prefix_does_not_trigger_team( self, websocket, session_manager ): """@board 前缀不触发 @team 协作""" session_id = session_manager._test_session_id result = await _execute_team_collab( websocket, session_id, "@board 讨论主题", session_manager ) assert result is False @pytest.mark.asyncio async def test_team_with_dev_team_template( self, websocket, session_manager, mock_orchestrator_result ): """@team:dev_team 触发团队协作""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team_cls.return_value = _make_mock_team() mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) result = await _execute_team_collab( websocket, session_id, "@team:dev_team 开发功能", session_manager ) assert result is True # ── 错误处理测试 ────────────────────────────────────────── class TestTeamCollabErrorHandling: """_execute_team_collab 错误处理测试""" @pytest.mark.asyncio async def test_team_without_task_sends_error( self, websocket, session_manager ): """@team 无任务内容时发送错误(通过 mock router 模拟空 task_content)""" session_id = session_manager._test_session_id # Mock ExpertTeamRouter to return empty task_content mock_routing_result = MagicMock() mock_routing_result.matched = True mock_routing_result.task_content = "" # 空 task_content mock_routing_result.specified_experts = ["tech_lead"] with patch( "agentkit.experts.router.ExpertTeamRouter" ) as mock_router_cls: mock_router = MagicMock() mock_router.resolve = MagicMock(return_value=mock_routing_result) mock_router_cls.return_value = mock_router result = await _execute_team_collab( websocket, session_id, "@team", session_manager ) assert result is True # 应该发送了错误消息 assert any( msg.get("type") == "error" and "描述" in msg.get("data", {}).get("message", "") for msg in websocket.sent ) @pytest.mark.asyncio async def test_team_execution_failure_sends_error( self, websocket, session_manager ): """团队执行失败时发送错误并清理""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team = _make_mock_team() mock_team_cls.return_value = mock_team mock_orch_cls.return_value = _make_mock_orchestrator_result_failing() result = await _execute_team_collab( websocket, session_id, "@team 开发功能", session_manager ) assert result is True # 应该发送了错误消息 assert any( msg.get("type") == "error" and "团队协作执行失败" in msg.get("data", {}).get("message", "") for msg in websocket.sent ) # 应该调用了 dissolve 清理 mock_team.dissolve.assert_called() # 失败时不应发送 final_answer assert not any(msg.get("type") == "final_answer" for msg in websocket.sent) def _make_mock_orchestrator_result_failing() -> MagicMock: """创建 mock TeamOrchestrator that raises an exception""" mock_orch = MagicMock() mock_orch.execute = AsyncMock(side_effect=RuntimeError("LLM 不可用")) return mock_orch # ── 事件中继与持久化测试 ────────────────────────────────── class TestTeamCollabEventRelay: """_execute_team_collab 事件中继与持久化测试""" @pytest.mark.asyncio async def test_final_answer_sent_after_execution( self, websocket, session_manager, mock_orchestrator_result ): """执行完成后发送 final_answer""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team_cls.return_value = _make_mock_team() mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) await _execute_team_collab( websocket, session_id, "@team 开发登录功能", session_manager ) final_msgs = [msg for msg in websocket.sent if msg.get("type") == "final_answer"] assert len(final_msgs) == 1 assert "团队最终结果" in final_msgs[0]["content"] assert final_msgs[0]["is_final"] is True @pytest.mark.asyncio async def test_user_message_persisted( self, websocket, session_manager, mock_orchestrator_result ): """用户消息持久化到会话历史""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team_cls.return_value = _make_mock_team() mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) await _execute_team_collab( websocket, session_id, "@team 开发登录功能", session_manager ) messages = await session_manager.get_messages(session_id) user_msgs = [m for m in messages if m.role == MessageRole.USER] assert len(user_msgs) == 1 assert "@team 开发登录功能" in user_msgs[0].content @pytest.mark.asyncio async def test_final_result_persisted( self, websocket, session_manager, mock_orchestrator_result ): """最终结果持久化为 assistant 消息""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team_cls.return_value = _make_mock_team() mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) await _execute_team_collab( websocket, session_id, "@team 开发登录功能", session_manager ) messages = await session_manager.get_messages(session_id) assistant_msgs = [m for m in messages if m.role == MessageRole.ASSISTANT] assert len(assistant_msgs) == 1 assert "团队最终结果" in assistant_msgs[0].content assert assistant_msgs[0].agent_name == "team_collab" @pytest.mark.asyncio async def test_team_dissolved_after_execution( self, websocket, session_manager, mock_orchestrator_result ): """执行后 team.dissolve() 被调用""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team = _make_mock_team() mock_team_cls.return_value = mock_team mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) await _execute_team_collab( websocket, session_id, "@team 开发登录功能", session_manager ) mock_team.dissolve.assert_called_once() @pytest.mark.asyncio async def test_handoff_handler_registered( self, websocket, session_manager, mock_orchestrator_result ): """handoff_transport handler 被注册用于事件中继""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team = _make_mock_team() mock_team.team_channel = "team:test-channel" mock_team_cls.return_value = mock_team mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) await _execute_team_collab( websocket, session_id, "@team 开发登录功能", session_manager ) mock_team.handoff_transport.register_handler.assert_called_once() call_args = mock_team.handoff_transport.register_handler.call_args assert call_args[0][0] == "team:test-channel" @pytest.mark.asyncio async def test_team_events_relayed_to_websocket( self, websocket, session_manager, mock_orchestrator_result ): """注册的 handler 将团队事件转发到 WebSocket""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team = _make_mock_team() mock_team.team_channel = "team:test-channel" mock_team_cls.return_value = mock_team mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) await _execute_team_collab( websocket, session_id, "@team 开发功能", session_manager ) # Get the registered handler and invoke it with a sample event register_call = mock_team.handoff_transport.register_handler.call_args handler = register_call[0][1] # second arg is the handler callback await handler({"type": "expert_step", "expert": "lead", "message": "开始分析"}) # Verify the event was relayed to WebSocket via emit_team_event team_event_msgs = [ msg for msg in websocket.sent if msg.get("type") == "expert_step" ] assert len(team_event_msgs) == 1 assert team_event_msgs[0].get("data", {}).get("expert") == "lead" @pytest.mark.asyncio async def test_create_team_called_with_lead_and_members( self, websocket, session_manager, mock_orchestrator_result ): """create_team 以 lead_config 和 member_configs 调用""" session_id = session_manager._test_session_id with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team = _make_mock_team() mock_team_cls.return_value = mock_team mock_orch_cls.return_value = _make_mock_orchestrator(mock_orchestrator_result) await _execute_team_collab( websocket, session_id, "@team:dev_team 开发功能", session_manager ) mock_team.create_team.assert_called_once() call_kwargs = mock_team.create_team.call_args.kwargs # lead_config 应该是第一个专家(tech_lead) assert call_kwargs["lead_config"].name == "tech_lead" # member_configs 应该包含其余专家 member_names = [c.name for c in call_kwargs["member_configs"]] assert "frontend_engineer" in member_names assert "backend_engineer" in member_names @pytest.mark.asyncio async def test_empty_synthesis_falls_back_to_phase_results( self, websocket, session_manager ): """synthesis 结果为空时 fallback 到 phase_results""" session_id = session_manager._test_session_id empty_result = { "status": "completed", "result": {"content": ""}, # 空的 synthesis "phase_results": { "phase-1": {"content": "阶段1结果", "phase_name": "规划"}, "phase-2": {"content": "阶段2结果", "phase_name": "执行"}, }, "plan": MagicMock(), } with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team_cls.return_value = _make_mock_team() mock_orch_cls.return_value = _make_mock_orchestrator(empty_result) await _execute_team_collab( websocket, session_id, "@team 开发功能", session_manager ) final_msgs = [msg for msg in websocket.sent if msg.get("type") == "final_answer"] assert len(final_msgs) == 1 # 应该包含 phase_results 的内容 assert "阶段1结果" in final_msgs[0]["content"] and "阶段2结果" in final_msgs[0]["content"] @pytest.mark.asyncio async def test_completely_empty_result( self, websocket, session_manager ): """result 和 phase_results 都为空时发送默认消息""" session_id = session_manager._test_session_id empty_result = { "status": "completed", "result": {}, "phase_results": {}, "plan": MagicMock(), } with patch( "agentkit.experts.team.ExpertTeam" ) as mock_team_cls, patch( "agentkit.experts.orchestrator.TeamOrchestrator" ) as mock_orch_cls: mock_team_cls.return_value = _make_mock_team() mock_orch_cls.return_value = _make_mock_orchestrator(empty_result) await _execute_team_collab( websocket, session_id, "@team 开发功能", session_manager ) final_msgs = [msg for msg in websocket.sent if msg.get("type") == "final_answer"] assert len(final_msgs) == 1 assert "未生成最终结果" in final_msgs[0]["content"]