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

973 lines
36 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.

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