Files
aiagent/backend/tests/test_workflow_engine.py
renjianbo beff3fac8d fix: delete agent 500 error + dynamic personality + deployment guide
- Fix delete agent 500: clean up FK records (agent_llm_logs, permissions,
  schedules, executions, team_members) and unbind goals/tasks before delete
- Remove hardcoded personality templates in Android, replace with dynamic
  system prompt generation from name + description
- Set promptSectionsEnabled=false to bypass PromptComposer for personality
- Add Tencent Cloud Linux deployment guide (Docker Compose)
- Accumulated backend service updates, frontend UI fixes, Android app changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-29 01:17:21 +08:00

1129 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
工作流执行引擎测试
覆盖 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()