feat: complete draft step model parity
This commit is contained in:
@@ -9,9 +9,11 @@ from wf_core import (
|
||||
ConditionNode,
|
||||
EndNode,
|
||||
ForeachNode,
|
||||
InterruptNode,
|
||||
NodeDef,
|
||||
NodeUse,
|
||||
SchemaRef,
|
||||
SubgraphNode,
|
||||
execute_workflow,
|
||||
)
|
||||
from wf_core.models.steps import InputValueBinding
|
||||
@@ -480,6 +482,99 @@ def test_adapter_lowers_foreach_policy_through_builder() -> None:
|
||||
assert str(foreach.item_error.collect_to) == "state.item_errors"
|
||||
|
||||
|
||||
def test_adapter_lowers_typed_and_untyped_interrupt_steps() -> None:
|
||||
request_schema = {
|
||||
"type": "object",
|
||||
"properties": {"issues": {"type": "array"}},
|
||||
"required": ["issues"],
|
||||
}
|
||||
resume_schema = {
|
||||
"type": "object",
|
||||
"properties": {"selected": {"type": "array"}},
|
||||
"required": ["selected"],
|
||||
}
|
||||
draft = WorkflowDraft.model_validate(
|
||||
{
|
||||
"name": "review",
|
||||
"input_schema": {},
|
||||
"state_schema": {"type": "object"},
|
||||
"output_schema": {},
|
||||
"start": "review",
|
||||
"steps": {
|
||||
"review": {
|
||||
"interrupt": {
|
||||
"kind": "issue_review",
|
||||
"request_schema": request_schema,
|
||||
"resume_schema": resume_schema,
|
||||
"outcomes": ["submitted", "cancelled"],
|
||||
}
|
||||
},
|
||||
"legacy": {"interrupt": {"kind": "legacy"}},
|
||||
},
|
||||
"routes": {
|
||||
"review": {"submitted": "__end__", "cancelled": "__end__"},
|
||||
"legacy": {"submitted": "__end__"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
workflow = build_workflow_from_draft(draft)
|
||||
|
||||
review = workflow.nodes[0]
|
||||
legacy = workflow.nodes[1]
|
||||
assert isinstance(review, InterruptNode)
|
||||
assert review.request_schema == request_schema
|
||||
assert review.resume_schema == resume_schema
|
||||
assert review.has_explicit_contract is True
|
||||
assert isinstance(legacy, InterruptNode)
|
||||
assert legacy.has_explicit_contract is False
|
||||
|
||||
|
||||
def test_adapter_lowers_subgraph_step_without_resolving_artifact() -> None:
|
||||
input_schema = {
|
||||
"type": "object",
|
||||
"properties": {"topic": {"type": "string"}},
|
||||
}
|
||||
output_schema = {
|
||||
"type": "object",
|
||||
"properties": {"report": {"type": "string"}},
|
||||
}
|
||||
draft = WorkflowDraft.model_validate(
|
||||
{
|
||||
"name": "parent",
|
||||
"input_schema": {},
|
||||
"state_schema": {"type": "object"},
|
||||
"output_schema": {},
|
||||
"start": "child",
|
||||
"steps": {
|
||||
"child": {
|
||||
"subgraph": {
|
||||
"workflow": {"artifact_id": "child_report", "version": 2},
|
||||
"input_schema": input_schema,
|
||||
"output_schema": output_schema,
|
||||
"input": [{"target": "topic", "path": "state.topic"}],
|
||||
"output": [
|
||||
{"source": "report", "target": "state.report"}
|
||||
],
|
||||
"outcomes": ["ok", "error"],
|
||||
}
|
||||
}
|
||||
},
|
||||
"routes": {"child": {"ok": "__end__", "error": "__end__"}},
|
||||
}
|
||||
)
|
||||
|
||||
workflow = build_workflow_from_draft(draft)
|
||||
child = workflow.nodes[0]
|
||||
|
||||
assert isinstance(child, SubgraphNode)
|
||||
assert child.workflow.artifact_id == "child_report"
|
||||
assert child.workflow.version == 2
|
||||
assert child.input_schema == SchemaRef.model_validate(input_schema)
|
||||
assert child.output_schema == SchemaRef.model_validate(output_schema)
|
||||
assert child.outcomes == ["ok", "error"]
|
||||
|
||||
|
||||
def test_validate_workflow_draft_reports_structured_output_destination_issue() -> None:
|
||||
draft = {
|
||||
"name": "missing_state_field",
|
||||
|
||||
@@ -9,7 +9,9 @@ from wf_artifacts.drafts import (
|
||||
DraftChooseStep,
|
||||
DraftEndStep,
|
||||
DraftForeachStep,
|
||||
DraftInterruptStep,
|
||||
DraftMatchStep,
|
||||
DraftSubgraphStep,
|
||||
DraftUseStep,
|
||||
DraftWhenStep,
|
||||
WorkflowDraft,
|
||||
@@ -81,6 +83,115 @@ def test_workflow_draft_accepts_legacy_interrupt_maps_but_dumps_canonical_bindin
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_draft_preserves_typed_interrupt_contracts() -> None:
|
||||
request_schema = {
|
||||
"type": "object",
|
||||
"properties": {"issues": {"type": "array"}},
|
||||
"required": ["issues"],
|
||||
}
|
||||
resume_schema = {
|
||||
"type": "object",
|
||||
"properties": {"selected": {"type": "array"}},
|
||||
"required": ["selected"],
|
||||
}
|
||||
draft = WorkflowDraft.model_validate(
|
||||
{
|
||||
**_keyed_echo_draft(),
|
||||
"start": "review",
|
||||
"steps": {
|
||||
"review": {
|
||||
"interrupt": {
|
||||
"kind": "issue_review",
|
||||
"request_schema": request_schema,
|
||||
"resume_schema": resume_schema,
|
||||
"outcomes": ["submitted", "cancelled"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
step = draft.steps["review"]
|
||||
dumped = draft.model_dump(mode="json", by_alias=True)
|
||||
|
||||
assert isinstance(step, DraftInterruptStep)
|
||||
assert step.interrupt.request_schema is not None
|
||||
assert step.interrupt.request_schema.type == "object"
|
||||
assert step.interrupt.request_schema.properties == {"issues": {"type": "array"}}
|
||||
assert step.interrupt.resume_schema is not None
|
||||
assert step.interrupt.resume_schema.required == ["selected"]
|
||||
assert dumped["steps"]["review"]["interrupt"]["request_schema"] == request_schema
|
||||
assert dumped["steps"]["review"]["interrupt"]["resume_schema"] == resume_schema
|
||||
|
||||
|
||||
def test_workflow_draft_rejects_non_object_interrupt_contracts() -> None:
|
||||
with pytest.raises(ValidationError, match="interrupt schema must describe"):
|
||||
WorkflowDraft.model_validate(
|
||||
{
|
||||
**_keyed_echo_draft(),
|
||||
"start": "review",
|
||||
"steps": {
|
||||
"review": {
|
||||
"interrupt": {
|
||||
"kind": "issue_review",
|
||||
"request_schema": {"type": "array"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_draft_preserves_subgraph_workflow_boundaries() -> None:
|
||||
child_report = {
|
||||
"workflow": {"artifact_id": "child_report", "version": 2},
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"topic": {"type": "string"}},
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"report": {"type": "string"}},
|
||||
},
|
||||
"input": [{"target": "topic", "path": "state.topic"}],
|
||||
"output": [{"source": "report", "target": "state.report"}],
|
||||
"outcomes": ["ok", "error"],
|
||||
}
|
||||
for step_id, subgraph in [
|
||||
(
|
||||
"child",
|
||||
{"workflow": {"name": "child"}, "outcomes": ["ok"]},
|
||||
),
|
||||
("child_report", child_report),
|
||||
]:
|
||||
draft = WorkflowDraft.model_validate(
|
||||
{
|
||||
**_keyed_echo_draft(),
|
||||
"start": step_id,
|
||||
"steps": {step_id: {"subgraph": subgraph}},
|
||||
}
|
||||
)
|
||||
|
||||
step = draft.steps[step_id]
|
||||
dumped = draft.model_dump(mode="json", by_alias=True)
|
||||
|
||||
assert isinstance(step, DraftSubgraphStep)
|
||||
dumped_subgraph = dumped["steps"][step_id]["subgraph"]
|
||||
assert dumped_subgraph["workflow"] == subgraph["workflow"]
|
||||
assert dumped_subgraph["outcomes"] == subgraph["outcomes"]
|
||||
if step_id == "child_report":
|
||||
assert dumped_subgraph["input_schema"]["type"] == "object"
|
||||
assert dumped_subgraph["input_schema"]["properties"] == {
|
||||
"topic": {"type": "string"}
|
||||
}
|
||||
assert dumped_subgraph["output_schema"]["type"] == "object"
|
||||
assert dumped_subgraph["output_schema"]["properties"] == {
|
||||
"report": {"type": "string"}
|
||||
}
|
||||
assert dumped_subgraph["input"] == child_report["input"]
|
||||
assert dumped_subgraph["output"] == child_report["output"]
|
||||
|
||||
|
||||
def test_draft_step_requires_exactly_one_kind_key() -> None:
|
||||
draft = _keyed_echo_draft()
|
||||
steps = draft["steps"]
|
||||
|
||||
Reference in New Issue
Block a user