"""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"] @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"] 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"} 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"