"""BoardOrchestrator 单元测试 — 私董会讨论引擎""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from agentkit.experts.board import BoardTeam from agentkit.experts.board_orchestrator import BoardOrchestrator from agentkit.experts.config import ExpertConfig from agentkit.experts.expert import Expert from tests.unit.experts._helpers import make_chat_stream_mock # ── 辅助函数 ────────────────────────────────────────────── def _make_expert_config( name: str = "test_expert", is_lead: bool = False, ) -> ExpertConfig: """创建测试用 ExpertConfig""" return ExpertConfig( name=name, agent_type="expert", persona=f"测试专家 {name}", thinking_style="analytical", speaking_style="直接", decision_framework="分析", bound_skills=[], is_lead=is_lead, task_mode="llm_generate", prompt={"identity": name}, avatar="T", color="#FF0000", ) def _make_mock_expert( name: str = "test_expert", is_lead: bool = False, is_active: bool = True, ) -> MagicMock: """创建 mock Expert 实例""" config = _make_expert_config(name, is_lead) expert = MagicMock(spec=Expert) expert.config = config expert.is_active = is_active expert.agent = MagicMock() expert.agent._llm_gateway = MagicMock() return expert def _make_mock_gateway(response_content: str = "测试回复") -> AsyncMock: """创建 mock LLM gateway""" gateway = AsyncMock() response = MagicMock() response.content = response_content gateway.chat = AsyncMock(return_value=response) # board_orchestrator._stream_expert_speech() calls gateway.chat_stream() # (async gen). Without this, AsyncMock returns a coroutine lacking __aiter__, # triggering a RuntimeWarning + falling back to non-streaming path. gateway.chat_stream = make_chat_stream_mock(response_content) return gateway def _setup_team_with_experts( team: BoardTeam, experts: list[MagicMock], moderator_name: str | None = None, ) -> None: """设置 BoardTeam 的内部专家字典""" for expert in experts: team._experts[expert.config.name] = expert if moderator_name: team._moderator_name = moderator_name elif experts: team._moderator_name = experts[0].config.name # ── BoardOrchestrator 初始化测试 ────────────────────────── class TestBoardOrchestratorInit: """BoardOrchestrator 初始化测试""" def test_init(self): """初始化""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) assert orchestrator._team is team def test_stop_commands(self): """停止命令集合""" assert "/stop" in BoardOrchestrator.STOP_COMMANDS assert "停止讨论" in BoardOrchestrator.STOP_COMMANDS assert "stop" in BoardOrchestrator.STOP_COMMANDS assert "结束讨论" in BoardOrchestrator.STOP_COMMANDS # ── BoardOrchestrator.execute 测试 ──────────────────────── class TestBoardOrchestratorExecute: """BoardOrchestrator.execute 执行流程测试""" @pytest.mark.asyncio async def test_execute_no_active_experts(self): """无活跃专家时返回失败""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) result = await orchestrator.execute("测试主题") assert result["status"] == "failed" assert "error" in result assert "No active expert" in result["error"] @pytest.mark.asyncio async def test_execute_success(self): """正常执行流程""" team = BoardTeam(max_rounds=2) moderator = _make_mock_expert("moderator", is_lead=True) member1 = _make_mock_expert("member1") member2 = _make_mock_expert("member2") _setup_team_with_experts(team, [moderator, member1, member2], "moderator") # Mock LLM gateway gateway = _make_mock_gateway("测试发言内容") moderator.agent._llm_gateway = gateway member1.agent._llm_gateway = gateway member2.agent._llm_gateway = gateway orchestrator = BoardOrchestrator(team=team) # Mock broadcast_event to avoid transport issues with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock): result = await orchestrator.execute("测试主题") assert result["status"] == "completed" assert result["total_rounds"] == 2 assert "summary" in result @pytest.mark.asyncio async def test_execute_with_stop_command(self): """用户发送停止命令时终止讨论""" team = BoardTeam(max_rounds=5) moderator = _make_mock_expert("moderator", is_lead=True) member1 = _make_mock_expert("member1") _setup_team_with_experts(team, [moderator, member1], "moderator") gateway = _make_mock_gateway("测试内容") moderator.agent._llm_gateway = gateway member1.agent._llm_gateway = gateway # 添加停止命令作为用户干预 await team.add_user_intervention("/stop") orchestrator = BoardOrchestrator(team=team) with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock): result = await orchestrator.execute("测试主题") # 应该在第1轮就停止(因为 consume_user_interventions 在循环开始时检查) assert result["status"] == "completed" assert result["total_rounds"] <= 1 @pytest.mark.asyncio async def test_execute_llm_unavailable_uses_fallback(self): """LLM 不可用时使用回退文本,仍正常完成""" team = BoardTeam(max_rounds=2) moderator = _make_mock_expert("moderator", is_lead=True) _setup_team_with_experts(team, [moderator], "moderator") # Mock gateway to raise exception for all LLM calls gateway = AsyncMock() gateway.chat = AsyncMock(side_effect=Exception("LLM 不可用")) moderator.agent._llm_gateway = gateway orchestrator = BoardOrchestrator(team=team) with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock): result = await orchestrator.execute("测试主题") # LLM 不可用时,orchestrator 使用回退文本,仍正常完成 assert result["status"] == "completed" assert result["total_rounds"] == 2 # summary 应包含回退文本 assert "summary" in result @pytest.mark.asyncio async def test_execute_unexpected_exception(self): """非 LLM 异常时进入回退处理""" team = BoardTeam(max_rounds=2) moderator = _make_mock_expert("moderator", is_lead=True) _setup_team_with_experts(team, [moderator], "moderator") # Provide a working gateway for fallback conclusion gateway = _make_mock_gateway("回退总结") moderator.agent._llm_gateway = gateway orchestrator = BoardOrchestrator(team=team) # Mock _generate_moderator_opening to raise an unexpected exception with patch.object( orchestrator, "_generate_moderator_opening", new_callable=AsyncMock, side_effect=RuntimeError("Unexpected error"), ): with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock): result = await orchestrator.execute("测试主题") # 非预期异常应返回 failed 状态 assert result["status"] == "failed" assert "error" in result # ── BoardOrchestrator._has_stop_command 测试 ────────────── class TestBoardOrchestratorStopCommand: """BoardOrchestrator 停止命令检测测试""" def test_has_stop_command_true(self): """检测到停止命令""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) assert orchestrator._has_stop_command(["/stop"]) is True assert orchestrator._has_stop_command(["停止讨论"]) is True assert orchestrator._has_stop_command(["some text", "stop"]) is True assert orchestrator._has_stop_command(["结束讨论"]) is True def test_has_stop_command_false(self): """无停止命令""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) assert orchestrator._has_stop_command([]) is False assert orchestrator._has_stop_command(["继续讨论"]) is False assert orchestrator._has_stop_command(["请多说一些"]) is False def test_has_stop_command_case_insensitive(self): """停止命令大小写不敏感""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) assert orchestrator._has_stop_command(["STOP"]) is True assert orchestrator._has_stop_command(["Stop"]) is True # ── BoardOrchestrator._get_llm_gateway 测试 ─────────────── class TestBoardOrchestratorGetGateway: """BoardOrchestrator._get_llm_gateway 测试""" def test_get_gateway_from_expert(self): """从指定专家获取 gateway""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) expert = _make_mock_expert("test") gateway = orchestrator._get_llm_gateway(expert) assert gateway is not None def test_get_gateway_fallback_to_active_experts(self): """从其他活跃专家回退获取 gateway""" team = BoardTeam() moderator = _make_mock_expert("moderator", is_lead=True) # moderator 没有 gateway moderator.agent._llm_gateway = None member = _make_mock_expert("member") _setup_team_with_experts(team, [moderator, member], "moderator") orchestrator = BoardOrchestrator(team=team) # 应该从 member 回退获取 gateway = orchestrator._get_llm_gateway(moderator) assert gateway is not None def test_get_gateway_none_when_no_gateway(self): """无可用 gateway 时返回 None""" team = BoardTeam() moderator = _make_mock_expert("moderator", is_lead=True) moderator.agent._llm_gateway = None _setup_team_with_experts(team, [moderator], "moderator") orchestrator = BoardOrchestrator(team=team) gateway = orchestrator._get_llm_gateway(moderator) assert gateway is None # ── BoardOrchestrator._broadcast_event 测试 ─────────────── class TestBoardOrchestratorBroadcast: """BoardOrchestrator._broadcast_event 测试""" @pytest.mark.asyncio async def test_broadcast_event_with_transport(self): """有 transport 时广播事件""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) # Mock transport team._handoff_transport = AsyncMock() team._handoff_transport.send = AsyncMock() await orchestrator._broadcast_event("board_started", {"topic": "测试"}) team._handoff_transport.send.assert_called_once() call_args = team._handoff_transport.send.call_args assert call_args[0][0] == team.team_channel assert call_args[0][1]["type"] == "board_started" assert call_args[0][1]["topic"] == "测试" @pytest.mark.asyncio async def test_broadcast_event_no_transport(self): """无 transport 时不报错""" team = BoardTeam() team._handoff_transport = None orchestrator = BoardOrchestrator(team=team) # 不应抛出异常 await orchestrator._broadcast_event("board_started", {"topic": "测试"}) @pytest.mark.asyncio async def test_broadcast_event_transport_error(self): """transport 错误时不传播异常""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) team._handoff_transport = AsyncMock() team._handoff_transport.send = AsyncMock(side_effect=Exception("Transport error")) # 不应抛出异常 await orchestrator._broadcast_event("board_started", {"topic": "测试"}) # ── BoardOrchestrator._stream_expert_speech 测试 ───────── class TestStreamExpertSpeechFallback: """_stream_expert_speech 首 chunk 超时 fallback 测试。 回归 commit 36b0296:该 commit 把 expert 发言从 gateway.chat() 改为 gateway.chat_stream() 以实现"逐个输出"UI 体验,但 DashScope/DeepSeek via LiteLLM 偶尔不返回首个 SSE chunk,导致 board 整体 hang。 修复:首 chunk 30s 超时 → fallback 到非流式 chat() + _replay_stream。 """ @pytest.mark.asyncio async def test_stream_normal_works(self): """chat_stream 正常返回 chunks 时走流式路径。""" team = BoardTeam() orchestrator = BoardOrchestrator(team=team) expert = _make_mock_expert("tester", is_lead=False) # chat_stream 立刻返回 chunks gateway = _make_mock_gateway("正常流式回复") expert.agent._llm_gateway = gateway orchestrator._get_llm_gateway = lambda e: gateway with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock): result = await orchestrator._stream_expert_speech(expert, 1, "test prompt") assert "正常流式回复" in result # chat_stream 应被调用(流式路径) gateway.chat_stream.assert_called_once() @pytest.mark.asyncio async def test_stream_first_chunk_timeout_falls_back_to_chat(self): """chat_stream 首 chunk 超时时 fallback 到 gateway.chat()。""" class _HungStream: """模拟 DashScope via LiteLLM 不返回首 chunk 的 hang。""" async def __anext__(self): # 永远不返回 — 触发 wait_for 超时 import asyncio as _asyncio await _asyncio.Event().wait() # 永久阻塞 raise StopAsyncIteration # pragma: no cover def __aiter__(self): return self async def aclose(self): pass team = BoardTeam() # 把超时调小让测试快速通过 orchestrator = BoardOrchestrator(team=team) orchestrator._STREAM_FIRST_CHUNK_TIMEOUT_S = 0.1 expert = _make_mock_expert("tester", is_lead=False) gateway = _make_mock_gateway("fallback 内容") # chat_stream 返回 hang 的 stream gateway.chat_stream = MagicMock(return_value=_HungStream()) expert.agent._llm_gateway = gateway orchestrator._get_llm_gateway = lambda e: gateway with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock): result = await orchestrator._stream_expert_speech(expert, 1, "test prompt") # 应该 fallback 到 chat() 并返回内容 assert "fallback 内容" in result gateway.chat.assert_called_once() @pytest.mark.asyncio async def test_stream_empty_falls_back_to_chat(self): """chat_stream 立即结束(无 chunks)时 fallback 到 chat()。""" async def _empty_stream(): return yield # pragma: no cover — make this an async generator team = BoardTeam() orchestrator = BoardOrchestrator(team=team) expert = _make_mock_expert("tester", is_lead=False) gateway = _make_mock_gateway("fallback 内容") gateway.chat_stream = MagicMock(return_value=_empty_stream()) expert.agent._llm_gateway = gateway orchestrator._get_llm_gateway = lambda e: gateway with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock): result = await orchestrator._stream_expert_speech(expert, 1, "test prompt") assert "fallback 内容" in result gateway.chat.assert_called_once() @pytest.mark.asyncio async def test_stream_chat_stream_raising_falls_back_to_chat(self): """chat_stream 抛异常时 fallback 到 chat()。""" def _raising_stream(*a, **kw): raise RuntimeError("provider error") team = BoardTeam() orchestrator = BoardOrchestrator(team=team) expert = _make_mock_expert("tester", is_lead=False) gateway = _make_mock_gateway("fallback 内容") gateway.chat_stream = MagicMock(side_effect=_raising_stream) expert.agent._llm_gateway = gateway orchestrator._get_llm_gateway = lambda e: gateway with patch.object(orchestrator, "_broadcast_event", new_callable=AsyncMock): result = await orchestrator._stream_expert_speech(expert, 1, "test prompt") assert "fallback 内容" in result gateway.chat.assert_called_once() @pytest.mark.asyncio async def test_stream_breaks_after_first_chunk_returns_partial(self): """首 chunk 成功但后续断流时返回部分内容,不调 chat() 避免双重广播。""" class _BreakAfterFirstStream: """首个 chunk 正常返回,第二个 __anext__ 抛异常模拟断流。""" def __init__(self): self._first_yielded = False async def __anext__(self): if not self._first_yielded: self._first_yielded = True chunk = MagicMock() chunk.content = "部分内容" return chunk raise RuntimeError("stream broke mid-way") def __aiter__(self): return self async def aclose(self): pass team = BoardTeam() orchestrator = BoardOrchestrator(team=team) expert = _make_mock_expert("tester", is_lead=False) gateway = _make_mock_gateway("完整 fallback 内容") gateway.chat_stream = MagicMock(return_value=_BreakAfterFirstStream()) expert.agent._llm_gateway = gateway orchestrator._get_llm_gateway = lambda e: gateway with patch.object( orchestrator, "_broadcast_event", new_callable=AsyncMock ) as mock_broadcast: result = await orchestrator._stream_expert_speech(expert, 1, "test prompt") # 返回部分内容,而不是完整 fallback 内容 assert "部分内容" in result assert "完整 fallback" not in result # 不应调 chat()(避免双重广播) gateway.chat.assert_not_called() # 只广播了 first_chunk 的部分内容 assert mock_broadcast.call_count == 1