456 lines
15 KiB
Python
456 lines
15 KiB
Python
"""SkillPipeline 单元测试"""
|
||
|
||
import pytest
|
||
|
||
from agentkit.skills.pipeline import SkillPipeline
|
||
from agentkit.skills.registry import SkillRegistry
|
||
|
||
|
||
# ---- Helpers ----
|
||
|
||
|
||
async def _mock_agent_factory(skill_name: str, input_data: dict) -> dict:
|
||
"""Mock agent factory: 返回包含 skill_name 和输入数据的字典"""
|
||
return {"skill": skill_name, "processed": True, **input_data}
|
||
|
||
|
||
async def _failing_agent_factory(skill_name: str, input_data: dict) -> dict:
|
||
"""Mock agent factory: 特定 skill 抛出异常"""
|
||
if skill_name == "failing_skill":
|
||
raise RuntimeError("Skill execution failed")
|
||
return {"skill": skill_name, "processed": True, **input_data}
|
||
|
||
|
||
async def _transform_agent_factory(skill_name: str, input_data: dict) -> dict:
|
||
"""Mock agent factory: 根据技能名做不同转换"""
|
||
if skill_name == "extract":
|
||
return {"title": input_data.get("raw_text", "").split()[0], "score": 0.9}
|
||
if skill_name == "enrich":
|
||
return {"title": input_data.get("title", ""), "enriched": True}
|
||
if skill_name == "format":
|
||
return {"result": f"Formatted: {input_data.get('title', '')}", "enriched": input_data.get("enriched", False)}
|
||
return {"skill": skill_name, **input_data}
|
||
|
||
|
||
# ---- SkillPipeline 核心测试 ----
|
||
|
||
|
||
class TestSkillPipelineSequential:
|
||
"""顺序执行测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sequential_three_skills(self):
|
||
"""3 个 Skill 顺序执行,输出在步骤间传递"""
|
||
pipeline = SkillPipeline(
|
||
name="seq_pipeline",
|
||
steps=[
|
||
{"skill_name": "skill_a"},
|
||
{"skill_name": "skill_b"},
|
||
{"skill_name": "skill_c"},
|
||
],
|
||
)
|
||
|
||
result = await pipeline.execute(
|
||
input_data={"query": "hello"},
|
||
agent_factory=_mock_agent_factory,
|
||
)
|
||
|
||
assert result["pipeline"] == "seq_pipeline"
|
||
assert len(result["steps"]) == 3
|
||
assert result["steps"][0]["status"] == "success"
|
||
assert result["steps"][0]["skill"] == "skill_a"
|
||
assert result["steps"][1]["status"] == "success"
|
||
assert result["steps"][1]["skill"] == "skill_b"
|
||
assert result["steps"][2]["status"] == "success"
|
||
assert result["steps"][2]["skill"] == "skill_c"
|
||
|
||
# 验证输出传递:第二步输入包含第一步输出
|
||
assert result["steps"][1]["output"]["query"] == "hello"
|
||
assert result["steps"][1]["output"]["processed"] is True
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_output_passes_between_steps(self):
|
||
"""输出在步骤间正确传递"""
|
||
pipeline = SkillPipeline(
|
||
name="transform_pipeline",
|
||
steps=[
|
||
{"skill_name": "extract"},
|
||
{"skill_name": "enrich"},
|
||
{"skill_name": "format"},
|
||
],
|
||
)
|
||
|
||
result = await pipeline.execute(
|
||
input_data={"raw_text": "Hello World"},
|
||
agent_factory=_transform_agent_factory,
|
||
)
|
||
|
||
# 第一步: extract → {"title": "Hello", "score": 0.9}
|
||
assert result["steps"][0]["output"]["title"] == "Hello"
|
||
assert result["steps"][0]["output"]["score"] == 0.9
|
||
|
||
# 第二步: enrich → {"title": "Hello", "enriched": True}
|
||
assert result["steps"][1]["output"]["title"] == "Hello"
|
||
assert result["steps"][1]["output"]["enriched"] is True
|
||
|
||
# 第三步: format → {"result": "Formatted: Hello", "enriched": True}
|
||
assert result["steps"][2]["output"]["result"] == "Formatted: Hello"
|
||
assert result["steps"][2]["output"]["enriched"] is True
|
||
|
||
# final_output 是最后一步的输出
|
||
assert result["final_output"]["result"] == "Formatted: Hello"
|
||
|
||
|
||
class TestSkillPipelineConditional:
|
||
"""条件分支测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_condition_met_executes_step(self):
|
||
"""条件满足时执行步骤"""
|
||
pipeline = SkillPipeline(
|
||
name="cond_pipeline",
|
||
steps=[
|
||
{"skill_name": "skill_a"},
|
||
{"skill_name": "skill_b", "condition": "status == 'ok'"},
|
||
],
|
||
)
|
||
|
||
async def factory(name, data):
|
||
if name == "skill_a":
|
||
return {"status": "ok", "data": "test"}
|
||
return {"skill": name, **data}
|
||
|
||
result = await pipeline.execute(input_data={}, agent_factory=factory)
|
||
|
||
assert result["steps"][0]["status"] == "success"
|
||
assert result["steps"][1]["status"] == "success"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_condition_not_met_skips_step(self):
|
||
"""条件不满足时跳过步骤"""
|
||
pipeline = SkillPipeline(
|
||
name="cond_pipeline_skip",
|
||
steps=[
|
||
{"skill_name": "skill_a"},
|
||
{"skill_name": "skill_b", "condition": "status == 'ok'"},
|
||
],
|
||
)
|
||
|
||
async def factory(name, data):
|
||
if name == "skill_a":
|
||
return {"status": "error", "data": "test"}
|
||
return {"skill": name, **data}
|
||
|
||
result = await pipeline.execute(input_data={}, agent_factory=factory)
|
||
|
||
assert result["steps"][0]["status"] == "success"
|
||
assert result["steps"][1]["status"] == "skipped"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_numeric_condition(self):
|
||
"""数值条件判断"""
|
||
pipeline = SkillPipeline(
|
||
name="num_cond_pipeline",
|
||
steps=[
|
||
{"skill_name": "skill_a"},
|
||
{"skill_name": "skill_b", "condition": "score > 0.5"},
|
||
],
|
||
)
|
||
|
||
async def factory(name, data):
|
||
if name == "skill_a":
|
||
return {"score": 0.9}
|
||
return {"skill": name, **data}
|
||
|
||
result = await pipeline.execute(input_data={}, agent_factory=factory)
|
||
assert result["steps"][1]["status"] == "success"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_numeric_condition_not_met(self):
|
||
"""数值条件不满足时跳过"""
|
||
pipeline = SkillPipeline(
|
||
name="num_cond_pipeline_fail",
|
||
steps=[
|
||
{"skill_name": "skill_a"},
|
||
{"skill_name": "skill_b", "condition": "score > 0.5"},
|
||
],
|
||
)
|
||
|
||
async def factory(name, data):
|
||
if name == "skill_a":
|
||
return {"score": 0.3}
|
||
return {"skill": name, **data}
|
||
|
||
result = await pipeline.execute(input_data={}, agent_factory=factory)
|
||
assert result["steps"][1]["status"] == "skipped"
|
||
|
||
|
||
class TestSkillPipelineFailure:
|
||
"""Pipeline 失败测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_step_failure_stops_pipeline(self):
|
||
"""步骤失败时中止 Pipeline"""
|
||
pipeline = SkillPipeline(
|
||
name="fail_pipeline",
|
||
steps=[
|
||
{"skill_name": "skill_a"},
|
||
{"skill_name": "failing_skill"},
|
||
{"skill_name": "skill_c"},
|
||
],
|
||
)
|
||
|
||
result = await pipeline.execute(
|
||
input_data={"query": "test"},
|
||
agent_factory=_failing_agent_factory,
|
||
)
|
||
|
||
assert len(result["steps"]) == 2
|
||
assert result["steps"][0]["status"] == "success"
|
||
assert result["steps"][1]["status"] == "failed"
|
||
assert result["steps"][1]["skill"] == "failing_skill"
|
||
assert "Skill execution failed" in result["steps"][1]["error"]
|
||
assert result["success"] is False
|
||
assert result["final_output"] is None
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_registry_no_factory_marks_step_failed(self):
|
||
"""无 registry 也无 factory 时步骤标记为 failed"""
|
||
pipeline = SkillPipeline(
|
||
name="no_exec_pipeline",
|
||
steps=[{"skill_name": "skill_a"}],
|
||
)
|
||
|
||
result = await pipeline.execute(input_data={})
|
||
|
||
assert len(result["steps"]) == 1
|
||
assert result["steps"][0]["status"] == "failed"
|
||
assert "no agent_factory or skill_registry" in result["steps"][0]["error"]
|
||
assert result["success"] is False
|
||
assert result["final_output"] is None
|
||
|
||
|
||
class TestSkillPipelineEmpty:
|
||
"""空 Pipeline 测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_pipeline(self):
|
||
"""空步骤列表返回空结果"""
|
||
pipeline = SkillPipeline(name="empty_pipeline", steps=[])
|
||
|
||
result = await pipeline.execute(input_data={"key": "value"})
|
||
|
||
assert result["pipeline"] == "empty_pipeline"
|
||
assert result["steps"] == []
|
||
assert result["final_output"] == {"key": "value"}
|
||
assert result["success"] is True
|
||
|
||
|
||
class TestSkillPipelineInputMapping:
|
||
"""输入映射测试"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_input_mapping(self):
|
||
"""将上一步输出字段映射到下一步输入字段"""
|
||
pipeline = SkillPipeline(
|
||
name="mapping_pipeline",
|
||
steps=[
|
||
{"skill_name": "extract"},
|
||
{
|
||
"skill_name": "enrich",
|
||
"input_mapping": {"title": "title"},
|
||
},
|
||
],
|
||
)
|
||
|
||
result = await pipeline.execute(
|
||
input_data={"raw_text": "Hello World"},
|
||
agent_factory=_transform_agent_factory,
|
||
)
|
||
|
||
# 第一步输出 {"title": "Hello", "score": 0.9}
|
||
# 映射后第二步输入 {"title": "Hello"}
|
||
assert result["steps"][1]["output"]["title"] == "Hello"
|
||
assert result["steps"][1]["output"]["enriched"] is True
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_nested_path_mapping(self):
|
||
"""嵌套路径映射"""
|
||
pipeline = SkillPipeline(
|
||
name="nested_mapping",
|
||
steps=[
|
||
{"skill_name": "skill_a"},
|
||
{
|
||
"skill_name": "skill_b",
|
||
"input_mapping": {"name": "user.name"},
|
||
},
|
||
],
|
||
)
|
||
|
||
async def factory(name, data):
|
||
if name == "skill_a":
|
||
return {"user": {"name": "Alice"}, "age": 30}
|
||
return {"skill": name, **data}
|
||
|
||
result = await pipeline.execute(input_data={}, agent_factory=factory)
|
||
|
||
# 第二步输入应为 {"name": "Alice"}
|
||
assert result["steps"][1]["output"]["name"] == "Alice"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mapping_missing_field_omitted(self):
|
||
"""映射字段不存在时省略该字段"""
|
||
pipeline = SkillPipeline(
|
||
name="missing_mapping",
|
||
steps=[
|
||
{"skill_name": "skill_a"},
|
||
{
|
||
"skill_name": "skill_b",
|
||
"input_mapping": {"title": "nonexistent.field"},
|
||
},
|
||
],
|
||
)
|
||
|
||
async def factory(name, data):
|
||
if name == "skill_a":
|
||
return {"other": "data"}
|
||
return {"skill": name, **data}
|
||
|
||
result = await pipeline.execute(input_data={}, agent_factory=factory)
|
||
|
||
# 映射字段不存在,第二步输入为空字典
|
||
assert result["steps"][1]["status"] == "success"
|
||
|
||
|
||
class TestSkillPipelineRegistry:
|
||
"""SkillPipeline 在 SkillRegistry 中的注册与查询"""
|
||
|
||
def test_register_pipeline(self):
|
||
registry = SkillRegistry()
|
||
pipeline = SkillPipeline(name="test_pipe", steps=[{"skill_name": "a"}])
|
||
registry.register_pipeline(pipeline)
|
||
assert registry.get_pipeline("test_pipe") is pipeline
|
||
|
||
def test_get_pipeline_not_found(self):
|
||
registry = SkillRegistry()
|
||
assert registry.get_pipeline("nonexistent") is None
|
||
|
||
def test_list_pipelines(self):
|
||
registry = SkillRegistry()
|
||
registry.register_pipeline(SkillPipeline(name="p1", steps=[]))
|
||
registry.register_pipeline(SkillPipeline(name="p2", steps=[]))
|
||
names = registry.list_pipelines()
|
||
assert "p1" in names
|
||
assert "p2" in names
|
||
|
||
def test_list_pipelines_empty(self):
|
||
registry = SkillRegistry()
|
||
assert registry.list_pipelines() == []
|
||
|
||
def test_unregister_pipeline(self):
|
||
registry = SkillRegistry()
|
||
registry.register_pipeline(SkillPipeline(name="p1", steps=[]))
|
||
registry.unregister_pipeline("p1")
|
||
assert registry.get_pipeline("p1") is None
|
||
|
||
def test_unregister_pipeline_nonexistent(self):
|
||
"""注销不存在的 Pipeline 不抛异常"""
|
||
registry = SkillRegistry()
|
||
registry.unregister_pipeline("nonexistent")
|
||
|
||
def test_register_pipeline_overwrites(self):
|
||
"""同名 Pipeline 覆盖注册"""
|
||
registry = SkillRegistry()
|
||
p1 = SkillPipeline(name="dup", steps=[{"skill_name": "a"}])
|
||
p2 = SkillPipeline(name="dup", steps=[{"skill_name": "b"}])
|
||
registry.register_pipeline(p1)
|
||
registry.register_pipeline(p2)
|
||
assert registry.get_pipeline("dup") is p2
|
||
|
||
|
||
class TestSkillPipelineAPI:
|
||
"""Pipeline API 端点测试"""
|
||
|
||
@pytest.fixture
|
||
def app(self):
|
||
from agentkit.server.app import create_app
|
||
|
||
application = create_app()
|
||
return application
|
||
|
||
@pytest.fixture
|
||
async def client(self, app):
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
transport = ASGITransport(app=app)
|
||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||
yield c
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_pipeline(self, client):
|
||
response = await client.post(
|
||
"/api/v1/skills/pipelines",
|
||
json={
|
||
"name": "test_pipe",
|
||
"steps": [
|
||
{"skill_name": "skill_a"},
|
||
{"skill_name": "skill_b"},
|
||
],
|
||
},
|
||
)
|
||
assert response.status_code == 201
|
||
data = response.json()
|
||
assert data["name"] == "test_pipe"
|
||
assert len(data["steps"]) == 2
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_pipeline_missing_skill_name(self, client):
|
||
response = await client.post(
|
||
"/api/v1/skills/pipelines",
|
||
json={
|
||
"name": "bad_pipe",
|
||
"steps": [{"no_skill_name": "oops"}],
|
||
},
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_pipelines_empty(self, client):
|
||
response = await client.get("/api/v1/skills/pipelines")
|
||
assert response.status_code == 200
|
||
assert response.json() == []
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_pipelines_after_create(self, client):
|
||
await client.post(
|
||
"/api/v1/skills/pipelines",
|
||
json={"name": "pipe1", "steps": [{"skill_name": "a"}]},
|
||
)
|
||
response = await client.get("/api/v1/skills/pipelines")
|
||
assert response.status_code == 200
|
||
assert "pipe1" in response.json()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_pipeline_not_found(self, client):
|
||
response = await client.post(
|
||
"/api/v1/skills/pipelines/nonexistent/execute",
|
||
json={"input_data": {}},
|
||
)
|
||
assert response.status_code == 404
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_pipeline_no_executor(self, client):
|
||
"""Pipeline 存在但 registry 中无 Skill 时步骤标记为 failed"""
|
||
await client.post(
|
||
"/api/v1/skills/pipelines",
|
||
json={"name": "exec_pipe", "steps": [{"skill_name": "missing_skill"}]},
|
||
)
|
||
response = await client.post(
|
||
"/api/v1/skills/pipelines/exec_pipe/execute",
|
||
json={"input_data": {"query": "test"}},
|
||
)
|
||
# Pipeline 执行返回 200,但步骤标记为 failed
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["steps"][0]["status"] == "failed"
|