322 lines
9.5 KiB
Python
322 lines
9.5 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from wf_artifacts.drafts import (
|
|
DraftChooseStep,
|
|
DraftEndStep,
|
|
DraftForeachStep,
|
|
DraftMatchStep,
|
|
DraftUseStep,
|
|
DraftWhenStep,
|
|
WorkflowDraft,
|
|
)
|
|
|
|
|
|
def test_workflow_draft_uses_keyed_steps() -> None:
|
|
draft = WorkflowDraft.model_validate(_keyed_echo_draft())
|
|
|
|
assert isinstance(draft.steps["echo"], DraftUseStep)
|
|
assert draft.steps["echo"].use == "demo.echo"
|
|
|
|
|
|
def test_workflow_draft_accepts_legacy_use_maps_but_dumps_canonical_bindings() -> None:
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"steps": {
|
|
"echo": {
|
|
"use": "demo.echo",
|
|
"in": {"input.text": "text"},
|
|
"with": {"limit": 3},
|
|
"out": {"echoed": "state.echoed"},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
dumped = draft.model_dump(mode="json")
|
|
|
|
assert "in" not in dumped["steps"]["echo"]
|
|
assert "with" not in dumped["steps"]["echo"]
|
|
assert "out" not in dumped["steps"]["echo"]
|
|
assert dumped["steps"]["echo"]["input"][0]["target"] == "limit"
|
|
assert dumped["steps"]["echo"]["input"][1]["path"] == "input.text"
|
|
assert dumped["steps"]["echo"]["output"][0]["target"] == "state.echoed"
|
|
|
|
|
|
def test_workflow_draft_accepts_legacy_interrupt_maps_but_dumps_canonical_bindings() -> (
|
|
None
|
|
):
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"start": "approval",
|
|
"steps": {
|
|
"approval": {
|
|
"interrupt": {
|
|
"kind": "approval",
|
|
"request": {"input.text": "message"},
|
|
"resume": {"approved": "state.approved"},
|
|
}
|
|
},
|
|
},
|
|
"routes": {"approval": {"submitted": "__end__"}},
|
|
}
|
|
)
|
|
|
|
dumped = draft.model_dump(mode="json")
|
|
|
|
assert (
|
|
dumped["steps"]["approval"]["interrupt"]["request"][0]["path"] == "input.text"
|
|
)
|
|
assert dumped["steps"]["approval"]["interrupt"]["request"][0]["target"] == "message"
|
|
assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["source"] == "approved"
|
|
assert (
|
|
dumped["steps"]["approval"]["interrupt"]["resume"][0]["target"]
|
|
== "state.approved"
|
|
)
|
|
|
|
|
|
def test_draft_step_requires_exactly_one_kind_key() -> None:
|
|
draft = _keyed_echo_draft()
|
|
steps = draft["steps"]
|
|
assert isinstance(steps, dict)
|
|
echo = steps["echo"]
|
|
assert isinstance(echo, dict)
|
|
echo["join"] = {}
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
WorkflowDraft.model_validate(draft)
|
|
|
|
assert "steps.echo" in str(exc_info.value)
|
|
|
|
|
|
def test_workflow_draft_accepts_when_step() -> None:
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"start": "decide",
|
|
"steps": {
|
|
**_keyed_echo_draft()["steps"],
|
|
"decide": {
|
|
"when": {
|
|
"if": {
|
|
"op": "ge",
|
|
"left": {"path": "state.count"},
|
|
"right": {"value": 1},
|
|
},
|
|
"then": "echo",
|
|
"otherwise": "__end__",
|
|
}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert isinstance(draft.steps["decide"], DraftWhenStep)
|
|
|
|
|
|
def test_workflow_draft_accepts_explicit_end_step() -> None:
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"outcomes": ["ok", "error"],
|
|
"steps": {
|
|
**_keyed_echo_draft()["steps"],
|
|
"end_error": {"end": {"outcome": "error"}},
|
|
},
|
|
"routes": {"echo": {"error": "end_error"}},
|
|
}
|
|
)
|
|
|
|
terminal = draft.steps["end_error"]
|
|
|
|
assert isinstance(terminal, DraftEndStep)
|
|
assert terminal.end.outcome == "error"
|
|
|
|
|
|
def test_workflow_draft_accepts_choose_step() -> None:
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"start": "choose_next",
|
|
"steps": {
|
|
**_keyed_echo_draft()["steps"],
|
|
"choose_next": {
|
|
"choose": {
|
|
"clauses": [
|
|
{
|
|
"if": {
|
|
"op": "exists",
|
|
"path": "state.text",
|
|
},
|
|
"then": "echo",
|
|
}
|
|
],
|
|
"default": "__end__",
|
|
}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert isinstance(draft.steps["choose_next"], DraftChooseStep)
|
|
|
|
|
|
def test_workflow_draft_foreach_over_dumps_structural_path() -> None:
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"start": "each_item",
|
|
"steps": {
|
|
**_keyed_echo_draft()["steps"],
|
|
"each_item": {
|
|
"foreach": {
|
|
"over": "state.items",
|
|
"as": "item",
|
|
}
|
|
},
|
|
},
|
|
"routes": {
|
|
"each_item": {"loop": "echo", "done": "__end__"},
|
|
"echo": {"ok": "__end__"},
|
|
},
|
|
}
|
|
)
|
|
|
|
dumped = draft.model_dump(mode="json")
|
|
|
|
assert dumped["steps"]["each_item"]["foreach"]["over"] == "state.items"
|
|
|
|
|
|
def test_workflow_draft_foreach_accepts_canonical_item_error_policy() -> None:
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"start": "each_item",
|
|
"steps": {
|
|
"each_item": {
|
|
"foreach": {
|
|
"over": "state.items",
|
|
"as": "item",
|
|
"mode": "concurrent",
|
|
"concurrent": {"max_active": 2, "max_outstanding": 3},
|
|
"item_error": {
|
|
"action": "collect",
|
|
"collect_to": "state.item_errors",
|
|
},
|
|
}
|
|
}
|
|
},
|
|
"routes": {"each_item": {"loop": "__end__", "done": "__end__"}},
|
|
}
|
|
)
|
|
|
|
step = draft.steps["each_item"]
|
|
dumped = draft.model_dump(mode="json")
|
|
|
|
assert isinstance(step, DraftForeachStep)
|
|
assert dumped["steps"]["each_item"]["foreach"]["item_error"] == {
|
|
"action": "collect",
|
|
"collect_to": "state.item_errors",
|
|
}
|
|
assert "on_item_error" not in dumped["steps"]["each_item"]["foreach"]
|
|
|
|
|
|
def test_workflow_draft_foreach_accepts_item_error_action_string() -> None:
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"start": "each_item",
|
|
"steps": {
|
|
"each_item": {
|
|
"foreach": {
|
|
"over": "state.items",
|
|
"as": "item",
|
|
"item_error": "skip",
|
|
}
|
|
}
|
|
},
|
|
"routes": {"each_item": {"loop": "__end__", "done": "__end__"}},
|
|
}
|
|
)
|
|
|
|
dumped = draft.model_dump(mode="json")
|
|
|
|
assert dumped["steps"]["each_item"]["foreach"]["item_error"]["action"] == "skip"
|
|
|
|
|
|
def test_workflow_draft_foreach_collect_string_requires_destination() -> None:
|
|
with pytest.raises(ValidationError, match="collect_to"):
|
|
WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"start": "each_item",
|
|
"steps": {
|
|
"each_item": {
|
|
"foreach": {
|
|
"over": "state.items",
|
|
"as": "item",
|
|
"item_error": "collect",
|
|
}
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def test_workflow_draft_accepts_match_step() -> None:
|
|
draft = WorkflowDraft.model_validate(
|
|
{
|
|
**_keyed_echo_draft(),
|
|
"start": "match_status",
|
|
"steps": {
|
|
**_keyed_echo_draft()["steps"],
|
|
"match_status": {
|
|
"match": {
|
|
"value": "state.status",
|
|
"cases": [
|
|
{"equals": "ready", "then": "echo"},
|
|
{"equals": "done", "then": "__end__"},
|
|
],
|
|
"default": "__end__",
|
|
}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert isinstance(draft.steps["match_status"], DraftMatchStep)
|
|
|
|
|
|
def _keyed_echo_draft() -> dict[str, Any]:
|
|
return {
|
|
"name": "echo",
|
|
"input_schema": {},
|
|
"state_schema": {"fields": {}},
|
|
"output_schema": {},
|
|
"start": "echo",
|
|
"steps": {
|
|
"echo": {
|
|
"use": "demo.echo",
|
|
"input": [
|
|
{
|
|
"target": {"root": "local", "parts": ["text"]},
|
|
"path": {"root": "input", "parts": ["text"]},
|
|
}
|
|
],
|
|
"output": [
|
|
{
|
|
"source": {"root": "local", "parts": ["echoed"]},
|
|
"target": {"root": "state", "parts": ["echoed"]},
|
|
}
|
|
],
|
|
}
|
|
},
|
|
"routes": {"echo": {"ok": "__end__"}},
|
|
}
|