"""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 from kilostar.utils.config_loader import WorkflowConfig, RetryConfig monkeypatch.setattr(config_loader, "_current", 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