340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""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
|
||
|
||
|
||
# ── 辅助函数 ──────────────────────────────────────────────
|
||
|
||
|
||
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)
|
||
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": "测试"})
|