fischer-agentkit/tests/unit/experts/test_team.py

769 lines
27 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.

"""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_idFORMING 状态"""
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