from __future__ import annotations import pytest from wf_core import ( END, Edge, EndNode, InputExpressionBinding, InputPathBinding, NodeDef, NodeUse, PreparedSubgraph, RuntimeContext, SchemaRef, StateField, StateSchema, StepInputBinding, SubgraphNode, Workflow, WorkflowExecutionError, WorkflowRef, execute_workflow, resume_workflow, ) from wf_core.models.steps import InterruptNode, Step from wf_core.paths import GraphSourcePath, LocalPath from wf_core.runtime.input_bindings import ( resolve_input_expression, resolve_step_input_bindings, ) COMPOSITE_BINDING = { "target": "request", "expression": { "kind": "object", "fields": { "items": { "kind": "array", "items": [ {"kind": "path", "path": "state.foo"}, {"kind": "literal", "value": "wowcool"}, ], }, "separator": {"kind": "literal", "value": " "}, }, }, } def _composite_binding() -> StepInputBinding: """Build this raw fixture through the canonical binding model.""" return InputExpressionBinding.model_validate(COMPOSITE_BINDING) def test_resolver_builds_nested_json_with_input_state_and_context_paths() -> None: expression = InputExpressionBinding.model_validate( { "target": "request", "expression": { "kind": "object", "fields": { "state_value": {"kind": "path", "path": "state.foo"}, "input_value": {"kind": "path", "path": "input.prefix"}, "context_value": { "kind": "path", "path": "context.prior_outcome", }, }, }, } ).expression resolved = resolve_input_expression( expression, state={"foo": "hello"}, workflow_input={"prefix": "say"}, context={"prior_outcome": "ok"}, label="node 'concat' input", location="request", ) assert resolved == { "state_value": "hello", "input_value": "say", "context_value": "ok", } def test_resolver_preserves_explicit_null_and_binding_order() -> None: bindings = [ InputExpressionBinding.model_validate( { "target": "request.first", "expression": {"kind": "literal", "value": None}, } ), InputExpressionBinding.model_validate( { "target": "request.second", "expression": {"kind": "path", "path": "input.value"}, } ), ] assert resolve_step_input_bindings( bindings, state={}, workflow_input={"value": "second"}, context={}, label="node 'ordered' input", ) == {"request": {"first": None, "second": "second"}} def test_simple_path_binding_preserves_legacy_value_identity() -> None: legacy_value = {"opaque": object()} resolved = resolve_step_input_bindings( [ InputPathBinding( target=LocalPath.parse("request.value"), path=GraphSourcePath.parse("state.value"), ) ], state={"value": legacy_value}, workflow_input={}, context={}, label="node 'legacy' input", ) assert resolved["request"]["value"] is legacy_value def test_composite_path_expression_keeps_strict_json_contract() -> None: legacy_value = {"opaque": object()} binding = InputExpressionBinding.model_validate( { "target": "request.value", "expression": {"kind": "path", "path": "state.value"}, } ) with pytest.raises(WorkflowExecutionError, match="node 'composite' input"): resolve_step_input_bindings( [binding], state={"value": legacy_value}, workflow_input={}, context={}, label="node 'composite' input", ) def test_resolver_reports_nested_missing_path_location() -> None: expression = InputExpressionBinding.model_validate( { "target": "request", "expression": { "kind": "object", "fields": { "items": { "kind": "array", "items": [ {"kind": "path", "path": "state.missing"}, ], } }, }, } ).expression with pytest.raises( WorkflowExecutionError, match=r"node 'concat' input request\.items\[0\]", ): resolve_input_expression( expression, state={}, workflow_input={}, context={}, label="node 'concat' input", location="request", ) def test_resolver_reports_local_target_location() -> None: bindings = [ InputExpressionBinding.model_validate( { "target": "request", "expression": { "kind": "literal", "value": "first", }, } ), InputExpressionBinding.model_validate( { "target": "request.title", "expression": {"kind": "literal", "value": "second"}, } ), ] with pytest.raises( WorkflowExecutionError, match=r"node 'concat' input request\.title", ): resolve_step_input_bindings( bindings, state={}, workflow_input={}, context={}, label="node 'concat' input", ) def test_normal_node_execution_resolves_composite_input() -> None: seen: dict[str, object] = {} def concat( payload: dict[str, object], _context: RuntimeContext ) -> dict[str, object]: seen.update(payload) return {"outcome": "ok", "output": {}} workflow = _node_workflow( input_bindings=[_composite_binding()], state_fields={"foo": StateField(type="string", default="hello")}, ) run = execute_workflow(workflow, {}, {"concat": concat}) assert run.status.value == "completed" assert seen == {"request": {"items": ["hello", "wowcool"], "separator": " "}} def test_prepared_subgraph_input_resolves_composite_input() -> None: parent = _parent_subgraph_workflow([_composite_binding()]) child_step: Step = EndNode(id="done", type="end", outcome="ok") child = Workflow( name="child", input_schema=_schema({"request": {"type": "object"}}), state_schema=StateSchema.from_field_map({}), output_schema=_schema({}), outcomes=["ok"], start="done", nodes=[child_step], edges=[], ) run = execute_workflow( parent, {}, {}, subgraphs={"child": PreparedSubgraph(workflow=child, registry={})}, ) child_scope = next( scope for scope in run.scopes.values() if scope.workflow_name == "child" ) assert child_scope.workflow_input == { "request": {"items": ["hello", "wowcool"], "separator": " "} } def test_interrupt_request_resolves_composite_input_and_resume_continues() -> None: workflow = Workflow( name="interrupt_expression", input_schema=_schema({}), state_schema=StateSchema.from_field_map( {"foo": StateField(type="string", default="hello")} ), output_schema=_schema({}), outcomes=["submitted"], start="review", nodes=[ InterruptNode.model_validate( { "id": "review", "type": "interrupt", "kind": "review", "request": [COMPOSITE_BINDING], "resume": [], } ), EndNode(id="done", type="end", outcome="submitted"), ], edges=[ Edge.model_validate( {"from": "review", "outcome": "submitted", "to": "done"} ) ], ) run = execute_workflow(workflow, {}, {}) assert run.interrupt is not None assert run.interrupt.payload == { "request": {"items": ["hello", "wowcool"], "separator": " "} } assert ( resume_workflow(workflow, run, {}, resume_payload={}).status.value == "completed" ) def _node_workflow( *, input_bindings: list[StepInputBinding], state_fields: dict[str, StateField], ) -> Workflow: node_step: Step = NodeUse( id="concat", type="node", node="concat", input=input_bindings, ) return Workflow( name="node_expression", input_schema=_schema({}), state_schema=StateSchema.from_field_map(state_fields), output_schema=_schema({}), outcomes=["ok"], start="concat", node_defs=[ NodeDef( name="concat", input_schema=_schema({"request": {"type": "object"}}), output_schema=_schema({}), outcomes=["ok"], ) ], nodes=[node_step], edges=[Edge.model_validate({"from": "concat", "outcome": "ok", "to": END})], ) def _parent_subgraph_workflow(input_bindings: list[StepInputBinding]) -> Workflow: child_step: Step = SubgraphNode( id="child", type="subgraph", workflow=WorkflowRef(name="child"), input_schema=_schema({"request": {"type": "object"}}), output_schema=_schema({}), input=input_bindings, ) return Workflow( name="parent", input_schema=_schema({}), state_schema=StateSchema.from_field_map( {"foo": StateField(type="string", default="hello")} ), output_schema=_schema({}), outcomes=["ok"], start="child", nodes=[child_step], edges=[Edge.model_validate({"from": "child", "outcome": "ok", "to": END})], ) def _schema(properties: dict[str, object]) -> SchemaRef: return SchemaRef.model_validate({"type": "object", "properties": properties})