273 lines
9.8 KiB
Python
273 lines
9.8 KiB
Python
"""Router 单元测试 — 三种路由模式 + 校验 + fallback"""
|
||
|
||
import pytest
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from src.daemon.router import (
|
||
AgentRouter, AgentProfile, RouteDecision, KNOWN_CAPABILITIES,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.fixture
|
||
def profiles():
|
||
return {
|
||
"zhangfei-dev": AgentProfile(
|
||
agent_id="zhangfei-dev",
|
||
capabilities=["coding", "implementation", "scripting"],
|
||
can_review=False, max_concurrent=1,
|
||
),
|
||
"simayi-challenger": AgentProfile(
|
||
agent_id="simayi-challenger",
|
||
capabilities=["review", "quality_check", "debate"],
|
||
can_review=True, max_concurrent=2,
|
||
),
|
||
"pangtong-fujunshi": AgentProfile(
|
||
agent_id="pangtong-fujunshi",
|
||
capabilities=["planning", "coordination", "escalation", "strategy"],
|
||
can_review=True, is_fallback=True, max_concurrent=3,
|
||
),
|
||
}
|
||
|
||
|
||
@pytest.fixture
|
||
def router(profiles):
|
||
return AgentRouter(agent_profiles=profiles)
|
||
|
||
|
||
def make_task(**overrides):
|
||
base = {
|
||
"id": "t1", "title": "Test Task", "status": "pending",
|
||
"assignee": "zhangfei-dev", "current_agent": None,
|
||
"next_capability": None, "task_type": "coding",
|
||
"description": "Write some code",
|
||
}
|
||
base.update(overrides)
|
||
return base
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T1: 确定性快速路径
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDeterministic:
|
||
def test_local_action(self, router):
|
||
"""机械检查 → daemon"""
|
||
d = router.route(make_task(), action_type="L1_guardrail")
|
||
assert d.agent_id == "daemon"
|
||
assert d.mode == "deterministic"
|
||
|
||
def test_format_check_local(self, router):
|
||
d = router.route(make_task(), action_type="format_check")
|
||
assert d.agent_id == "daemon"
|
||
|
||
def test_retry_same_agent(self, router):
|
||
"""retry → 原执行者"""
|
||
d = router.route(make_task(current_agent="zhangfei-dev"),
|
||
action_type="retry")
|
||
assert d.agent_id == "zhangfei-dev"
|
||
assert d.mode == "deterministic"
|
||
|
||
def test_direct_assignee(self, router):
|
||
"""有 assignee 且非生命周期流转 → 直接用"""
|
||
d = router.route(make_task(), action_type="execute")
|
||
assert d.agent_id == "zhangfei-dev"
|
||
assert d.mode == "deterministic"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T2: Mode B — Agent 声明式交接
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestAgentHandoff:
|
||
def test_handoff_review(self, router):
|
||
"""张飞说需要 review → 匹配司马懿"""
|
||
d = router.route(make_task(
|
||
current_agent="zhangfei-dev",
|
||
next_capability="review",
|
||
))
|
||
assert d.agent_id == "simayi-challenger"
|
||
assert d.mode == "agent_handoff"
|
||
assert "review" in d.reason
|
||
|
||
def test_handoff_excludes_current(self, router):
|
||
"""交接排除当前执行者(不能交给自己)"""
|
||
d = router.route(make_task(
|
||
current_agent="simayi-challenger",
|
||
next_capability="review",
|
||
))
|
||
# simayi 被排除,只剩 pangtong(can_review=True 且有 review 能力)
|
||
assert d.agent_id != "simayi-challenger"
|
||
|
||
def test_handoff_invalid_capability_ignored(self, router):
|
||
"""BUG-2: 非法 next_capability 被忽略,不进路由"""
|
||
d = router.route(make_task(
|
||
current_agent="zhangfei-dev",
|
||
next_capability="nonexistent_capability",
|
||
), action_type="execute")
|
||
# 不应该走 handoff,应该走 assignee 直派或其他
|
||
assert d.mode != "agent_handoff"
|
||
|
||
def test_handoff_known_but_no_match(self):
|
||
"""合法 capability 但无匹配 Agent → 降级"""
|
||
router2 = AgentRouter(agent_profiles={
|
||
"zhangfei-dev": AgentProfile(
|
||
agent_id="zhangfei-dev",
|
||
capabilities=["coding"],
|
||
),
|
||
})
|
||
d = router2.route(make_task(
|
||
assignee=None, # 无 assignee,不会走快速路径 4
|
||
current_agent="zhangfei-dev",
|
||
next_capability="review", # 合法但在 profiles 中无人匹配
|
||
))
|
||
# 无 LLM driver、无 assignee → fallback 庞统
|
||
assert d.agent_id == "pangtong-fujunshi"
|
||
assert d.mode == "delegate"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T3: 生命周期流转
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestLifecycle:
|
||
def test_review_action(self, router):
|
||
"""review action_type → 查 review 能力,排除 current_agent"""
|
||
d = router.route(make_task(
|
||
current_agent="zhangfei-dev",
|
||
), action_type="review")
|
||
assert d.agent_id == "simayi-challenger"
|
||
assert d.mode == "deterministic"
|
||
|
||
def test_review_excludes_executor(self, router):
|
||
"""review 排除执行者(张飞不能审张飞)"""
|
||
d = router.route(make_task(
|
||
current_agent="zhangfei-dev",
|
||
assignee="zhangfei-dev",
|
||
), action_type="review")
|
||
assert d.agent_id != "zhangfei-dev"
|
||
assert d.agent_id == "simayi-challenger"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T4: Mode A — LLM 路由 (v3.0: LLMDriver 已移除,模糊路由 delegate 庞统)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.skip(reason="v3.0 removed LLMDriver, fuzzy routing delegates to pangtong")
|
||
class TestLLMRoute:
|
||
def test_llm_returns_valid(self, router):
|
||
"""LLM 返回合法 Agent"""
|
||
mock_driver = MagicMock(spec=LLMDriver)
|
||
mock_driver.route.return_value = RouteDecision(
|
||
agent_id="simayi-challenger",
|
||
reason="LLM selected reviewer",
|
||
mode="llm_route",
|
||
confidence=0.9,
|
||
model="zhipu/glm-5.1",
|
||
latency_ms=1200,
|
||
)
|
||
router.llm_driver = mock_driver
|
||
|
||
# 无 assignee、无 next_capability、非 lifecycle → 走 LLM
|
||
d = router.route(make_task(
|
||
assignee=None, current_agent=None,
|
||
next_capability=None,
|
||
))
|
||
assert d.agent_id == "simayi-challenger"
|
||
assert d.mode == "llm_route"
|
||
assert d.confidence == 0.9
|
||
|
||
def test_llm_invalid_agent_fallback(self, router):
|
||
"""LLM 返回不存在的 Agent → fallback"""
|
||
mock_driver = MagicMock(spec=LLMDriver)
|
||
mock_driver.route.return_value = RouteDecision(
|
||
agent_id="nonexistent-agent",
|
||
reason="Bad pick",
|
||
mode="llm_route",
|
||
confidence=0.8,
|
||
latency_ms=500,
|
||
)
|
||
router.llm_driver = mock_driver
|
||
|
||
d = router.route(make_task(assignee=None))
|
||
assert d.agent_id == "pangtong-fujunshi"
|
||
assert d.mode == "fallback"
|
||
|
||
def test_llm_low_confidence_fallback(self, router):
|
||
"""LLM 置信度低于阈值 → fallback"""
|
||
mock_driver = MagicMock(spec=LLMDriver)
|
||
mock_driver.route.return_value = RouteDecision(
|
||
agent_id="simayi-challenger",
|
||
reason="Not sure",
|
||
mode="llm_route",
|
||
confidence=0.5,
|
||
latency_ms=300,
|
||
)
|
||
router.llm_driver = mock_driver
|
||
|
||
d = router.route(make_task(assignee=None))
|
||
assert d.agent_id == "pangtong-fujunshi"
|
||
assert d.mode == "fallback"
|
||
assert d.confidence == 0.5
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T5: Fallback
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestFallback:
|
||
def test_no_llm_no_match(self, router):
|
||
"""无 LLM、无匹配 → fallback 庞统"""
|
||
d = router.route(make_task(
|
||
assignee=None, current_agent=None,
|
||
next_capability=None, task_type="unknown_type",
|
||
), action_type="")
|
||
assert d.agent_id == "pangtong-fujunshi"
|
||
assert d.mode == "delegate"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T6: Capability 校验
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestCapabilityValidation:
|
||
def test_known_capabilities_populated(self, router):
|
||
"""profiles 自动填充 known capabilities"""
|
||
assert "coding" in router._known_capabilities
|
||
assert "review" in router._known_capabilities
|
||
assert "escalation" in router._known_capabilities
|
||
|
||
def test_validate_known(self, router):
|
||
assert router._validate_capability("coding") is True
|
||
assert router._validate_capability("review") is True
|
||
|
||
def test_validate_unknown(self, router):
|
||
assert router._validate_capability("administration") is False
|
||
assert router._validate_capability("hack") is False
|
||
|
||
def test_known_capabilities_set(self):
|
||
"""KNOWN_CAPABILITIES 包含所有预期能力"""
|
||
assert "coding" in KNOWN_CAPABILITIES
|
||
assert "review" in KNOWN_CAPABILITIES
|
||
assert "escalation" in KNOWN_CAPABILITIES
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# T7: Latency 记录
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestLatency:
|
||
def test_deterministic_has_latency(self, router):
|
||
d = router.route(make_task(), action_type="L1_guardrail")
|
||
assert d.latency_ms >= 0
|
||
|
||
def test_handoff_has_latency(self, router):
|
||
d = router.route(make_task(
|
||
current_agent="zhangfei-dev",
|
||
next_capability="review",
|
||
))
|
||
assert d.latency_ms >= 0
|