Files
KiloStar/tests/unit/test_workflow_graph.py
T
zhaoxi 99520c69d7 feat(system):优化后端
1.新增后端测试
2.增加了后端的加密
3.增加了i18n(国际化)
2026-05-31 15:39:34 +00:00

291 lines
11 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 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_linkInitialize → 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 == ""