interrupt to get the same treatment too!

This commit is contained in:
lda
2026-05-21 14:15:52 +07:00 Verified
parent 274bf34edf
commit b619e529fa
15 changed files with 317 additions and 66 deletions
+40
View File
@@ -55,6 +55,46 @@ def test_workflow_draft_accepts_legacy_use_maps_but_dumps_canonical_bindings() -
}
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"] == {
"root": "input",
"parts": ["text"],
}
assert dumped["steps"]["approval"]["interrupt"]["request"][0]["target"] == {
"root": "local",
"parts": ["message"],
}
assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["source"] == {
"root": "local",
"parts": ["approved"],
}
assert dumped["steps"]["approval"]["interrupt"]["resume"][0]["target"] == {
"root": "state",
"parts": ["approved"],
}
def test_draft_step_requires_exactly_one_kind_key() -> None:
draft = _keyed_echo_draft()
steps = draft["steps"]
+21
View File
@@ -277,6 +277,27 @@ def test_builder_can_auto_id_condition_foreach_and_interrupt() -> None:
assert interrupt.id == "interrupt_approval"
def test_builder_interrupt_accepts_canonical_request_and_resume_bindings() -> None:
builder = WorkflowBuilder(
name="interrupt_bindings",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
interrupt = builder.interrupt(
kind="approval",
request=[input_from(input_path("text"), "message")],
resume=[output_to("text", state_path("text"))],
)
assert isinstance(interrupt.request[0], InputPathBinding)
assert interrupt.request[0].path == GraphSourcePath.input("text")
assert interrupt.request[0].target == LocalPath.of("message")
assert interrupt.resume[0].source == LocalPath.of("text")
assert interrupt.resume[0].target == StatePath.of("text")
def test_builder_connect_can_use_node_specs_and_returns_resolved_refs() -> None:
builder = WorkflowBuilder(
name="connect_specs_demo",
+8 -10
View File
@@ -18,8 +18,6 @@ from examples.demo_workflow import build_demo_registry, build_demo_workflow
from wf_authoring import (
NodeReturn,
WorkflowBuilder,
bind_fields,
bind_state,
build_registry,
input_from,
state,
@@ -192,14 +190,14 @@ def build_authoring_demo_workflow():
approve_email = builder.interrupt(
id="approve_email",
kind="approval",
request_map=bind_fields(
summary=state_path("summary"),
folder_id=input_path("folder_id"),
),
out_map=bind_state(
approved=state_path("approved"),
comment=state_path("approval_comment"),
),
request=[
input_from(state_path("summary"), "summary"),
input_from(input_path("folder_id"), "folder_id"),
],
resume=[
output_to("approved", state_path("approved")),
output_to("comment", state_path("approval_comment")),
],
outcomes=["submitted", "cancelled"],
)
skip_email = builder.use(
+57 -1
View File
@@ -1,7 +1,12 @@
import pytest
from pydantic import ValidationError
from wf_core.models.steps import InputPathBinding, InputValueBinding, NodeUse
from wf_core.models.steps import (
InputPathBinding,
InputValueBinding,
InterruptNode,
NodeUse,
)
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
@@ -220,3 +225,54 @@ def test_deprecated_input_value_preserves_explicit_null():
dumped_input = node.model_dump(mode="json")["input"]
assert dumped_input[0]["target"] == {"root": "local", "parts": ["maybe"]}
assert dumped_input[0]["value"] is None
def test_interrupt_node_accepts_canonical_request_and_resume_bindings():
node = InterruptNode.model_validate(
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
"request": [{"target": "summary", "path": "state.summary"}],
"resume": [{"source": "approved", "target": "state.approved"}],
}
)
assert isinstance(node.request[0], InputPathBinding)
assert node.request[0].path == GraphSourcePath.state("summary")
assert node.request[0].target == LocalPath.of("summary")
assert node.resume[0].source == LocalPath.of("approved")
assert node.resume[0].target == StatePath.of("approved")
def test_interrupt_node_converts_old_maps_to_canonical_bindings():
node = InterruptNode.model_validate(
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
"request_map": {"input.message": "message"},
"out_map": {"approved": "state.approved"},
}
)
dumped = node.model_dump(mode="json")
assert "request_map" not in dumped
assert "out_map" not in dumped
assert dumped["request"][0]["path"] == {"root": "input", "parts": ["message"]}
assert dumped["request"][0]["target"] == {"root": "local", "parts": ["message"]}
assert dumped["resume"][0]["source"] == {"root": "local", "parts": ["approved"]}
assert dumped["resume"][0]["target"] == {"root": "state", "parts": ["approved"]}
def test_interrupt_node_rejects_mixed_old_and_new_binding_styles():
with pytest.raises(ValidationError):
InterruptNode.model_validate(
{
"id": "approval",
"type": "interrupt",
"kind": "approval",
"request": [{"target": "message", "path": "input.message"}],
"request_map": {"input.other": "other"},
}
)
+2 -2
View File
@@ -564,8 +564,8 @@ def _interrupt_artifact() -> WorkflowArtifact:
"id": "approval",
"type": "interrupt",
"kind": "approval",
"request_map": {"input.message": "message"},
"out_map": {},
"request": [input_binding("input.message", "message")],
"resume": [],
"outcomes": ["submitted"],
}
],