76a67e8237
将分散的 config.yml、workflow.yaml、sandbox.yaml 加载逻辑统一到 AppConfig 模型, 启动时一次性校验,失败则 fast-fail。sandbox.py 改为从统一配置取值,消除重复加载。 同时修复 onebot 测试并新增14个统一配置测试(总测试 285→300)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
12 KiB
Python
326 lines
12 KiB
Python
"""Workflow graph 引擎本身(节点跳转 / 失败处理 / 派发 / HITL)的单元测试。
|
||
|
||
不经过 ConsciousnessNode 入口,也不依赖 ray runtime —— 直接调用
|
||
``run_workflow_graph(workflow_data, trace_id, deps=...)``,注入一套全部用
|
||
``AsyncMock``/lambda 实现的 ``WorkflowDeps``。验证 graph 驱动确实把状态机跑通。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, List, Tuple
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
|
||
from kilostar.core.work.workflow.workflow_engine import (
|
||
WorkflowDeps,
|
||
WorkflowGraphState,
|
||
run_workflow_graph,
|
||
)
|
||
from kilostar.core.work.workflow.model import WorkflowStatus
|
||
|
||
|
||
def _make_deps(
|
||
*,
|
||
skill_outputs: List[Tuple[str, bool]] | None = None,
|
||
consciousness_outputs: List[Tuple[str, bool]] | None = None,
|
||
received_replies: List[str] | None = None,
|
||
) -> tuple[WorkflowDeps, Dict[str, List[Any]]]:
|
||
"""构造一对 ``(deps, sink)``:sink 收集所有 IO 调用,方便断言。
|
||
|
||
``skill_outputs`` / ``consciousness_outputs`` 是按顺序消费的 ``(text, success)``
|
||
队列,run_skill / run_consciousness 每被调一次取一个。``received_replies``
|
||
用于 HumanApproval 节点的 get_received。
|
||
"""
|
||
sink: Dict[str, List[Any]] = {
|
||
"upsert": [],
|
||
"status": [],
|
||
"pending": [],
|
||
"received_calls": [],
|
||
"skill_calls": [],
|
||
"consciousness_calls": [],
|
||
}
|
||
|
||
skill_queue = list(skill_outputs or [])
|
||
consc_queue = list(consciousness_outputs or [])
|
||
reply_queue = list(received_replies or [])
|
||
|
||
upsert = AsyncMock(side_effect=lambda *a, **kw: sink["upsert"].append((a, kw)))
|
||
status = AsyncMock(side_effect=lambda tid, st: sink["status"].append((tid, st)))
|
||
pending = AsyncMock(side_effect=lambda tid, msg: sink["pending"].append((tid, msg)))
|
||
|
||
async def _get_received(tid: str) -> str:
|
||
sink["received_calls"].append(tid)
|
||
return reply_queue.pop(0) if reply_queue else ""
|
||
|
||
async def _run_skill(step, state):
|
||
sink["skill_calls"].append((step, state.current_step_index))
|
||
if not skill_queue:
|
||
return "(no fixture)", True
|
||
return skill_queue.pop(0)
|
||
|
||
async def _run_consciousness(step, state):
|
||
sink["consciousness_calls"].append((step, state.current_step_index))
|
||
if not consc_queue:
|
||
return "(no fixture)", True
|
||
return consc_queue.pop(0)
|
||
|
||
return (
|
||
WorkflowDeps(
|
||
upsert_workflow_context=upsert,
|
||
update_workflow_status=status,
|
||
put_pending=pending,
|
||
get_received=_get_received,
|
||
run_skill=_run_skill,
|
||
run_consciousness=_run_consciousness,
|
||
),
|
||
sink,
|
||
)
|
||
|
||
|
||
# ─── 基本路径 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_skill_individual_path_completes_linear_steps():
|
||
"""两步顺序工作流 (skill_individual):成功 → 成功,应推进到 COMPLETED。"""
|
||
deps, sink = _make_deps(skill_outputs=[("ok1", True), ("ok2", True)])
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "s1", "action": "do1", "outputs": "o1",
|
||
"node": "skill_individual", "agent_id": "a1"},
|
||
{"step": 2, "name": "s2", "action": "do2", "outputs": "o2",
|
||
"node": "skill_individual", "agent_id": "a2"},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-ok", deps=deps)
|
||
assert final == WorkflowStatus.COMPLETED.value
|
||
# run_skill 被调了两次,consciousness 没被调
|
||
assert len(sink["skill_calls"]) == 2
|
||
assert len(sink["consciousness_calls"]) == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_consciousness_node_path_dispatches_to_consciousness():
|
||
"""node=consciousness_node 的 step 应被派给 run_consciousness。"""
|
||
deps, sink = _make_deps(
|
||
consciousness_outputs=[("ok-from-consciousness", True)]
|
||
)
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "s1", "action": "summarize",
|
||
"node": "consciousness_node"},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-c", deps=deps)
|
||
assert final == WorkflowStatus.COMPLETED.value
|
||
assert len(sink["consciousness_calls"]) == 1
|
||
assert len(sink["skill_calls"]) == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mixed_path_dispatches_correctly():
|
||
"""混合 step:第一步 skill_individual,第二步 consciousness_node。"""
|
||
deps, sink = _make_deps(
|
||
skill_outputs=[("o1", True)],
|
||
consciousness_outputs=[("o2", True)],
|
||
)
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "s1", "action": "do",
|
||
"node": "skill_individual", "agent_id": "a1"},
|
||
{"step": 2, "name": "s2", "action": "review",
|
||
"node": "consciousness_node"},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-mix", deps=deps)
|
||
assert final == WorkflowStatus.COMPLETED.value
|
||
assert len(sink["skill_calls"]) == 1
|
||
assert len(sink["consciousness_calls"]) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unknown_node_type_falls_to_failed():
|
||
"""未识别的 node 类型应直接收尾 FAILED,不静默跑成功。"""
|
||
deps, sink = _make_deps()
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "s1", "action": "do",
|
||
"node": "phantom_node"},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-unknown", deps=deps)
|
||
assert final == WorkflowStatus.FAILED.value
|
||
assert len(sink["skill_calls"]) == 0
|
||
assert len(sink["consciousness_calls"]) == 0
|
||
|
||
|
||
# ─── logic_gate 跳转语义 ──────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_if_pass_exit_short_circuits():
|
||
"""``logic_gate.if_pass=exit`` 应在该步成功后立即收尾。"""
|
||
deps, sink = _make_deps(skill_outputs=[("ok", True)])
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "s1", "action": "do", "outputs": "o",
|
||
"node": "skill_individual", "agent_id": "a1",
|
||
"logic_gate": {"if_pass": "exit", "if_fail": "jump_to_step_1"}},
|
||
{"step": 2, "name": "s2", "action": "skipped",
|
||
"node": "skill_individual", "agent_id": "a2"},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-exit", deps=deps)
|
||
assert final == WorkflowStatus.COMPLETED.value
|
||
# 第二步不应被派发
|
||
assert len(sink["skill_calls"]) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_failure_without_jump_marks_failed():
|
||
"""run_skill 报失败且 if_fail 不指向跳转 → FAILED。"""
|
||
deps, sink = _make_deps(skill_outputs=[("boom", False)])
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "s1", "action": "do",
|
||
"node": "skill_individual", "agent_id": "a1"},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-fail", deps=deps)
|
||
assert final == WorkflowStatus.FAILED.value
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_failure_jumps_back_then_succeeds():
|
||
"""步 2 失败但 if_fail=jump_to_step_1,回到步 1 重跑后继续到步 2 成功。"""
|
||
deps, sink = _make_deps(
|
||
skill_outputs=[
|
||
("o1-first", True), # step 1 第一次成功
|
||
("boom", False), # step 2 第一次失败 → 跳回 step 1
|
||
("o1-retry", True), # step 1 重跑
|
||
("o2-final", True), # step 2 第二次成功
|
||
]
|
||
)
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "s1", "action": "do",
|
||
"node": "skill_individual", "agent_id": "a1"},
|
||
{"step": 2, "name": "s2", "action": "do",
|
||
"node": "skill_individual", "agent_id": "a2",
|
||
"logic_gate": {"if_fail": "jump_to_step_1", "if_pass": "continue"}},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-jump", deps=deps)
|
||
assert final == WorkflowStatus.COMPLETED.value
|
||
# skill 应被调 4 次
|
||
assert len(sink["skill_calls"]) == 4
|
||
|
||
|
||
# ─── HITL ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_human_approval_approve_continues():
|
||
"""require_approval=True 时进入 HumanApproval;用户回 approve 后继续执行。"""
|
||
deps, sink = _make_deps(
|
||
skill_outputs=[("ok", True)],
|
||
received_replies=["approve"],
|
||
)
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "danger", "action": "rm -rf /",
|
||
"node": "skill_individual", "agent_id": "a1",
|
||
"require_approval": True},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-hitl-ok", deps=deps)
|
||
assert final == WorkflowStatus.COMPLETED.value
|
||
# get_received 被调用过一次
|
||
assert sink["received_calls"] == ["trace-hitl-ok"]
|
||
# 应该有一条"需要人工审批"的 SSE
|
||
msgs = [m for _, m in sink["pending"]]
|
||
assert any("需要人工审批" in m for m in msgs)
|
||
# skill 才被实际派发
|
||
assert len(sink["skill_calls"]) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_human_approval_reject_aborts():
|
||
"""用户回 reject → 不执行 step,整个工作流落到 FAILED。"""
|
||
deps, sink = _make_deps(
|
||
skill_outputs=[("ok", True)],
|
||
received_replies=["reject"],
|
||
)
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "danger", "action": "do",
|
||
"node": "skill_individual", "agent_id": "a1",
|
||
"require_approval": True},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-hitl-no", deps=deps)
|
||
assert final == WorkflowStatus.FAILED.value
|
||
# skill 不应被派发
|
||
assert len(sink["skill_calls"]) == 0
|
||
|
||
|
||
# ─── 边界 ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_work_link_completes_immediately():
|
||
"""空 work_link:Initialize → Dispatch 直接判定越界 → Finalize(COMPLETED)。"""
|
||
deps, sink = _make_deps()
|
||
final = await run_workflow_graph({"work_link": []}, "trace-empty", deps=deps)
|
||
assert final == WorkflowStatus.COMPLETED.value
|
||
msgs = [m for _, m in sink["pending"]]
|
||
assert not any("执行步骤" in m for m in msgs)
|
||
assert any("工作流执行完成" in m for m in msgs)
|
||
|
||
|
||
def test_workflow_graph_state_defaults():
|
||
"""State 默认值确保各字段类型契约稳定。"""
|
||
state = WorkflowGraphState(trace_id="t")
|
||
assert state.blackboard == {}
|
||
assert state.work_link == []
|
||
assert state.current_step_index == 0
|
||
assert state.final_status == WorkflowStatus.RUNNING.value
|
||
assert state.logs == []
|
||
assert state.original_command == ""
|
||
assert state.jump_counts == {}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_loop_retry_exceeds_max_attempts_fails(monkeypatch):
|
||
"""环跳转超过 max_attempts 后,工作流应直接 FAILED 而非无限重试。"""
|
||
from kilostar.utils import config_loader
|
||
monkeypatch.setattr(config_loader, "_app_current", None)
|
||
from kilostar.utils.config_loader import WorkflowConfig, RetryConfig, AppConfig
|
||
|
||
monkeypatch.setattr(config_loader, "_app_current", AppConfig(workflow=WorkflowConfig(retry=RetryConfig(max_attempts=2))))
|
||
|
||
deps, sink = _make_deps(
|
||
skill_outputs=[
|
||
("o1", True),
|
||
("fail", False),
|
||
("o1", True),
|
||
("fail", False),
|
||
("o1", True),
|
||
("fail", False),
|
||
("o1", True),
|
||
("fail", False),
|
||
]
|
||
)
|
||
workflow_data = {
|
||
"work_link": [
|
||
{"step": 1, "name": "s1", "action": "do",
|
||
"node": "skill_individual", "agent_id": "a1"},
|
||
{"step": 2, "name": "s2", "action": "do",
|
||
"node": "skill_individual", "agent_id": "a2",
|
||
"logic_gate": {"if_fail": "jump_to_step_1", "if_pass": "continue"}},
|
||
]
|
||
}
|
||
final = await run_workflow_graph(workflow_data, "trace-loop-limit", deps=deps)
|
||
assert final == WorkflowStatus.FAILED.value
|