fischer-agentkit/tests/unit/server/test_chat_team.py

527 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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"]