""" 工作流执行引擎测试 覆盖 workflow_engine.py 核心路径:DAG拓扑/环检测/条件分支/审批/路径安全/eval限制 """ import asyncio import pytest from app.services.workflow_engine import WorkflowEngine from app.core.exceptions import WorkflowExecutionError @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineDAG: """DAG 执行图构建与拓扑排序""" def test_build_execution_graph_linear(self): workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "llm-1", "type": "llm"}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "llm-1"}, {"id": "e2", "source": "llm-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-linear", workflow_data) order = engine.build_execution_graph() assert order.index("start-1") < order.index("llm-1") assert order.index("llm-1") < order.index("end-1") assert len(order) == 3 def test_build_execution_graph_branching(self): workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "condition-1", "type": "condition"}, {"id": "llm-a", "type": "llm"}, {"id": "llm-b", "type": "llm"}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "condition-1"}, {"id": "e2", "source": "condition-1", "target": "llm-a"}, {"id": "e3", "source": "condition-1", "target": "llm-b"}, {"id": "e4", "source": "llm-a", "target": "end-1"}, {"id": "e5", "source": "llm-b", "target": "end-1"}, ], } engine = WorkflowEngine("test-branch", workflow_data) order = engine.build_execution_graph() assert order.index("start-1") == 0 assert order.index("condition-1") < order.index("llm-a") assert order.index("condition-1") < order.index("llm-b") assert order.index("llm-a") < order.index("end-1") assert order.index("llm-b") < order.index("end-1") def test_cycle_detection_raises_error(self): workflow_data = { "nodes": [ {"id": "a", "type": "llm"}, {"id": "b", "type": "llm"}, ], "edges": [ {"id": "e1", "source": "a", "target": "b"}, {"id": "e2", "source": "b", "target": "a"}, ], } engine = WorkflowEngine("test-cycle", workflow_data) with pytest.raises(WorkflowExecutionError): engine.build_execution_graph() def test_self_loop_detection(self): workflow_data = { "nodes": [ {"id": "a", "type": "llm"}, ], "edges": [ {"id": "e1", "source": "a", "target": "a"}, ], } engine = WorkflowEngine("test-self-loop", workflow_data) with pytest.raises(WorkflowExecutionError): engine.build_execution_graph() def test_disconnected_node_ordering(self): workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "orphan", "type": "llm"}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-orphan", workflow_data) order = engine.build_execution_graph() # orphan has no incoming edges and is a start node assert "start-1" in order assert "end-1" in order assert "orphan" in order def test_active_edges_filter(self): workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "llm-1", "type": "llm"}, {"id": "llm-2", "type": "llm"}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "llm-1"}, {"id": "e2", "source": "start-1", "target": "llm-2"}, {"id": "e3", "source": "llm-1", "target": "end-1"}, {"id": "e4", "source": "llm-2", "target": "end-1"}, ], } engine = WorkflowEngine("test-active", workflow_data) active = [ {"id": "e1", "source": "start-1", "target": "llm-1"}, {"id": "e3", "source": "llm-1", "target": "end-1"}, ] order = engine.build_execution_graph(active_edges=active) # llm-2 still appears (no incoming edges in subgraph = start) # but start-1 → llm-1 → end-1 order is preserved assert "start-1" in order assert "llm-1" in order assert order.index("start-1") < order.index("llm-1") assert order.index("llm-1") < order.index("end-1") assert "end-1" in order @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEnginePathSafety: """路径安全:_resolve_safe_path""" def test_safe_path_within_workspace(self): engine = WorkflowEngine("test-safe", {"nodes": [], "edges": []}) result = engine._resolve_safe_path("data/output.txt") assert "data" in result or "output.txt" in result def test_safe_path_traversal_blocked(self): engine = WorkflowEngine("test-traversal", {"nodes": [], "edges": []}) with pytest.raises(ValueError, match="路径访问被拒绝"): engine._resolve_safe_path("../../../etc/passwd") def test_safe_path_absolute_blocked(self): engine = WorkflowEngine("test-abs", {"nodes": [], "edges": []}) with pytest.raises(ValueError, match="路径访问被拒绝"): engine._resolve_safe_path("C:/Windows/System32/evil.dll") @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineNodeExecution: """节点执行测试""" def test_get_node_input_basic(self): workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "llm-1", "type": "llm"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "llm-1"}, ], } engine = WorkflowEngine("test-input", workflow_data) engine.node_outputs = {"start-1": {"input": "test data"}} result = engine.get_node_input("llm-1", engine.node_outputs) assert "input" in result def test_get_node_input_multiple_sources(self): workflow_data = { "nodes": [ {"id": "src-a", "type": "llm"}, {"id": "src-b", "type": "llm"}, {"id": "merge-1", "type": "llm"}, ], "edges": [ {"id": "e1", "source": "src-a", "target": "merge-1"}, {"id": "e2", "source": "src-b", "target": "merge-1"}, ], } engine = WorkflowEngine("test-merge", workflow_data) engine.node_outputs = { "src-a": {"field_a": "value_a"}, "src-b": {"field_b": "value_b"}, } result = engine.get_node_input("merge-1", engine.node_outputs) assert "field_a" in result or "field_b" in result @pytest.mark.asyncio async def test_execute_start_node(self): workflow_data = { "nodes": [ {"id": "start-1", "type": "start", "data": {"label": "开始"}}, ], "edges": [], } engine = WorkflowEngine("test-start", workflow_data) result = await engine.execute_node(workflow_data["nodes"][0], {"test": "data"}) assert result["status"] == "success" assert result["output"] == {"test": "data"} @pytest.mark.asyncio async def test_execute_end_node(self): workflow_data = { "nodes": [ {"id": "end-1", "type": "end", "data": {"label": "结束"}}, ], "edges": [], } engine = WorkflowEngine("test-end", workflow_data) input_data = {"result": "final output"} result = await engine.execute_node(workflow_data["nodes"][0], input_data) assert result["status"] == "success" @pytest.mark.asyncio async def test_execute_condition_node_true(self): workflow_data = { "nodes": [ {"id": "condition-1", "type": "condition", "data": {"label": "判断", "condition": "{value} > 10"}}, ], "edges": [], } engine = WorkflowEngine("test-cond-true", workflow_data) result = await engine.execute_node(workflow_data["nodes"][0], {"value": 15}) assert result["status"] == "success" # branch should be "true" when value > 10 assert result["branch"] == "true" @pytest.mark.asyncio async def test_execute_condition_node_false(self): workflow_data = { "nodes": [ {"id": "condition-1", "type": "condition", "data": {"label": "判断", "condition": "{value} > 10"}}, ], "edges": [], } engine = WorkflowEngine("test-cond-false", workflow_data) result = await engine.execute_node(workflow_data["nodes"][0], {"value": 5}) assert result["status"] == "success" assert result["branch"] == "false" @pytest.mark.asyncio async def test_execute_transform_node_mapping(self): workflow_data = { "nodes": [ {"id": "transform-1", "type": "transform", "data": {"label": "转换", "mode": "mapping", "mapping": {"new_field": "{old_field}"}}}, ], "edges": [], } engine = WorkflowEngine("test-xform", workflow_data) result = await engine.execute_node(workflow_data["nodes"][0], {"old_field": "test value"}) assert result["status"] == "success" @pytest.mark.asyncio async def test_execute_transform_data_merge(self): """数据转换节点合并输入数据""" workflow_data = { "nodes": [ {"id": "transform-1", "type": "transform", "data": {"label": "转换"}}, ], "edges": [], } engine = WorkflowEngine("test-xform2", workflow_data) result = await engine.execute_node(workflow_data["nodes"][0], {"key": "value"}) assert result["status"] in ("success", "error") assert "output" in result @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineEvalSafety: """eval() 安全限制测试""" @pytest.mark.asyncio async def test_eval_length_limit(self): """超长表达式被拒绝""" workflow_data = { "nodes": [ {"id": "condition-1", "type": "condition", "data": {"label": "检查", "condition": "x" * 20000}}, ], "edges": [], } engine = WorkflowEngine("test-eval-len", workflow_data) # 超长 condition 表达式应触发错误,不会导致崩溃 result = await engine.execute_node(workflow_data["nodes"][0], {"x": 1}) # 要么抛异常,要么返回失败状态 assert result["status"] in ("success", "error") @pytest.mark.asyncio async def test_condition_with_template_vars(self): """条件表达式含模板变量""" workflow_data = { "nodes": [ {"id": "condition-1", "type": "condition", "data": {"label": "判断", "condition": "{count} >= 5"}}, ], "edges": [], } engine = WorkflowEngine("test-cond-var", workflow_data) result = await engine.execute_node(workflow_data["nodes"][0], {"count": 10}) assert result["status"] == "success" assert result["branch"] == "true" @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineApproval: """审批/HIL 节点测试""" def test_approval_node_isolated_keys(self): workflow_data = { "nodes": [ {"id": "hil-1", "type": "human_in_loop", "data": {"label": "审批1", "approvers": ["user1"]}}, {"id": "hil-2", "type": "human_in_loop", "data": {"label": "审批2", "approvers": ["user2"]}}, ], "edges": [], } engine = WorkflowEngine("test-hil", workflow_data) assert engine.nodes["hil-1"]["id"] != engine.nodes["hil-2"]["id"] @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineEdgeCases: """边界情况""" def test_empty_workflow(self): workflow_data = {"nodes": [], "edges": []} engine = WorkflowEngine("test-empty", workflow_data) order = engine.build_execution_graph() assert order == [] def test_single_node_no_edges(self): workflow_data = { "nodes": [{"id": "only", "type": "llm"}], "edges": [], } engine = WorkflowEngine("test-single", workflow_data) order = engine.build_execution_graph() assert order == ["only"] def test_missing_node_in_edge(self): workflow_data = { "nodes": [{"id": "a", "type": "start"}], "edges": [{"id": "e1", "source": "a", "target": "nonexistent"}], } engine = WorkflowEngine("test-missing", workflow_data) order = engine.build_execution_graph() assert "a" in order @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineRetry: """节点级自动重试 & error_handler 重试测试""" # ── error_handler 节点独立测试 ── @pytest.mark.asyncio async def test_error_handler_notify_on_failed_upstream(self): """error_handler 检测上游失败 → notify 模式记录错误""" workflow_data = { "nodes": [ {"id": "eh-1", "type": "error_handler", "data": {"on_error": "notify", "retry_count": 3}}, ], "edges": [ {"id": "e1", "source": "bad-node", "target": "eh-1"}, ], } engine = WorkflowEngine("test-eh-notify", workflow_data) # 模拟上游失败输入 result = await engine.execute_node( workflow_data["nodes"][0], {"status": "failed", "error": "模拟失败", "output": {}} ) assert result["status"] == "error_handled" assert result["notified"] is True assert "模拟失败" in result["error"] @pytest.mark.asyncio async def test_error_handler_retry_mode(self): """error_handler 检测上游失败 → retry 模式请求重试前驱""" workflow_data = { "nodes": [ {"id": "eh-1", "type": "error_handler", "data": {"on_error": "retry", "retry_count": 3, "retry_delay": 500}}, ], "edges": [ {"id": "e1", "source": "bad-node", "target": "eh-1"}, ], } engine = WorkflowEngine("test-eh-retry", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], {"status": "failed", "error": "上游错误"} ) assert result["status"] == "retry_predecessor" assert result["predecessor_id"] == "bad-node" assert result["retry_count"] == 3 assert result["retry_delay_ms"] == 500 @pytest.mark.asyncio async def test_error_handler_stop_mode(self): """error_handler on_error=stop → 停止工作流""" workflow_data = { "nodes": [ {"id": "eh-1", "type": "error_handler", "data": {"on_error": "stop"}}, ], "edges": [ {"id": "e1", "source": "bad-node", "target": "eh-1"}, ], } engine = WorkflowEngine("test-eh-stop", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], {"status": "failed", "error": "致命错误"} ) assert result["status"] == "failed" assert result["stopped"] is True @pytest.mark.asyncio async def test_error_handler_passthrough_on_success(self): """error_handler 上游正常 → 透传""" workflow_data = { "nodes": [ {"id": "eh-1", "type": "error_handler", "data": {"on_error": "notify"}}, ], "edges": [ {"id": "e1", "source": "good-node", "target": "eh-1"}, ], } engine = WorkflowEngine("test-eh-pass", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], {"status": "success", "output": {"result": "ok"}} ) assert result["status"] == "success" @pytest.mark.asyncio async def test_error_handler_non_dict_input(self): """error_handler 接收非 dict 输入 → 当作成功透传""" workflow_data = { "nodes": [ {"id": "eh-1", "type": "error_handler", "data": {"on_error": "notify"}}, ], "edges": [], } engine = WorkflowEngine("test-eh-nondict", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], "plain string input" ) assert result["status"] == "success" # ── 节点级 retry_config 集成测试(使用 code 节点触发可控失败)── @pytest.mark.asyncio async def test_retry_config_exhausted_raises_error(self): """节点 retry_config 重试耗尽 → WorkflowExecutionError""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "code-1", "type": "code", "data": { "language": "python", "code": "raise Exception('code node failure')", "retry_config": {"max_retries": 2, "retry_delay_ms": 10} }}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "code-1"}, {"id": "e2", "source": "code-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-retry-exhaust", workflow_data) with pytest.raises(WorkflowExecutionError) as exc_info: await engine.execute({"value": 10}) assert "执行失败" in str(exc_info.value.detail) @pytest.mark.asyncio async def test_retry_config_skip_on_exhausted(self): """节点 retry_config on_exhausted=skip → 跳过节点继续执行""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "code-1", "type": "code", "data": { "language": "python", "code": "raise Exception('fail')", "retry_config": { "max_retries": 1, "retry_delay_ms": 10, "on_exhausted": "skip" } }}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "code-1"}, {"id": "e2", "source": "code-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-retry-skip", workflow_data) result = await engine.execute({"value": 10}) assert result["status"] in ("success", "completed") assert engine.node_outputs["code-1"]["status"] == "skipped" @pytest.mark.asyncio async def test_retry_config_notify_on_exhausted(self): """节点 retry_config on_exhausted=notify → 标记错误并继续""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "code-1", "type": "code", "data": { "language": "python", "code": "raise Exception('fail')", "retry_config": { "max_retries": 1, "retry_delay_ms": 10, "on_exhausted": "notify" } }}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "code-1"}, {"id": "e2", "source": "code-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-retry-notify", workflow_data) result = await engine.execute({"value": 10}) assert result["status"] in ("success", "completed") assert engine.node_outputs["code-1"]["status"] == "error_notified" @pytest.mark.asyncio async def test_no_retry_config_fails_immediately(self): """节点无 retry_config → 失败立即抛 WorkflowExecutionError""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "code-1", "type": "code", "data": {"language": "python", "code": "raise Exception('fatal')"}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "code-1"}, {"id": "e2", "source": "code-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-noretry", workflow_data) with pytest.raises(WorkflowExecutionError) as exc_info: await engine.execute({"value": 10}) assert "执行失败" in str(exc_info.value.detail) # ── error_handler + retry_config 协同 ── @pytest.mark.asyncio async def test_error_handler_retry_workflow(self): """error_handler notify 模式在节点失败后兜底 → 工作流继续""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "code-1", "type": "code", "data": { "language": "python", "code": "raise Exception('fail')", "retry_config": { "max_retries": 1, "on_exhausted": "notify" } }}, {"id": "eh-1", "type": "error_handler", "data": {"on_error": "notify"}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "code-1"}, {"id": "e2", "source": "code-1", "target": "eh-1"}, {"id": "e3", "source": "eh-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-eh-wf", workflow_data) # code-1 失败 → retry 1次再失败 → exhausted=notify → 标记 error_notified # → eh-1 接收输出并透传 → end-1 完成 result = await engine.execute({"value": 10}) assert result["status"] in ("success", "completed") @pytest.mark.asyncio async def test_retry_config_with_error_handler_chain(self): """节点 retry_config 先触发 → 耗尽后 error_handler 兜底 notify""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "code-1", "type": "code", "data": { "language": "python", "code": "raise Exception('fail')", "retry_config": { "max_retries": 1, "retry_delay_ms": 10, "on_exhausted": "notify" } }}, {"id": "eh-1", "type": "error_handler", "data": {"on_error": "notify"}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "code-1"}, {"id": "e2", "source": "code-1", "target": "end-1"}, {"id": "e3", "source": "code-1", "target": "eh-1"}, {"id": "e4", "source": "eh-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-retry-eh-chain", workflow_data) result = await engine.execute({"value": 10}) assert result["status"] in ("success", "completed") @pytest.mark.asyncio async def test_retry_config_zero_retries(self): """retry_config max_retries=0 → 无重试立即失败""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "code-1", "type": "code", "data": { "language": "python", "code": "raise Exception('fail')", "retry_config": {"max_retries": 0} }}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "code-1"}, {"id": "e2", "source": "code-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-retry0", workflow_data) with pytest.raises(WorkflowExecutionError) as exc_info: await engine.execute({"value": 10}) assert "执行失败" in str(exc_info.value.detail) @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineEvaluator: """输出质量评估节点测试(LLM 调用已 mock)""" @staticmethod async def _mock_judge_pass(*args, **kwargs): """模拟评审通过""" return '{"score": 0.85, "passed": true, "issues": [], "suggestions": [], "summary": "good"}' @staticmethod async def _mock_judge_fail(*args, **kwargs): """模拟评审不通过""" return '{"score": 0.3, "passed": false, "issues": ["不完整"], "suggestions": ["补充细节"], "summary": "poor"}' @pytest.mark.asyncio async def test_evaluator_passes_high_quality(self, monkeypatch): """evaluator 评审高分 → passed=true, branch=true""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_pass, ) workflow_data = { "nodes": [ {"id": "eval-1", "type": "evaluator", "data": {"criteria": "准确性", "pass_threshold": 0.6}}, ], "edges": [], } engine = WorkflowEngine("test-eval-pass", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], {"output": "The answer is 42"} ) assert result["status"] == "success" assert result["branch"] == "true" assert result["output"]["passed"] is True assert result["output"]["score"] == 0.85 @pytest.mark.asyncio async def test_evaluator_fails_low_quality(self, monkeypatch): """evaluator 评审低分 → passed=false, branch=false""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_fail, ) workflow_data = { "nodes": [ {"id": "eval-1", "type": "evaluator", "data": {"criteria": "完整性", "pass_threshold": 0.6}}, ], "edges": [], } engine = WorkflowEngine("test-eval-fail", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], {"output": "bad"} ) assert result["status"] == "success" assert result["branch"] == "false" assert result["output"]["passed"] is False assert result["output"]["score"] == 0.3 @pytest.mark.asyncio async def test_evaluator_custom_threshold(self, monkeypatch): """evaluator 自定义阈值:高分但阈值更高 → 不通过""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_pass, # score=0.85 ) workflow_data = { "nodes": [ {"id": "eval-1", "type": "evaluator", "data": {"criteria": "严格", "pass_threshold": 0.9}}, ], "edges": [], } engine = WorkflowEngine("test-eval-threshold", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], {"output": "test"} ) assert result["status"] == "success" assert result["branch"] == "false" # 0.85 < 0.9 assert result["output"]["threshold"] == 0.9 assert result["output"]["passed"] is False @pytest.mark.asyncio async def test_evaluator_output_structure(self, monkeypatch): """evaluator 输出包含完整字段""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_pass, ) workflow_data = { "nodes": [ {"id": "eval-1", "type": "evaluator", "data": {"criteria": "完整性", "pass_threshold": 0.5}}, ], "edges": [], } engine = WorkflowEngine("test-eval-out", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], {"output": "sample text"} ) output = result.get("output", {}) assert "score" in output assert isinstance(output["score"], float) assert "passed" in output assert isinstance(output["passed"], bool) assert "issues" in output assert "suggestions" in output assert "summary" in output @pytest.mark.asyncio async def test_evaluator_default_threshold(self, monkeypatch): """evaluator 默认阈值 0.6""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_pass, ) workflow_data = { "nodes": [ {"id": "eval-1", "type": "evaluator", "data": {}}, ], "edges": [], } engine = WorkflowEngine("test-eval-default", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], {"output": "content"} ) output = result.get("output", {}) assert output["threshold"] == 0.6 @pytest.mark.asyncio async def test_evaluator_empty_input(self, monkeypatch): """evaluator 处理空输入""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_pass, ) workflow_data = { "nodes": [ {"id": "eval-1", "type": "evaluator", "data": {}}, ], "edges": [], } engine = WorkflowEngine("test-eval-empty", workflow_data) result = await engine.execute_node(workflow_data["nodes"][0], {}) assert result is not None assert "output" in result @pytest.mark.asyncio async def test_evaluator_non_dict_input(self, monkeypatch): """evaluator 处理非 dict 输入""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_pass, ) workflow_data = { "nodes": [ {"id": "eval-1", "type": "evaluator", "data": {}}, ], "edges": [], } engine = WorkflowEngine("test-eval-str", workflow_data) result = await engine.execute_node( workflow_data["nodes"][0], "plain string content" ) assert result is not None @pytest.mark.asyncio async def test_evaluator_branch_routing_workflow(self, monkeypatch): """evaluator 集成工作流 → branch 路由正确""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_pass, # 通过评审 ) workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "eval-1", "type": "evaluator", "data": {"criteria": "准确性", "pass_threshold": 0.6}}, {"id": "pass-node", "type": "log", "data": {"label": "通过"}}, {"id": "fail-node", "type": "log", "data": {"label": "失败"}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "eval-1"}, {"id": "e2", "source": "eval-1", "target": "pass-node", "sourceHandle": "true"}, {"id": "e3", "source": "eval-1", "target": "fail-node", "sourceHandle": "false"}, {"id": "e4", "source": "pass-node", "target": "end-1"}, {"id": "e5", "source": "fail-node", "target": "end-1"}, ], } engine = WorkflowEngine("test-eval-branch", workflow_data) result = await engine.execute({"output": "test content"}) assert result["status"] in ("success", "completed") assert "eval-1" in engine.node_outputs @pytest.mark.asyncio async def test_evaluator_with_error_handler(self, monkeypatch): """evaluator 失败分支 → error_handler 兜底""" monkeypatch.setattr( "app.services.llm_service.llm_service.call_openai_with_tools", self._mock_judge_fail, # 不通过 ) workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "eval-1", "type": "evaluator", "data": {"criteria": "严格", "pass_threshold": 0.9}}, {"id": "eh-1", "type": "error_handler", "data": {"on_error": "notify"}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "eval-1"}, {"id": "e2", "source": "eval-1", "target": "end-1", "sourceHandle": "true"}, {"id": "e3", "source": "eval-1", "target": "eh-1", "sourceHandle": "false"}, {"id": "e4", "source": "eh-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-eval-eh", workflow_data) result = await engine.execute({"output": "low quality"}) assert result["status"] in ("success", "completed") @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineParallel: """DAG 并行执行验证""" def test_parallel_ready_nodes_detection(self): """分支工作流中多个独立节点同时就绪""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "branch-a", "type": "log", "data": {"label": "分支A"}}, {"id": "branch-b", "type": "log", "data": {"label": "分支B"}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "branch-a"}, {"id": "e2", "source": "start-1", "target": "branch-b"}, {"id": "e3", "source": "branch-a", "target": "end-1"}, {"id": "e4", "source": "branch-b", "target": "end-1"}, ], } engine = WorkflowEngine("test-parallel", workflow_data) order = engine.build_execution_graph() # start-1 完成后,branch-a 和 branch-b 同时就绪 assert order.index("start-1") < order.index("branch-a") assert order.index("start-1") < order.index("branch-b") @pytest.mark.asyncio async def test_parallel_execution_completes(self): """并行分支工作流正常完成""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "log-a", "type": "log", "data": {"label": "A"}}, {"id": "log-b", "type": "log", "data": {"label": "B"}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "log-a"}, {"id": "e2", "source": "start-1", "target": "log-b"}, {"id": "e3", "source": "log-a", "target": "end-1"}, {"id": "e4", "source": "log-b", "target": "end-1"}, ], } engine = WorkflowEngine("test-parallel-run", workflow_data) result = await engine.execute({"input": "test"}) assert result["status"] in ("success", "completed") @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineOrchestrator: """Orchestrator 节点集成验证""" def test_orchestrator_node_type_registered(self): """orchestrator 节点类型在工作流引擎中已注册""" workflow_data = { "nodes": [ {"id": "orch-1", "type": "orchestrator", "data": {"mode": "sequential", "agents": ["agent-1", "agent-2"]}}, ], "edges": [], } engine = WorkflowEngine("test-orch", workflow_data) assert "orch-1" in engine.nodes assert engine.nodes["orch-1"]["type"] == "orchestrator" def test_orchestrator_modes_available(self): """orchestrator 支持 5 种编排模式""" valid_modes = ["route", "sequential", "debate", "pipeline", "graph"] for mode in valid_modes: node_data = {"mode": mode, "agents": ["a1", "a2"]} # 验证模式字符串合法 assert mode in valid_modes @pytest.mark.unit @pytest.mark.workflow class TestWorkflowEngineSubworkflow: """子工作流/委派节点测试(subworkflow / invoke_agent)""" # ─── 辅助:构造一个可被子工作流调用的 mini workflow ─── @staticmethod def _make_mini_workflow(): return { "nodes": [ {"id": "s-start", "type": "start"}, {"id": "s-log", "type": "log", "data": {"message": "child executed"}}, {"id": "s-end", "type": "end"}, ], "edges": [ {"id": "se1", "source": "s-start", "target": "s-log"}, {"id": "se2", "source": "s-log", "target": "s-end"}, ], } def test_subworkflow_depth_limit_exceeded(self): """子工作流深度超限时抛出错误""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "sub-1", "type": "subworkflow", "data": {"workflow_id": "wf-1", "max_subworkflow_depth": 2}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "sub-1"}, {"id": "e2", "source": "sub-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-depth", workflow_data) # 模拟深度已达上限 with pytest.raises(WorkflowExecutionError) as exc_info: asyncio.run(engine.execute({"__subworkflow_depth": 2})) assert "深度" in str(exc_info.value.detail) or "超限" in str(exc_info.value.detail) def test_subworkflow_build_input_mapping(self): """子工作流根据 input_mapping 组装输入""" engine = WorkflowEngine("test-map", {"nodes": [], "edges": []}) input_data = {"x": 1, "y": 2, "z": 3} mapping = {"a": "x", "b": "y"} result = engine._build_subworkflow_input(input_data, mapping) assert result["a"] == 1 assert result["b"] == 2 def test_subworkflow_build_input_no_mapping(self): """无 input_mapping 时直接透传 input_data""" engine = WorkflowEngine("test-nomap", {"nodes": [], "edges": []}) input_data = {"x": 1} result = engine._build_subworkflow_input(input_data, None) assert result == input_data result2 = engine._build_subworkflow_input(input_data, []) assert result2 == input_data def test_subworkflow_build_input_raw_value(self): """非 dict 输入 + 无 mapping 时包裹为 {'input': ...}""" engine = WorkflowEngine("test-raw", {"nodes": [], "edges": []}) result = engine._build_subworkflow_input("hello", None) assert result == {"input": "hello"} @pytest.mark.asyncio async def test_subworkflow_execution_with_mock_target(self, monkeypatch): """子工作流真实执行:mock _resolve_subworkflow_target,验证子流程执行""" mini_wf = self._make_mini_workflow() def _mock_resolve(*args, **kwargs): return ("workflow", "wf-child", mini_wf) monkeypatch.setattr( "app.services.workflow_engine.WorkflowEngine._resolve_subworkflow_target", _mock_resolve, ) workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "sub-1", "type": "subworkflow", "data": {"workflow_id": "wf-child", "input_mapping": {"msg": "input"}}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "sub-1"}, {"id": "e2", "source": "sub-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-sub-exec", workflow_data) # 不带 db,子工作流无 Execution 持久化,仍应正常执行 result = await engine.execute({"input": "hello"}) assert result["status"] == "completed" # 子工作流节点的输出 node_results = result.get("node_results", {}) sub_output = node_results.get("sub-1", {}).get("output", {}) assert sub_output.get("target_type") == "workflow" assert sub_output.get("target_id") == "wf-child" assert sub_output.get("status") == "completed" @pytest.mark.asyncio async def test_subworkflow_error_propagation(self, monkeypatch): """子工作流失败时错误向上传播为 WorkflowExecutionError""" def _mock_resolve_fail(*args, **kwargs): raise ValueError("目标工作流不存在: wf-gone") monkeypatch.setattr( "app.services.workflow_engine.WorkflowEngine._resolve_subworkflow_target", _mock_resolve_fail, ) workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "sub-1", "type": "subworkflow", "data": {"workflow_id": "wf-gone"}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "sub-1"}, {"id": "e2", "source": "sub-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-sub-err", workflow_data) with pytest.raises(WorkflowExecutionError) as exc_info: await engine.execute({"input": "test"}) assert "不存在" in str(exc_info.value.detail) or "wf-gone" in str(exc_info.value.detail) def test_subworkflow_missing_target_error(self): """subworkflow 节点缺少 workflow_id 且缺少 agent_id 时抛错""" workflow_data = { "nodes": [ {"id": "start-1", "type": "start"}, {"id": "sub-1", "type": "subworkflow", "data": {}}, {"id": "end-1", "type": "end"}, ], "edges": [ {"id": "e1", "source": "start-1", "target": "sub-1"}, {"id": "e2", "source": "sub-1", "target": "end-1"}, ], } engine = WorkflowEngine("test-no-target", workflow_data) with pytest.raises(WorkflowExecutionError) as exc_info: asyncio.run(engine.execute({"input": "test"})) assert "缺少" in str(exc_info.value.detail) or "workflow_id" in str(exc_info.value.detail).lower()