81e425b44f
* 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.
1967 lines
76 KiB
Python
1967 lines
76 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
"""Unit tests for AgentFunctionApp."""
|
|
|
|
# pyright: reportPrivateUsage=false
|
|
|
|
import json
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any, TypeVar
|
|
from unittest.mock import ANY, AsyncMock, Mock, patch
|
|
|
|
import azure.durable_functions as df
|
|
import azure.functions as func
|
|
import pytest
|
|
from agent_framework import AgentResponse, Message
|
|
from agent_framework_durabletask import (
|
|
MIMETYPE_APPLICATION_JSON,
|
|
MIMETYPE_TEXT_PLAIN,
|
|
THREAD_ID_HEADER,
|
|
WAIT_FOR_RESPONSE_FIELD,
|
|
WAIT_FOR_RESPONSE_HEADER,
|
|
AgentEntity,
|
|
AgentEntityStateProviderMixin,
|
|
DurableAgentState,
|
|
workflow_orchestrator_name,
|
|
)
|
|
|
|
from agent_framework_azurefunctions import AgentFunctionApp
|
|
from agent_framework_azurefunctions._entities import create_agent_entity
|
|
|
|
FuncT = TypeVar("FuncT", bound=Callable[..., Any])
|
|
|
|
|
|
def _identity_decorator(func: FuncT) -> FuncT:
|
|
return func
|
|
|
|
|
|
class _InMemoryStateProvider(AgentEntityStateProviderMixin):
|
|
def __init__(self, *, thread_id: str = "test-thread", initial_state: dict[str, Any] | None = None) -> None:
|
|
self._thread_id = thread_id
|
|
self._state_dict: dict[str, Any] = initial_state or {}
|
|
|
|
def _get_state_dict(self) -> dict[str, Any]:
|
|
return self._state_dict
|
|
|
|
def _set_state_dict(self, state: dict[str, Any]) -> None:
|
|
self._state_dict = state
|
|
|
|
def _get_thread_id_from_entity(self) -> str:
|
|
return self._thread_id
|
|
|
|
|
|
class TestAgentFunctionAppInit:
|
|
"""Test suite for AgentFunctionApp initialization."""
|
|
|
|
def test_init_with_defaults(self) -> None:
|
|
"""Test initialization with default parameters."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
|
|
assert len(app.agents) == 1
|
|
assert "TestAgent" in app.agents
|
|
assert app.enable_health_check is True
|
|
|
|
def test_init_with_custom_auth_level(self) -> None:
|
|
"""Test initialization with custom auth level."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent], http_auth_level=func.AuthLevel.FUNCTION)
|
|
|
|
# App should be created successfully
|
|
assert "TestAgent" in app.agents
|
|
|
|
def test_init_with_health_check_disabled(self) -> None:
|
|
"""Test initialization with health check disabled."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent], enable_health_check=False)
|
|
|
|
assert app.enable_health_check is False
|
|
|
|
def test_init_with_http_endpoints_disabled(self) -> None:
|
|
"""Test initialization with HTTP endpoints disabled."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False)
|
|
|
|
assert app.enable_http_endpoints is False
|
|
|
|
def test_init_stores_agent_reference(self) -> None:
|
|
"""Test that agent reference is stored correctly."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
|
|
assert app.agents["TestAgent"].name == "TestAgent"
|
|
|
|
def test_add_agent_uses_specific_callback(self) -> None:
|
|
"""Verify that a per-agent callback overrides the default."""
|
|
|
|
mock_agent = Mock()
|
|
mock_agent.name = "CallbackAgent"
|
|
specific_callback = Mock()
|
|
|
|
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
|
app = AgentFunctionApp(default_callback=Mock())
|
|
app.add_agent(mock_agent, callback=specific_callback)
|
|
|
|
setup_mock.assert_called_once()
|
|
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
|
|
assert passed_callback is specific_callback
|
|
assert enable_http_endpoint is True
|
|
|
|
def test_default_callback_applied_when_no_specific(self) -> None:
|
|
"""Ensure the default callback is supplied when add_agent lacks override."""
|
|
|
|
mock_agent = Mock()
|
|
mock_agent.name = "DefaultAgent"
|
|
default_callback = Mock()
|
|
|
|
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
|
app = AgentFunctionApp(default_callback=default_callback)
|
|
app.add_agent(mock_agent)
|
|
|
|
setup_mock.assert_called_once()
|
|
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
|
|
assert passed_callback is default_callback
|
|
assert enable_http_endpoint is True
|
|
|
|
def test_init_with_agents_uses_default_callback(self) -> None:
|
|
"""Agents provided in __init__ should receive the default callback."""
|
|
|
|
mock_agent = Mock()
|
|
mock_agent.name = "InitAgent"
|
|
default_callback = Mock()
|
|
|
|
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
|
AgentFunctionApp(agents=[mock_agent], default_callback=default_callback)
|
|
|
|
setup_mock.assert_called_once()
|
|
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
|
|
assert passed_callback is default_callback
|
|
assert enable_http_endpoint is True
|
|
|
|
|
|
class TestAgentFunctionAppSetup:
|
|
"""Test suite for AgentFunctionApp setup and configuration."""
|
|
|
|
def test_app_is_dfapp_instance(self) -> None:
|
|
"""Test that AgentFunctionApp is a DFApp instance."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
|
|
assert isinstance(app, df.DFApp)
|
|
|
|
def test_setup_creates_http_trigger(self) -> None:
|
|
"""Test that setup creates an HTTP trigger."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]:
|
|
def decorator(func: FuncT) -> FuncT:
|
|
return func
|
|
|
|
return decorator
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "route", new=passthrough_decorator),
|
|
patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator),
|
|
patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator),
|
|
):
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
|
|
# Verify agent is registered
|
|
assert "TestAgent" in app.agents
|
|
|
|
def test_http_function_name_uses_prefix_format(self) -> None:
|
|
"""Ensure function names follow the prefix-agent naming convention."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "Agent 42"
|
|
|
|
captured_names: list[str] = []
|
|
|
|
def capture_function_name(
|
|
self: AgentFunctionApp, name: str, *args: Any, **kwargs: Any
|
|
) -> Callable[[FuncT], FuncT]:
|
|
def decorator(func: FuncT) -> FuncT:
|
|
captured_names.append(name)
|
|
return func
|
|
|
|
return decorator
|
|
|
|
def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]:
|
|
def decorator(func: FuncT) -> FuncT:
|
|
return func
|
|
|
|
return decorator
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "function_name", new=capture_function_name),
|
|
patch.object(AgentFunctionApp, "route", new=passthrough_decorator),
|
|
patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator),
|
|
patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator),
|
|
):
|
|
AgentFunctionApp(agents=[mock_agent])
|
|
|
|
assert captured_names == ["http-Agent_42"]
|
|
|
|
def test_setup_skips_http_trigger_when_disabled(self) -> None:
|
|
"""Test that HTTP trigger is not created when disabled."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
captured_routes: list[str | None] = []
|
|
|
|
def capture_route(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]:
|
|
def decorator(func: FuncT) -> FuncT:
|
|
route_key = kwargs.get("route") if kwargs else None
|
|
captured_routes.append(route_key)
|
|
return func
|
|
|
|
return decorator
|
|
|
|
def passthrough_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]:
|
|
def decorator(func: FuncT) -> FuncT:
|
|
return func
|
|
|
|
return decorator
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "function_name", new=passthrough_decorator),
|
|
patch.object(AgentFunctionApp, "route", new=capture_route),
|
|
patch.object(AgentFunctionApp, "durable_client_input", new=passthrough_decorator),
|
|
patch.object(AgentFunctionApp, "entity_trigger", new=passthrough_decorator),
|
|
):
|
|
app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False)
|
|
|
|
# Verify agent is registered
|
|
assert "TestAgent" in app.agents
|
|
|
|
# Verify that no HTTP run route was created
|
|
run_route = f"agents/{mock_agent.name}/run"
|
|
assert run_route not in captured_routes
|
|
|
|
def test_agent_override_enables_http_route_when_app_disabled(self) -> None:
|
|
"""Agent-level override should enable HTTP route even when app disables it."""
|
|
|
|
mock_agent = Mock()
|
|
mock_agent.name = "OverrideAgent"
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_http_run_route") as http_route_mock,
|
|
patch.object(AgentFunctionApp, "_setup_agent_entity") as agent_entity_mock,
|
|
):
|
|
app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=False)
|
|
app.add_agent(mock_agent, enable_http_endpoint=True)
|
|
|
|
http_route_mock.assert_called_once_with("OverrideAgent")
|
|
agent_entity_mock.assert_called_once_with(mock_agent, "OverrideAgent", ANY)
|
|
assert app._agent_metadata["OverrideAgent"].http_endpoint_enabled is True
|
|
|
|
def test_agent_override_disables_http_route_when_app_enabled(self) -> None:
|
|
"""Agent-level override should disable HTTP route even when app enables it."""
|
|
|
|
mock_agent = Mock()
|
|
mock_agent.name = "DisabledOverride"
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_http_run_route") as http_route_mock,
|
|
patch.object(AgentFunctionApp, "_setup_agent_entity") as agent_entity_mock,
|
|
):
|
|
app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=True)
|
|
app.add_agent(mock_agent, enable_http_endpoint=False)
|
|
|
|
http_route_mock.assert_not_called()
|
|
agent_entity_mock.assert_called_once_with(mock_agent, "DisabledOverride", ANY)
|
|
assert app._agent_metadata["DisabledOverride"].http_endpoint_enabled is False
|
|
|
|
def test_multiple_apps_independent(self) -> None:
|
|
"""Test that multiple AgentFunctionApp instances are independent."""
|
|
agent1 = Mock()
|
|
agent1.name = "Agent1"
|
|
agent2 = Mock()
|
|
agent2.name = "Agent2"
|
|
|
|
app1 = AgentFunctionApp(agents=[agent1])
|
|
app2 = AgentFunctionApp(agents=[agent2])
|
|
|
|
assert app1.agents["Agent1"].name == "Agent1"
|
|
assert app2.agents["Agent2"].name == "Agent2"
|
|
assert "Agent1" in app1.agents
|
|
assert "Agent2" in app2.agents
|
|
|
|
|
|
class TestWaitForResponseAndCorrelationId:
|
|
"""Tests for wait_for_response flag and correlation ID handling."""
|
|
|
|
def _create_app(self) -> AgentFunctionApp:
|
|
mock_agent = Mock()
|
|
mock_agent.__class__.__name__ = "MockAgent"
|
|
mock_agent.name = "MockAgent"
|
|
return AgentFunctionApp(agents=[mock_agent], enable_health_check=False)
|
|
|
|
def _make_request(
|
|
self,
|
|
headers: dict[str, str] | None = None,
|
|
params: dict[str, str] | None = None,
|
|
) -> Mock:
|
|
request = Mock()
|
|
request.headers = headers or {}
|
|
request.params = params or {}
|
|
return request
|
|
|
|
def test_wait_for_response_header_true(self) -> None:
|
|
"""Test that the wait-for-response header is honored."""
|
|
app = self._create_app()
|
|
request = self._make_request(headers={WAIT_FOR_RESPONSE_HEADER: "true"})
|
|
|
|
assert app._should_wait_for_response(request, {}) is True
|
|
|
|
def test_wait_for_response_body_snake_case(self) -> None:
|
|
"""Test that payload controls wait_for_response."""
|
|
app = self._create_app()
|
|
request = self._make_request()
|
|
|
|
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "true"}) is True
|
|
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "false"}) is False
|
|
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "0"}) is False
|
|
|
|
def test_wait_for_response_query_parameter(self) -> None:
|
|
"""Test that query parameter controls wait_for_response."""
|
|
app = self._create_app()
|
|
request = self._make_request(params={WAIT_FOR_RESPONSE_FIELD: "true"})
|
|
|
|
assert app._should_wait_for_response(request, {}) is True
|
|
|
|
def test_wait_for_response_query_precedence(self) -> None:
|
|
"""Test that query parameter overrides body value."""
|
|
app = self._create_app()
|
|
request = self._make_request(params={WAIT_FOR_RESPONSE_FIELD: "false"})
|
|
|
|
assert app._should_wait_for_response(request, {WAIT_FOR_RESPONSE_FIELD: "true"}) is False
|
|
|
|
|
|
class TestAgentEntityOperations:
|
|
"""Test suite for entity operations."""
|
|
|
|
async def test_entity_run_agent_operation(self) -> None:
|
|
"""Test that entity can run agent operation."""
|
|
mock_agent = Mock()
|
|
mock_agent.run = AsyncMock(
|
|
return_value=AgentResponse(messages=[Message(role="assistant", contents=["Test response"])])
|
|
)
|
|
|
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123"))
|
|
|
|
result = await entity.run({
|
|
"message": "Test message",
|
|
"correlationId": "corr-app-entity-1",
|
|
})
|
|
|
|
assert isinstance(result, AgentResponse)
|
|
assert result.text == "Test response"
|
|
assert entity.state.message_count == 2
|
|
|
|
async def test_entity_stores_conversation_history(self) -> None:
|
|
"""Test that the entity stores conversation history."""
|
|
mock_agent = Mock()
|
|
mock_agent.run = AsyncMock(
|
|
return_value=AgentResponse(messages=[Message(role="assistant", contents=["Response 1"])])
|
|
)
|
|
|
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
|
|
|
# Send first message
|
|
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-2"})
|
|
|
|
# Each conversation turn creates 2 entries: request and response
|
|
history = entity.state.data.conversation_history[0].messages # Request entry
|
|
assert len(history) == 1 # Just the user message
|
|
|
|
# Send second message
|
|
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-2b"})
|
|
|
|
# Now we have 4 entries total (2 requests + 2 responses)
|
|
# Access the first request entry
|
|
history2 = entity.state.data.conversation_history[2].messages # Second request entry
|
|
assert len(history2) == 1 # Just the user message
|
|
|
|
user_msg = history[0]
|
|
user_role = getattr(user_msg.role, "value", user_msg.role)
|
|
assert user_role == "user"
|
|
assert user_msg.text == "Message 1"
|
|
|
|
assistant_msg = entity.state.data.conversation_history[1].messages[0]
|
|
assistant_role = getattr(assistant_msg.role, "value", assistant_msg.role)
|
|
assert assistant_role == "assistant"
|
|
assert assistant_msg.text == "Response 1"
|
|
|
|
async def test_entity_increments_message_count(self) -> None:
|
|
"""Test that the entity increments the message count."""
|
|
mock_agent = Mock()
|
|
mock_agent.run = AsyncMock(
|
|
return_value=AgentResponse(messages=[Message(role="assistant", contents=["Response"])])
|
|
)
|
|
|
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
|
|
|
assert len(entity.state.data.conversation_history) == 0
|
|
|
|
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-3a"})
|
|
assert len(entity.state.data.conversation_history) == 2
|
|
|
|
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-3b"})
|
|
assert len(entity.state.data.conversation_history) == 4
|
|
|
|
def test_entity_reset(self) -> None:
|
|
"""Test that entity reset clears state."""
|
|
mock_agent = Mock()
|
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider())
|
|
|
|
# Set some state
|
|
entity.state = DurableAgentState()
|
|
|
|
# Reset
|
|
entity.reset()
|
|
|
|
assert len(entity.state.data.conversation_history) == 0
|
|
|
|
|
|
class TestAgentEntityFactory:
|
|
"""Test suite for the entity factory function."""
|
|
|
|
def test_create_agent_entity_returns_function(self) -> None:
|
|
"""Test that create_agent_entity returns a function."""
|
|
mock_agent = Mock()
|
|
entity_function = create_agent_entity(mock_agent)
|
|
|
|
assert callable(entity_function)
|
|
|
|
def test_entity_function_handles_run_operation(self) -> None:
|
|
"""Test that the entity function handles the run operation."""
|
|
mock_agent = Mock()
|
|
mock_agent.run = AsyncMock(
|
|
return_value=AgentResponse(messages=[Message(role="assistant", contents=["Response"])])
|
|
)
|
|
|
|
entity_function = create_agent_entity(mock_agent)
|
|
|
|
# Mock context
|
|
mock_context = Mock()
|
|
mock_context.operation_name = "run"
|
|
mock_context.get_input.return_value = {
|
|
"message": "Test message",
|
|
"correlationId": "corr-app-factory-1",
|
|
}
|
|
mock_context.get_state.return_value = None
|
|
|
|
# Execute entity function
|
|
entity_function(mock_context)
|
|
|
|
# Verify result was set
|
|
assert mock_context.set_result.called
|
|
assert mock_context.set_state.called
|
|
result_call = mock_context.set_result.call_args[0][0]
|
|
assert "error" not in result_call
|
|
|
|
def test_entity_function_handles_run_agent_operation(self) -> None:
|
|
"""Test that the entity function handles the deprecated run_agent operation for backward compatibility."""
|
|
mock_agent = Mock()
|
|
mock_agent.run = AsyncMock(
|
|
return_value=AgentResponse(messages=[Message(role="assistant", contents=["Response"])])
|
|
)
|
|
|
|
entity_function = create_agent_entity(mock_agent)
|
|
|
|
# Mock context
|
|
mock_context = Mock()
|
|
mock_context.operation_name = "run_agent"
|
|
mock_context.get_input.return_value = {
|
|
"message": "Test message",
|
|
"correlationId": "corr-app-factory-1",
|
|
}
|
|
mock_context.get_state.return_value = None
|
|
|
|
# Execute entity function
|
|
entity_function(mock_context)
|
|
|
|
# Verify result was set
|
|
assert mock_context.set_result.called
|
|
assert mock_context.set_state.called
|
|
result_call = mock_context.set_result.call_args[0][0]
|
|
assert "error" not in result_call
|
|
|
|
def test_entity_function_handles_reset_operation(self) -> None:
|
|
"""Test that the entity function handles the reset operation."""
|
|
mock_agent = Mock()
|
|
entity_function = create_agent_entity(mock_agent)
|
|
|
|
# Mock context
|
|
mock_context = Mock()
|
|
mock_context.operation_name = "reset"
|
|
mock_context.get_state.return_value = {
|
|
"schemaVersion": "1.0.0",
|
|
"data": {
|
|
"conversationHistory": [
|
|
{
|
|
"$type": "request",
|
|
"correlationId": "corr-reset-test",
|
|
"createdAt": "2024-01-01T00:00:00Z",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"contents": [
|
|
{
|
|
"$type": "text",
|
|
"text": "test",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
# Execute entity function
|
|
entity_function(mock_context)
|
|
|
|
# Verify result was set
|
|
assert mock_context.set_result.called
|
|
result_call = mock_context.set_result.call_args[0][0]
|
|
assert result_call["status"] == "reset"
|
|
|
|
def test_entity_function_handles_unknown_operation(self) -> None:
|
|
"""Test that the entity function handles an unknown operation."""
|
|
mock_agent = Mock()
|
|
entity_function = create_agent_entity(mock_agent)
|
|
|
|
# Mock context with unknown operation
|
|
mock_context = Mock()
|
|
mock_context.operation_name = "unknown_operation"
|
|
mock_context.get_state.return_value = None
|
|
|
|
# Execute entity function
|
|
entity_function(mock_context)
|
|
|
|
# Verify error result was set
|
|
assert mock_context.set_result.called
|
|
result_call = mock_context.set_result.call_args[0][0]
|
|
assert "error" in result_call
|
|
assert "unknown_operation" in result_call["error"]
|
|
|
|
def test_entity_function_restores_state(self) -> None:
|
|
"""Test that the entity function restores state from the context."""
|
|
mock_agent = Mock()
|
|
entity_function = create_agent_entity(mock_agent)
|
|
|
|
# Mock context with existing state
|
|
existing_state = {
|
|
"schemaVersion": "1.0.0",
|
|
"data": {
|
|
"conversationHistory": [
|
|
{
|
|
"$type": "request",
|
|
"correlationId": "corr-existing-1",
|
|
"createdAt": "2024-01-01T00:00:00Z",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"contents": [
|
|
{
|
|
"$type": "text",
|
|
"text": "msg1",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"$type": "response",
|
|
"correlationId": "corr-existing-1",
|
|
"createdAt": "2024-01-01T00:05:00Z",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"contents": [
|
|
{
|
|
"$type": "text",
|
|
"text": "resp1",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
mock_context = Mock()
|
|
mock_context.operation_name = "run"
|
|
mock_context.get_input.return_value = {
|
|
"message": "Test message",
|
|
"correlationId": "corr-restore-1",
|
|
}
|
|
mock_context.get_state.return_value = existing_state
|
|
|
|
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
|
|
entity_function(mock_context)
|
|
|
|
from_dict_mock.assert_called_once_with(existing_state)
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""Test suite for error handling."""
|
|
|
|
async def test_entity_handles_agent_error(self) -> None:
|
|
"""Test that the entity handles agent execution errors."""
|
|
mock_agent = Mock()
|
|
mock_agent.run = AsyncMock(side_effect=Exception("Agent error"))
|
|
|
|
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
|
|
|
|
result = await entity.run({
|
|
"message": "Test message",
|
|
"correlationId": "corr-app-error-1",
|
|
})
|
|
|
|
assert isinstance(result, AgentResponse)
|
|
assert len(result.messages) == 1
|
|
content = result.messages[0].contents[0]
|
|
assert content.type == "error"
|
|
assert "Agent error" in (content.message or "")
|
|
assert content.error_code == "Exception"
|
|
|
|
def test_entity_function_handles_exception(self) -> None:
|
|
"""Test that the entity function handles exceptions gracefully."""
|
|
mock_agent = Mock()
|
|
# Force an exception by making get_input fail
|
|
mock_agent.run = AsyncMock(side_effect=Exception("Test error"))
|
|
|
|
entity_function = create_agent_entity(mock_agent)
|
|
|
|
mock_context = Mock()
|
|
mock_context.operation_name = "run"
|
|
mock_context.get_input.side_effect = Exception("Input error")
|
|
mock_context.get_state.return_value = None
|
|
|
|
# Execute entity function - should not raise
|
|
entity_function(mock_context)
|
|
|
|
# Verify error result was set
|
|
assert mock_context.set_result.called
|
|
result_call = mock_context.set_result.call_args[0][0]
|
|
assert "error" in result_call
|
|
|
|
|
|
class TestIncomingRequestParsing:
|
|
"""Tests for parsing run requests with JSON and plain text bodies."""
|
|
|
|
def _create_app(self) -> AgentFunctionApp:
|
|
mock_agent = Mock()
|
|
mock_agent.name = "ParserAgent"
|
|
return AgentFunctionApp(agents=[mock_agent], enable_health_check=False)
|
|
|
|
def test_parse_plain_text_body(self) -> None:
|
|
"""Test parsing a plain-text request body."""
|
|
app = self._create_app()
|
|
|
|
request = Mock()
|
|
request.headers = {}
|
|
request.params = {}
|
|
request.get_json.side_effect = ValueError("Invalid JSON")
|
|
request.get_body.return_value = b"Plain text message"
|
|
|
|
req_body, message, response_format = app._parse_incoming_request(request)
|
|
|
|
assert req_body == {}
|
|
assert message == "Plain text message"
|
|
|
|
assert response_format == "text"
|
|
|
|
def test_parse_plain_text_trims_whitespace(self) -> None:
|
|
"""Plain-text parser returns an empty string when the body contains only whitespace."""
|
|
app = self._create_app()
|
|
|
|
request = Mock()
|
|
request.headers = {}
|
|
request.params = {}
|
|
request.get_json.side_effect = ValueError("Invalid JSON")
|
|
request.get_body.return_value = b" "
|
|
|
|
req_body, message, response_format = app._parse_incoming_request(request)
|
|
|
|
assert req_body == {}
|
|
assert message == ""
|
|
assert response_format == "text"
|
|
|
|
def test_accept_header_prefers_json(self) -> None:
|
|
"""Test that the Accept header can force JSON responses for plain-text bodies."""
|
|
app = self._create_app()
|
|
|
|
request = Mock()
|
|
request.headers = {"accept": MIMETYPE_APPLICATION_JSON}
|
|
request.params = {}
|
|
request.get_json.side_effect = ValueError("Invalid JSON")
|
|
request.get_body.return_value = b"Plain text message"
|
|
|
|
_, message, response_format = app._parse_incoming_request(request)
|
|
|
|
assert message == "Plain text message"
|
|
assert response_format == "json"
|
|
|
|
def test_extract_thread_id_from_query_params(self) -> None:
|
|
"""Test thread identifier extraction from query parameters."""
|
|
app = self._create_app()
|
|
|
|
request = Mock()
|
|
request.params = {"thread_id": "query-thread"}
|
|
req_body: dict[str, Any] = {}
|
|
|
|
thread_id = app._resolve_thread_id(request, req_body)
|
|
|
|
assert thread_id == "query-thread"
|
|
|
|
|
|
class TestHttpRunRoute:
|
|
"""Tests for the HTTP run route behavior."""
|
|
|
|
@staticmethod
|
|
def _get_run_handler(agent: Mock) -> Callable[[func.HttpRequest, Any], Awaitable[func.HttpResponse]]:
|
|
captured_handlers: dict[str | None, Callable[..., Awaitable[func.HttpResponse]]] = {}
|
|
|
|
def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]:
|
|
def decorator(func: FuncT) -> FuncT:
|
|
return func
|
|
|
|
return decorator
|
|
|
|
def capture_route(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]:
|
|
def decorator(func: FuncT) -> FuncT:
|
|
route_key = kwargs.get("route") if kwargs else None
|
|
captured_handlers[route_key] = func
|
|
return func
|
|
|
|
return decorator
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "function_name", new=capture_decorator),
|
|
patch.object(AgentFunctionApp, "route", new=capture_route),
|
|
patch.object(AgentFunctionApp, "durable_client_input", new=capture_decorator),
|
|
patch.object(AgentFunctionApp, "entity_trigger", new=capture_decorator),
|
|
):
|
|
AgentFunctionApp(agents=[agent], enable_health_check=False)
|
|
|
|
run_route = f"agents/{agent.name}/run"
|
|
return captured_handlers[run_route]
|
|
|
|
async def test_http_run_accepts_plain_text(self) -> None:
|
|
"""Test that the HTTP handler accepts plain-text requests."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "HttpAgent"
|
|
|
|
handler = self._get_run_handler(mock_agent)
|
|
|
|
request = Mock()
|
|
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false"}
|
|
request.params = {}
|
|
request.route_params = {}
|
|
request.get_json.side_effect = ValueError("Invalid JSON")
|
|
request.get_body.return_value = b"Plain text via HTTP"
|
|
|
|
client = AsyncMock()
|
|
|
|
response = await handler(request, client)
|
|
|
|
assert response.status_code == 202
|
|
assert response.mimetype == MIMETYPE_TEXT_PLAIN
|
|
assert response.headers.get(THREAD_ID_HEADER) is not None
|
|
assert response.get_body().decode("utf-8") == "Agent request accepted"
|
|
|
|
signal_args = client.signal_entity.call_args[0]
|
|
run_request = signal_args[2]
|
|
|
|
assert run_request["message"] == "Plain text via HTTP"
|
|
assert run_request["role"] == "user"
|
|
assert "thread_id" not in run_request
|
|
|
|
async def test_http_run_accept_header_returns_json(self) -> None:
|
|
"""Test that Accept header requesting JSON results in JSON response."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "HttpAgentJson"
|
|
|
|
handler = self._get_run_handler(mock_agent)
|
|
|
|
request = Mock()
|
|
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false", "Accept": MIMETYPE_APPLICATION_JSON}
|
|
request.params = {}
|
|
request.route_params = {}
|
|
request.get_json.side_effect = ValueError("Invalid JSON")
|
|
request.get_body.return_value = b"Plain text via HTTP"
|
|
|
|
client = AsyncMock()
|
|
|
|
response = await handler(request, client)
|
|
|
|
assert response.status_code == 202
|
|
assert response.mimetype == MIMETYPE_APPLICATION_JSON
|
|
assert response.headers.get(THREAD_ID_HEADER) is None
|
|
body = response.get_body().decode("utf-8")
|
|
assert '"status": "accepted"' in body
|
|
|
|
async def test_http_run_rejects_empty_message(self) -> None:
|
|
"""Test that the HTTP handler rejects empty messages with a 400 response."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "HttpAgentEmpty"
|
|
|
|
handler = self._get_run_handler(mock_agent)
|
|
|
|
request = Mock()
|
|
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false"}
|
|
request.params = {}
|
|
request.route_params = {}
|
|
request.get_json.side_effect = ValueError("Invalid JSON")
|
|
request.get_body.return_value = b" "
|
|
|
|
client = AsyncMock()
|
|
|
|
response = await handler(request, client)
|
|
|
|
assert response.status_code == 400
|
|
assert response.mimetype == MIMETYPE_TEXT_PLAIN
|
|
assert response.headers.get(THREAD_ID_HEADER) is not None
|
|
assert response.get_body().decode("utf-8") == "Message is required"
|
|
client.signal_entity.assert_not_called()
|
|
|
|
|
|
class TestMCPToolEndpoint:
|
|
"""Test suite for MCP tool endpoint functionality."""
|
|
|
|
def test_init_with_mcp_tool_endpoint_enabled(self) -> None:
|
|
"""Test initialization with MCP tool endpoint enabled."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
|
|
|
|
assert app.enable_mcp_tool_trigger is True
|
|
|
|
def test_init_with_mcp_tool_endpoint_disabled(self) -> None:
|
|
"""Test initialization with MCP tool endpoint disabled (default)."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
|
|
assert app.enable_mcp_tool_trigger is False
|
|
|
|
def test_add_agent_with_mcp_tool_trigger_enabled(self) -> None:
|
|
"""Test adding an agent with MCP tool trigger explicitly enabled."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "MCPAgent"
|
|
mock_agent.description = "Test MCP Agent"
|
|
|
|
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
|
app = AgentFunctionApp()
|
|
app.add_agent(mock_agent, enable_mcp_tool_trigger=True)
|
|
|
|
setup_mock.assert_called_once()
|
|
_, _, _, _, enable_mcp = setup_mock.call_args[0]
|
|
assert enable_mcp is True
|
|
|
|
def test_add_agent_with_mcp_tool_trigger_disabled(self) -> None:
|
|
"""Test adding an agent with MCP tool trigger explicitly disabled."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "NoMCPAgent"
|
|
|
|
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
|
|
app = AgentFunctionApp(enable_mcp_tool_trigger=True)
|
|
app.add_agent(mock_agent, enable_mcp_tool_trigger=False)
|
|
|
|
setup_mock.assert_called_once()
|
|
_, _, _, _, enable_mcp = setup_mock.call_args[0]
|
|
assert enable_mcp is False
|
|
|
|
def test_agent_override_enables_mcp_when_app_disabled(self) -> None:
|
|
"""Test that per-agent override can enable MCP when app-level is disabled."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "OverrideAgent"
|
|
|
|
with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock:
|
|
app = AgentFunctionApp(enable_mcp_tool_trigger=False)
|
|
app.add_agent(mock_agent, enable_mcp_tool_trigger=True)
|
|
|
|
mcp_setup_mock.assert_called_once()
|
|
|
|
def test_agent_override_disables_mcp_when_app_enabled(self) -> None:
|
|
"""Test that per-agent override can disable MCP when app-level is enabled."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "NoOverrideAgent"
|
|
|
|
with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock:
|
|
app = AgentFunctionApp(enable_mcp_tool_trigger=True)
|
|
app.add_agent(mock_agent, enable_mcp_tool_trigger=False)
|
|
|
|
mcp_setup_mock.assert_not_called()
|
|
|
|
def test_setup_mcp_tool_trigger_registers_decorators(self) -> None:
|
|
"""Test that _setup_mcp_tool_trigger registers the correct decorators."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "MCPToolAgent"
|
|
mock_agent.description = "Test MCP Tool"
|
|
|
|
app = AgentFunctionApp()
|
|
|
|
# Mock the decorators
|
|
with (
|
|
patch.object(app, "function_name") as func_name_mock,
|
|
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
|
|
patch.object(app, "durable_client_input") as client_mock,
|
|
):
|
|
# Setup mock decorator chain
|
|
func_name_mock.return_value = _identity_decorator
|
|
mcp_trigger_mock.return_value = _identity_decorator
|
|
client_mock.return_value = _identity_decorator
|
|
|
|
app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description)
|
|
|
|
# Verify decorators were called with correct parameters
|
|
func_name_mock.assert_called_once()
|
|
mcp_trigger_mock.assert_called_once_with(
|
|
arg_name="context",
|
|
tool_name=mock_agent.name,
|
|
description=mock_agent.description,
|
|
tool_properties=ANY,
|
|
data_type=func.DataType.UNDEFINED,
|
|
)
|
|
client_mock.assert_called_once_with(client_name="client")
|
|
|
|
def test_setup_mcp_tool_trigger_uses_default_description(self) -> None:
|
|
"""Test that _setup_mcp_tool_trigger uses default description when none provided."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "NoDescAgent"
|
|
|
|
app = AgentFunctionApp()
|
|
|
|
with (
|
|
patch.object(app, "function_name", return_value=_identity_decorator),
|
|
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
|
|
patch.object(app, "durable_client_input", return_value=_identity_decorator),
|
|
):
|
|
mcp_trigger_mock.return_value = _identity_decorator
|
|
|
|
app._setup_mcp_tool_trigger(mock_agent.name, None)
|
|
|
|
# Verify default description was used
|
|
call_args = mcp_trigger_mock.call_args
|
|
assert call_args[1]["description"] == f"Interact with {mock_agent.name} agent"
|
|
|
|
async def test_handle_mcp_tool_invocation_with_json_string(self) -> None:
|
|
"""Test _handle_mcp_tool_invocation with JSON string context."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
client = AsyncMock()
|
|
|
|
# Mock the entity response
|
|
mock_state = Mock()
|
|
mock_state.entity_state = {
|
|
"schemaVersion": "1.0.0",
|
|
"data": {"conversationHistory": []},
|
|
}
|
|
client.read_entity_state.return_value = mock_state
|
|
|
|
# Create JSON string context
|
|
context = '{"arguments": {"query": "test query", "threadId": "test-thread"}}'
|
|
|
|
with patch.object(app, "_get_response_from_entity") as get_response_mock:
|
|
get_response_mock.return_value = {"status": "success", "response": "Test response"}
|
|
|
|
result = await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
|
|
|
assert result == "Test response"
|
|
get_response_mock.assert_called_once()
|
|
|
|
async def test_handle_mcp_tool_invocation_with_json_context(self) -> None:
|
|
"""Test _handle_mcp_tool_invocation with JSON string context."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
client = AsyncMock()
|
|
|
|
# Mock the entity response
|
|
mock_state = Mock()
|
|
mock_state.entity_state = {
|
|
"schemaVersion": "1.0.0",
|
|
"data": {"conversationHistory": []},
|
|
}
|
|
client.read_entity_state.return_value = mock_state
|
|
|
|
# Create JSON string context
|
|
context = json.dumps({"arguments": {"query": "test query", "threadId": "test-thread"}})
|
|
|
|
with patch.object(app, "_get_response_from_entity") as get_response_mock:
|
|
get_response_mock.return_value = {"status": "success", "response": "Test response"}
|
|
|
|
result = await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
|
|
|
assert result == "Test response"
|
|
get_response_mock.assert_called_once()
|
|
|
|
async def test_handle_mcp_tool_invocation_missing_query(self) -> None:
|
|
"""Test _handle_mcp_tool_invocation raises ValueError when query is missing."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
client = AsyncMock()
|
|
|
|
# Context missing query (as JSON string)
|
|
context = json.dumps({"arguments": {}})
|
|
|
|
with pytest.raises(ValueError, match="missing required 'query' argument"):
|
|
await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
|
|
|
async def test_handle_mcp_tool_invocation_invalid_json(self) -> None:
|
|
"""Test _handle_mcp_tool_invocation raises ValueError for invalid JSON."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
client = AsyncMock()
|
|
|
|
# Invalid JSON string
|
|
context = "not valid json"
|
|
|
|
with pytest.raises(ValueError, match="Invalid MCP context format"):
|
|
await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
|
|
|
async def test_handle_mcp_tool_invocation_runtime_error(self) -> None:
|
|
"""Test _handle_mcp_tool_invocation raises RuntimeError when agent fails."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
client = AsyncMock()
|
|
|
|
# Mock the entity response
|
|
mock_state = Mock()
|
|
mock_state.entity_state = {
|
|
"schemaVersion": "1.0.0",
|
|
"data": {"conversationHistory": []},
|
|
}
|
|
client.read_entity_state.return_value = mock_state
|
|
|
|
context = '{"arguments": {"query": "test query"}}'
|
|
|
|
with patch.object(app, "_get_response_from_entity") as get_response_mock:
|
|
get_response_mock.return_value = {"status": "failed", "error": "Agent error"}
|
|
|
|
with pytest.raises(RuntimeError, match="Agent execution failed"):
|
|
await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
|
|
|
async def test_handle_mcp_tool_invocation_ignores_agent_name_in_thread_id(self) -> None:
|
|
"""Test that MCP tool invocation uses the agent_name parameter, not the name from thread_id."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "PlantAdvisor"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
client = AsyncMock()
|
|
|
|
# Mock the entity response
|
|
mock_state = Mock()
|
|
mock_state.entity_state = {
|
|
"schemaVersion": "1.0.0",
|
|
"data": {"conversationHistory": []},
|
|
}
|
|
client.read_entity_state.return_value = mock_state
|
|
|
|
# Thread ID contains a different agent name (@StockAdvisor@poc123)
|
|
# but we're invoking PlantAdvisor - it should use PlantAdvisor's entity
|
|
context = json.dumps({"arguments": {"query": "test query", "threadId": "@StockAdvisor@test123"}})
|
|
|
|
with patch.object(app, "_get_response_from_entity") as get_response_mock:
|
|
get_response_mock.return_value = {"status": "success", "response": "Test response"}
|
|
|
|
await app._handle_mcp_tool_invocation("PlantAdvisor", context, client)
|
|
|
|
# Verify signal_entity was called with PlantAdvisor's entity, not StockAdvisor's
|
|
client.signal_entity.assert_called_once()
|
|
call_args = client.signal_entity.call_args
|
|
entity_id = call_args[0][0]
|
|
|
|
# Entity name should be dafx-PlantAdvisor, not dafx-StockAdvisor
|
|
assert entity_id.name == "dafx-PlantAdvisor"
|
|
assert entity_id.key == "test123"
|
|
|
|
async def test_handle_mcp_tool_invocation_uses_plain_thread_id_as_key(self) -> None:
|
|
"""Test that a plain thread_id (not in @name@key format) is used as-is for the key."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent])
|
|
client = AsyncMock()
|
|
|
|
mock_state = Mock()
|
|
mock_state.entity_state = {
|
|
"schemaVersion": "1.0.0",
|
|
"data": {"conversationHistory": []},
|
|
}
|
|
client.read_entity_state.return_value = mock_state
|
|
|
|
# Plain thread_id without @name@key format
|
|
context = json.dumps({"arguments": {"query": "test query", "threadId": "simple-thread-123"}})
|
|
|
|
with patch.object(app, "_get_response_from_entity") as get_response_mock:
|
|
get_response_mock.return_value = {"status": "success", "response": "Test response"}
|
|
|
|
await app._handle_mcp_tool_invocation("TestAgent", context, client)
|
|
|
|
client.signal_entity.assert_called_once()
|
|
call_args = client.signal_entity.call_args
|
|
entity_id = call_args[0][0]
|
|
|
|
assert entity_id.name == "dafx-TestAgent"
|
|
assert entity_id.key == "simple-thread-123"
|
|
|
|
def test_health_check_includes_mcp_tool_enabled(self) -> None:
|
|
"""Test that health check endpoint includes mcp_tool_enabled field."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "HealthAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
|
|
|
|
# Capture the health check handler function
|
|
captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None
|
|
|
|
def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[FuncT], FuncT]:
|
|
def decorator(func: FuncT) -> FuncT:
|
|
nonlocal captured_handler
|
|
captured_handler = func
|
|
return func
|
|
|
|
return decorator
|
|
|
|
with patch.object(app, "route", side_effect=capture_decorator):
|
|
app._setup_health_route()
|
|
|
|
# Verify we captured the handler
|
|
assert captured_handler is not None
|
|
|
|
# Call the health handler
|
|
request = Mock()
|
|
response = captured_handler(request)
|
|
|
|
# Verify response includes mcp_tool_enabled
|
|
import json
|
|
|
|
body = json.loads(response.get_body().decode("utf-8"))
|
|
assert "agents" in body
|
|
assert len(body["agents"]) == 1
|
|
assert "mcp_tool_enabled" in body["agents"][0]
|
|
assert body["agents"][0]["mcp_tool_enabled"] is True
|
|
|
|
|
|
class TestAgentFunctionAppErrorPaths:
|
|
"""Test suite for error handling paths."""
|
|
|
|
def test_init_with_invalid_max_poll_retries(self) -> None:
|
|
"""Test initialization handles invalid max_poll_retries by falling back to default."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
# Test with invalid type
|
|
app = AgentFunctionApp(agents=[mock_agent], max_poll_retries="invalid") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
|
|
assert app.max_poll_retries >= 1 # Should use default
|
|
|
|
# Test with None
|
|
app2 = AgentFunctionApp(agents=[mock_agent], max_poll_retries=None) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
|
|
assert app2.max_poll_retries >= 1 # Should use default
|
|
|
|
def test_init_with_invalid_poll_interval_seconds(self) -> None:
|
|
"""Test initialization handles invalid poll_interval_seconds by falling back to default."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
# Test with invalid type
|
|
app = AgentFunctionApp(agents=[mock_agent], poll_interval_seconds="invalid") # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
|
|
assert app.poll_interval_seconds > 0 # Should use default
|
|
|
|
# Test with None
|
|
app2 = AgentFunctionApp(agents=[mock_agent], poll_interval_seconds=None) # type: ignore[arg-type] # pyrefly: ignore[bad-argument-type] # ty: ignore[invalid-argument-type]
|
|
assert app2.poll_interval_seconds > 0 # Should use default
|
|
|
|
def test_get_agent_raises_for_unregistered_agent(self) -> None:
|
|
"""Test get_agent raises ValueError for unregistered agent."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "RegisteredAgent"
|
|
|
|
app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False)
|
|
|
|
# Create mock orchestration context
|
|
mock_context = Mock()
|
|
|
|
# Should raise ValueError for unregistered agent
|
|
with pytest.raises(ValueError, match="Agent 'UnknownAgent' is not registered"):
|
|
app.get_agent(mock_context, "UnknownAgent")
|
|
|
|
def test_convert_payload_to_text_with_response_key(self) -> None:
|
|
"""Test _convert_payload_to_text returns response key value."""
|
|
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
|
|
|
# Test with response key
|
|
payload = {"response": "Test response"}
|
|
result = app._convert_payload_to_text(payload)
|
|
assert result == "Test response"
|
|
|
|
# Test with error key
|
|
payload = {"error": "Error message"}
|
|
result = app._convert_payload_to_text(payload)
|
|
assert result == "Error message"
|
|
|
|
# Test with message key
|
|
payload = {"message": "Message text"}
|
|
result = app._convert_payload_to_text(payload)
|
|
assert result == "Message text"
|
|
|
|
# Test with no matching keys - should return JSON string
|
|
payload = {"other": "value"}
|
|
result = app._convert_payload_to_text(payload)
|
|
assert "other" in result
|
|
assert "value" in result
|
|
|
|
def test_create_session_id_with_thread_id(self) -> None:
|
|
"""Test _create_session_id with provided thread_id."""
|
|
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
|
|
|
# With thread_id provided
|
|
session_id = app._create_session_id("TestAgent", "my-thread-123")
|
|
assert session_id.key == "my-thread-123"
|
|
|
|
# Without thread_id (None) - should generate random
|
|
session_id = app._create_session_id("TestAgent", None)
|
|
assert session_id.key is not None
|
|
assert len(session_id.key) > 0
|
|
|
|
def test_resolve_thread_id_from_body(self) -> None:
|
|
"""Test _resolve_thread_id extracts from body."""
|
|
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
|
|
|
mock_req = Mock()
|
|
mock_req.params = {}
|
|
|
|
# Thread ID in body - field name is "thread_id"
|
|
req_body = {"thread_id": "body-thread-123"}
|
|
result = app._resolve_thread_id(mock_req, req_body)
|
|
assert result == "body-thread-123"
|
|
|
|
def test_select_body_parser_json_content_type(self) -> None:
|
|
"""Test _select_body_parser for JSON content type."""
|
|
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
|
|
|
# Test with application/json
|
|
parser, format_str = app._select_body_parser("application/json")
|
|
assert parser == app._parse_json_body
|
|
assert format_str == "json"
|
|
|
|
# Test with +json suffix
|
|
parser, format_str = app._select_body_parser("application/vnd.api+json")
|
|
assert parser == app._parse_json_body
|
|
assert format_str == "json"
|
|
|
|
def test_accepts_json_response_with_accept_header(self) -> None:
|
|
"""Test _accepts_json_response checks accept header."""
|
|
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
|
|
|
# With application/json in accept header
|
|
headers = {"accept": "application/json"}
|
|
result = app._accepts_json_response(headers)
|
|
assert result is True
|
|
|
|
# Without accept header
|
|
headers = {}
|
|
result = app._accepts_json_response(headers)
|
|
assert result is False
|
|
|
|
def test_parse_json_body_invalid_type(self) -> None:
|
|
"""Test _parse_json_body raises error for invalid JSON."""
|
|
from agent_framework_azurefunctions._errors import IncomingRequestError
|
|
|
|
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
|
|
|
# Mock request with non-dict JSON
|
|
mock_req = Mock()
|
|
mock_req.get_json.return_value = ["not", "a", "dict"]
|
|
|
|
with pytest.raises(IncomingRequestError, match="Invalid JSON payload"):
|
|
app._parse_json_body(mock_req)
|
|
|
|
def test_coerce_to_bool_with_none(self) -> None:
|
|
"""Test _coerce_to_bool handles None and various value types."""
|
|
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
|
|
|
|
# None returns False
|
|
assert app._coerce_to_bool(None) is False
|
|
|
|
# Integer
|
|
assert app._coerce_to_bool(1) is True
|
|
assert app._coerce_to_bool(0) is False
|
|
|
|
# String
|
|
assert app._coerce_to_bool("true") is True
|
|
assert app._coerce_to_bool("false") is False
|
|
|
|
# Other type returns False
|
|
assert app._coerce_to_bool([]) is False
|
|
|
|
|
|
class TestAgentFunctionAppWorkflow:
|
|
"""Test suite for AgentFunctionApp workflow support."""
|
|
|
|
def test_init_with_workflow_stores_workflow(self) -> None:
|
|
"""Test that workflow is stored when provided."""
|
|
mock_workflow = Mock()
|
|
mock_workflow.name = "test_workflow"
|
|
mock_workflow.executors = {}
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
):
|
|
app = AgentFunctionApp(workflow=mock_workflow)
|
|
|
|
assert app.workflow is mock_workflow
|
|
|
|
def test_init_with_workflow_registers_agent_entity_by_executor_id(self) -> None:
|
|
"""Workflow agent executors are registered as entities keyed by executor id."""
|
|
from agent_framework import AgentExecutor
|
|
|
|
mock_agent = Mock()
|
|
mock_agent.name = "WorkflowAgent"
|
|
|
|
mock_executor = Mock(spec=AgentExecutor)
|
|
mock_executor.agent = mock_agent
|
|
# Executor id intentionally differs from the agent name to exercise the
|
|
# identity fix: dispatch uses the executor id, so registration must too.
|
|
mock_executor.id = "custom-executor-id"
|
|
|
|
mock_workflow = Mock()
|
|
mock_workflow.name = "orders"
|
|
mock_workflow.executors = {"custom-executor-id": mock_executor}
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
patch.object(AgentFunctionApp, "_setup_agent_entity") as setup_entity,
|
|
):
|
|
app = AgentFunctionApp(workflow=mock_workflow)
|
|
|
|
# The entity is registered under the workflow-scoped dispatch identity.
|
|
setup_entity.assert_called_once()
|
|
call_args = setup_entity.call_args.args
|
|
assert call_args[0] is mock_agent
|
|
assert call_args[1] == "orders-custom-executor-id"
|
|
|
|
# Regression guard: the workflow agent must also be tracked on the app's
|
|
# normal registration surface, keyed by the scoped id, so it appears in
|
|
# ``agents`` and is retrievable via ``get_agent``.
|
|
assert "orders-custom-executor-id" in app.agents
|
|
assert app.agents["orders-custom-executor-id"] is mock_agent
|
|
|
|
def test_init_with_workflow_calls_setup_methods(self) -> None:
|
|
"""Test that workflow setup methods are called."""
|
|
mock_executor = Mock()
|
|
mock_executor.id = "TestExecutor"
|
|
|
|
mock_workflow = Mock()
|
|
mock_workflow.name = "test_workflow"
|
|
# Include a non-AgentExecutor so _setup_executor_activity is called
|
|
mock_workflow.executors = {"TestExecutor": mock_executor}
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec,
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
|
|
):
|
|
AgentFunctionApp(workflow=mock_workflow)
|
|
|
|
setup_exec.assert_called_once()
|
|
setup_orch.assert_called_once()
|
|
|
|
def test_init_without_workflow_does_not_call_workflow_setup(self) -> None:
|
|
"""Test that workflow setup is not called when no workflow provided."""
|
|
mock_agent = Mock()
|
|
mock_agent.name = "TestAgent"
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec,
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
|
|
):
|
|
AgentFunctionApp(agents=[mock_agent])
|
|
|
|
setup_exec.assert_not_called()
|
|
setup_orch.assert_not_called()
|
|
|
|
def test_init_with_workflow_and_explicit_agent_does_not_raise(self) -> None:
|
|
"""An agent passed explicitly and present in the workflow registers without error."""
|
|
from agent_framework import AgentExecutor
|
|
|
|
mock_agent = Mock()
|
|
mock_agent.name = "SharedAgent"
|
|
|
|
mock_executor = Mock(spec=AgentExecutor)
|
|
mock_executor.agent = mock_agent
|
|
mock_executor.id = "SharedAgent"
|
|
|
|
mock_workflow = Mock()
|
|
mock_workflow.name = "shared_flow"
|
|
mock_workflow.executors = {"SharedAgent": mock_executor}
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
patch.object(AgentFunctionApp, "_setup_agent_functions"),
|
|
patch.object(AgentFunctionApp, "_setup_agent_entity"),
|
|
):
|
|
# Same agent passed explicitly AND present in workflow — should not raise
|
|
app = AgentFunctionApp(agents=[mock_agent], workflow=mock_workflow)
|
|
|
|
assert "SharedAgent" in app.agents
|
|
|
|
def test_init_with_multiple_workflows_registers_each(self) -> None:
|
|
"""The workflows= list registers each workflow keyed by name."""
|
|
from agent_framework import Executor
|
|
|
|
def _wf(name: str, executor_id: str) -> Mock:
|
|
ex = Mock(spec=Executor)
|
|
ex.id = executor_id
|
|
wf = Mock()
|
|
wf.name = name
|
|
wf.executors = {executor_id: ex}
|
|
return wf
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec,
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
|
|
):
|
|
app = AgentFunctionApp(workflows=[_wf("orders", "router"), _wf("billing", "router")])
|
|
|
|
assert set(app.workflows) == {"orders", "billing"}
|
|
assert app.workflow is None # ambiguous with >1 workflow
|
|
assert setup_exec.call_count == 2
|
|
assert setup_orch.call_count == 2
|
|
|
|
def test_init_rejects_duplicate_workflow_name(self) -> None:
|
|
"""Two workflows with the same name are rejected."""
|
|
from agent_framework import Executor
|
|
|
|
def _wf(executor_id: str) -> Mock:
|
|
ex = Mock(spec=Executor)
|
|
ex.id = executor_id
|
|
wf = Mock()
|
|
wf.name = "orders"
|
|
wf.executors = {executor_id: ex}
|
|
return wf
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
pytest.raises(ValueError, match="already registered"),
|
|
):
|
|
AgentFunctionApp(workflows=[_wf("a"), _wf("b")])
|
|
|
|
def test_init_rejects_case_insensitive_duplicate_workflow_name(self) -> None:
|
|
"""Workflow names that differ only by case collide and are rejected.
|
|
|
|
The route ownership guard folds case, so hosting both ``orders`` and
|
|
``Orders`` would let one workflow's routes reach the other's instances.
|
|
"""
|
|
from agent_framework import Executor
|
|
|
|
def _wf(name: str, executor_id: str) -> Mock:
|
|
ex = Mock(spec=Executor)
|
|
ex.id = executor_id
|
|
wf = Mock()
|
|
wf.name = name
|
|
wf.executors = {executor_id: ex}
|
|
return wf
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
pytest.raises(ValueError, match="case-insensitively"),
|
|
):
|
|
AgentFunctionApp(workflows=[_wf("orders", "a"), _wf("Orders", "b")])
|
|
|
|
def test_init_rejects_mapping_key_mismatch(self) -> None:
|
|
"""A workflows mapping whose key disagrees with Workflow.name is rejected."""
|
|
mock_workflow = Mock()
|
|
mock_workflow.name = "orders"
|
|
mock_workflow.executors = {}
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
pytest.raises(ValueError, match="does not match"),
|
|
):
|
|
AgentFunctionApp(workflows={"wrong_key": mock_workflow})
|
|
|
|
def test_init_rejects_auto_generated_workflow_name(self) -> None:
|
|
"""An auto-generated WorkflowBuilder name is rejected."""
|
|
import uuid
|
|
|
|
mock_workflow = Mock()
|
|
mock_workflow.name = f"WorkflowBuilder-{uuid.uuid4()}"
|
|
mock_workflow.executors = {}
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
pytest.raises(ValueError, match="auto-generated"),
|
|
):
|
|
AgentFunctionApp(workflow=mock_workflow)
|
|
|
|
|
|
class TestAgentFunctionAppSubworkflow:
|
|
"""Test recursive registration of nested sub-workflows on the Functions app."""
|
|
|
|
@staticmethod
|
|
def _inner_agent_wf(name: str, executor_id: str) -> tuple[Mock, Mock]:
|
|
from agent_framework import AgentExecutor
|
|
|
|
agent = Mock()
|
|
agent.name = "InnerAssistant"
|
|
ex = Mock(spec=AgentExecutor)
|
|
ex.agent = agent
|
|
ex.id = executor_id
|
|
wf = Mock()
|
|
wf.name = name
|
|
wf.executors = {executor_id: ex}
|
|
return wf, agent
|
|
|
|
@staticmethod
|
|
def _outer_wf(name: str, inner: Mock, *, sub_ids: tuple[str, ...] = ("sub",)) -> Mock:
|
|
from agent_framework import Executor, WorkflowExecutor
|
|
|
|
executors: dict[str, Mock] = {}
|
|
for sid in sub_ids:
|
|
sub = Mock(spec=WorkflowExecutor)
|
|
sub.id = sid
|
|
sub.workflow = inner
|
|
sub.allow_direct_output = False
|
|
executors[sid] = sub
|
|
router = Mock(spec=Executor)
|
|
router.id = "router"
|
|
executors["router"] = router
|
|
wf = Mock()
|
|
wf.name = name
|
|
wf.executors = executors
|
|
return wf
|
|
|
|
def test_nested_workflow_registers_both_orchestrations(self) -> None:
|
|
"""An outer workflow registers an orchestration for itself and the inner workflow."""
|
|
inner, _ = self._inner_agent_wf("inner", "agent_node")
|
|
outer = self._outer_wf("outer", inner)
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
|
|
):
|
|
app = AgentFunctionApp(workflow=outer)
|
|
|
|
assert setup_orch.call_count == 2
|
|
registered = {call.args[0].name for call in setup_orch.call_args_list}
|
|
assert registered == {"outer", "inner"}
|
|
# Only the top-level workflow is tracked as an addressable workflow.
|
|
assert set(app.workflows) == {"outer"}
|
|
|
|
def test_nested_workflow_registers_inner_agent_scoped(self) -> None:
|
|
"""The inner workflow's agent entity is registered under the inner-scoped id."""
|
|
inner, inner_agent = self._inner_agent_wf("inner", "agent_node")
|
|
outer = self._outer_wf("outer", inner)
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
patch.object(AgentFunctionApp, "_setup_agent_entity") as setup_entity,
|
|
):
|
|
app = AgentFunctionApp(workflow=outer)
|
|
|
|
setup_entity.assert_called_once()
|
|
call_args = setup_entity.call_args.args
|
|
assert call_args[0] is inner_agent
|
|
assert call_args[1] == "inner-agent_node"
|
|
assert "inner-agent_node" in app.agents
|
|
|
|
def test_nested_workflow_routes_only_top_level(self) -> None:
|
|
"""HTTP routes are registered only for the top-level workflow."""
|
|
inner, _ = self._inner_agent_wf("inner", "agent_node")
|
|
outer = self._outer_wf("outer", inner)
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
patch.object(AgentFunctionApp, "_register_workflow_routes") as routes,
|
|
):
|
|
AgentFunctionApp(workflow=outer)
|
|
|
|
routes.assert_called_once()
|
|
assert routes.call_args.args[0] is outer
|
|
|
|
def test_shared_subworkflow_registered_once(self) -> None:
|
|
"""A sub-workflow reused by two nodes registers its orchestration only once."""
|
|
inner, _ = self._inner_agent_wf("inner", "agent_node")
|
|
outer = self._outer_wf("outer", inner, sub_ids=("sub_a", "sub_b"))
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
|
|
):
|
|
AgentFunctionApp(workflow=outer)
|
|
|
|
registered = sorted(call.args[0].name for call in setup_orch.call_args_list)
|
|
assert registered == ["inner", "outer"]
|
|
|
|
def test_nested_workflow_with_invalid_name_is_rejected(self) -> None:
|
|
"""A nested sub-workflow must also have a valid, stable name."""
|
|
inner, _ = self._inner_agent_wf("has space", "agent_node")
|
|
outer = self._outer_wf("outer", inner)
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
pytest.raises(ValueError, match="invalid"),
|
|
):
|
|
AgentFunctionApp(workflow=outer)
|
|
|
|
def test_different_subworkflow_sharing_a_name_is_rejected(self) -> None:
|
|
"""Two different sub-workflow instances that share a name collide and are rejected."""
|
|
inner_a, _ = self._inner_agent_wf("shared", "agent_node")
|
|
inner_b, _ = self._inner_agent_wf("shared", "other_node") # different instance, same name
|
|
from agent_framework import WorkflowExecutor
|
|
|
|
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
|
|
outer = Mock()
|
|
outer.name = "outer"
|
|
outer.executors = {"a": sub_a, "b": sub_b}
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
pytest.raises(ValueError, match="different workflow"),
|
|
):
|
|
AgentFunctionApp(workflow=outer)
|
|
|
|
def test_cross_registration_nested_collision_is_atomic(self) -> None:
|
|
"""A later top-level workflow whose nested child collides aborts before committing it.
|
|
|
|
Hosting ``[first, second]`` where ``second``'s nested sub-workflow reuses
|
|
``first``'s child name must raise *before* ``second`` registers any primitives,
|
|
so the app is never left with ``second`` half-configured.
|
|
"""
|
|
shared_a, _ = self._inner_agent_wf("shared", "agent_node")
|
|
shared_b, _ = self._inner_agent_wf("shared", "other_node") # different instance, same name
|
|
first = self._outer_wf("first", shared_a)
|
|
second = self._outer_wf("second", shared_b)
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
|
|
pytest.raises(ValueError, match="collides"),
|
|
):
|
|
AgentFunctionApp(workflows=[first, second])
|
|
|
|
# Only 'first' and its child 'shared' committed primitives; the collision aborted
|
|
# before 'second' (or its colliding child) registered anything.
|
|
registered = {call.args[0].name for call in setup_orch.call_args_list}
|
|
assert registered == {"first", "shared"}
|
|
assert "second" not in registered
|
|
|
|
def test_executor_id_with_reserved_separator_is_rejected(self) -> None:
|
|
"""An executor id containing the nested-HITL separator is rejected at registration."""
|
|
from agent_framework import Executor
|
|
|
|
ex = Mock(spec=Executor)
|
|
ex.id = "bad~id"
|
|
wf = Mock()
|
|
wf.name = "orders"
|
|
wf.executors = {"bad~id": ex}
|
|
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
pytest.raises(ValueError, match="reserved sub-workflow request separator"),
|
|
):
|
|
AgentFunctionApp(workflow=wf)
|
|
|
|
|
|
# NOTE: State snapshot/diff tests were moved to durabletask once the activity
|
|
# execution body was extracted into the host-agnostic execute_workflow_activity.
|
|
# See packages/durabletask/tests/test_workflow_activity.py.
|
|
|
|
|
|
class TestWorkflowStatusOutputEncoding:
|
|
"""The workflow status endpoint emits clean domain JSON for reconstructed outputs.
|
|
|
|
Reconstructed outputs (see ``deserialize_workflow_output``) may be framework
|
|
models or dataclasses; ``_json_default`` converts them via their own
|
|
serialization so the HTTP body is clean domain JSON rather than opaque
|
|
checkpoint-marker dicts or repr strings.
|
|
"""
|
|
|
|
def test_encodes_framework_model_via_to_dict(self) -> None:
|
|
from agent_framework_azurefunctions._app import _json_default
|
|
|
|
response = AgentResponse(messages=[Message(role="assistant", contents=["hello"])])
|
|
|
|
encoded = _json_default(response)
|
|
|
|
assert encoded == response.to_dict()
|
|
# The result must be JSON-serializable (no marker dicts, no objects).
|
|
assert "hello" in json.dumps(encoded)
|
|
|
|
def test_encodes_dataclass_via_asdict(self) -> None:
|
|
from dataclasses import dataclass
|
|
|
|
from agent_framework_azurefunctions._app import _json_default
|
|
|
|
@dataclass
|
|
class Decision:
|
|
approved: bool
|
|
note: str
|
|
|
|
encoded = _json_default(Decision(approved=True, note="ok"))
|
|
|
|
assert encoded == {"approved": True, "note": "ok"}
|
|
|
|
def test_falls_back_to_str_for_plain_objects(self) -> None:
|
|
from agent_framework_azurefunctions._app import _json_default
|
|
|
|
class Opaque:
|
|
def __str__(self) -> str:
|
|
return "opaque-value"
|
|
|
|
assert _json_default(Opaque()) == "opaque-value"
|
|
|
|
|
|
class TestWorkflowOrchestrationScoping:
|
|
"""Scoping of the workflow status/respond endpoints to the workflow orchestrator.
|
|
|
|
Both endpoints address durable instances by ID only, but the durable client resolves
|
|
IDs across every orchestration in the task hub (agent entities, user-registered
|
|
orchestrations, other apps on the same hub). ``_is_owned_orchestration`` gates the
|
|
endpoints so a leaked instance ID for a different orchestration is treated as
|
|
"not found" instead of leaking its status/HITL details or accepting injected events.
|
|
"""
|
|
|
|
def _app_for(self, workflow_name: str) -> AgentFunctionApp:
|
|
mock_workflow = Mock()
|
|
mock_workflow.name = workflow_name
|
|
mock_workflow.executors = {}
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
):
|
|
return AgentFunctionApp(workflow=mock_workflow)
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
workflow_orchestrator_name("orders"), # exact dafx-orders
|
|
workflow_orchestrator_name("orders").upper(), # case-insensitive: must match
|
|
"DAFX-orders", # mixed case prefix
|
|
],
|
|
)
|
|
def test_accepts_matching_workflow_orchestration(self, name: str) -> None:
|
|
app = self._app_for("orders")
|
|
status = Mock()
|
|
status.name = name
|
|
assert app._is_owned_orchestration(status, "orders") is True
|
|
|
|
def test_rejects_none_status(self) -> None:
|
|
# client.get_status returns None when no instance resolves for the ID.
|
|
app = self._app_for("orders")
|
|
assert app._is_owned_orchestration(None, "orders") is False
|
|
|
|
def test_rejects_status_without_name(self) -> None:
|
|
app = self._app_for("orders")
|
|
status = Mock()
|
|
status.name = None
|
|
assert app._is_owned_orchestration(status, "orders") is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"other_name",
|
|
[
|
|
"SomeUserOrchestration",
|
|
"dafx-WeatherAgent", # an agent entity, not this workflow's orchestration
|
|
"dafx-billing", # a *different* workflow's orchestration
|
|
"workflow_orchestrator", # the deprecated fixed name
|
|
],
|
|
)
|
|
def test_rejects_other_orchestration_name(self, other_name: str) -> None:
|
|
app = self._app_for("orders")
|
|
status = Mock()
|
|
status.name = other_name
|
|
assert app._is_owned_orchestration(status, "orders") is False
|
|
|
|
|
|
class TestAgentFunctionAppSubworkflowHitl:
|
|
"""Sub-workflow HITL plumbing: gather nested pending requests and route responses.
|
|
|
|
These exercise the host-side helpers the ``workflow/{name}/status`` and
|
|
``.../respond`` routes use to support nested sub-workflows behind a single
|
|
top-level addressing surface (B2 qualified request ids).
|
|
"""
|
|
|
|
@staticmethod
|
|
def _app() -> AgentFunctionApp:
|
|
mock_workflow = Mock()
|
|
mock_workflow.name = "orders"
|
|
mock_workflow.executors = {}
|
|
with (
|
|
patch.object(AgentFunctionApp, "_setup_executor_activity"),
|
|
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
|
|
):
|
|
return AgentFunctionApp(workflow=mock_workflow)
|
|
|
|
@staticmethod
|
|
def _client(by_instance: dict[str, dict | None]) -> AsyncMock:
|
|
"""An AsyncMock durable client whose get_status returns a per-instance custom status."""
|
|
|
|
async def _get_status(instance_id: str) -> Mock | None:
|
|
if instance_id not in by_instance:
|
|
return None
|
|
status = Mock()
|
|
status.custom_status = by_instance[instance_id]
|
|
return status
|
|
|
|
client = AsyncMock()
|
|
client.get_status.side_effect = _get_status
|
|
return client
|
|
|
|
async def test_gather_returns_top_level_requests_unqualified(self) -> None:
|
|
app = self._app()
|
|
client = self._client({})
|
|
custom_status = {"pending_requests": {"top-1": {"source_executor_id": "outer"}}}
|
|
|
|
gathered = await app._gather_pending_hitl_requests(client, custom_status)
|
|
|
|
assert gathered == [("top-1", {"source_executor_id": "outer"})]
|
|
|
|
async def test_gather_qualifies_nested_requests(self) -> None:
|
|
app = self._app()
|
|
client = self._client({"child-1": {"pending_requests": {"inner-1": {"source_executor_id": "inner"}}}})
|
|
parent_status = {
|
|
"pending_requests": {"top-1": {"source_executor_id": "outer"}},
|
|
"subworkflows": {"sub": ["child-1"]},
|
|
}
|
|
|
|
gathered = await app._gather_pending_hitl_requests(client, parent_status)
|
|
|
|
ids = {qid for qid, _ in gathered}
|
|
assert ids == {"top-1", "sub~0~inner-1"}
|
|
|
|
async def test_gather_accumulates_deep_path(self) -> None:
|
|
app = self._app()
|
|
client = self._client({
|
|
"child-1": {"subworkflows": {"leaf": ["child-2"]}},
|
|
"child-2": {"pending_requests": {"deep": {"source_executor_id": "leaf_node"}}},
|
|
})
|
|
parent_status = {"subworkflows": {"mid": ["child-1"]}}
|
|
|
|
gathered = await app._gather_pending_hitl_requests(client, parent_status)
|
|
|
|
assert [qid for qid, _ in gathered] == ["mid~0~leaf~0~deep"]
|
|
|
|
async def test_resolve_unqualified_targets_same_instance(self) -> None:
|
|
app = self._app()
|
|
client = self._client({})
|
|
|
|
resolved = await app._resolve_hitl_target(client, "parent", "req-1")
|
|
|
|
assert resolved == ("parent", "req-1")
|
|
|
|
async def test_resolve_qualified_targets_child_instance(self) -> None:
|
|
app = self._app()
|
|
client = self._client({"parent": {"subworkflows": {"sub": ["child-1"]}}})
|
|
|
|
resolved = await app._resolve_hitl_target(client, "parent", "sub~0~req-9")
|
|
|
|
assert resolved == ("child-1", "req-9")
|
|
|
|
async def test_resolve_deeply_qualified_targets_leaf(self) -> None:
|
|
app = self._app()
|
|
client = self._client({
|
|
"parent": {"subworkflows": {"mid": ["child-1"]}},
|
|
"child-1": {"subworkflows": {"leaf": ["child-2"]}},
|
|
})
|
|
|
|
resolved = await app._resolve_hitl_target(client, "parent", "mid~0~leaf~0~deep")
|
|
|
|
assert resolved == ("child-2", "deep")
|
|
|
|
async def test_resolve_unknown_subworkflow_returns_none(self) -> None:
|
|
app = self._app()
|
|
client = self._client({"parent": {"state": "running"}}) # no subworkflows map
|
|
|
|
resolved = await app._resolve_hitl_target(client, "parent", "sub~0~req-9")
|
|
|
|
assert resolved is None
|
|
|
|
async def test_multiple_children_of_one_executor_stay_addressable(self) -> None:
|
|
app = self._app()
|
|
client = self._client({
|
|
"parent": {"subworkflows": {"sub": ["child-1", "child-2"]}},
|
|
"child-1": {"pending_requests": {"r1": {"source_executor_id": "a"}}},
|
|
"child-2": {"pending_requests": {"r2": {"source_executor_id": "b"}}},
|
|
})
|
|
parent_status = {"subworkflows": {"sub": ["child-1", "child-2"]}}
|
|
|
|
gathered = await app._gather_pending_hitl_requests(client, parent_status)
|
|
assert {qid for qid, _ in gathered} == {"sub~0~r1", "sub~1~r2"}
|
|
|
|
# The second child (ordinal 1) resolves distinctly, not shadowed by the first.
|
|
resolved = await app._resolve_hitl_target(client, "parent", "sub~1~r2")
|
|
assert resolved == ("child-2", "r2")
|
|
|
|
async def test_nested_double_colon_leaf_round_trips(self) -> None:
|
|
app = self._app()
|
|
client = self._client({
|
|
"parent": {"subworkflows": {"sub": ["child-1"]}},
|
|
"child-1": {"pending_requests": {"auto::0": {"request_id": "auto::0", "source_executor_id": "fn"}}},
|
|
})
|
|
parent_status = {"subworkflows": {"sub": ["child-1"]}}
|
|
|
|
gathered = await app._gather_pending_hitl_requests(client, parent_status)
|
|
assert [qid for qid, _ in gathered] == ["sub~0~auto::0"]
|
|
|
|
resolved = await app._resolve_hitl_target(client, "parent", "sub~0~auto::0")
|
|
assert resolved == ("child-1", "auto::0")
|
|
|
|
async def test_top_level_double_colon_leaf_is_not_nested(self) -> None:
|
|
app = self._app()
|
|
client = self._client({})
|
|
|
|
resolved = await app._resolve_hitl_target(client, "parent", "auto::0")
|
|
|
|
assert resolved == ("parent", "auto::0")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v", "--tb=short"])
|