"""ExpertTeam 容器单元测试""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from agentkit.core.config_driven import ConfigDrivenAgent from agentkit.core.handoff_transport import InProcessHandoffTransport from agentkit.core.shared_workspace import SharedWorkspace from agentkit.experts.config import ExpertConfig, ExpertTemplate from agentkit.experts.expert import Expert from agentkit.experts.plan import ( CollaborationPlan, PlanPhase, PlanStatus, ) from agentkit.experts.registry import ExpertTemplateRegistry from agentkit.experts.team import ExpertTeam, TeamStatus # ── 辅助函数 ────────────────────────────────────────────── def _make_expert_config( name: str = "test_expert", agent_type: str = "expert", persona: str = "测试专家", thinking_style: str = "逻辑推理", bound_skills: list[str] | None = None, is_lead: bool = False, **kwargs, ) -> ExpertConfig: """创建测试用 ExpertConfig 实例""" return ExpertConfig( name=name, agent_type=agent_type, persona=persona, thinking_style=thinking_style, bound_skills=bound_skills or ["skill_a"], is_lead=is_lead, task_mode="llm_generate", prompt={"identity": "测试"}, **kwargs, ) def _make_mock_agent() -> MagicMock: """创建 mock ConfigDrivenAgent""" agent = MagicMock(spec=ConfigDrivenAgent) agent.name = "test_expert" agent._prompt_template = None return agent def _make_mock_pool() -> AsyncMock: """创建 mock AgentPool""" pool = AsyncMock() pool.create_agent = AsyncMock(return_value=_make_mock_agent()) pool.remove_agent = AsyncMock() return pool def _make_mock_expert( name: str = "test_expert", is_lead: bool = False, is_active: bool = True, ) -> MagicMock: """创建 mock Expert""" config = _make_expert_config(name=name, is_lead=is_lead) expert = MagicMock(spec=Expert) expert.config = config expert.is_active = is_active expert.team_id = None expert.get_capabilities_summary.return_value = { "name": name, "persona": config.persona, "thinking_style": config.thinking_style, "bound_skills": config.bound_skills, "is_lead": is_lead, } expert.destroy = AsyncMock() return expert def _make_valid_plan( plan_id: str = "plan_1", task: str = "测试任务", lead_expert: str = "lead", ) -> CollaborationPlan: """创建有效的 CollaborationPlan""" return CollaborationPlan( id=plan_id, task=task, phases=[ PlanPhase( id="phase_1", name="阶段1", assigned_expert=lead_expert, task_description="执行任务", ) ], lead_expert=lead_expert, ) # ── ExpertTeam 创建测试 ─────────────────────────────────── class TestExpertTeamCreation: """ExpertTeam 初始化与默认值测试""" def test_default_values(self): """默认值:自动生成 team_id,FORMING 状态""" team = ExpertTeam() assert team.team_id is not None assert len(team.team_id) > 0 assert team.status == TeamStatus.FORMING assert team.lead_expert is None assert team.plan is None assert team.experts == [] assert team.active_experts == [] def test_custom_team_id(self): """自定义 team_id""" team = ExpertTeam(team_id="my_team") assert team.team_id == "my_team" def test_custom_workspace(self): """自定义 SharedWorkspace""" workspace = SharedWorkspace() team = ExpertTeam(workspace=workspace) assert team._workspace is workspace def test_custom_pool(self): """自定义 AgentPool""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) assert team._pool is pool def test_custom_template_registry(self): """自定义 ExpertTemplateRegistry""" registry = ExpertTemplateRegistry() team = ExpertTeam(template_registry=registry) assert team._template_registry is registry # ── ExpertTeam.create_team 测试 ──────────────────────────── class TestExpertTeamCreateTeam: """ExpertTeam.create_team 团队创建测试""" @pytest.mark.asyncio async def test_create_team_with_lead_only(self): """仅创建 Lead Expert""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: mock_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = mock_expert await team.create_team(lead_config) assert team._lead_expert_name == "lead" assert team.lead_expert is mock_expert assert team.status == TeamStatus.PLANNING assert mock_expert.team_id == team.team_id @pytest.mark.asyncio async def test_create_team_with_lead_and_members(self): """创建 Lead Expert 和成员 Expert""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) member_config = _make_expert_config(name="member1", is_lead=False) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) member_expert = _make_mock_expert(name="member1", is_lead=False) mock_create.side_effect = [lead_expert, member_expert] await team.create_team(lead_config, [member_config]) assert len(team.experts) == 2 assert team._lead_expert_name == "lead" assert team.status == TeamStatus.PLANNING @pytest.mark.asyncio async def test_create_team_without_pool_raises(self): """没有 AgentPool 时 create_team 抛出 RuntimeError""" team = ExpertTeam(pool=None) lead_config = _make_expert_config(name="lead", is_lead=True) with pytest.raises(RuntimeError, match="AgentPool not configured"): await team.create_team(lead_config) # ── ExpertTeam.add_expert 测试 ───────────────────────────── class TestExpertTeamAddExpert: """ExpertTeam.add_expert 动态添加 Expert 测试""" @pytest.mark.asyncio async def test_add_expert_with_config(self): """通过 ExpertConfig 添加 Expert""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) # 先创建团队 lead_config = _make_expert_config(name="lead", is_lead=True) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert await team.create_team(lead_config) # 添加新成员 new_config = _make_expert_config(name="new_member") with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: new_expert = _make_mock_expert(name="new_member") mock_create.return_value = new_expert result = await team.add_expert(new_config) assert result is new_expert assert "new_member" in team._experts @pytest.mark.asyncio async def test_add_expert_with_template_name(self): """通过模板名称添加 Expert""" pool = _make_mock_pool() registry = ExpertTemplateRegistry() template_config = _make_expert_config(name="analyst", persona="分析师") registry.register(ExpertTemplate(name="analyst", config=template_config)) team = ExpertTeam(pool=pool, template_registry=registry) # 先创建团队 lead_config = _make_expert_config(name="lead", is_lead=True) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert await team.create_team(lead_config) # 通过模板名称添加 with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: analyst_expert = _make_mock_expert(name="analyst") mock_create.return_value = analyst_expert result = await team.add_expert("analyst") assert result is analyst_expert @pytest.mark.asyncio async def test_add_expert_with_nonexistent_template_raises(self): """使用不存在的模板名称添加 Expert 抛出 ValueError""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) # 先创建团队 lead_config = _make_expert_config(name="lead", is_lead=True) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert await team.create_team(lead_config) with pytest.raises(ValueError, match="ExpertTemplate 'nonexistent' not found"): await team.add_expert("nonexistent") @pytest.mark.asyncio async def test_add_expert_broadcasts_joined_message(self): """添加 Expert 时广播 expert_joined 消息""" pool = _make_mock_pool() transport = AsyncMock(spec=InProcessHandoffTransport) team = ExpertTeam(pool=pool) team._handoff_transport = transport # 先创建团队 lead_config = _make_expert_config(name="lead", is_lead=True) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert await team.create_team(lead_config) # 添加新成员 new_config = _make_expert_config(name="new_member") with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: new_expert = _make_mock_expert(name="new_member") mock_create.return_value = new_expert await team.add_expert(new_config) # 验证 broadcast 消息(create_team 也会调用 send,所以检查最后一次) calls = transport.send.call_args_list joined_calls = [ c for c in calls if c[0][1].get("type") == "expert_joined" ] assert len(joined_calls) >= 1 last_joined = joined_calls[-1] assert last_joined[0][1]["expert_name"] == "new_member" # ── ExpertTeam.remove_expert 测试 ────────────────────────── class TestExpertTeamRemoveExpert: """ExpertTeam.remove_expert 移除 Expert 测试""" @pytest.mark.asyncio async def test_remove_expert(self): """移除普通 Expert""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) member_config = _make_expert_config(name="member1") with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) member_expert = _make_mock_expert(name="member1") mock_create.side_effect = [lead_expert, member_expert] await team.create_team(lead_config, [member_config]) await team.remove_expert("member1") assert "member1" not in team._experts member_expert.destroy.assert_awaited_once_with(pool) @pytest.mark.asyncio async def test_remove_lead_expert_reassigns(self): """移除 Lead Expert 时重新分配给下一个活跃 Expert""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) member_config = _make_expert_config(name="member1") with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) member_expert = _make_mock_expert(name="member1") mock_create.side_effect = [lead_expert, member_expert] await team.create_team(lead_config, [member_config]) await team.remove_expert("lead") assert team._lead_expert_name == "member1" assert member_expert.config.is_lead is True @pytest.mark.asyncio async def test_remove_lead_expert_no_active_members(self): """移除 Lead Expert 且无其他活跃成员时 lead_expert_name 为 None""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert await team.create_team(lead_config) await team.remove_expert("lead") assert team._lead_expert_name is None @pytest.mark.asyncio async def test_remove_nonexistent_expert_no_error(self): """移除不存在的 Expert 不报错""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) # 不应抛出异常 await team.remove_expert("nonexistent") @pytest.mark.asyncio async def test_remove_expert_broadcasts_left_message(self): """移除 Expert 时广播 expert_left 消息""" pool = _make_mock_pool() transport = AsyncMock(spec=InProcessHandoffTransport) team = ExpertTeam(pool=pool) team._handoff_transport = transport lead_config = _make_expert_config(name="lead", is_lead=True) member_config = _make_expert_config(name="member1") with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) member_expert = _make_mock_expert(name="member1") mock_create.side_effect = [lead_expert, member_expert] await team.create_team(lead_config, [member_config]) await team.remove_expert("member1") # 验证 expert_left 消息 calls = transport.send.call_args_list left_calls = [c for c in calls if c[0][1].get("type") == "expert_left"] assert len(left_calls) >= 1 last_left = left_calls[-1] assert last_left[0][1]["expert_name"] == "member1" # ── ExpertTeam.update_plan 测试 ──────────────────────────── class TestExpertTeamUpdatePlan: """ExpertTeam.update_plan 协作计划更新测试""" def test_update_plan_with_valid_plan(self): """有效计划更新成功,返回受影响的 Expert 名称""" team = ExpertTeam() plan = _make_valid_plan(lead_expert="lead") affected = team.update_plan(plan) assert team.plan is plan assert "lead" in affected def test_update_plan_confirmed_sets_executing(self): """CONFIRMED 状态的计划将团队状态设为 EXECUTING""" team = ExpertTeam() plan = _make_valid_plan(lead_expert="lead") plan.status = PlanStatus.CONFIRMED team.update_plan(plan) assert team.status == TeamStatus.EXECUTING def test_update_plan_with_invalid_plan_no_update(self): """无效计划(validate 返回错误)不更新,返回验证错误列表""" team = ExpertTeam() # 创建有循环依赖的无效计划 plan = CollaborationPlan( id="bad_plan", task="无效任务", phases=[ PlanPhase( id="p1", name="阶段1", assigned_expert="a", task_description="t1", depends_on=["p2"], ), PlanPhase( id="p2", name="阶段2", assigned_expert="b", task_description="t2", depends_on=["p1"], ), ], lead_expert="lead", ) result = team.update_plan(plan) assert len(result) > 0 # 返回验证错误而非空列表 assert team.plan is None # 未更新 # ── ExpertTeam.broadcast_user_message 测试 ───────────────── class TestExpertTeamBroadcastUserMessage: """ExpertTeam.broadcast_user_message 用户干预消息广播测试""" @pytest.mark.asyncio async def test_broadcast_user_message(self): """广播用户干预消息到团队频道""" team = ExpertTeam() transport = AsyncMock(spec=InProcessHandoffTransport) team._handoff_transport = transport await team.broadcast_user_message("请暂停执行") transport.send.assert_awaited_once() call_args = transport.send.call_args assert call_args[0][0] == team._team_channel message = call_args[0][1] assert message["type"] == "user_intervention" assert message["content"] == "请暂停执行" assert "timestamp" in message # ── ExpertTeam.get_shared_context 测试 ──────────────────── class TestExpertTeamGetSharedContext: """ExpertTeam.get_shared_context 共享上下文读取测试""" @pytest.mark.asyncio async def test_get_shared_context_reads_team_keys(self): """读取团队范围的共享上下文""" workspace = AsyncMock(spec=SharedWorkspace) workspace.list_keys = AsyncMock( return_value=[ "team:abc:output1", "team:abc:output2", "other:key", ] ) workspace.read = AsyncMock( side_effect=lambda key: {"value": f"data_{key}"} if key.startswith("team:abc") else None ) team = ExpertTeam(team_id="abc", workspace=workspace) context = await team.get_shared_context() assert "team:abc:output1" in context assert "team:abc:output2" in context assert "other:key" not in context @pytest.mark.asyncio async def test_get_shared_context_empty(self): """没有团队范围的键时返回空字典""" workspace = AsyncMock(spec=SharedWorkspace) workspace.list_keys = AsyncMock(return_value=[]) team = ExpertTeam(team_id="abc", workspace=workspace) context = await team.get_shared_context() assert context == {} # ── ExpertTeam.generate_plan 测试 ────────────────────────── class TestExpertTeamGeneratePlan: """ExpertTeam.generate_plan 计划生成测试""" @pytest.mark.asyncio async def test_generate_plan(self): """生成空的 CollaborationPlan""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert await team.create_team(lead_config) plan = await team.generate_plan("分析数据") assert plan is not None assert plan.task == "分析数据" assert plan.lead_expert == "lead" assert plan.phases == [] assert team.plan is plan @pytest.mark.asyncio async def test_generate_plan_without_lead(self): """没有 Lead Expert 时生成计划,lead_expert 为空字符串""" team = ExpertTeam() plan = await team.generate_plan("测试任务") assert plan.lead_expert == "" # ── ExpertTeam.dissolve 测试 ─────────────────────────────── class TestExpertTeamDissolve: """ExpertTeam.dissolve 团队解散测试""" @pytest.mark.asyncio async def test_dissolve_recycles_experts(self): """解散团队时回收所有 Expert""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) member_config = _make_expert_config(name="member1") with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) member_expert = _make_mock_expert(name="member1") mock_create.side_effect = [lead_expert, member_expert] await team.create_team(lead_config, [member_config]) await team.dissolve() assert team.status == TeamStatus.DISSOLVED assert team.experts == [] assert team._lead_expert_name is None lead_expert.destroy.assert_awaited_once_with(pool) member_expert.destroy.assert_awaited_once_with(pool) @pytest.mark.asyncio async def test_dissolve_preserves_outputs_in_workspace(self): """解散团队后 SharedWorkspace 中的输出仍保留""" workspace = SharedWorkspace() pool = _make_mock_pool() team = ExpertTeam(pool=pool, workspace=workspace) # 写入一些数据到 workspace await workspace.write("team:abc:result", "重要输出", "lead") with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert lead_config = _make_expert_config(name="lead", is_lead=True) await team.create_team(lead_config) await team.dissolve() # workspace 数据仍然存在 data = await workspace.read("team:abc:result") assert data is not None assert data["value"] == "重要输出" @pytest.mark.asyncio async def test_dissolve_closes_handoff_transport(self): """解散团队时关闭 handoff_transport""" pool = _make_mock_pool() transport = MagicMock(spec=InProcessHandoffTransport) transport.close = MagicMock() team = ExpertTeam(pool=pool) team._handoff_transport = transport with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert lead_config = _make_expert_config(name="lead", is_lead=True) await team.create_team(lead_config) await team.dissolve() transport.close.assert_called_once() # ── ExpertTeam 操作已解散团队测试 ────────────────────────── class TestExpertTeamDissolvedOperations: """解散后的团队操作应报错""" @pytest.mark.asyncio async def test_create_team_on_dissolved_raises(self): """在已解散的团队上 create_team 应报错(因为 pool 可能已被清理)""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert lead_config = _make_expert_config(name="lead", is_lead=True) await team.create_team(lead_config) await team.dissolve() # 解散后状态为 DISSOLVED assert team.status == TeamStatus.DISSOLVED # 再次 create_team 时,由于 experts 已清空, # 但 pool 仍然存在,理论上可以重新创建 # 但这里验证状态是 DISSOLVED assert team.status == TeamStatus.DISSOLVED # ── ExpertTeam.lead_expert 属性测试 ──────────────────────── class TestExpertTeamLeadExpert: """ExpertTeam.lead_expert 属性测试""" @pytest.mark.asyncio async def test_lead_expert_returns_lead(self): """lead_expert 返回 Lead Expert""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) mock_create.return_value = lead_expert await team.create_team(lead_config) assert team.lead_expert is lead_expert def test_lead_expert_none_when_no_lead(self): """没有 Lead Expert 时返回 None""" team = ExpertTeam() assert team.lead_expert is None @pytest.mark.asyncio async def test_active_experts_filters_inactive(self): """active_experts 只返回活跃的 Expert""" pool = _make_mock_pool() team = ExpertTeam(pool=pool) lead_config = _make_expert_config(name="lead", is_lead=True) member_config = _make_expert_config(name="member1") with patch.object(Expert, "create", new_callable=AsyncMock) as mock_create: lead_expert = _make_mock_expert(name="lead", is_lead=True) member_expert = _make_mock_expert(name="member1") mock_create.side_effect = [lead_expert, member_expert] await team.create_team(lead_config, [member_config]) # 标记 member1 为非活跃 member_expert.is_active = False active = team.active_experts assert len(active) == 1 assert active[0] is lead_expert # ── ExpertTeam._build_team_context 测试 ──────────────────── class TestExpertTeamBuildContext: """ExpertTeam._build_team_context 团队上下文构建测试""" def test_build_team_context_with_lead_and_members(self): """构建包含 Lead 和成员的团队上下文""" team = ExpertTeam() lead_config = _make_expert_config( name="lead", persona="领导者", is_lead=True ) member_config = _make_expert_config( name="analyst", persona="分析师", bound_skills=["data_query"] ) context = team._build_team_context(lead_config, [member_config]) assert "You are part of an Expert Team." in context assert "Lead Expert: lead (领导者)" in context assert "Team Member: analyst (分析师), Skills: data_query" in context assert "send_message() and request_assist()" in context def test_build_team_context_no_lead(self): """没有 Lead Expert 时构建上下文""" team = ExpertTeam() member_config = _make_expert_config(name="analyst") context = team._build_team_context(None, [member_config]) assert "Lead Expert" not in context assert "Team Member: analyst" in context def test_build_team_context_skips_lead_in_members(self): """成员列表中包含 Lead 时跳过""" team = ExpertTeam() lead_config = _make_expert_config(name="lead", is_lead=True) context = team._build_team_context(lead_config, [lead_config]) # Lead 不应出现在 Team Member 行 assert "Team Member: lead" not in context