"""TeamOrchestrator 分歧检测 + 方案评审辩论单元测试 (U3) 测试覆盖: - 方案评审辩论 (_maybe_add_plan_review_debate) * Happy path: LLM 判断需要评审 → 插入 DEBATE phase,所有原 phase 依赖它 * 边界: phases <= 2 时跳过 * 边界: MAX_DEBATES 已达上限时跳过 * 边界: 无其他成员时跳过 * 错误路径: LLM 不可用时跳过 * 错误路径: LLM 抛异常时跳过 - 分歧检测 (_detect_divergence) * Happy path: LLM 判断有分歧 → 返回 True * Happy path: LLM 判断无分歧 → 返回 False * 边界: 无其他已完成阶段时返回 False * 错误路径: LLM 不可用时返回 False * 错误路径: LLM 抛异常时返回 False - 动态插入辩论 (_insert_debate_phase) * Happy path: 插入 DEBATE,依赖重 wiring * 边界: participants 为空时返回 None - 协调入口 (_check_divergence_and_insert_debates) * Happy path: 检测到分歧 → 插入辩论 + 广播 plan_update * Happy path: 无分歧 → 不插入 * 边界: MAX_DEBATES 达上限时跳过 - 集成: 插入的 DEBATE phase 在 topological_sort 中正确分层 """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock import pytest from agentkit.core.handoff_transport import InProcessHandoffTransport from agentkit.experts.config import ExpertConfig from agentkit.experts.orchestrator import TeamOrchestrator from agentkit.experts.plan import PhaseStatus, PhaseType, PlanPhase, TeamPlan from agentkit.experts.team import ExpertTeam # ── 辅助函数 ────────────────────────────────────────────── def _make_expert_config( name: str = "test_expert", is_lead: bool = False, ) -> ExpertConfig: return ExpertConfig( name=name, agent_type="expert", persona=f"{name}的角色描述", thinking_style="逻辑推理", speaking_style="简洁直接", decision_framework="数据驱动决策", bound_skills=["skill_a"], is_lead=is_lead, task_mode="llm_generate", prompt={"identity": "测试"}, ) def _make_mock_expert( name: str = "test_expert", is_lead: bool = False, is_active: bool = True, gateway: MagicMock | None = None, ) -> MagicMock: config = _make_expert_config(name=name, is_lead=is_lead) expert = MagicMock() 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, } mock_agent = MagicMock() mock_agent._llm_gateway = gateway expert.agent = mock_agent return expert def _make_team_with_experts( expert_names: list[str] | None = None, lead_name: str = "lead", gateway: MagicMock | None = None, ) -> ExpertTeam: team = ExpertTeam() transport = AsyncMock(spec=InProcessHandoffTransport) team._handoff_transport = transport if expert_names is None: expert_names = [lead_name, "member1", "member2"] for name in expert_names: is_lead = name == lead_name expert = _make_mock_expert(name=name, is_lead=is_lead, gateway=gateway) team._experts[name] = expert if is_lead: team._lead_expert_name = name return team def _make_execution_phase( phase_id: str = "phase_1", name: str = "阶段一", assigned_expert: str = "member1", depends_on: list[str] | None = None, status: PhaseStatus = PhaseStatus.PENDING, result: dict | None = None, ) -> PlanPhase: """创建测试用 EXECUTION 阶段""" return PlanPhase( id=phase_id, name=name, assigned_expert=assigned_expert, task_description=f"{name}的任务描述", depends_on=depends_on or [], phase_type=PhaseType.EXECUTION, status=status, result=result, ) def _make_plan( phases: list[PlanPhase], task: str = "测试任务", lead_expert: str = "lead", ) -> TeamPlan: return TeamPlan( id="test_plan", task=task, phases=phases, lead_expert=lead_expert, ) def _make_bool_gateway( responses: list[bool], ) -> AsyncMock: """创建返回 true/false 字符串的 mock LLM gateway Args: responses: 按调用顺序返回的布尔值列表 """ queue = list(responses) async def chat_side_effect(messages, model=None, **kwargs): if not queue: # Default to false if exhausted response = MagicMock() response.content = "false" return response val = queue.pop(0) response = MagicMock() response.content = "true" if val else "false" return response gateway = AsyncMock() gateway.chat = AsyncMock(side_effect=chat_side_effect) return gateway def _make_error_gateway() -> AsyncMock: """创建总是抛异常的 mock LLM gateway""" async def chat_side_effect(messages, model=None, **kwargs): raise RuntimeError("LLM unavailable") gateway = AsyncMock() gateway.chat = AsyncMock(side_effect=chat_side_effect) return gateway # ── 方案评审辩论测试 ───────────────────────────────────── class TestMaybeAddPlanReviewDebate: """_maybe_add_plan_review_debate 测试""" @pytest.mark.asyncio async def test_adds_plan_review_debate_when_llm_says_yes(self): """LLM 判断需要评审 → 插入 DEBATE phase,所有原 phase 依赖它""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) # 3 个执行阶段(>2 才会考虑评审) phases = [ _make_execution_phase(phase_id="p1", name="阶段一"), _make_execution_phase(phase_id="p2", name="阶段二"), _make_execution_phase(phase_id="p3", name="阶段三"), ] plan = _make_plan(phases=phases, task="复杂任务") await orchestrator._maybe_add_plan_review_debate( team.lead_expert, plan, "复杂任务" ) # 应该插入一个 DEBATE phase 在最前面 assert len(plan.phases) == 4 review_phase = plan.phases[0] assert review_phase.phase_type == PhaseType.DEBATE assert review_phase.name == "方案评审" assert review_phase.assigned_expert == "lead" assert review_phase.debate_config is not None assert review_phase.debate_config["participants"] == ["member1", "member2"] assert review_phase.debate_config["max_rounds"] == 2 # 所有原 phase 都应该依赖 review_phase for ph in plan.phases[1:]: assert review_phase.id in ph.depends_on # debate_count 应该 +1 assert orchestrator._debate_count == 1 @pytest.mark.asyncio async def test_skips_when_llm_says_no(self): """LLM 判断不需要评审 → 不插入""" gateway = _make_bool_gateway([False]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phases = [ _make_execution_phase(phase_id="p1"), _make_execution_phase(phase_id="p2"), _make_execution_phase(phase_id="p3"), ] plan = _make_plan(phases=phases) await orchestrator._maybe_add_plan_review_debate( team.lead_expert, plan, "简单任务" ) assert len(plan.phases) == 3 assert orchestrator._debate_count == 0 @pytest.mark.asyncio async def test_skips_when_phases_le_two(self): """phases <= 2 时跳过(简单任务)""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phases = [ _make_execution_phase(phase_id="p1"), _make_execution_phase(phase_id="p2"), ] plan = _make_plan(phases=phases) await orchestrator._maybe_add_plan_review_debate( team.lead_expert, plan, "任务" ) assert len(plan.phases) == 2 assert orchestrator._debate_count == 0 @pytest.mark.asyncio async def test_skips_when_max_debates_reached(self): """MAX_DEBATES 已达上限时跳过""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) orchestrator._debate_count = orchestrator.MAX_DEBATES phases = [ _make_execution_phase(phase_id="p1"), _make_execution_phase(phase_id="p2"), _make_execution_phase(phase_id="p3"), ] plan = _make_plan(phases=phases) await orchestrator._maybe_add_plan_review_debate( team.lead_expert, plan, "任务" ) assert len(plan.phases) == 3 assert orchestrator._debate_count == orchestrator.MAX_DEBATES @pytest.mark.asyncio async def test_skips_when_no_other_members(self): """无其他成员时跳过(只有 lead)""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts( expert_names=["lead"], gateway=gateway ) orchestrator = TeamOrchestrator(team) phases = [ _make_execution_phase(phase_id="p1"), _make_execution_phase(phase_id="p2"), _make_execution_phase(phase_id="p3"), ] plan = _make_plan(phases=phases) await orchestrator._maybe_add_plan_review_debate( team.lead_expert, plan, "任务" ) assert len(plan.phases) == 3 assert orchestrator._debate_count == 0 @pytest.mark.asyncio async def test_skips_when_llm_unavailable(self): """LLM gateway 为 None 时跳过""" team = _make_team_with_experts(gateway=None) orchestrator = TeamOrchestrator(team) phases = [ _make_execution_phase(phase_id="p1"), _make_execution_phase(phase_id="p2"), _make_execution_phase(phase_id="p3"), ] plan = _make_plan(phases=phases) await orchestrator._maybe_add_plan_review_debate( team.lead_expert, plan, "任务" ) assert len(plan.phases) == 3 assert orchestrator._debate_count == 0 @pytest.mark.asyncio async def test_skips_when_llm_raises_exception(self): """LLM 抛异常时跳过,不抛出""" gateway = _make_error_gateway() team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phases = [ _make_execution_phase(phase_id="p1"), _make_execution_phase(phase_id="p2"), _make_execution_phase(phase_id="p3"), ] plan = _make_plan(phases=phases) # 不应该抛异常 await orchestrator._maybe_add_plan_review_debate( team.lead_expert, plan, "任务" ) assert len(plan.phases) == 3 assert orchestrator._debate_count == 0 # ── 分歧检测测试 ───────────────────────────────────────── class TestDetectDivergence: """_detect_divergence 测试""" @pytest.mark.asyncio async def test_returns_true_when_llm_detects_divergence(self): """LLM 判断有分歧 → 返回 True""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) # 两个已完成的阶段,产出不同 phase_a = _make_execution_phase( phase_id="a", name="阶段A", status=PhaseStatus.COMPLETED, result={"content": "采用 React"}, ) phase_b = _make_execution_phase( phase_id="b", name="阶段B", status=PhaseStatus.COMPLETED, result={"content": "采用 Vue"}, ) plan = _make_plan(phases=[phase_a, phase_b]) result = await orchestrator._detect_divergence( team.lead_expert, phase_a, plan ) assert result is True @pytest.mark.asyncio async def test_returns_false_when_llm_says_no_divergence(self): """LLM 判断无分歧 → 返回 False""" gateway = _make_bool_gateway([False]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phase_a = _make_execution_phase( phase_id="a", status=PhaseStatus.COMPLETED, result={"content": "结果A"}, ) phase_b = _make_execution_phase( phase_id="b", status=PhaseStatus.COMPLETED, result={"content": "结果B"}, ) plan = _make_plan(phases=[phase_a, phase_b]) result = await orchestrator._detect_divergence( team.lead_expert, phase_a, plan ) assert result is False @pytest.mark.asyncio async def test_returns_false_when_no_other_completed_phases(self): """无其他已完成阶段时返回 False(无法比较)""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phase_a = _make_execution_phase( phase_id="a", status=PhaseStatus.COMPLETED, result={"content": "结果A"}, ) # 另一个阶段还在 PENDING phase_b = _make_execution_phase(phase_id="b", status=PhaseStatus.PENDING) plan = _make_plan(phases=[phase_a, phase_b]) result = await orchestrator._detect_divergence( team.lead_expert, phase_a, plan ) assert result is False @pytest.mark.asyncio async def test_returns_false_when_llm_unavailable(self): """LLM gateway 为 None 时返回 False""" team = _make_team_with_experts(gateway=None) orchestrator = TeamOrchestrator(team) phase_a = _make_execution_phase( phase_id="a", status=PhaseStatus.COMPLETED, result={"content": "结果A"}, ) phase_b = _make_execution_phase( phase_id="b", status=PhaseStatus.COMPLETED, result={"content": "结果B"}, ) plan = _make_plan(phases=[phase_a, phase_b]) result = await orchestrator._detect_divergence( team.lead_expert, phase_a, plan ) assert result is False @pytest.mark.asyncio async def test_returns_false_when_llm_raises_exception(self): """LLM 抛异常时返回 False,不抛出""" gateway = _make_error_gateway() team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phase_a = _make_execution_phase( phase_id="a", status=PhaseStatus.COMPLETED, result={"content": "结果A"}, ) phase_b = _make_execution_phase( phase_id="b", status=PhaseStatus.COMPLETED, result={"content": "结果B"}, ) plan = _make_plan(phases=[phase_a, phase_b]) result = await orchestrator._detect_divergence( team.lead_expert, phase_a, plan ) assert result is False # ── 动态插入辩论测试 ───────────────────────────────────── class TestInsertDebatePhase: """_insert_debate_phase 测试""" def test_inserts_debate_and_rewires_dependencies(self): """插入 DEBATE phase,依赖重 wiring:原依赖 trigger 的 phase 现在依赖 DEBATE""" gateway = _make_bool_gateway([]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) trigger = _make_execution_phase(phase_id="trigger", name="触发阶段") dependent = _make_execution_phase( phase_id="dependent", name="依赖阶段", depends_on=["trigger"], ) plan = _make_plan(phases=[trigger, dependent]) debate = orchestrator._insert_debate_phase( plan, trigger, "产出分歧", ["member1", "member2"] ) assert debate is not None assert debate.phase_type == PhaseType.DEBATE assert debate.depends_on == ["trigger"] assert debate.debate_config["topic"] == "产出分歧" assert debate.debate_config["participants"] == ["member1", "member2"] assert debate.debate_config["max_rounds"] == 2 # dependent 现在依赖 debate,不再直接依赖 trigger assert debate.id in dependent.depends_on assert "trigger" not in dependent.depends_on # debate 被加入 plan assert debate in plan.phases assert orchestrator._debate_count == 1 def test_returns_none_when_no_participants(self): """participants 为空时返回 None""" gateway = _make_bool_gateway([]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) trigger = _make_execution_phase(phase_id="trigger") plan = _make_plan(phases=[trigger]) debate = orchestrator._insert_debate_phase( plan, trigger, "产出分歧", [] ) assert debate is None assert orchestrator._debate_count == 0 def test_debate_assigned_to_lead(self): """DEBATE phase 的 assigned_expert 是 lead""" gateway = _make_bool_gateway([]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) trigger = _make_execution_phase(phase_id="trigger") plan = _make_plan(phases=[trigger]) debate = orchestrator._insert_debate_phase( plan, trigger, "分歧", ["member1"] ) assert debate is not None assert debate.assigned_expert == "lead" # ── 协调入口测试 ───────────────────────────────────────── class TestCheckDivergenceAndInsertDebates: """_check_divergence_and_insert_debates 测试""" @pytest.mark.asyncio async def test_inserts_debate_when_divergence_detected(self): """检测到分歧 → 插入辩论 + 广播 plan_update""" gateway = _make_bool_gateway([True]) # 检测到分歧 team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phase_a = _make_execution_phase( phase_id="a", name="阶段A", status=PhaseStatus.COMPLETED, result={"content": "采用 React"}, ) phase_b = _make_execution_phase( phase_id="b", name="阶段B", status=PhaseStatus.COMPLETED, result={"content": "采用 Vue"}, ) plan = _make_plan(phases=[phase_a, phase_b]) await orchestrator._check_divergence_and_insert_debates( team.lead_expert, plan, [phase_a] ) # 应该插入一个 DEBATE phase assert len(plan.phases) == 3 debate = plan.phases[-1] assert debate.phase_type == PhaseType.DEBATE assert orchestrator._debate_count == 1 # 应该广播 plan_update 事件 transport = team._handoff_transport assert transport.send.called # 最后一次 send 应该是 plan_update last_call = transport.send.call_args_list[-1] event_data = last_call[0][1] # 第二个位置参数是 data dict assert event_data["type"] == "plan_update" assert "debate_inserted" in event_data @pytest.mark.asyncio async def test_no_debate_when_no_divergence(self): """无分歧 → 不插入辩论""" gateway = _make_bool_gateway([False]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phase_a = _make_execution_phase( phase_id="a", status=PhaseStatus.COMPLETED, result={"content": "结果A"}, ) phase_b = _make_execution_phase( phase_id="b", status=PhaseStatus.COMPLETED, result={"content": "结果B"}, ) plan = _make_plan(phases=[phase_a, phase_b]) await orchestrator._check_divergence_and_insert_debates( team.lead_expert, plan, [phase_a] ) assert len(plan.phases) == 2 assert orchestrator._debate_count == 0 @pytest.mark.asyncio async def test_skips_when_max_debates_reached(self): """MAX_DEBATES 达上限时跳过检测""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) orchestrator._debate_count = orchestrator.MAX_DEBATES phase_a = _make_execution_phase( phase_id="a", status=PhaseStatus.COMPLETED, result={"content": "结果A"}, ) phase_b = _make_execution_phase( phase_id="b", status=PhaseStatus.COMPLETED, result={"content": "结果B"}, ) plan = _make_plan(phases=[phase_a, phase_b]) await orchestrator._check_divergence_and_insert_debates( team.lead_expert, plan, [phase_a] ) assert len(plan.phases) == 2 assert orchestrator._debate_count == orchestrator.MAX_DEBATES @pytest.mark.asyncio async def test_skips_non_completed_phases(self): """非 COMPLETED 状态的 phase 被跳过""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) # 传入一个 PENDING 的 phase(不应该被检测) phase_pending = _make_execution_phase( phase_id="pending", status=PhaseStatus.PENDING ) phase_completed = _make_execution_phase( phase_id="completed", status=PhaseStatus.COMPLETED, result={"content": "结果"}, ) plan = _make_plan(phases=[phase_pending, phase_completed]) await orchestrator._check_divergence_and_insert_debates( team.lead_expert, plan, [phase_pending, phase_completed] ) # phase_pending 被跳过;phase_completed 无其他完成阶段可比较 → 无分歧 assert orchestrator._debate_count == 0 # ── 集成测试 ───────────────────────────────────────────── class TestInsertedDebateLayering: """插入的 DEBATE phase 在 topological_sort 中正确分层""" def test_inserted_debate_blocks_dependents(self): """插入的 DEBATE phase 应该在 trigger 之后、dependent 之前""" gateway = _make_bool_gateway([]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) trigger = _make_execution_phase( phase_id="trigger", name="触发阶段", status=PhaseStatus.COMPLETED, result={"content": "触发结果"}, ) dependent = _make_execution_phase( phase_id="dependent", name="依赖阶段", depends_on=["trigger"], ) plan = _make_plan(phases=[trigger, dependent]) debate = orchestrator._insert_debate_phase( plan, trigger, "分歧", ["member1", "member2"] ) assert debate is not None layers = plan.topological_sort() # 找到各 phase 所在的层 trigger_layer = None debate_layer = None dependent_layer = None for i, layer in enumerate(layers): for ph in layer: if ph.id == "trigger": trigger_layer = i elif ph.id == debate.id: debate_layer = i elif ph.id == "dependent": dependent_layer = i assert trigger_layer is not None assert debate_layer is not None assert dependent_layer is not None # trigger < debate < dependent assert trigger_layer < debate_layer assert debate_layer < dependent_layer @pytest.mark.asyncio async def test_plan_review_debate_runs_first(self): """方案评审 DEBATE 应该在第 0 层,所有执行阶段在后续层""" gateway = _make_bool_gateway([True]) team = _make_team_with_experts(gateway=gateway) orchestrator = TeamOrchestrator(team) phases = [ _make_execution_phase(phase_id="p1", name="阶段一"), _make_execution_phase(phase_id="p2", name="阶段二"), _make_execution_phase(phase_id="p3", name="阶段三"), ] plan = _make_plan(phases=phases, task="复杂任务") await orchestrator._maybe_add_plan_review_debate( team.lead_expert, plan, "复杂任务" ) layers = plan.topological_sort() # 第 0 层应该只有方案评审 DEBATE assert len(layers[0]) == 1 assert layers[0][0].phase_type == PhaseType.DEBATE assert layers[0][0].name == "方案评审" # 所有执行阶段在后续层 for layer in layers[1:]: for ph in layer: assert ph.phase_type == PhaseType.EXECUTION