438 lines
15 KiB
Python
438 lines
15 KiB
Python
"""Expert 运行时包装器单元测试"""
|
||
|
||
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
|
||
from agentkit.experts.expert import Expert
|
||
|
||
|
||
# ── 辅助函数 ──────────────────────────────────────────────
|
||
|
||
|
||
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,
|
||
color: str = "#1890ff",
|
||
avatar: str = "",
|
||
**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,
|
||
color=color,
|
||
avatar=avatar,
|
||
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
|
||
|
||
|
||
# ── Expert 创建测试 ───────────────────────────────────────
|
||
|
||
|
||
class TestExpertCreate:
|
||
"""Expert.create 工厂方法测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_expert_via_pool(self):
|
||
"""通过 AgentPool 创建 Expert"""
|
||
config = _make_expert_config()
|
||
pool = _make_mock_pool()
|
||
|
||
expert = await Expert.create(config=config, pool=pool)
|
||
|
||
pool.create_agent.assert_awaited_once_with(config)
|
||
assert expert.config is config
|
||
assert expert.agent is pool.create_agent.return_value
|
||
assert expert.is_active is True
|
||
assert expert.team_id is None
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_with_handoff_transport(self):
|
||
"""创建 Expert 时传入 handoff_transport"""
|
||
config = _make_expert_config()
|
||
pool = _make_mock_pool()
|
||
transport = MagicMock(spec=InProcessHandoffTransport)
|
||
|
||
expert = await Expert.create(
|
||
config=config, pool=pool, handoff_transport=transport
|
||
)
|
||
|
||
assert expert._handoff_transport is transport
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_with_workspace(self):
|
||
"""创建 Expert 时传入 workspace"""
|
||
config = _make_expert_config()
|
||
pool = _make_mock_pool()
|
||
workspace = MagicMock(spec=SharedWorkspace)
|
||
|
||
expert = await Expert.create(
|
||
config=config, pool=pool, workspace=workspace
|
||
)
|
||
|
||
assert expert._workspace is workspace
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_with_team_context_injects_into_prompt(self):
|
||
"""team_context 注入到 Agent 的 prompt 中"""
|
||
config = _make_expert_config()
|
||
pool = _make_mock_pool()
|
||
mock_agent = _make_mock_agent()
|
||
|
||
# 模拟有 _prompt_template 的 Agent
|
||
mock_template = MagicMock()
|
||
mock_sections = MagicMock()
|
||
mock_sections.context = "原始上下文"
|
||
mock_template._sections = mock_sections
|
||
mock_agent._prompt_template = mock_template
|
||
|
||
pool.create_agent = AsyncMock(return_value=mock_agent)
|
||
|
||
expert = await Expert.create(
|
||
config=config,
|
||
pool=pool,
|
||
team_context="你是团队中的分析专家",
|
||
)
|
||
|
||
# 验证 team_context 被注入到 context 段落
|
||
assert "你是团队中的分析专家" in mock_sections.context
|
||
assert "原始上下文" in mock_sections.context
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_with_team_context_no_existing_context(self):
|
||
"""team_context 注入到空 context 段落"""
|
||
config = _make_expert_config()
|
||
pool = _make_mock_pool()
|
||
mock_agent = _make_mock_agent()
|
||
|
||
mock_template = MagicMock()
|
||
mock_sections = MagicMock()
|
||
mock_sections.context = ""
|
||
mock_template._sections = mock_sections
|
||
mock_agent._prompt_template = mock_template
|
||
|
||
pool.create_agent = AsyncMock(return_value=mock_agent)
|
||
|
||
expert = await Expert.create(
|
||
config=config,
|
||
pool=pool,
|
||
team_context="你是团队中的分析专家",
|
||
)
|
||
|
||
assert mock_sections.context == "你是团队中的分析专家"
|
||
|
||
|
||
# ── Expert.send_message 测试 ──────────────────────────────
|
||
|
||
|
||
class TestExpertSendMessage:
|
||
"""Expert.send_message 消息广播测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_broadcasts_to_transport(self):
|
||
"""send_message 通过 handoff_transport 广播消息"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
transport = AsyncMock(spec=InProcessHandoffTransport)
|
||
|
||
expert = Expert(config=config, agent=agent, handoff_transport=transport)
|
||
|
||
await expert.send_message("team:chat", "你好团队")
|
||
|
||
transport.send.assert_awaited_once()
|
||
call_args = transport.send.call_args
|
||
assert call_args[0][0] == "team:chat"
|
||
message = call_args[0][1]
|
||
assert message["expert_id"] == "test_expert"
|
||
assert message["content"] == "你好团队"
|
||
assert message["type"] == "chat"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_no_transport(self):
|
||
"""没有 handoff_transport 时 send_message 不报错(静默忽略)"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
|
||
expert = Expert(config=config, agent=agent, handoff_transport=None)
|
||
|
||
# 不应抛出异常
|
||
await expert.send_message("team:chat", "你好")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_long_content_stores_in_workspace(self):
|
||
"""长内容(>500 字符)存储到 SharedWorkspace,广播摘要"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
transport = AsyncMock(spec=InProcessHandoffTransport)
|
||
workspace = AsyncMock(spec=SharedWorkspace)
|
||
|
||
expert = Expert(
|
||
config=config, agent=agent,
|
||
handoff_transport=transport, workspace=workspace,
|
||
)
|
||
|
||
long_content = "x" * 600
|
||
summary = "长内容摘要"
|
||
|
||
await expert.send_message("team:chat", long_content, summary=summary)
|
||
|
||
# 验证 workspace.write 被调用存储完整内容
|
||
workspace.write.assert_awaited_once()
|
||
write_call = workspace.write.call_args
|
||
assert write_call[0][1] == long_content # value = 完整内容
|
||
assert write_call[0][2] == "test_expert" # agent_id
|
||
|
||
# 验证 transport.send 广播的是摘要
|
||
transport.send.assert_awaited_once()
|
||
message = transport.send.call_args[0][1]
|
||
assert message["content"] == summary
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_short_content_no_workspace(self):
|
||
"""短内容(<=500 字符)不存储到 workspace"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
transport = AsyncMock(spec=InProcessHandoffTransport)
|
||
workspace = AsyncMock(spec=SharedWorkspace)
|
||
|
||
expert = Expert(
|
||
config=config, agent=agent,
|
||
handoff_transport=transport, workspace=workspace,
|
||
)
|
||
|
||
await expert.send_message("team:chat", "短消息")
|
||
|
||
workspace.write.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_message_long_content_without_summary(self):
|
||
"""长内容没有 summary 时,广播截断内容(前500字符),但仍存储到 workspace 防止数据丢失"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
transport = AsyncMock(spec=InProcessHandoffTransport)
|
||
workspace = AsyncMock(spec=SharedWorkspace)
|
||
|
||
expert = Expert(
|
||
config=config, agent=agent,
|
||
handoff_transport=transport, workspace=workspace,
|
||
)
|
||
|
||
long_content = "x" * 600
|
||
|
||
await expert.send_message("team:chat", long_content)
|
||
|
||
# 即使没有 summary,长内容也会存储到 workspace 防止数据丢失
|
||
workspace.write.assert_awaited_once()
|
||
|
||
# 广播的是截断内容
|
||
transport.send.assert_awaited_once()
|
||
message = transport.send.call_args[0][1]
|
||
assert message["content"] == long_content[:500]
|
||
|
||
|
||
# ── Expert.request_assist 测试 ────────────────────────────
|
||
|
||
|
||
class TestExpertRequestAssist:
|
||
"""Expert.request_assist 协助请求测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_request_assist_sends_handoff_message(self):
|
||
"""request_assist 通过 handoff_transport 发送协助请求"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
transport = AsyncMock(spec=InProcessHandoffTransport)
|
||
|
||
expert = Expert(config=config, agent=agent, handoff_transport=transport)
|
||
|
||
await expert.request_assist(
|
||
target_expert="analyst", task="分析数据", reason="需要专业分析"
|
||
)
|
||
|
||
transport.send.assert_awaited_once()
|
||
call_args = transport.send.call_args
|
||
assert call_args[0][0] == "expert:analyst:handoff"
|
||
message = call_args[0][1]
|
||
assert message["source_expert"] == "test_expert"
|
||
assert message["target_expert"] == "analyst"
|
||
assert message["task"] == "分析数据"
|
||
assert message["reason"] == "需要专业分析"
|
||
assert message["type"] == "assist_request"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_request_assist_raises_without_transport(self):
|
||
"""没有 handoff_transport 时 request_assist 抛出 RuntimeError"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
|
||
expert = Expert(config=config, agent=agent, handoff_transport=None)
|
||
|
||
with pytest.raises(RuntimeError, match="No handoff transport configured"):
|
||
await expert.request_assist("analyst", "分析数据")
|
||
|
||
|
||
# ── Expert.propose_plan_modification 测试 ─────────────────
|
||
|
||
|
||
class TestExpertProposePlanModification:
|
||
"""Expert.propose_plan_modification 计划修改提案测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_propose_plan_modification_sends_proposal(self):
|
||
"""propose_plan_modification 通过 handoff_transport 发送提案"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
transport = AsyncMock(spec=InProcessHandoffTransport)
|
||
|
||
expert = Expert(config=config, agent=agent, handoff_transport=transport)
|
||
|
||
modification = {"action": "add_step", "step": "验证结果"}
|
||
await expert.propose_plan_modification(plan_id="plan_1", modification=modification)
|
||
|
||
transport.send.assert_awaited_once()
|
||
call_args = transport.send.call_args
|
||
assert call_args[0][0] == "team:plan_modifications"
|
||
message = call_args[0][1]
|
||
assert message["proposing_expert"] == "test_expert"
|
||
assert message["plan_id"] == "plan_1"
|
||
assert message["modification"] == modification
|
||
assert message["type"] == "plan_modification_proposal"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_propose_plan_modification_raises_without_transport(self):
|
||
"""没有 handoff_transport 时 propose_plan_modification 抛出 RuntimeError"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
|
||
expert = Expert(config=config, agent=agent, handoff_transport=None)
|
||
|
||
with pytest.raises(RuntimeError, match="No handoff transport configured"):
|
||
await expert.propose_plan_modification("plan_1", {"action": "add"})
|
||
|
||
|
||
# ── Expert.get_capabilities_summary 测试 ──────────────────
|
||
|
||
|
||
class TestExpertGetCapabilitiesSummary:
|
||
"""Expert.get_capabilities_summary 能力摘要测试"""
|
||
|
||
def test_returns_correct_dict(self):
|
||
"""返回正确的能力摘要字典"""
|
||
config = _make_expert_config(
|
||
name="analyst",
|
||
persona="数据分析师",
|
||
thinking_style="逻辑推理",
|
||
bound_skills=["data_query", "chart_gen"],
|
||
is_lead=True,
|
||
color="#52c41a",
|
||
avatar="C",
|
||
)
|
||
agent = _make_mock_agent()
|
||
|
||
expert = Expert(config=config, agent=agent)
|
||
|
||
summary = expert.get_capabilities_summary()
|
||
|
||
assert summary == {
|
||
"name": "analyst",
|
||
"persona": "数据分析师",
|
||
"thinking_style": "逻辑推理",
|
||
"bound_skills": ["data_query", "chart_gen"],
|
||
"is_lead": True,
|
||
"color": "#52c41a",
|
||
"avatar": "C",
|
||
}
|
||
|
||
|
||
# ── Expert.destroy 测试 ───────────────────────────────────
|
||
|
||
|
||
class TestExpertDestroy:
|
||
"""Expert.destroy 销毁测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_destroy_removes_agent_from_pool(self):
|
||
"""destroy 从 AgentPool 中移除 Agent"""
|
||
config = _make_expert_config(name="removable_expert")
|
||
agent = _make_mock_agent()
|
||
pool = _make_mock_pool()
|
||
|
||
expert = Expert(config=config, agent=agent)
|
||
|
||
await expert.destroy(pool)
|
||
|
||
pool.remove_agent.assert_awaited_once_with("removable_expert")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_is_active_after_destroy(self):
|
||
"""destroy 后 is_active 为 False"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
pool = _make_mock_pool()
|
||
|
||
expert = Expert(config=config, agent=agent)
|
||
assert expert.is_active is True
|
||
|
||
await expert.destroy(pool)
|
||
|
||
assert expert.is_active is False
|
||
|
||
|
||
# ── Expert.team_id 测试 ───────────────────────────────────
|
||
|
||
|
||
class TestExpertTeamId:
|
||
"""Expert.team_id 属性测试"""
|
||
|
||
def test_team_id_defaults_to_none(self):
|
||
"""team_id 默认为 None"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
|
||
expert = Expert(config=config, agent=agent)
|
||
|
||
assert expert.team_id is None
|
||
|
||
def test_team_id_setter(self):
|
||
"""team_id setter 正确设置值"""
|
||
config = _make_expert_config()
|
||
agent = _make_mock_agent()
|
||
|
||
expert = Expert(config=config, agent=agent)
|
||
expert.team_id = "team_alpha"
|
||
|
||
assert expert.team_id == "team_alpha"
|