Files
microsoft--agent-framework/python/packages/durabletask/tests/test_workflow_client.py
T
wehub-resource-sync db620d33df
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:25 +08:00

693 lines
30 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for DurableWorkflowClient.
Covers starting workflows, awaiting output (including error/timeout paths),
parsing pending human-in-the-loop (HITL) requests from custom status, and
sanitizing HITL responses before delivery.
"""
import json
from dataclasses import dataclass
from unittest.mock import Mock
import pytest
from agent_framework import WorkflowEvent
from agent_framework_durabletask import DurableWorkflowClient
from agent_framework_durabletask._workflows.naming import workflow_orchestrator_name
from agent_framework_durabletask._workflows.serialization import serialize_value, serialize_workflow_event
@dataclass
class _Receipt:
"""Module-level dataclass so it is picklable by serialize_value."""
order_id: int
total: float
@pytest.fixture
def mock_client() -> Mock:
"""Create a mock TaskHubGrpcClient."""
return Mock()
@pytest.fixture
def workflow_client(mock_client: Mock) -> DurableWorkflowClient:
"""Create a DurableWorkflowClient wrapping the mock client."""
return DurableWorkflowClient(mock_client)
class TestStartWorkflow:
"""Test starting workflow orchestrations."""
def test_start_workflow_schedules_orchestrator(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""start_workflow schedules the per-workflow orchestration by name."""
mock_client.schedule_new_orchestration.return_value = "instance-1"
result = workflow_client.start_workflow(input="hello", workflow_name="orders")
assert result == "instance-1"
mock_client.schedule_new_orchestration.assert_called_once_with(
workflow_orchestrator_name("orders"), input="hello", instance_id=None
)
def test_start_workflow_passes_non_string_input_unchanged(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""Non-string payloads are forwarded as-is (no string coercion)."""
mock_client.schedule_new_orchestration.return_value = "instance-2"
payload = {"order_id": 42, "items": ["a", "b"]}
workflow_client.start_workflow(input=payload, workflow_name="orders")
_, kwargs = mock_client.schedule_new_orchestration.call_args
assert kwargs["input"] == payload
def test_start_workflow_strips_forged_subworkflow_envelope(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""Reserved sub-workflow envelope keys in client input are stripped at the boundary.
Only an internal child dispatch may carry these keys; if untrusted input could,
it would smuggle a payload onto the orchestrator's trusted (pickle) path.
"""
mock_client.schedule_new_orchestration.return_value = "i"
forged = {"__subworkflow_input__": {"__pickled__": "evil", "__type__": "x"}, "real": 1}
workflow_client.start_workflow(input=forged, workflow_name="orders")
_, kwargs = mock_client.schedule_new_orchestration.call_args
assert kwargs["input"] == {"real": 1}
assert "__subworkflow_input__" not in kwargs["input"]
def test_start_workflow_forwards_instance_id(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""An explicit instance id is forwarded to the underlying client."""
mock_client.schedule_new_orchestration.return_value = "explicit-id"
workflow_client.start_workflow(input="x", workflow_name="orders", instance_id="explicit-id")
_, kwargs = mock_client.schedule_new_orchestration.call_args
assert kwargs["instance_id"] == "explicit-id"
class TestWorkflowNameTargeting:
"""Resolving the target workflow name from a default or per-call value."""
def test_uses_constructor_default(self, mock_client: Mock) -> None:
"""A client default workflow name is used when none is passed per call."""
client = DurableWorkflowClient(mock_client, workflow_name="billing")
mock_client.schedule_new_orchestration.return_value = "i"
client.start_workflow(input="x")
mock_client.schedule_new_orchestration.assert_called_once_with(
workflow_orchestrator_name("billing"), input="x", instance_id=None
)
def test_per_call_overrides_default(self, mock_client: Mock) -> None:
"""A per-call workflow name overrides the constructor default."""
client = DurableWorkflowClient(mock_client, workflow_name="billing")
mock_client.schedule_new_orchestration.return_value = "i"
client.start_workflow(input="x", workflow_name="orders")
mock_client.schedule_new_orchestration.assert_called_once_with(
workflow_orchestrator_name("orders"), input="x", instance_id=None
)
def test_raises_when_no_name_resolvable(self, workflow_client: DurableWorkflowClient) -> None:
"""With no default and no per-call name, starting raises a clear error."""
with pytest.raises(ValueError, match="No workflow name"):
workflow_client.start_workflow(input="x")
class TestOwnershipValidation:
"""Opt-in validation that an instance belongs to the targeted workflow."""
def test_runtime_status_returns_none_for_foreign_instance(self, mock_client: Mock) -> None:
"""A status query scoped to a workflow returns None for a foreign instance."""
client = DurableWorkflowClient(mock_client, workflow_name="orders")
state = Mock()
state.name = workflow_orchestrator_name("billing") # different workflow
state.runtime_status.name = "RUNNING"
mock_client.get_orchestration_state.return_value = state
assert client.get_runtime_status("instance-1") is None
def test_runtime_status_returns_status_for_owned_instance(self, mock_client: Mock) -> None:
"""A status query returns the status for an instance of the targeted workflow."""
client = DurableWorkflowClient(mock_client, workflow_name="orders")
state = Mock()
state.name = workflow_orchestrator_name("orders")
state.runtime_status.name = "RUNNING"
mock_client.get_orchestration_state.return_value = state
assert client.get_runtime_status("instance-1") == "RUNNING"
def test_pending_hitl_empty_for_foreign_instance(self, mock_client: Mock) -> None:
"""Pending HITL is empty for an instance of a different workflow."""
client = DurableWorkflowClient(mock_client, workflow_name="orders")
state = Mock()
state.name = workflow_orchestrator_name("billing")
state.serialized_custom_status = json.dumps({"pending_requests": {"req-1": {"source_executor_id": "x"}}})
mock_client.get_orchestration_state.return_value = state
assert client.get_pending_hitl_requests("instance-1") == []
def test_send_hitl_rejects_foreign_instance(self, mock_client: Mock) -> None:
"""Sending a HITL response to a foreign instance raises and does not deliver."""
client = DurableWorkflowClient(mock_client, workflow_name="orders")
state = Mock()
state.name = workflow_orchestrator_name("billing")
mock_client.get_orchestration_state.return_value = state
with pytest.raises(ValueError, match="does not belong"):
client.send_hitl_response("instance-1", "req-1", {"approved": True})
mock_client.raise_orchestration_event.assert_not_called()
def test_send_hitl_allows_owned_instance(self, mock_client: Mock) -> None:
"""Sending a HITL response to an owned instance delivers the event."""
client = DurableWorkflowClient(mock_client, workflow_name="orders")
state = Mock()
state.name = workflow_orchestrator_name("orders")
mock_client.get_orchestration_state.return_value = state
client.send_hitl_response("instance-1", "req-1", {"approved": True})
mock_client.raise_orchestration_event.assert_called_once()
class TestAwaitWorkflowOutput:
"""Test awaiting workflow completion and output."""
def test_returns_deserialized_output_on_completion(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A COMPLETED workflow returns its deserialized output."""
metadata = Mock()
metadata.runtime_status.name = "COMPLETED"
metadata.serialized_output = json.dumps(["result"])
mock_client.wait_for_orchestration_completion.return_value = metadata
output = workflow_client.await_workflow_output("instance-1")
assert output == ["result"]
def test_returns_none_when_no_output(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""A COMPLETED workflow with no output returns None."""
metadata = Mock()
metadata.runtime_status.name = "COMPLETED"
metadata.serialized_output = None
mock_client.wait_for_orchestration_completion.return_value = metadata
assert workflow_client.await_workflow_output("instance-1") is None
def test_reconstructs_typed_outputs(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""Typed outputs encoded by the activity come back as objects, not marker dicts."""
receipt = _Receipt(order_id=7, total=19.99)
# The shared activity stores each yielded output via serialize_value(), so a
# typed object is persisted as a checkpoint-marker dict.
metadata = Mock()
metadata.runtime_status.name = "COMPLETED"
metadata.serialized_output = json.dumps([serialize_value(receipt)])
mock_client.wait_for_orchestration_completion.return_value = metadata
output = workflow_client.await_workflow_output("instance-1")
assert output == [receipt]
assert isinstance(output[0], _Receipt)
def test_raises_timeout_when_not_completed(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""A None metadata (no completion) raises TimeoutError."""
mock_client.wait_for_orchestration_completion.return_value = None
with pytest.raises(TimeoutError, match="did not complete"):
workflow_client.await_workflow_output("instance-1", timeout_seconds=5)
def test_raises_runtime_error_on_failed_status(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A non-COMPLETED status raises RuntimeError."""
metadata = Mock()
metadata.runtime_status.name = "FAILED"
metadata.serialized_output = "boom"
mock_client.wait_for_orchestration_completion.return_value = metadata
with pytest.raises(RuntimeError, match="status FAILED"):
workflow_client.await_workflow_output("instance-1")
class TestGetRuntimeStatus:
"""Test reading the workflow's runtime status."""
def test_returns_status_name(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""The runtime status name is returned when state is available."""
state = Mock()
state.runtime_status.name = "RUNNING"
mock_client.get_orchestration_state.return_value = state
assert workflow_client.get_runtime_status("instance-1") == "RUNNING"
def test_returns_none_when_no_state(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""No orchestration state yields None (status unknown)."""
mock_client.get_orchestration_state.return_value = None
assert workflow_client.get_runtime_status("instance-1") is None
class TestGetPendingHitlRequests:
"""Test parsing pending HITL requests from custom status."""
def _state_with_status(self, status: object) -> Mock:
state = Mock()
state.serialized_custom_status = json.dumps(status) if status is not None else None
return state
def test_returns_empty_when_no_state(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""No orchestration state yields an empty list."""
mock_client.get_orchestration_state.return_value = None
assert workflow_client.get_pending_hitl_requests("instance-1") == []
def test_returns_empty_when_status_blank(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""A blank custom status yields an empty list."""
state = Mock()
state.serialized_custom_status = ""
mock_client.get_orchestration_state.return_value = state
assert workflow_client.get_pending_hitl_requests("instance-1") == []
def test_returns_empty_on_invalid_json(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""Malformed custom status JSON yields an empty list."""
state = Mock()
state.serialized_custom_status = "{not-json"
mock_client.get_orchestration_state.return_value = state
assert workflow_client.get_pending_hitl_requests("instance-1") == []
def test_parses_pending_requests(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""Pending requests are normalized into the documented shape."""
status = {
"pending_requests": {
"req-1": {
"request_id": "req-1",
"source_executor_id": "approver",
"data": {"prompt": "approve?"},
"request_type": "ApprovalRequest",
"response_type": "ApprovalResponse",
}
}
}
mock_client.get_orchestration_state.return_value = self._state_with_status(status)
requests = workflow_client.get_pending_hitl_requests("instance-1")
assert requests == [
{
"request_id": "req-1",
"source_executor_id": "approver",
"data": {"prompt": "approve?"},
"request_type": "ApprovalRequest",
"response_type": "ApprovalResponse",
}
]
def test_falls_back_to_dict_key_for_request_id(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""When a request omits request_id, the dict key is used."""
status = {"pending_requests": {"req-key": {"source_executor_id": "x"}}}
mock_client.get_orchestration_state.return_value = self._state_with_status(status)
requests = workflow_client.get_pending_hitl_requests("instance-1")
assert requests[0]["request_id"] == "req-key"
def test_ignores_non_dict_entries(self, workflow_client: DurableWorkflowClient, mock_client: Mock) -> None:
"""Non-dict request entries are skipped."""
status = {"pending_requests": {"req-1": "not-a-dict"}}
mock_client.get_orchestration_state.return_value = self._state_with_status(status)
assert workflow_client.get_pending_hitl_requests("instance-1") == []
def test_returns_empty_when_pending_not_dict(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A non-dict pending_requests field yields an empty list."""
status = {"pending_requests": ["unexpected"]}
mock_client.get_orchestration_state.return_value = self._state_with_status(status)
assert workflow_client.get_pending_hitl_requests("instance-1") == []
class TestSendHitlResponse:
"""Test delivering HITL responses."""
def test_raises_orchestration_event_with_request_id(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""The response is delivered as an external event named by request id."""
workflow_client.send_hitl_response("instance-1", "req-1", {"approved": True})
mock_client.raise_orchestration_event.assert_called_once()
_, kwargs = mock_client.raise_orchestration_event.call_args
assert kwargs["event_name"] == "req-1"
assert kwargs["data"] == {"approved": True}
def test_strips_pickle_markers_before_delivery(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A crafted pickle-marker payload is neutralized before reaching the worker.
The HITL response is sent to the worker which deserializes it, so a payload
carrying the checkpoint ``__pickled__`` marker must be stripped client-side
(regression guard for the strip_pickle_markers call in send_hitl_response).
"""
malicious = {"__pickled__": "<crafted-base64-payload>", "approved": True}
workflow_client.send_hitl_response("instance-1", "req-1", malicious)
_, kwargs = mock_client.raise_orchestration_event.call_args
# The whole marker-bearing dict is neutralized (replaced with None) rather
# than forwarded, so it can never reach pickle.loads on the worker.
assert kwargs["data"] is None
class TestStreamWorkflow:
"""Test streaming typed workflow events by polling custom status."""
def _state(self, *, status: str, events: list[dict] | None = None) -> Mock:
state = Mock()
state.runtime_status.name = status
if events is None:
state.serialized_custom_status = None
else:
state.serialized_custom_status = json.dumps({"state": "running", "events": events})
return state
async def test_streams_events_in_order_until_terminal(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""Events accrue across polls and stream in order; streaming ends at a terminal state."""
# Each poll returns a growing accumulated event list, then a terminal status.
mock_client.get_orchestration_state.side_effect = [
self._state(status="RUNNING", events=[{"type": "executor_invoked", "executor_id": "a"}]),
self._state(
status="RUNNING",
events=[
{"type": "executor_invoked", "executor_id": "a"},
{"type": "executor_completed", "executor_id": "a"},
],
),
self._state(
status="COMPLETED",
events=[
{"type": "executor_invoked", "executor_id": "a"},
{"type": "executor_completed", "executor_id": "a"},
],
),
]
seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)]
# Each accumulated event is yielded exactly once, in order, as a typed event.
assert all(isinstance(e, WorkflowEvent) for e in seen)
assert [e.type for e in seen] == ["executor_invoked", "executor_completed"]
assert [e.executor_id for e in seen] == ["a", "a"]
async def test_terminal_with_no_status_yields_nothing(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A workflow that is already terminal with no custom status streams no events."""
mock_client.get_orchestration_state.return_value = self._state(status="COMPLETED")
seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)]
assert seen == []
async def test_streams_typed_event_data_roundtrip(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""An output event's data is reconstructed into its original typed object."""
receipt = _Receipt(order_id=7, total=42.5)
serialized_event = serialize_workflow_event(WorkflowEvent("output", data=receipt, executor_id="processor"))
mock_client.get_orchestration_state.side_effect = [
self._state(status="RUNNING", events=[serialized_event]),
self._state(status="COMPLETED", events=[serialized_event]),
]
seen = [event async for event in workflow_client.stream_workflow("instance-1", poll_interval_seconds=0)]
assert len(seen) == 1
assert isinstance(seen[0], WorkflowEvent)
assert seen[0].type == "output"
assert seen[0].executor_id == "processor"
assert seen[0].data == receipt
class TestRunWorkflow:
"""Test the async run_workflow convenience (start + optional wait)."""
async def test_waits_and_returns_output_by_default(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""By default run_workflow starts the workflow and returns its deserialized output."""
mock_client.schedule_new_orchestration.return_value = "instance-1"
metadata = Mock()
metadata.name = workflow_orchestrator_name("orders")
metadata.runtime_status.name = "COMPLETED"
metadata.serialized_output = json.dumps(["done"])
mock_client.wait_for_orchestration_completion.return_value = metadata
result = await workflow_client.run_workflow(input="hello", workflow_name="orders")
assert result == ["done"]
mock_client.schedule_new_orchestration.assert_called_once()
mock_client.wait_for_orchestration_completion.assert_called_once()
async def test_no_wait_returns_instance_id_without_awaiting(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""With wait=False, run_workflow returns the instance id and does not await completion."""
mock_client.schedule_new_orchestration.return_value = "instance-2"
result = await workflow_client.run_workflow(input="hello", workflow_name="orders", wait=False)
assert result == "instance-2"
mock_client.wait_for_orchestration_completion.assert_not_called()
class TestSubworkflowHitl:
"""Sub-workflow HITL: qualified request ids in/out (B2 single-surface addressing)."""
@staticmethod
def _states(mock_client: Mock, by_instance: dict[str, dict | None]) -> None:
"""Wire get_orchestration_state to return a state per instance id.
Each value is the custom-status dict for that instance (or None for no
status). ``name`` is unset so ownership validation is skipped (these tests
construct the client without a workflow_name default).
"""
def _get_state(instance_id: str) -> Mock | None:
if instance_id not in by_instance:
return None
status = by_instance[instance_id]
state = Mock()
state.serialized_custom_status = json.dumps(status) if status is not None else None
return state
mock_client.get_orchestration_state.side_effect = _get_state
def test_collects_nested_request_with_qualified_id(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A request pending in a child sub-workflow surfaces with an {executor}~{ordinal}~{id} id."""
self._states(
mock_client,
{
"parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}},
"child-1": {
"state": "waiting_for_human_input",
"pending_requests": {"req-9": {"request_id": "req-9", "source_executor_id": "inner_node"}},
},
},
)
requests = workflow_client.get_pending_hitl_requests("parent")
assert len(requests) == 1
assert requests[0]["request_id"] == "sub~0~req-9"
assert requests[0]["source_executor_id"] == "inner_node"
def test_collects_parent_and_nested_requests_together(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""Top-level and nested pending requests are both returned (nested qualified)."""
self._states(
mock_client,
{
"parent": {
"state": "waiting_for_human_input",
"pending_requests": {"top-1": {"request_id": "top-1", "source_executor_id": "outer_node"}},
"subworkflows": {"sub": ["child-1"]},
},
"child-1": {
"state": "waiting_for_human_input",
"pending_requests": {"inner-1": {"request_id": "inner-1", "source_executor_id": "inner_node"}},
},
},
)
ids = {r["request_id"] for r in workflow_client.get_pending_hitl_requests("parent")}
assert ids == {"top-1", "sub~0~inner-1"}
def test_collects_deeply_nested_request_with_full_path(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""Two levels of nesting accumulate a full {a}~{i}~{b}~{j}~{id} path."""
self._states(
mock_client,
{
"parent": {"state": "running", "subworkflows": {"mid": ["child-1"]}},
"child-1": {"state": "running", "subworkflows": {"leaf": ["child-2"]}},
"child-2": {
"state": "waiting_for_human_input",
"pending_requests": {"deep": {"request_id": "deep", "source_executor_id": "leaf_node"}},
},
},
)
requests = workflow_client.get_pending_hitl_requests("parent")
assert [r["request_id"] for r in requests] == ["mid~0~leaf~0~deep"]
def test_send_qualified_response_routes_to_child_instance(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A qualified id resolves to the owning child instance and bare request id."""
self._states(
mock_client,
{"parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}}},
)
workflow_client.send_hitl_response("parent", "sub~0~req-9", {"approved": True})
mock_client.raise_orchestration_event.assert_called_once()
args, kwargs = mock_client.raise_orchestration_event.call_args
assert args[0] == "child-1"
assert kwargs["event_name"] == "req-9"
assert kwargs["data"] == {"approved": True}
def test_send_deeply_qualified_response_routes_to_leaf(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A two-level qualified id lands on the leaf child with the bare id."""
self._states(
mock_client,
{
"parent": {"state": "running", "subworkflows": {"mid": ["child-1"]}},
"child-1": {"state": "running", "subworkflows": {"leaf": ["child-2"]}},
},
)
workflow_client.send_hitl_response("parent", "mid~0~leaf~0~deep", {"ok": 1})
args, kwargs = mock_client.raise_orchestration_event.call_args
assert args[0] == "child-2"
assert kwargs["event_name"] == "deep"
def test_send_qualified_response_unknown_subworkflow_raises(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A qualified id for an inactive sub-workflow raises and delivers nothing."""
self._states(mock_client, {"parent": {"state": "running"}}) # no subworkflows map
with pytest.raises(ValueError, match="No active sub-workflow"):
workflow_client.send_hitl_response("parent", "sub~0~req-9", {"approved": True})
mock_client.raise_orchestration_event.assert_not_called()
def test_unqualified_response_still_targets_named_instance(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A plain (unqualified) request id targets the given instance directly."""
self._states(mock_client, {"parent": {"state": "waiting_for_human_input"}})
workflow_client.send_hitl_response("parent", "req-1", {"approved": True})
args, kwargs = mock_client.raise_orchestration_event.call_args
assert args[0] == "parent"
assert kwargs["event_name"] == "req-1"
def test_multiple_children_of_one_executor_stay_addressable(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""Two children dispatched by one node are qualified by ordinal, not collapsed."""
self._states(
mock_client,
{
"parent": {"state": "running", "subworkflows": {"sub": ["child-1", "child-2"]}},
"child-1": {
"state": "waiting_for_human_input",
"pending_requests": {"r1": {"request_id": "r1", "source_executor_id": "a"}},
},
"child-2": {
"state": "waiting_for_human_input",
"pending_requests": {"r2": {"request_id": "r2", "source_executor_id": "b"}},
},
},
)
ids = {r["request_id"] for r in workflow_client.get_pending_hitl_requests("parent")}
assert ids == {"sub~0~r1", "sub~1~r2"}
# The second child (ordinal 1) is reachable, not shadowed by the first.
workflow_client.send_hitl_response("parent", "sub~1~r2", {"ok": 1})
args, kwargs = mock_client.raise_orchestration_event.call_args
assert args[0] == "child-2"
assert kwargs["event_name"] == "r2"
def test_nested_leaf_request_id_with_double_colon_round_trips(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A functional sub-workflow's ``auto::N`` leaf id survives qualification and routing."""
self._states(
mock_client,
{
"parent": {"state": "running", "subworkflows": {"sub": ["child-1"]}},
"child-1": {
"state": "waiting_for_human_input",
"pending_requests": {"auto::0": {"request_id": "auto::0", "source_executor_id": "fn"}},
},
},
)
requests = workflow_client.get_pending_hitl_requests("parent")
assert [r["request_id"] for r in requests] == ["sub~0~auto::0"]
workflow_client.send_hitl_response("parent", "sub~0~auto::0", {"ok": 1})
args, kwargs = mock_client.raise_orchestration_event.call_args
assert args[0] == "child-1"
assert kwargs["event_name"] == "auto::0"
def test_top_level_auto_request_id_is_not_treated_as_nested(
self, workflow_client: DurableWorkflowClient, mock_client: Mock
) -> None:
"""A top-level ``auto::N`` id (contains ``::`` but no ``~``) routes to the instance itself."""
self._states(mock_client, {"parent": {"state": "waiting_for_human_input"}})
workflow_client.send_hitl_response("parent", "auto::0", {"approved": True})
args, kwargs = mock_client.raise_orchestration_event.call_args
assert args[0] == "parent"
assert kwargs["event_name"] == "auto::0"