09-agentic-workflow-sys / tests /test_routing.py
FlyingNunchucks's picture
Sync from GitHub via hub-sync
152be26 verified
Raw History Blame Contribute Delete
12.6 kB
import pytest
from pydantic import ValidationError
from src.executor import WorkflowExecutionError, execute_workflow, submit_human_decision
from src.persistence import InMemoryStateStore
from src.registry import HandlerRegistry
from src.schemas import (
EventType,
HumanDecision,
NodeDefinition,
NodeStatus,
NodeType,
WorkflowDefinition,
WorkflowStatus,
)
from src.validator import WorkflowValidationError, validate_workflow
def make_risk_workflow() -> WorkflowDefinition:
return WorkflowDefinition(
workflow_id="risk-routing-workflow",
name="Risk Routing Workflow",
nodes=[
NodeDefinition(
node_id="intake",
name="Intake",
node_type=NodeType.TASK,
handler="intake_handler",
),
NodeDefinition(
node_id="risk_gate",
name="Risk Gate",
node_type=NodeType.DECISION,
handler="risk_handler",
depends_on=["intake"],
routes={
"LOW_RISK": "low_finalize",
"HIGH_RISK": "human_review",
},
),
NodeDefinition(
node_id="low_finalize",
name="Low Risk Finalize",
node_type=NodeType.TASK,
handler="low_finalize_handler",
depends_on=["risk_gate"],
),
NodeDefinition(
node_id="human_review",
name="Human Review",
node_type=NodeType.HUMAN_GATE,
depends_on=["risk_gate"],
config={"reason": "High-risk request requires approval."},
),
NodeDefinition(
node_id="high_finalize",
name="High Risk Finalize",
node_type=NodeType.TASK,
handler="high_finalize_handler",
depends_on=["human_review"],
),
],
)
def build_registry(route: str, counters: dict[str, int]) -> HandlerRegistry:
registry = HandlerRegistry()
def intake(payload: dict) -> dict:
counters["intake"] += 1
return {"request_id": "REQ-001"}
def risk(payload: dict) -> dict:
counters["risk"] += 1
assert payload["dependencies"] == {"intake": {"request_id": "REQ-001"}}
return {"route": route}
def low_finalize(payload: dict) -> dict:
counters["low"] += 1
assert payload["dependencies"]["risk_gate"]["route"] == "LOW_RISK"
return {"outcome": "auto_finalized"}
def high_finalize(payload: dict) -> dict:
counters["high"] += 1
assert payload["dependencies"]["human_review"]["decision"] == "APPROVE"
return {"outcome": "human_approved"}
registry.register("intake_handler", intake)
registry.register("risk_handler", risk)
registry.register("low_finalize_handler", low_finalize)
registry.register("high_finalize_handler", high_finalize)
return registry
def test_decision_node_requires_handler_and_multiple_routes() -> None:
with pytest.raises(ValidationError, match="must declare a handler"):
NodeDefinition(
node_id="decision",
name="Decision",
node_type=NodeType.DECISION,
routes={"A": "alpha", "B": "beta"},
)
with pytest.raises(ValidationError, match="at least two routes"):
NodeDefinition(
node_id="decision",
name="Decision",
node_type=NodeType.DECISION,
handler="handler",
routes={"A": "alpha"},
)
def test_routes_are_rejected_on_non_decision_nodes() -> None:
with pytest.raises(ValidationError, match="only allowed on DECISION"):
NodeDefinition(
node_id="task",
name="Task",
node_type=NodeType.TASK,
handler="handler",
routes={"A": "alpha", "B": "beta"},
)
def test_validator_rejects_unknown_route_target() -> None:
workflow = WorkflowDefinition(
workflow_id="bad-route",
name="Bad Route",
nodes=[
NodeDefinition(
node_id="decision",
name="Decision",
node_type=NodeType.DECISION,
handler="handler",
routes={"A": "alpha", "B": "missing"},
),
NodeDefinition(
node_id="alpha",
name="Alpha",
node_type=NodeType.TASK,
handler="handler",
depends_on=["decision"],
),
],
)
with pytest.raises(WorkflowValidationError, match="unknown node 'missing'"):
validate_workflow(workflow)
def test_validator_requires_route_target_to_directly_depend_on_decision() -> None:
workflow = WorkflowDefinition(
workflow_id="bad-dependency",
name="Bad Dependency",
nodes=[
NodeDefinition(
node_id="decision",
name="Decision",
node_type=NodeType.DECISION,
handler="handler",
routes={"A": "alpha", "B": "beta"},
),
NodeDefinition(
node_id="alpha",
name="Alpha",
node_type=NodeType.TASK,
handler="handler",
),
NodeDefinition(
node_id="beta",
name="Beta",
node_type=NodeType.TASK,
handler="handler",
depends_on=["decision"],
),
],
)
with pytest.raises(WorkflowValidationError, match="must directly depend"):
validate_workflow(workflow)
def test_validator_rejects_unrouted_direct_dependent() -> None:
workflow = WorkflowDefinition(
workflow_id="unrouted",
name="Unrouted",
nodes=[
NodeDefinition(
node_id="decision",
name="Decision",
node_type=NodeType.DECISION,
handler="handler",
routes={"A": "alpha", "B": "beta"},
),
NodeDefinition(
node_id="alpha",
name="Alpha",
node_type=NodeType.TASK,
handler="handler",
depends_on=["decision"],
),
NodeDefinition(
node_id="beta",
name="Beta",
node_type=NodeType.TASK,
handler="handler",
depends_on=["decision"],
),
NodeDefinition(
node_id="gamma",
name="Gamma",
node_type=NodeType.TASK,
handler="handler",
depends_on=["decision"],
),
],
)
with pytest.raises(WorkflowValidationError, match="unrouted dependents: gamma"):
validate_workflow(workflow)
def test_validator_rejects_branch_reconvergence_in_current_mvp() -> None:
workflow = WorkflowDefinition(
workflow_id="reconverge",
name="Reconverge",
nodes=[
NodeDefinition(
node_id="decision",
name="Decision",
node_type=NodeType.DECISION,
handler="handler",
routes={"A": "alpha", "B": "beta"},
),
NodeDefinition(
node_id="alpha",
name="Alpha",
node_type=NodeType.TASK,
handler="handler",
depends_on=["decision"],
),
NodeDefinition(
node_id="beta",
name="Beta",
node_type=NodeType.TASK,
handler="handler",
depends_on=["decision"],
),
NodeDefinition(
node_id="join",
name="Join",
node_type=NodeType.TASK,
handler="handler",
depends_on=["alpha", "beta"],
),
],
)
with pytest.raises(WorkflowValidationError, match="reconvergence is not supported"):
validate_workflow(workflow)
def test_low_risk_route_executes_only_selected_branch_and_skips_other_branch() -> None:
counters = {"intake": 0, "risk": 0, "low": 0, "high": 0}
run = execute_workflow(
make_risk_workflow(),
build_registry("LOW_RISK", counters),
run_id="low-risk",
state_store=InMemoryStateStore(),
)
assert run.status is WorkflowStatus.COMPLETED
assert counters == {"intake": 1, "risk": 1, "low": 1, "high": 0}
assert run.node_runs["risk_gate"].output == {"route": "LOW_RISK"}
assert run.node_runs["low_finalize"].status is NodeStatus.COMPLETED
assert run.node_runs["human_review"].status is NodeStatus.SKIPPED
assert run.node_runs["high_finalize"].status is NodeStatus.SKIPPED
assert run.final_output == {"low_finalize": {"outcome": "auto_finalized"}}
routed = [event for event in run.events if event.event_type is EventType.DECISION_ROUTED]
assert len(routed) == 1
assert routed[0].details == {
"route": "LOW_RISK",
"selected_target": "low_finalize",
}
assert EventType.HUMAN_REVIEW_REQUESTED not in [
event.event_type for event in run.events
]
def test_high_risk_route_pauses_for_human_then_resumes_without_replaying_decision() -> None:
counters = {"intake": 0, "risk": 0, "low": 0, "high": 0}
workflow = make_risk_workflow()
registry = build_registry("HIGH_RISK", counters)
store = InMemoryStateStore()
paused = execute_workflow(
workflow,
registry,
run_id="high-risk",
state_store=store,
)
assert paused.status is WorkflowStatus.WAITING_FOR_HUMAN
assert counters == {"intake": 1, "risk": 1, "low": 0, "high": 0}
assert paused.node_runs["low_finalize"].status is NodeStatus.SKIPPED
assert paused.node_runs["human_review"].status is NodeStatus.WAITING_FOR_HUMAN
review_id = paused.human_reviews[0].review_id
completed = submit_human_decision(
workflow,
registry,
run_id="high-risk",
review_id=review_id,
decision=HumanDecision.APPROVE,
state_store=store,
)
assert completed.status is WorkflowStatus.COMPLETED
assert counters == {"intake": 1, "risk": 1, "low": 0, "high": 1}
assert completed.node_runs["risk_gate"].attempt == 1
assert completed.node_runs["low_finalize"].status is NodeStatus.SKIPPED
assert completed.node_runs["human_review"].status is NodeStatus.COMPLETED
assert completed.node_runs["high_finalize"].status is NodeStatus.COMPLETED
assert completed.final_output == {"high_finalize": {"outcome": "human_approved"}}
def test_unknown_decision_route_fails_safely_before_any_branch_executes() -> None:
counters = {"intake": 0, "risk": 0, "low": 0, "high": 0}
with pytest.raises(WorkflowExecutionError, match="unknown route 'UNAPPROVED'") as exc_info:
execute_workflow(
make_risk_workflow(),
build_registry("UNAPPROVED", counters),
run_id="bad-route-output",
state_store=InMemoryStateStore(),
)
run = exc_info.value.run
assert run.status is WorkflowStatus.FAILED
assert counters == {"intake": 1, "risk": 1, "low": 0, "high": 0}
assert run.node_runs["risk_gate"].status is NodeStatus.FAILED
assert run.node_runs["low_finalize"].status is NodeStatus.PENDING
assert run.node_runs["human_review"].status is NodeStatus.PENDING
assert EventType.DECISION_ROUTED not in [event.event_type for event in run.events]
def test_decision_route_must_be_string() -> None:
counters = {"intake": 0, "risk": 0, "low": 0, "high": 0}
registry = build_registry("LOW_RISK", counters)
def malformed_route(payload: dict) -> dict:
counters["risk"] += 1
return {"route": 42}
registry = HandlerRegistry()
def intake(payload: dict) -> dict:
counters["intake"] += 1
return {"request_id": "REQ-001"}
registry.register("intake_handler", intake)
registry.register("risk_handler", malformed_route)
registry.register("low_finalize_handler", lambda payload: {"outcome": "low"})
registry.register("high_finalize_handler", lambda payload: {"outcome": "high"})
with pytest.raises(WorkflowExecutionError, match="non-blank string route"):
execute_workflow(
make_risk_workflow(),
registry,
state_store=InMemoryStateStore(),
)