Files
microsoft--agent-framework/python/packages/durabletask/tests/test_worker.py
Ahmed Muhsin 81e425b44f Python: [BREAKING] Durable Task multi-workflow hosting and sub-workflows (#6696)
* feat(durabletask): add workflow naming helpers (multi-workflow phase 0)

Foundation for hosting multiple workflows (and later sub-workflows) on one
durable task host. Adds a host-agnostic naming module that derives the stable
durable names a hosted workflow registers under.

- New `_workflows/naming.py`:
  - `workflow_orchestrator_name(name)` -> `dafx-{name}` (orchestration name,
    aligned byte-for-byte with .NET `WorkflowNamingHelper`).
  - `workflow_name_from_orchestrator(name)` -> reverse, `None` when not prefixed.
  - `validate_workflow_name(name)` -> rejects empty / malformed / auto-generated
    `WorkflowBuilder-<uuid>` names (validate-and-reject rather than silently
    sanitize, since the name becomes a durable identity and an HTTP route segment).
  - `is_auto_generated_workflow_name(name)`, `DURABLE_NAME_PREFIX`.
- Export the helpers from the package public API.
- Mark `WORKFLOW_ORCHESTRATOR_NAME` deprecated in favor of per-workflow names
  (kept functional; the single-workflow path still uses it until phase 1).
- 39 unit tests covering round-trips and validation.

Design: docs/design/durabletask-multiworkflow-and-subworkflows.md

* feat(durabletask): host multiple workflows per worker with scoped names (phase 1)

Enables hosting more than one MAF workflow on a single standalone Durable Task
worker, and aligns both hosts on workflow-scoped durable names so two co-hosted
workflows that reuse an executor id cannot collide.

Naming (shared, host-agnostic):
- orchestration: dafx-{workflowName} (matches .NET; the name DT tooling surfaces)
- non-agent activity / agent entity: dafx-{workflowName}-{executorId} (scoped)
- New naming helpers workflow_scoped_executor_id / workflow_executor_activity_name.

Standalone worker (agent-framework-durabletask):
- configure_workflow is now additive: stores workflows keyed by Workflow.name,
  rejects duplicate / auto-generated (WorkflowBuilder-<uuid>) / invalid names,
  registers one orchestrator per workflow plus its scoped activities/entities.
- The shared orchestrator dispatches scoped names derived from workflow.name.
- New registered_workflow_names property.

Client (DurableWorkflowClient):
- Optional default workflow_name on the client; start/run/stream accept a per-call
  workflow_name and target dafx-{name}.
- Opt-in ownership validation on status/HITL methods: when a workflow name is
  resolvable, an instance whose orchestration name does not match is treated as
  not-found (status -> None, pending -> [], send_hitl_response / await -> raise),
  mirroring the Azure Functions route-scoping check.

Azure Functions host (agent-framework-azurefunctions):
- Registration now uses the same scoped names so the shared orchestrator's
  dispatch matches (single workflow per app for now; flat workflow/* routes kept).
- Workflow name is validated up front; workflow agents register under the scoped
  entity id; _is_workflow_orchestration scopes to dafx-{workflow.name}.

Samples + tests:
- Durable Task and Azure Functions workflow samples now name their workflow.
- Unit tests cover multi-workflow registration, name validation, client targeting,
  and ownership; integration tests target the named workflows.

WORKFLOW_ORCHESTRATOR_NAME remains exported (deprecated). This is a hard switch:
in-flight single-workflow instances created before upgrade (under the old
workflow_orchestrator name) will not resume.

Design: docs/design/durabletask-multiworkflow-and-subworkflows.md

* feat(azurefunctions): host multiple workflows per app with per-workflow routes (phase 2)

Completes multi-workflow hosting on the Azure Functions host, building on the
shared scoped-naming foundation from the worker phase.

AgentFunctionApp:
- New `workflows=` parameter accepting a list (keyed by each `Workflow.name`) or a
  name->Workflow mapping; the existing `workflow=` is a single-workflow alias.
  Both may be combined. Duplicate names and mapping-key/name mismatches are rejected.
- Each workflow registers its own `dafx-{name}` orchestration, workflow-scoped
  activities/entities, and per-workflow HTTP routes:
  `workflow/{name}/run`, `workflow/{name}/status/{instanceId}`,
  `workflow/{name}/respond/{instanceId}/{requestId}`. Routes are always
  per-workflow (even for a single workflow) so callers don't change URLs as an app
  grows from one workflow to many.
- Route ownership check is per-workflow (`_is_owned_orchestration(status, name)`):
  a leaked instance id for another orchestration -- or another workflow -- is
  treated as not-found, extending the route-scoping defense.
- `get_agent(context, name, workflow_name=...)` resolves a workflow agent under its
  scoped id; bare `agents=` registration keeps the standalone surface. New
  `workflows` introspection property; `.workflow` now returns the sole workflow
  (or None when several are hosted).
- Removed the now-unused flat-URL helper `_build_status_url` (handlers inline
  per-workflow URLs).

Samples + tests:
- Azure Functions workflow samples (09-12) name their workflow; integration tests
  target the per-workflow routes.
- Unit tests cover multi-workflow registration, duplicate/mapping/auto-name
  rejection, and per-workflow ownership.

Note: sample README / demo.http route docs are updated in the docs phase.

Design: docs/design/durabletask-multiworkflow-and-subworkflows.md

* feat(durabletask): sub-workflows via durable child orchestrations (phase 3)

Run WorkflowExecutor nodes as durable child orchestrations on both hosts.

- Protocol: add call_sub_orchestrator to WorkflowOrchestrationContext, implemented by the durabletask and Azure Functions adapters.

- Registration: planner classifies WorkflowExecutor as subworkflow_executors; collect_hosted_workflows walks nested workflows (parent first, deduped by name). Both hosts recursively register every nested workflow's orchestration/agents/activities once; only top-level workflows get HTTP routes. Names validated up front before any registration side effects.

- Orchestrator: dispatch WorkflowExecutor nodes via call_sub_orchestrator(dafx-{innerName}) with deterministic child instance ids ({instanceId}::{executorId}::{counter}), a trusted-input marker carrying nesting depth (bounded at 25), and outputs routed as messages (default) or parent outputs (allow_direct_output).

- Tests: registration/collect, orchestrator prepare/process/unwrap, recursive registration on both hosts. Sample: 11_subworkflow.

* feat(durabletask): sub-workflow HITL via qualified request ids (phase 4)

Surface a nested sub-workflow's human-in-the-loop request behind the top-level instance (B2 single addressing surface).

- Orchestrator records dispatched sub-workflow child instance ids in its custom status (subworkflows map) before suspending in task_all, so the read side can reach a child's pending request while the parent is paused.

- Read side (durabletask client get_pending_hitl_requests; AF status route) recurses into nested child statuses, qualifying each nested request id as {executorId}::{requestId} (accumulated for deeper nesting).

- Write side (durabletask client send_hitl_response; AF respond route) splits a qualified id on '::', resolves the owning child orchestration via the parent's subworkflows map, and raises the event on the leaf child with the bare request id. Unknown/inactive sub-workflow -> error/404.

- Shared SUBWORKFLOW_REQUEST_SEPARATOR ('::') in naming so both hosts and the client agree. respondUrl/respond always targets the top-level instance.

- Tests: TestSubworkflowHitl (durabletask client, 7), TestAgentFunctionAppSubworkflowHitl (AF, 7). Sample: 12_subworkflow_hitl (HITL pause inside an embedded sub-workflow).

* docs(durabletask): ADR + sample route docs for multi-workflow and sub-workflows (phase 5)

- Add ADR-0030 capturing the multi-workflow and sub-workflow hosting decisions (naming, scoped inner names, per-workflow routes, child-orchestration sub-workflows, hard-switch migration, B2 sub-workflow HITL, scoped agent addressing) with considered alternatives; mark the design doc as implemented and link the ADR.

- Update Azure Functions workflow samples (09-12) README/demo.http to the per-workflow route shape (workflow/{name}/run|status|respond) introduced in phase 2.

- Extend the durabletask sample catalog with the workflow hosting patterns (08-12), including the new 11_subworkflow and 12_subworkflow_hitl samples.

* fix(durabletask): harden sub-workflow hosting + add sub-workflow integration tests

Post-review hardening of the multi-workflow / sub-workflow durable hosting:

- Trust boundary: strip the reserved sub-workflow envelope key from untrusted
  client input at both host boundaries (DurableWorkflowClient.start_workflow and
  the AF start route) so a forged envelope cannot reach the trusted pickle path.
- Nested HITL addressing: qualify nested pending requests by (executorId, ordinal)
  using a '~' separator (was '::', which collided with core's auto::N functional
  request ids); the parent status subworkflows map is now a per-executor list so
  multiple children dispatched in one superstep stay independently addressable.
- Reject two different workflow instances that share a name (the same instance
  reused by sibling nodes is still deduped); validate executor ids (separator-free,
  length-bounded) when hosting durably.
- Remove the arbitrary sub-workflow nesting depth cap: a WorkflowExecutor wraps a
  concrete Workflow so the nesting tree is finite at build time, and the durable
  instance-id length limit is the natural ceiling (matches .NET, which has none).

Tests/samples:
- New durabletask integration tests for sub-workflow composition (11) and nested
  sub-workflow HITL (12); new no-agent AF sub-workflow HITL sample (13) + test.
- Exempt no-agent samples from the model-credential gate in both integration
  conftests so the nested-HITL plumbing is covered deterministically.
- Update durabletask sample 12 docs to the new qualified-id format.

Validated: 484 unit tests; durabletask integration 08/09/11/12 and AF 12/13 pass
against the live emulators; pyright 0 errors; ruff clean.

* fix(durabletask): address PR review feedback on naming, typing, and docs

- Unquote df.DurableOrchestrationClient annotations so pyupgrade passes.
- Narrow the split_subworkflow_request_id result before unpacking in a naming test so the strict type checkers pass.
- Correct the durabletask sample catalog to the {executor}~{ordinal}~{requestId} qualified id format.
- Reword the Azure Functions sub-workflow sample intro so it does not imply a difference from a same-numbered sample.
- Drop internal shorthand (B2, phase labels) from code comments.

* fix(durabletask): reject case-insensitive workflow name collisions

The route ownership guard compares the durable orchestration name with casefold(), but registration kept raw names as distinct keys. Hosting 'Orders' and 'orders' therefore succeeded while either workflow's status/respond route could operate on the other's instances. Reject case-insensitive name collisions at registration (within a composition via collect_hosted_workflows, and across registration calls via the case-folded _registered_orchestrations map and the top-level guard in both hosts) so the case-folded ownership boundary stays real. Single names of any case remain valid; only collisions are rejected.

* docs(durabletask): remove multiworkflow/subworkflow ADR and design docs

Drop the ADR and design exploration documents and the dangling docstring reference to them.

* refactor(durabletask): simplify workflow client status parsing and drop deprecated orchestrator-name symbols

Extract a shared _parse_custom_status helper in DurableWorkflowClient to remove duplicated custom-status JSON parsing across three call sites.

Drop the now-unused single-workflow compatibility shims WORKFLOW_ORCHESTRATOR_NAME and WorkflowRegistrationPlan.orchestrator_name, replaced by per-workflow workflow_orchestrator_name(name).

* fix(core): drop WORKFLOW_ORCHESTRATOR_NAME from agent_framework.azure re-exports

The constant was removed from agent-framework-durabletask, but the core azure lazy-loading namespace still re-exported it, breaking pyright in packages/core. Remove it from both the runtime _IMPORTS map and the .pyi stub.

* fix(durabletask): atomic multi-workflow registration and bubble sub-workflow events

Make configure_workflow / AgentFunctionApp registration atomic: check every cross-call name collision before mutating any state, so a colliding nested sub-workflow no longer leaves a host partially configured (with the top-level name stuck in the registry). Applied to both the standalone worker and the Functions app.

Bubble sub-workflow intermediate events: a workflow run as a child orchestration now returns a SUBWORKFLOW_RESULT_KEY envelope carrying its outputs plus event timeline, and the parent re-tags the child's intermediate events with the WorkflowExecutor node id and republishes them, matching the in-process WorkflowExecutor contract. Top-level runs still return a bare outputs list.

Adds cross-registration atomicity tests on both hosts and unit tests for the result envelope and event bubbling. Resolves review threads on _worker.py, orchestrator.py, and test coverage.

* fix(azurefunctions): widen workflow orchestrator wrapper return type

The shared run_workflow_orchestrator now returns list | dict (the sub-workflow result envelope), so the azurefunctions _workflow.py wrapper that delegates to it must widen its Generator return annotation to match. Caught by the package-level pyright in CI (Package Checks), which type-checks the whole package, not just the files changed in the previous commit.
2026-07-07 14:43:28 +00:00

474 lines
19 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for DurableAIAgentWorker.
Focuses on critical worker flows: agent registration, validation, callbacks, and lifecycle.
"""
from unittest.mock import Mock
import pytest
from agent_framework_durabletask import DurableAIAgentWorker
@pytest.fixture
def mock_grpc_worker() -> Mock:
"""Create a mock TaskHubGrpcWorker for testing."""
mock = Mock()
mock.add_entity = Mock(return_value="dafx-test_agent")
mock.start = Mock()
mock.stop = Mock()
return mock
@pytest.fixture
def mock_agent() -> Mock:
"""Create a mock agent for testing."""
agent = Mock()
agent.name = "test_agent"
return agent
@pytest.fixture
def agent_worker(mock_grpc_worker: Mock) -> DurableAIAgentWorker:
"""Create a DurableAIAgentWorker with mock worker."""
return DurableAIAgentWorker(mock_grpc_worker)
class TestDurableAIAgentWorkerRegistration:
"""Test agent registration behavior."""
def test_add_agent_accepts_agent_with_name(
self, agent_worker: DurableAIAgentWorker, mock_agent: Mock, mock_grpc_worker: Mock
) -> None:
"""Verify that agents with names can be registered."""
agent_worker.add_agent(mock_agent)
# Verify entity was registered with underlying worker
mock_grpc_worker.add_entity.assert_called_once()
# Verify agent name is tracked
assert "test_agent" in agent_worker.registered_agent_names
def test_add_agent_rejects_agent_without_name(self, agent_worker: DurableAIAgentWorker) -> None:
"""Verify that agents without names are rejected."""
agent_no_name = Mock()
agent_no_name.name = None
with pytest.raises(ValueError, match="Agent must have a name"):
agent_worker.add_agent(agent_no_name)
def test_add_agent_rejects_empty_name(self, agent_worker: DurableAIAgentWorker) -> None:
"""Verify that agents with empty names are rejected."""
agent_empty_name = Mock()
agent_empty_name.name = ""
with pytest.raises(ValueError, match="Agent must have a name"):
agent_worker.add_agent(agent_empty_name)
def test_add_agent_rejects_duplicate_names(self, agent_worker: DurableAIAgentWorker, mock_agent: Mock) -> None:
"""Verify duplicate agent names are not allowed."""
agent_worker.add_agent(mock_agent)
# Try to register another agent with the same name
duplicate_agent = Mock()
duplicate_agent.name = "test_agent"
with pytest.raises(ValueError, match="already registered"):
agent_worker.add_agent(duplicate_agent)
def test_registered_agent_names_tracks_multiple_agents(self, agent_worker: DurableAIAgentWorker) -> None:
"""Verify registered_agent_names tracks all registered agents."""
agent1 = Mock()
agent1.name = "agent1"
agent2 = Mock()
agent2.name = "agent2"
agent3 = Mock()
agent3.name = "agent3"
agent_worker.add_agent(agent1)
agent_worker.add_agent(agent2)
agent_worker.add_agent(agent3)
registered = agent_worker.registered_agent_names
assert "agent1" in registered
assert "agent2" in registered
assert "agent3" in registered
assert len(registered) == 3
class TestDurableAIAgentWorkerCallbacks:
"""Test callback configuration behavior."""
def test_worker_level_callback_accepted(self, mock_grpc_worker: Mock) -> None:
"""Verify worker-level callback can be set."""
mock_callback = Mock()
agent_worker = DurableAIAgentWorker(mock_grpc_worker, callback=mock_callback)
assert agent_worker is not None
def test_agent_level_callback_accepted(self, agent_worker: DurableAIAgentWorker, mock_agent: Mock) -> None:
"""Verify agent-level callback can be set during registration."""
mock_callback = Mock()
# Should not raise exception
agent_worker.add_agent(mock_agent, callback=mock_callback)
assert "test_agent" in agent_worker.registered_agent_names
def test_none_callback_accepted(self, mock_grpc_worker: Mock, mock_agent: Mock) -> None:
"""Verify None callback is valid (no callbacks required)."""
agent_worker = DurableAIAgentWorker(mock_grpc_worker, callback=None)
agent_worker.add_agent(mock_agent, callback=None)
assert "test_agent" in agent_worker.registered_agent_names
class TestDurableAIAgentWorkerLifecycle:
"""Test worker lifecycle behavior."""
def test_start_delegates_to_underlying_worker(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""Verify start() delegates to wrapped worker."""
agent_worker.start()
mock_grpc_worker.start.assert_called_once()
def test_stop_delegates_to_underlying_worker(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""Verify stop() delegates to wrapped worker."""
agent_worker.stop()
mock_grpc_worker.stop.assert_called_once()
def test_start_works_with_no_agents(self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock) -> None:
"""Verify worker can start even with no agents registered."""
agent_worker.start()
mock_grpc_worker.start.assert_called_once()
def test_start_works_with_multiple_agents(self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock) -> None:
"""Verify worker can start with multiple agents registered."""
agent1 = Mock()
agent1.name = "agent1"
agent2 = Mock()
agent2.name = "agent2"
agent_worker.add_agent(agent1)
agent_worker.add_agent(agent2)
agent_worker.start()
mock_grpc_worker.start.assert_called_once()
assert len(agent_worker.registered_agent_names) == 2
class TestDurableAIAgentWorkerWorkflow:
"""Test workflow registration, including the agent-executor identity fix."""
def test_add_agent_with_entity_id_registers_under_override(
self, agent_worker: DurableAIAgentWorker, mock_agent: Mock
) -> None:
"""An explicit entity_id overrides the agent name as the entity identity."""
agent_worker.add_agent(mock_agent, entity_id="node-7")
assert "node-7" in agent_worker.registered_agent_names
assert "test_agent" not in agent_worker.registered_agent_names
def test_configure_workflow_registers_agent_entity_by_executor_id(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""Workflow agent executors register entities keyed by the workflow-scoped id.
The orchestrator dispatches by the scoped identity
``{workflow}-{executorId}``, so an ``AgentExecutor(agent, id=...)`` whose id
differs from the agent name must still be reachable under that scoped id.
"""
from agent_framework import AgentExecutor
agent = Mock()
agent.name = "Reviewer"
agent_executor = Mock(spec=AgentExecutor)
agent_executor.id = "custom-executor-id"
agent_executor.agent = agent
workflow = Mock()
workflow.name = "review"
workflow.executors = {"custom-executor-id": agent_executor}
agent_worker.configure_workflow(workflow)
assert "review-custom-executor-id" in agent_worker.registered_agent_names
assert "Reviewer" not in agent_worker.registered_agent_names
assert "custom-executor-id" not in agent_worker.registered_agent_names
mock_grpc_worker.add_orchestrator.assert_called_once()
def test_configure_workflow_registers_non_agent_executor_as_activity(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""Non-agent executors are registered as activities, not entities."""
from agent_framework import Executor
activity_executor = Mock(spec=Executor)
activity_executor.id = "router-node"
workflow = Mock()
workflow.name = "route"
workflow.executors = {"router-node": activity_executor}
agent_worker.configure_workflow(workflow)
assert agent_worker.registered_agent_names == []
mock_grpc_worker.add_activity.assert_called_once()
mock_grpc_worker.add_orchestrator.assert_called_once()
# The activity is registered under the workflow-scoped name.
registered_activity = mock_grpc_worker.add_activity.call_args[0][0]
assert registered_activity.__name__ == "dafx-route-router-node"
class TestMultiWorkflowRegistration:
"""Test hosting multiple workflows on one worker with scoped names."""
def _agent_workflow(self, name: str, executor_id: str) -> Mock:
from agent_framework import AgentExecutor
agent = Mock()
agent.name = "Assistant"
agent_executor = Mock(spec=AgentExecutor)
agent_executor.id = executor_id
agent_executor.agent = agent
workflow = Mock()
workflow.name = name
workflow.executors = {executor_id: agent_executor}
return workflow
def test_two_workflows_reusing_executor_id_do_not_collide(self, agent_worker: DurableAIAgentWorker) -> None:
"""Two workflows that reuse an executor id register distinct scoped entities."""
agent_worker.configure_workflow(self._agent_workflow("orders", "assistant"))
agent_worker.configure_workflow(self._agent_workflow("billing", "assistant"))
assert "orders-assistant" in agent_worker.registered_agent_names
assert "billing-assistant" in agent_worker.registered_agent_names
assert set(agent_worker.registered_workflow_names) == {"orders", "billing"}
def test_registers_one_orchestrator_per_workflow(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""Each configured workflow registers its own orchestrator."""
agent_worker.configure_workflow(self._agent_workflow("orders", "a"))
agent_worker.configure_workflow(self._agent_workflow("billing", "b"))
assert mock_grpc_worker.add_orchestrator.call_count == 2
registered_names = {call.args[0].__name__ for call in mock_grpc_worker.add_orchestrator.call_args_list}
assert registered_names == {"dafx-orders", "dafx-billing"}
def test_rejects_duplicate_workflow_name(self, agent_worker: DurableAIAgentWorker) -> None:
"""Configuring two workflows with the same name is rejected."""
agent_worker.configure_workflow(self._agent_workflow("orders", "a"))
with pytest.raises(ValueError, match="already registered"):
agent_worker.configure_workflow(self._agent_workflow("orders", "b"))
def test_rejects_case_insensitive_duplicate_workflow_name(self, agent_worker: DurableAIAgentWorker) -> None:
"""Workflow names that differ only by case collide and are rejected.
The route ownership guard folds case, so allowing both ``orders`` and
``Orders`` would let one workflow's surface reach the other's instances.
"""
agent_worker.configure_workflow(self._agent_workflow("orders", "a"))
with pytest.raises(ValueError, match="case-insensitively"):
agent_worker.configure_workflow(self._agent_workflow("Orders", "b"))
def test_rejects_auto_generated_workflow_name(self, agent_worker: DurableAIAgentWorker) -> None:
"""A workflow with an auto-generated WorkflowBuilder name is rejected."""
import uuid
workflow = self._agent_workflow(f"WorkflowBuilder-{uuid.uuid4()}", "a")
with pytest.raises(ValueError, match="auto-generated"):
agent_worker.configure_workflow(workflow)
def test_rejects_invalid_workflow_name(self, agent_worker: DurableAIAgentWorker) -> None:
"""A workflow with an invalid name is rejected."""
workflow = self._agent_workflow("has space", "a")
with pytest.raises(ValueError, match="invalid"):
agent_worker.configure_workflow(workflow)
class TestSubworkflowRegistration:
"""Test recursive registration of nested sub-workflows on one worker."""
def _inner_agent_workflow(self, name: str, executor_id: str) -> Mock:
from agent_framework import AgentExecutor
agent = Mock()
agent.name = "InnerAssistant"
agent_executor = Mock(spec=AgentExecutor)
agent_executor.id = executor_id
agent_executor.agent = agent
workflow = Mock()
workflow.name = name
workflow.executors = {executor_id: agent_executor}
return workflow
def _outer_workflow(self, name: str, inner: Mock, *, sub_ids: tuple[str, ...] = ("sub",)) -> Mock:
from agent_framework import Executor, WorkflowExecutor
executors: dict[str, Mock] = {}
for sub_id in sub_ids:
sub = Mock(spec=WorkflowExecutor)
sub.id = sub_id
sub.workflow = inner
sub.allow_direct_output = False
executors[sub_id] = sub
router = Mock(spec=Executor)
router.id = "router"
executors["router"] = router
workflow = Mock()
workflow.name = name
workflow.executors = executors
return workflow
def test_nested_workflow_registers_both_orchestrations(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""Configuring an outer workflow registers the inner workflow's orchestration too."""
inner = self._inner_agent_workflow("inner", "agent_node")
outer = self._outer_workflow("outer", inner)
agent_worker.configure_workflow(outer)
registered = {call.args[0].__name__ for call in mock_grpc_worker.add_orchestrator.call_args_list}
assert registered == {"dafx-outer", "dafx-inner"}
def test_nested_workflow_registers_inner_agent_scoped(self, agent_worker: DurableAIAgentWorker) -> None:
"""The inner workflow's agent is registered under the inner-scoped id."""
inner = self._inner_agent_workflow("inner", "agent_node")
outer = self._outer_workflow("outer", inner)
agent_worker.configure_workflow(outer)
assert "inner-agent_node" in agent_worker.registered_agent_names
def test_subworkflow_node_not_registered_as_activity(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""A WorkflowExecutor node is driven as a child orchestration, not an activity."""
inner = self._inner_agent_workflow("inner", "agent_node")
outer = self._outer_workflow("outer", inner)
agent_worker.configure_workflow(outer)
# Only the outer 'router' non-agent executor becomes an activity.
registered_activities = {call.args[0].__name__ for call in mock_grpc_worker.add_activity.call_args_list}
assert registered_activities == {"dafx-outer-router"}
def test_top_level_names_exclude_nested_workflows(self, agent_worker: DurableAIAgentWorker) -> None:
"""``registered_workflow_names`` reports only top-level workflows."""
inner = self._inner_agent_workflow("inner", "agent_node")
outer = self._outer_workflow("outer", inner)
agent_worker.configure_workflow(outer)
assert agent_worker.registered_workflow_names == ["outer"]
def test_shared_subworkflow_registered_once(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""A sub-workflow reused by two nodes registers its orchestration only once."""
inner = self._inner_agent_workflow("inner", "agent_node")
outer = self._outer_workflow("outer", inner, sub_ids=("sub_a", "sub_b"))
agent_worker.configure_workflow(outer)
registered = [call.args[0].__name__ for call in mock_grpc_worker.add_orchestrator.call_args_list]
assert sorted(registered) == ["dafx-inner", "dafx-outer"]
def test_nested_workflow_with_invalid_name_is_rejected(self, agent_worker: DurableAIAgentWorker) -> None:
"""A nested sub-workflow must also have a valid, stable name."""
inner = self._inner_agent_workflow("has space", "agent_node")
outer = self._outer_workflow("outer", inner)
with pytest.raises(ValueError, match="invalid"):
agent_worker.configure_workflow(outer)
def test_different_subworkflow_sharing_a_name_is_rejected(self, agent_worker: DurableAIAgentWorker) -> None:
"""Two different sub-workflow instances that share a name collide and are rejected."""
from agent_framework import Executor, WorkflowExecutor
inner_a = self._inner_agent_workflow("shared", "agent_node")
inner_b = self._inner_agent_workflow("shared", "other_node") # different instance, same name
sub_a = Mock(spec=WorkflowExecutor)
sub_a.id = "a"
sub_a.workflow = inner_a
sub_b = Mock(spec=WorkflowExecutor)
sub_b.id = "b"
sub_b.workflow = inner_b
router = Mock(spec=Executor)
router.id = "router"
outer = Mock()
outer.name = "outer"
outer.executors = {"a": sub_a, "b": sub_b, "router": router}
with pytest.raises(ValueError, match="different workflow|different workflows"):
agent_worker.configure_workflow(outer)
def test_cross_registration_nested_collision_is_atomic(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""A later configure_workflow whose nested child collides leaves the worker unchanged.
Reproduces the partial-registration path: configure one workflow, then configure
a second whose nested sub-workflow reuses the first's child name. The second call
must raise *before* mutating any state, so the second top-level workflow is not
left half-registered (which would also wedge a corrected retry on the duplicate
guard).
"""
shared_a = self._inner_agent_workflow("shared", "agent_node")
agent_worker.configure_workflow(self._outer_workflow("first", shared_a))
orchestrators_before = mock_grpc_worker.add_orchestrator.call_count
# A *different* 'shared' instance nested under a new top-level workflow collides.
shared_b = self._inner_agent_workflow("shared", "other_node")
with pytest.raises(ValueError, match="collides"):
agent_worker.configure_workflow(self._outer_workflow("second", shared_b))
# The worker is not partially configured: 'second' was never added, and no new
# orchestration was registered.
assert agent_worker.registered_workflow_names == ["first"]
assert mock_grpc_worker.add_orchestrator.call_count == orchestrators_before
def test_executor_id_with_reserved_separator_is_rejected(self, agent_worker: DurableAIAgentWorker) -> None:
"""An executor id containing the nested-HITL separator is rejected at registration."""
workflow = self._agent_workflow_with_executor_id("orders", "bad~id")
with pytest.raises(ValueError, match="reserved sub-workflow request separator"):
agent_worker.configure_workflow(workflow)
@staticmethod
def _agent_workflow_with_executor_id(name: str, executor_id: str) -> Mock:
from agent_framework import AgentExecutor
agent = Mock()
agent.name = "Assistant"
agent_executor = Mock(spec=AgentExecutor)
agent_executor.id = executor_id
agent_executor.agent = agent
workflow = Mock()
workflow.name = name
workflow.executors = {executor_id: agent_executor}
return workflow
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])