- 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>
1129 lines
44 KiB
Python
1129 lines
44 KiB
Python
"""
|
||
工作流执行引擎测试
|
||
|
||
覆盖 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()
|