973 lines
36 KiB
Python
973 lines
36 KiB
Python
"""TeamOrchestrator 单元测试 (流水线模式)
|
||
|
||
测试覆盖:
|
||
- 流水线执行(阶段依赖、拓扑排序)
|
||
- 并行阶段执行
|
||
- 上下文隔离(独立 Agent 实例)
|
||
- SharedWorkspace 数据传递
|
||
- 阶段失败与依赖失败传播
|
||
- 全失败 fallback
|
||
- 事件广播(phase_started/phase_completed/phase_failed)
|
||
- 模型路由(_get_model)
|
||
- LLM 分解失败容错
|
||
- 循环依赖检测
|
||
- 无效专家引用 fallback
|
||
- TeamStatus.PLANNING 状态流转
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from agentkit.core.handoff_transport import InProcessHandoffTransport
|
||
from agentkit.core.protocol import TaskResult, TaskStatus
|
||
from agentkit.experts.config import ExpertConfig
|
||
from agentkit.experts.expert import Expert
|
||
from agentkit.experts.orchestrator import TeamOrchestrator
|
||
from agentkit.experts.plan import PhaseStatus, PlanPhase, PlanStatus
|
||
from agentkit.experts.team import ExpertTeam, TeamStatus
|
||
|
||
|
||
# ── 辅助函数 ──────────────────────────────────────────────
|
||
|
||
|
||
def _make_expert_config(
|
||
name: str = "test_expert",
|
||
is_lead: bool = False,
|
||
llm: dict | None = None,
|
||
) -> ExpertConfig:
|
||
"""创建测试用 ExpertConfig"""
|
||
return ExpertConfig(
|
||
name=name,
|
||
agent_type="expert",
|
||
persona="测试专家",
|
||
thinking_style="逻辑推理",
|
||
bound_skills=["skill_a"],
|
||
is_lead=is_lead,
|
||
task_mode="llm_generate",
|
||
prompt={"identity": "测试"},
|
||
llm=llm,
|
||
)
|
||
|
||
|
||
def _make_mock_expert(
|
||
name: str = "test_expert",
|
||
is_lead: bool = False,
|
||
is_active: bool = True,
|
||
llm: dict | None = None,
|
||
) -> MagicMock:
|
||
"""创建 mock Expert"""
|
||
config = _make_expert_config(name=name, is_lead=is_lead, llm=llm)
|
||
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,
|
||
}
|
||
# Mock agent.execute() to return a successful TaskResult
|
||
mock_agent = MagicMock()
|
||
mock_agent.execute = AsyncMock(return_value=TaskResult(
|
||
task_id="test",
|
||
agent_name=name,
|
||
status=TaskStatus.COMPLETED.value,
|
||
output_data={"content": f"Result from {name}"},
|
||
error_message=None,
|
||
started_at=None,
|
||
completed_at=None,
|
||
))
|
||
# No LLM gateway by default (tests single-phase path)
|
||
mock_agent._llm_gateway = None
|
||
expert.agent = mock_agent
|
||
return expert
|
||
|
||
|
||
def _make_team_with_experts(
|
||
expert_names: list[str] | None = None,
|
||
lead_name: str = "lead",
|
||
pool: MagicMock | None = None,
|
||
) -> ExpertTeam:
|
||
"""创建包含 mock experts 的 ExpertTeam"""
|
||
team = ExpertTeam()
|
||
transport = AsyncMock(spec=InProcessHandoffTransport)
|
||
team._handoff_transport = transport
|
||
if pool is not None:
|
||
team._pool = pool
|
||
|
||
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)
|
||
team._experts[name] = expert
|
||
if is_lead:
|
||
team._lead_expert_name = name
|
||
|
||
return team
|
||
|
||
|
||
def _make_mock_llm_gateway(
|
||
phases: list[dict] | None = None,
|
||
synthesis_content: str = "综合结果",
|
||
) -> MagicMock:
|
||
"""创建 mock LLM gateway.
|
||
|
||
If phases is provided, the gateway returns a JSON array of phases for
|
||
decomposition. Otherwise returns a simple response for synthesis.
|
||
"""
|
||
gateway = AsyncMock()
|
||
if phases:
|
||
phases_json = json.dumps(phases)
|
||
decomp_response = MagicMock()
|
||
decomp_response.content = phases_json
|
||
synth_response = MagicMock()
|
||
synth_response.content = synthesis_content
|
||
gateway.chat = AsyncMock(side_effect=[decomp_response, synth_response, synth_response])
|
||
else:
|
||
response = MagicMock()
|
||
response.content = synthesis_content
|
||
gateway.chat = AsyncMock(return_value=response)
|
||
return gateway
|
||
|
||
|
||
def _make_mock_pool() -> MagicMock:
|
||
"""创建 mock AgentPool,模拟上下文隔离的 agent 创建"""
|
||
pool = MagicMock()
|
||
pool.create_agent = AsyncMock(side_effect=lambda config: MagicMock(
|
||
execute=AsyncMock(return_value=TaskResult(
|
||
task_id="test",
|
||
agent_name=config.name,
|
||
status=TaskStatus.COMPLETED.value,
|
||
output_data={"content": f"Isolated result from {config.name}"},
|
||
error_message=None,
|
||
started_at=None,
|
||
completed_at=None,
|
||
))
|
||
))
|
||
pool.remove_agent = AsyncMock()
|
||
return pool
|
||
|
||
|
||
# ── 流水线执行测试 ────────────────────────────────────────
|
||
|
||
|
||
class TestPipelineExecution:
|
||
"""流水线模式执行测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_single_phase_completes(self):
|
||
"""无 LLM 时,任务作为单个阶段执行完成"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
assert result["status"] == "completed"
|
||
assert "result" in result
|
||
assert "phase_results" in result
|
||
assert "plan" in result
|
||
assert team.status == TeamStatus.COMPLETED
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_sets_team_status_completed(self):
|
||
"""执行完成后设置 team 状态为 COMPLETED"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
assert team.status == TeamStatus.COMPLETED
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipeline_sequential_execution(self):
|
||
"""3 阶段(A→B→C)按序执行"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
# LLM 分解为 3 个串行阶段
|
||
gateway = _make_mock_llm_gateway(
|
||
phases=[
|
||
{"name": "A", "assigned_expert": "lead", "task_description": "阶段A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member1", "task_description": "阶段B", "depends_on": ["A"]},
|
||
{"name": "C", "assigned_expert": "member2", "task_description": "阶段C", "depends_on": ["B"]},
|
||
]
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
# Track execution order
|
||
execution_order: list[str] = []
|
||
for name in ["lead", "member1", "member2"]:
|
||
original_execute = team._experts[name].agent.execute
|
||
async def tracking_execute(task_msg, _orig=original_execute, _name=name):
|
||
execution_order.append(_name)
|
||
return await _orig(task_msg)
|
||
team._experts[name].agent.execute = tracking_execute
|
||
|
||
result = await orchestrator.execute("串行任务")
|
||
|
||
assert result["status"] == "completed"
|
||
plan = result["plan"]
|
||
assert len(plan.phases) == 3
|
||
# All phases should be completed
|
||
for ph in plan.phases:
|
||
assert ph.status == PhaseStatus.COMPLETED
|
||
# Verify sequential execution order: A(lead) → B(member1) → C(member2)
|
||
assert execution_order == ["lead", "member1", "member2"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipeline_parallel_phases(self):
|
||
"""2 个无依赖阶段并行执行"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = _make_mock_llm_gateway(
|
||
phases=[
|
||
{"name": "A", "assigned_expert": "lead", "task_description": "阶段A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member1", "task_description": "阶段B", "depends_on": []},
|
||
{"name": "C", "assigned_expert": "member2", "task_description": "阶段C", "depends_on": ["A", "B"]},
|
||
]
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
result = await orchestrator.execute("并行任务")
|
||
|
||
assert result["status"] == "completed"
|
||
plan = result["plan"]
|
||
assert len(plan.phases) == 3
|
||
# A and B should be in the same layer (parallel)
|
||
layers = plan.topological_sort()
|
||
assert len(layers) == 2 # [A, B], [C]
|
||
assert len(layers[0]) == 2 # A and B parallel
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipeline_emits_team_formed_event(self):
|
||
"""执行时广播 team_formed 事件"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
calls = team._handoff_transport.send.call_args_list
|
||
event_types = [c[0][1]["type"] for c in calls]
|
||
assert "team_formed" in event_types
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipeline_emits_plan_update_with_phases(self):
|
||
"""执行时广播 plan_update 事件(包含阶段列表)"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
calls = team._handoff_transport.send.call_args_list
|
||
plan_updates = [c for c in calls if c[0][1].get("type") == "plan_update"]
|
||
assert len(plan_updates) >= 1
|
||
assert "plan_phases" in plan_updates[0][0][1]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipeline_emits_team_synthesis_event(self):
|
||
"""执行完成时广播 team_synthesis 事件"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
calls = team._handoff_transport.send.call_args_list
|
||
event_types = [c[0][1]["type"] for c in calls]
|
||
assert "team_synthesis" in event_types
|
||
|
||
|
||
# ── 阶段事件广播测试 ──────────────────────────────────────
|
||
|
||
|
||
class TestPhaseEvents:
|
||
"""阶段事件广播测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_emits_phase_started_and_completed(self):
|
||
"""执行时广播 phase_started 和 phase_completed 事件"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
calls = team._handoff_transport.send.call_args_list
|
||
event_types = [c[0][1]["type"] for c in calls]
|
||
assert "phase_started" in event_types
|
||
assert "phase_completed" in event_types
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_emits_expert_step_and_result(self):
|
||
"""执行时广播 expert_step 和 expert_result 事件"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
calls = team._handoff_transport.send.call_args_list
|
||
event_types = [c[0][1]["type"] for c in calls]
|
||
assert "expert_step" in event_types
|
||
assert "expert_result" in event_types
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_phase_started_contains_depends_on(self):
|
||
"""phase_started 事件包含 depends_on 字段"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = _make_mock_llm_gateway(
|
||
phases=[
|
||
{"name": "A", "assigned_expert": "lead", "task_description": "阶段A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member1", "task_description": "阶段B", "depends_on": ["A"]},
|
||
]
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
await orchestrator.execute("依赖任务")
|
||
|
||
calls = team._handoff_transport.send.call_args_list
|
||
phase_started_events = [c[0][1] for c in calls if c[0][1].get("type") == "phase_started"]
|
||
# Should have 2 phase_started events (A and B)
|
||
assert len(phase_started_events) == 2
|
||
# B's phase_started should have depends_on with A's id
|
||
phase_b_event = next(e for e in phase_started_events if e["phase_name"] == "B")
|
||
assert len(phase_b_event["depends_on"]) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_phase_failed_event_on_failure(self):
|
||
"""阶段失败时广播 phase_failed 事件"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
# Make all agents fail to trigger phase_failed
|
||
for expert in team._experts.values():
|
||
expert.agent.execute = AsyncMock(side_effect=RuntimeError("Execution failed"))
|
||
|
||
result = await orchestrator.execute("失败任务")
|
||
|
||
# Should fallback since all phases failed
|
||
assert result["status"] == "fallback"
|
||
# phase_failed should be emitted (or phase status is FAILED in plan)
|
||
plan = result["plan"]
|
||
assert all(ph.status == PhaseStatus.FAILED for ph in plan.phases)
|
||
|
||
|
||
# ── LLM 任务分解测试 ──────────────────────────────────────
|
||
|
||
|
||
class TestTaskDecomposition:
|
||
"""LLM 任务分解测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_llm_decomposes_task_into_phases(self):
|
||
"""LLM 将任务分解为多个阶段"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = _make_mock_llm_gateway(
|
||
phases=[
|
||
{"name": "规划", "assigned_expert": "lead", "task_description": "设计架构", "depends_on": []},
|
||
{"name": "前端", "assigned_expert": "member1", "task_description": "实现UI", "depends_on": ["规划"]},
|
||
{"name": "后端", "assigned_expert": "member2", "task_description": "实现API", "depends_on": ["规划"]},
|
||
]
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
result = await orchestrator.execute("开发功能")
|
||
|
||
assert result["status"] == "completed"
|
||
plan = result["plan"]
|
||
assert len(plan.phases) == 3
|
||
assert plan.phases[0].name == "规划"
|
||
assert plan.phases[1].name == "前端"
|
||
assert plan.phases[2].name == "后端"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_decomposition_fallback_to_single_phase(self):
|
||
"""LLM 不可用时回退到单个阶段"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
# No LLM gateway — should fall back to single phase
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
assert result["status"] == "completed"
|
||
plan = result["plan"]
|
||
assert len(plan.phases) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_llm_decomposition_invalid_json_falls_back(self):
|
||
"""LLM 返回无效 JSON 时回退到单阶段"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = AsyncMock()
|
||
bad_response = MagicMock()
|
||
bad_response.content = "这不是JSON"
|
||
synth_response = MagicMock()
|
||
synth_response.content = "综合结果"
|
||
gateway.chat = AsyncMock(side_effect=[bad_response, synth_response])
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
assert result["status"] == "completed"
|
||
plan = result["plan"]
|
||
assert len(plan.phases) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parse_phases_valid_json(self):
|
||
"""_parse_phases 正确解析 JSON 数组"""
|
||
content = json.dumps([
|
||
{"name": "A", "assigned_expert": "member1", "task_description": "任务A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member2", "task_description": "任务B", "depends_on": ["A"]},
|
||
])
|
||
phases = TeamOrchestrator._parse_phases(content, ["member1", "member2"], "lead")
|
||
assert len(phases) == 2
|
||
assert phases[0].name == "A"
|
||
assert phases[0].assigned_expert == "member1"
|
||
assert phases[1].name == "B"
|
||
assert phases[1].assigned_expert == "member2"
|
||
# B depends on A
|
||
assert len(phases[1].depends_on) == 1
|
||
assert phases[1].depends_on[0] == phases[0].id
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parse_phases_invalid_expert_falls_back_to_lead(self):
|
||
"""_parse_phases 对无效专家名回退到 lead"""
|
||
content = json.dumps([
|
||
{"name": "A", "assigned_expert": "nonexistent", "task_description": "任务A", "depends_on": []},
|
||
])
|
||
phases = TeamOrchestrator._parse_phases(content, ["member1"], "lead")
|
||
assert len(phases) == 1
|
||
assert phases[0].assigned_expert == "lead"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parse_phases_invalid_json_returns_empty(self):
|
||
"""_parse_phases 对无效 JSON 返回空列表"""
|
||
phases = TeamOrchestrator._parse_phases("not json at all", ["member1"], "lead")
|
||
assert phases == []
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parse_phases_empty_name_skipped(self):
|
||
"""_parse_phases 跳过空名称的阶段"""
|
||
content = json.dumps([
|
||
{"name": "", "assigned_expert": "member1", "task_description": "任务A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member1", "task_description": "任务B", "depends_on": []},
|
||
])
|
||
phases = TeamOrchestrator._parse_phases(content, ["member1"], "lead")
|
||
assert len(phases) == 1
|
||
assert phases[0].name == "B"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parse_phases_resolves_depends_on_by_name(self):
|
||
"""_parse_phases 通过名称解析 depends_on 为 ID"""
|
||
content = json.dumps([
|
||
{"name": "规划", "assigned_expert": "lead", "task_description": "设计", "depends_on": []},
|
||
{"name": "实现", "assigned_expert": "member1", "task_description": "编码", "depends_on": ["规划"]},
|
||
])
|
||
phases = TeamOrchestrator._parse_phases(content, ["lead", "member1"], "lead")
|
||
assert len(phases) == 2
|
||
# 实现 depends on 规划
|
||
assert phases[1].depends_on == [phases[0].id]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parse_phases_unknown_dependency_ignored(self):
|
||
"""_parse_phases 对未知依赖名称忽略"""
|
||
content = json.dumps([
|
||
{"name": "A", "assigned_expert": "lead", "task_description": "任务A", "depends_on": ["unknown_phase"]},
|
||
])
|
||
phases = TeamOrchestrator._parse_phases(content, ["lead"], "lead")
|
||
assert len(phases) == 1
|
||
assert len(phases[0].depends_on) == 0 # unknown dependency ignored
|
||
|
||
|
||
# ── 阶段执行测试 ──────────────────────────────────────────
|
||
|
||
|
||
class TestPhaseExecution:
|
||
"""阶段执行测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_phase_execution_calls_agent_execute(self):
|
||
"""阶段执行调用 agent.execute()"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
# Lead expert's agent should have been called
|
||
team._experts["lead"].agent.execute.assert_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_phase_marks_completed(self):
|
||
"""阶段执行后状态标记为 COMPLETED"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
plan = result["plan"]
|
||
for ph in plan.phases:
|
||
assert ph.status == PhaseStatus.COMPLETED
|
||
assert ph.result is not None
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_phase_with_invalid_expert_falls_back_to_lead(self):
|
||
"""阶段分配的专家不可用时回退到 lead"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = _make_mock_llm_gateway(
|
||
phases=[
|
||
{"name": "A", "assigned_expert": "nonexistent", "task_description": "任务A", "depends_on": []},
|
||
]
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
assert result["status"] == "completed"
|
||
plan = result["plan"]
|
||
# The phase should have been reassigned to lead
|
||
assert plan.phases[0].assigned_expert == "lead"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_shared_workspace_passing(self):
|
||
"""阶段 A 的输出写入 SharedWorkspace,阶段 B 能读取"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = _make_mock_llm_gateway(
|
||
phases=[
|
||
{"name": "A", "assigned_expert": "lead", "task_description": "阶段A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member1", "task_description": "阶段B", "depends_on": ["A"]},
|
||
]
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
result = await orchestrator.execute("依赖任务")
|
||
|
||
assert result["status"] == "completed"
|
||
# Verify workspace.write was called for each phase
|
||
workspace = team.workspace
|
||
# Check that phase outputs were written
|
||
plan = result["plan"]
|
||
for ph in plan.phases:
|
||
key = f"{plan.id}/phase/{ph.id}/output"
|
||
data = await workspace.read(key)
|
||
assert data is not None
|
||
assert "value" in data
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_context_isolation_with_pool(self):
|
||
"""使用 AgentPool 时创建独立 agent 实例(上下文隔离)"""
|
||
pool = _make_mock_pool()
|
||
team = _make_team_with_experts(pool=pool)
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
# Pool.create_agent should have been called for context isolation
|
||
pool.create_agent.assert_awaited()
|
||
# Pool.remove_agent should have been called for cleanup
|
||
pool.remove_agent.assert_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_context_isolation_fallback_without_pool(self):
|
||
"""无 AgentPool 时使用 expert 的现有 agent"""
|
||
team = _make_team_with_experts() # No pool
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
# Should still complete successfully using expert's existing agent
|
||
assert result["status"] == "completed"
|
||
team._experts["lead"].agent.execute.assert_awaited()
|
||
|
||
|
||
# ── 阶段失败与依赖传播测试 ────────────────────────────────
|
||
|
||
|
||
class TestPhaseFailure:
|
||
"""阶段失败与依赖传播测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_phase_failure_marks_dependents_failed(self):
|
||
"""阶段 B 失败时,依赖 B 的阶段 C 标记为 FAILED"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = _make_mock_llm_gateway(
|
||
phases=[
|
||
{"name": "A", "assigned_expert": "lead", "task_description": "阶段A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member1", "task_description": "阶段B", "depends_on": ["A"]},
|
||
{"name": "C", "assigned_expert": "member2", "task_description": "阶段C", "depends_on": ["B"]},
|
||
]
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
# Make member1's agent fail (phase B)
|
||
team._experts["member1"].agent.execute = AsyncMock(side_effect=RuntimeError("B failed"))
|
||
|
||
result = await orchestrator.execute("失败传播任务")
|
||
|
||
# Should still complete (phase A succeeded)
|
||
assert result["status"] == "completed"
|
||
plan = result["plan"]
|
||
# Phase A should be completed
|
||
phase_a = next(ph for ph in plan.phases if ph.name == "A")
|
||
assert phase_a.status == PhaseStatus.COMPLETED
|
||
# Phase B should be failed
|
||
phase_b = next(ph for ph in plan.phases if ph.name == "B")
|
||
assert phase_b.status == PhaseStatus.FAILED
|
||
# Phase C should be failed (dependency B failed)
|
||
phase_c = next(ph for ph in plan.phases if ph.name == "C")
|
||
assert phase_c.status == PhaseStatus.FAILED
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_all_phases_fail_triggers_fallback(self):
|
||
"""所有阶段失败时触发 fallback"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
# Make all agents fail
|
||
for expert in team._experts.values():
|
||
expert.agent.execute = AsyncMock(side_effect=RuntimeError("Execution failed"))
|
||
|
||
result = await orchestrator.execute("全失败任务")
|
||
|
||
assert result["status"] == "fallback"
|
||
assert result["plan"].status == PlanStatus.FALLBACK
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fallback_uses_lead_expert(self):
|
||
"""回退使用 lead expert 执行原始任务"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
call_count = 0
|
||
|
||
async def mock_execute(task_msg):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
if task_msg.task_type == "team_phase":
|
||
raise RuntimeError("Phase failed")
|
||
# Fallback succeeds
|
||
return TaskResult(
|
||
task_id=task_msg.task_id,
|
||
agent_name="lead",
|
||
status=TaskStatus.COMPLETED.value,
|
||
output_data={"content": "Fallback result"},
|
||
error_message=None,
|
||
started_at=None,
|
||
completed_at=None,
|
||
)
|
||
|
||
team._experts["lead"].agent.execute = AsyncMock(side_effect=mock_execute)
|
||
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
assert result["status"] == "fallback"
|
||
assert result["result"]["content"] == "Fallback result"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_active_experts_returns_failed(self):
|
||
"""没有活跃专家时返回 failed"""
|
||
team = _make_team_with_experts()
|
||
for expert in team._experts.values():
|
||
expert.is_active = False
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
assert result["status"] == "failed"
|
||
assert "error" in result
|
||
|
||
|
||
# ── 循环依赖检测测试 ──────────────────────────────────────
|
||
|
||
|
||
class TestCircularDependency:
|
||
"""循环依赖检测测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_circular_dependency_triggers_fallback(self):
|
||
"""A→B→A 的循环依赖触发 fallback"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
# Manually create a plan with circular dependency
|
||
# We need to mock _decompose_task to return phases with circular dep
|
||
phase_a = PlanPhase(name="A", assigned_expert="lead", task_description="A")
|
||
phase_b = PlanPhase(name="B", assigned_expert="member1", task_description="B")
|
||
phase_a.depends_on = [phase_b.id]
|
||
phase_b.depends_on = [phase_a.id]
|
||
|
||
with patch.object(
|
||
TeamOrchestrator,
|
||
"_decompose_task",
|
||
return_value=[phase_a, phase_b],
|
||
):
|
||
result = await orchestrator.execute("循环依赖任务")
|
||
|
||
# Should fallback due to ValueError from topological_sort
|
||
assert result["status"] == "fallback"
|
||
assert result["plan"].status == PlanStatus.FALLBACK
|
||
|
||
|
||
# ── 结果综合测试 ──────────────────────────────────────────
|
||
|
||
|
||
class TestResultSynthesis:
|
||
"""结果综合测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_synthesize_single_result(self):
|
||
"""单个阶段结果直接返回"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
result = await orchestrator.execute("测试任务")
|
||
|
||
assert result["status"] == "completed"
|
||
final = result["result"]
|
||
assert "content" in final
|
||
assert final["strategy"] == "best"
|
||
assert final["phases_completed"] == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_synthesize_multiple_results_with_llm(self):
|
||
"""多个阶段结果通过 LLM 综合"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = _make_mock_llm_gateway(
|
||
phases=[
|
||
{"name": "A", "assigned_expert": "member1", "task_description": "阶段A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member2", "task_description": "阶段B", "depends_on": []},
|
||
],
|
||
synthesis_content="综合结果",
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
result = await orchestrator.execute("复杂任务")
|
||
|
||
assert result["status"] == "completed"
|
||
final = result["result"]
|
||
assert final["content"] == "综合结果"
|
||
assert final["strategy"] == "best"
|
||
assert final["phases_completed"] == 2
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_synthesize_without_llm_concatenates(self):
|
||
"""无 LLM 时拼接所有结果"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
gateway = AsyncMock()
|
||
decomp_response = MagicMock()
|
||
decomp_response.content = json.dumps([
|
||
{"name": "A", "assigned_expert": "member1", "task_description": "阶段A", "depends_on": []},
|
||
{"name": "B", "assigned_expert": "member2", "task_description": "阶段B", "depends_on": []},
|
||
])
|
||
# Synthesis call raises to force concatenation fallback
|
||
gateway.chat = AsyncMock(
|
||
side_effect=[decomp_response, RuntimeError("LLM unavailable")]
|
||
)
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
|
||
result = await orchestrator.execute("复杂任务")
|
||
|
||
assert result["status"] == "completed"
|
||
final = result["result"]
|
||
assert "content" in final
|
||
# Should contain both results concatenated
|
||
assert "Result from member1" in final["content"]
|
||
assert "Result from member2" in final["content"]
|
||
|
||
|
||
# ── 模型路由测试 ──────────────────────────────────────────
|
||
|
||
|
||
class TestModelRouting:
|
||
"""模型路由测试(_get_model)"""
|
||
|
||
def test_get_model_default_when_no_llm_config(self):
|
||
"""无 llm 配置时返回 'default'"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
model = orchestrator._get_model()
|
||
assert model == "default"
|
||
|
||
def test_get_model_from_expert_config(self):
|
||
"""从 expert.config.llm 读取模型名"""
|
||
team = _make_team_with_experts()
|
||
# Set llm config on lead expert
|
||
team._experts["lead"].config.llm = {"model": "gpt-4", "temperature": 0.7}
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
model = orchestrator._get_model()
|
||
assert model == "gpt-4"
|
||
|
||
def test_get_model_falls_back_to_default_when_no_model_key(self):
|
||
"""llm 配置中无 model 键时回退到 'default'"""
|
||
team = _make_team_with_experts()
|
||
team._experts["lead"].config.llm = {"temperature": 0.5}
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
model = orchestrator._get_model()
|
||
assert model == "default"
|
||
|
||
def test_get_model_uses_specific_expert(self):
|
||
"""_get_model 使用指定 expert 的配置"""
|
||
team = _make_team_with_experts()
|
||
team._experts["member1"].config.llm = {"model": "claude-3"}
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
model = orchestrator._get_model(team._experts["member1"])
|
||
assert model == "claude-3"
|
||
|
||
|
||
# ── TeamStatus.PLANNING 状态流转测试 ──────────────────────
|
||
|
||
|
||
class TestTeamStatusPlanning:
|
||
"""TeamStatus.PLANNING 状态流转测试(KTD6)"""
|
||
|
||
def test_team_status_planning_exists(self):
|
||
"""TeamStatus.PLANNING 枚举值存在"""
|
||
assert TeamStatus.PLANNING == "planning"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_transitions_through_planning(self):
|
||
"""执行时经过 PLANNING 状态"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
# Track status transitions
|
||
statuses_seen: list[str] = []
|
||
|
||
original_set_status = team.set_status
|
||
|
||
def tracking_set_status(status: TeamStatus) -> None:
|
||
statuses_seen.append(status.value)
|
||
original_set_status(status)
|
||
|
||
team.set_status = tracking_set_status
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
# Should have transitioned through PLANNING
|
||
assert "planning" in statuses_seen
|
||
# Should end at COMPLETED
|
||
assert statuses_seen[-1] == "completed"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_transitions_to_executing_after_planning(self):
|
||
"""PLANNING 后转为 EXECUTING"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
statuses_seen: list[str] = []
|
||
original_set_status = team.set_status
|
||
|
||
def tracking_set_status(status: TeamStatus) -> None:
|
||
statuses_seen.append(status.value)
|
||
original_set_status(status)
|
||
|
||
team.set_status = tracking_set_status
|
||
|
||
await orchestrator.execute("测试任务")
|
||
|
||
# PLANNING should come before EXECUTING
|
||
planning_idx = statuses_seen.index("planning")
|
||
executing_idx = statuses_seen.index("executing")
|
||
assert planning_idx < executing_idx
|
||
|
||
|
||
# ── 事件广播基础设施测试 ──────────────────────────────────
|
||
|
||
|
||
class TestBroadcastEvent:
|
||
"""事件广播基础设施测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_broadcast_event_sends_to_transport(self):
|
||
"""广播事件通过 handoff_transport 发送"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
await orchestrator._broadcast_event("test_event", {"key": "value"})
|
||
|
||
team._handoff_transport.send.assert_awaited()
|
||
call_args = team._handoff_transport.send.call_args
|
||
assert call_args[0][0] == team._team_channel
|
||
message = call_args[0][1]
|
||
assert message["type"] == "test_event"
|
||
assert message["key"] == "value"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_broadcast_event_no_transport(self):
|
||
"""没有 handoff_transport 时不报错"""
|
||
team = _make_team_with_experts()
|
||
team._handoff_transport = None
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
# Should not raise
|
||
await orchestrator._broadcast_event("test_event", {"key": "value"})
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_broadcast_event_handles_transport_error(self):
|
||
"""handoff_transport 发送失败时不影响执行"""
|
||
team = _make_team_with_experts()
|
||
team._handoff_transport.send = AsyncMock(side_effect=RuntimeError("Transport error"))
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
# Should not raise
|
||
await orchestrator._broadcast_event("test_event", {"key": "value"})
|
||
|
||
|
||
# ── LLM Gateway 测试 ──────────────────────────────────────
|
||
|
||
|
||
class TestLLMGateway:
|
||
"""LLM Gateway 获取测试"""
|
||
|
||
def test_get_llm_gateway_from_lead(self):
|
||
"""从 lead expert 获取 LLM gateway"""
|
||
team = _make_team_with_experts()
|
||
gateway = MagicMock()
|
||
team._experts["lead"].agent._llm_gateway = gateway
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
result = orchestrator._get_llm_gateway()
|
||
assert result is gateway
|
||
|
||
def test_get_llm_gateway_no_gateway(self):
|
||
"""没有 LLM gateway 时返回 None"""
|
||
team = _make_team_with_experts()
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
result = orchestrator._get_llm_gateway()
|
||
assert result is None
|
||
|
||
def test_get_llm_gateway_fallback_to_active_expert(self):
|
||
"""lead 没有 gateway 时从其他活跃专家获取"""
|
||
team = _make_team_with_experts()
|
||
gateway = MagicMock()
|
||
team._experts["member1"].agent._llm_gateway = gateway
|
||
orchestrator = TeamOrchestrator(team)
|
||
|
||
result = orchestrator._get_llm_gateway()
|
||
assert result is gateway
|