chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# Azure Functions Package (agent-framework-azurefunctions)
Hosting agents as Azure Functions.
## Main Classes
- **`AgentFunctionApp`** - Azure Functions app wrapper for agents
## Usage
```python
from agent_framework.azure import AgentFunctionApp
app = AgentFunctionApp(agent=my_agent)
```
## Import Path
```python
from agent_framework.azure import AgentFunctionApp
# or directly:
from agent_framework_azurefunctions import AgentFunctionApp
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+28
View File
@@ -0,0 +1,28 @@
# Get Started with Microsoft Agent Framework Durable Functions
[![PyPI](https://img.shields.io/pypi/v/agent-framework-azurefunctions)](https://pypi.org/project/agent-framework-azurefunctions/)
Please install this package via pip:
```bash
pip install agent-framework-azurefunctions --pre
```
## Durable Agent Extension
The durable agent extension lets you host Microsoft Agent Framework agents on Azure Durable Functions so they can persist state, replay conversation history, and recover from failures automatically.
### Basic Usage Example
See the durable functions integration sample in the repository to learn how to:
```python
from agent_framework.azure import AgentFunctionApp
_app = AgentFunctionApp()
```
- Register agents with `AgentFunctionApp`
- Post messages using the generated `/api/agents/{agent_name}/run` endpoint
For more details, review the Python [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) and the samples directory.
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._app import AgentFunctionApp
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AgentFunctionApp",
"__version__",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
# Copyright (c) Microsoft. All rights reserved.
"""Runner context for Azure Functions activity execution.
This module provides the CapturingRunnerContext class that captures messages
and events produced during executor execution within Azure Functions activities.
"""
from __future__ import annotations
import asyncio
from copy import copy
from typing import Any
from agent_framework import (
CheckpointStorage,
RunnerContext,
WorkflowCheckpoint,
WorkflowEvent,
WorkflowMessage,
)
from agent_framework._workflows._runner_context import YieldOutputClassifier, YieldOutputEventType
from agent_framework._workflows._state import State
class CapturingRunnerContext(RunnerContext):
"""A RunnerContext implementation that captures messages and events for Azure Functions activities.
This context is designed for executing standard Executors within Azure Functions activities.
It captures all messages and events produced during execution without requiring durable
entity storage, allowing the results to be returned to the orchestrator.
Unlike InProcRunnerContext, this implementation does NOT support checkpointing
(always returns False for has_checkpointing). The orchestrator manages state
coordination; this context just captures execution output.
"""
def __init__(self) -> None:
"""Initialize the capturing runner context."""
self._messages: dict[str, list[WorkflowMessage]] = {}
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {}
self._workflow_id: str | None = None
self._streaming: bool = False
self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output"
# region Messaging
async def send_message(self, message: WorkflowMessage) -> None:
"""Capture a message sent by an executor."""
self._messages.setdefault(message.source_id, [])
self._messages[message.source_id].append(message)
async def drain_messages(self) -> dict[str, list[WorkflowMessage]]:
"""Drain and return all captured messages."""
messages = copy(self._messages)
self._messages.clear()
return messages
async def has_messages(self) -> bool:
"""Check if there are any captured messages."""
return bool(self._messages)
# endregion Messaging
# region Events
async def add_event(self, event: WorkflowEvent) -> None:
"""Capture an event produced during execution."""
await self._event_queue.put(event)
async def drain_events(self) -> list[WorkflowEvent]:
"""Drain all currently queued events without blocking."""
events: list[WorkflowEvent] = []
while True:
try:
events.append(self._event_queue.get_nowait())
except asyncio.QueueEmpty:
break
return events
async def has_events(self) -> bool:
"""Check if there are any queued events."""
return not self._event_queue.empty()
async def next_event(self) -> WorkflowEvent:
"""Wait for and return the next event."""
return await self._event_queue.get()
# endregion Events
# region Checkpointing (not supported in activity context)
def has_checkpointing(self) -> bool:
"""Checkpointing is not supported in activity context."""
return False
def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None:
"""No-op: checkpointing not supported in activity context."""
pass
def clear_runtime_checkpoint_storage(self) -> None:
"""No-op: checkpointing not supported in activity context."""
pass
async def create_checkpoint(
self,
workflow_name: str,
graph_signature_hash: str,
state: State,
previous_checkpoint_id: str | None,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
"""Checkpointing not supported in activity context."""
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Checkpointing not supported in activity context."""
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
"""Checkpointing not supported in activity context."""
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
# endregion Checkpointing
# region Workflow Configuration
def set_workflow_id(self, workflow_id: str) -> None:
"""Set the workflow ID."""
self._workflow_id = workflow_id
def reset_for_new_run(self) -> None:
"""Reset the context for a new run."""
self._messages.clear()
self._event_queue = asyncio.Queue()
self._pending_request_info_events.clear()
self._streaming = False
def set_streaming(self, streaming: bool) -> None:
"""Set streaming mode (not used in activity context)."""
self._streaming = streaming
def is_streaming(self) -> bool:
"""Check if streaming mode is enabled (always False in activity context)."""
return self._streaming
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
"""Set the classifier used by WorkflowContext.yield_output()."""
self._yield_output_classifier = classifier
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
return self._yield_output_classifier(executor_id)
# endregion Workflow Configuration
# region Request Info Events
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
"""Add a request_info WorkflowEvent and track it for correlation."""
self._pending_request_info_events[event.request_id] = event
await self.add_event(event)
async def send_request_info_response(self, request_id: str, response: Any) -> None:
"""Send a response correlated to a pending request.
Note: This is not supported in activity context since human-in-the-loop
scenarios require orchestrator-level coordination.
"""
raise NotImplementedError(
"send_request_info_response is not supported in Azure Functions activity context. "
"Human-in-the-loop scenarios should be handled at the orchestrator level."
)
async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]:
"""Get the mapping of request IDs to their corresponding request_info events."""
return dict(self._pending_request_info_events)
# endregion Request Info Events
@@ -0,0 +1,118 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable Entity for Agent Execution.
This module defines a durable entity that manages agent state and execution.
Using entities instead of orchestrations provides better state management and
allows for long-running agent conversations.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any, cast
import azure.durable_functions as df
from agent_framework import SupportsAgentRun
from agent_framework_durabletask import (
AgentEntity,
AgentEntityStateProviderMixin,
AgentResponseCallbackProtocol,
run_agent_coroutine,
)
logger = logging.getLogger("agent_framework.azurefunctions")
class AzureFunctionEntityStateProvider(AgentEntityStateProviderMixin):
"""Azure Functions Durable Entity state provider for AgentEntity.
This class utilizes the Durable Entity context from `azure-functions-durable` package
to get and set the state of the agent entity.
"""
def __init__(self, context: df.DurableEntityContext) -> None:
self._context = context
def _get_state_dict(self) -> dict[str, Any]:
raw_state = self._context.get_state(lambda: {})
if not isinstance(raw_state, dict):
return {}
return cast(dict[str, Any], raw_state)
def _set_state_dict(self, state: dict[str, Any]) -> None:
self._context.set_state(state)
def _get_thread_id_from_entity(self) -> str:
return str(self._context.entity_key)
def create_agent_entity(
agent: SupportsAgentRun,
callback: AgentResponseCallbackProtocol | None = None,
) -> Callable[[df.DurableEntityContext], None]:
"""Factory function to create an agent entity class.
Args:
agent: The Microsoft Agent Framework agent instance (must implement SupportsAgentRun)
callback: Optional callback invoked during streaming and final responses
Returns:
Entity function configured with the agent
"""
async def _entity_coroutine(context: df.DurableEntityContext) -> None:
"""Async handler that executes the entity operations."""
try:
logger.debug("[entity_function] Entity triggered")
logger.debug("[entity_function] Operation: %s", context.operation_name)
state_provider = AzureFunctionEntityStateProvider(context)
entity = AgentEntity(agent, callback, state_provider=state_provider)
operation = context.operation_name
if operation == "run" or operation == "run_agent":
input_data: Any = context.get_input()
request: str | dict[str, Any]
if isinstance(input_data, dict) and "message" in input_data:
request = cast(dict[str, Any], input_data)
else:
# Fall back to treating input as message string
request = "" if input_data is None else str(cast(object, input_data))
result = await entity.run(request)
context.set_result(result.to_dict())
elif operation == "reset":
entity.reset()
context.set_result({"status": "reset"})
else:
logger.error("[entity_function] Unknown operation: %s", operation)
context.set_result({"error": f"Unknown operation: {operation}"})
logger.info("[entity_function] Operation %s completed successfully", operation)
except Exception as exc:
logger.exception("[entity_function] Error executing entity operation %s", exc)
context.set_result({"error": str(exc), "status": "error"})
def entity_function(context: df.DurableEntityContext) -> None:
"""Synchronous wrapper invoked by the Durable Functions runtime.
All agent coroutines run on a single process-wide persistent event loop
(see ``run_agent_coroutine``). This keeps async resources created by
shared agent clients/credentials bound to a live loop across every
invocation, preventing cross-loop hangs when the host dispatches
successive entity operations onto different worker threads.
"""
try:
run_agent_coroutine(_entity_coroutine(context))
except Exception as exc: # pragma: no cover - defensive logging
logger.error("[entity_function] Unexpected error executing entity: %s", exc, exc_info=True)
context.set_result({"error": str(exc), "status": "error"})
return entity_function
@@ -0,0 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
"""Custom exception types for the durable agent framework."""
class IncomingRequestError(ValueError):
"""Raised when an incoming HTTP request cannot be parsed or validated."""
def __init__(self, message: str, status_code: int = 400) -> None:
super().__init__(message)
self.status_code = status_code
@@ -0,0 +1,222 @@
# Copyright (c) Microsoft. All rights reserved.
"""Orchestration Support for Durable Agents.
This module provides support for using agents inside Durable Function orchestrations.
"""
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeAlias
import azure.durable_functions as df
from agent_framework import AgentSession
from agent_framework_durabletask import (
DurableAgentExecutor,
RunRequest,
ensure_response_format,
load_agent_response,
)
from azure.durable_functions.models import TaskBase
from azure.durable_functions.models.actions.NoOpAction import NoOpAction
from azure.durable_functions.models.Task import CompoundTask, TaskState
from pydantic import BaseModel
logger = logging.getLogger("agent_framework.azurefunctions")
CompoundActionConstructor: TypeAlias = Callable[[list[Any]], Any] | None
if TYPE_CHECKING:
from azure.durable_functions import DurableOrchestrationContext
class _TypedCompoundTask(CompoundTask):
_first_error: Any
def __init__(
self,
tasks: list[TaskBase],
compound_action_constructor: CompoundActionConstructor = None,
) -> None: ...
AgentOrchestrationContextType: TypeAlias = DurableOrchestrationContext
else:
AgentOrchestrationContextType = Any
_TypedCompoundTask = CompoundTask
class PreCompletedTask(TaskBase):
"""A simple task that is already completed with a result.
Used for fire-and-forget mode where we want to return immediately
with an acceptance response without waiting for entity processing.
"""
def __init__(self, result: Any):
"""Initialize with a completed result.
Args:
result: The result value for this completed task
"""
# Initialize with a NoOp action since we don't need actual orchestration actions
super().__init__(-1, NoOpAction())
# Immediately mark as completed with the result
self.set_value(is_error=False, value=result)
class AgentTask(_TypedCompoundTask):
"""A custom Task that wraps entity calls and provides typed AgentResponse results.
This task wraps the underlying entity call task and intercepts its completion
to convert the raw result into a typed AgentResponse object.
"""
def __init__(
self,
entity_task: TaskBase,
response_format: type[BaseModel] | None,
correlation_id: str,
):
"""Initialize the AgentTask.
Args:
entity_task: The underlying entity call task
response_format: Optional Pydantic model for response parsing
correlation_id: Correlation ID for logging
"""
# Set instance variables BEFORE calling super().__init__
# because super().__init__ may trigger try_set_value for pre-completed tasks
self._response_format = response_format
self._correlation_id = correlation_id
super().__init__([entity_task])
# Override action_repr to expose the inner task's action directly
# This ensures compatibility with ReplaySchema V3 which expects Action objects.
self.action_repr = entity_task.action_repr
# Also copy the task ID to match the entity task's identity
self.id = entity_task.id
def try_set_value(self, child: TaskBase) -> None:
"""Transition the AgentTask to a terminal state and set its value to `AgentResponse`.
Parameters
----------
child : TaskBase
The entity call task that just completed
"""
if child.state is TaskState.SUCCEEDED:
# Delegate to parent class for standard completion logic
if len(self.pending_tasks) == 0:
# Transform the raw result before setting it
raw_result = child.result
logger.debug(
"[AgentTask] Converting raw result for correlation_id %s",
self._correlation_id,
)
try:
response = load_agent_response(raw_result)
if self._response_format is not None:
ensure_response_format(
self._response_format,
self._correlation_id,
response,
)
# Set the typed AgentResponse as this task's result
self.set_value(is_error=False, value=response)
except Exception as e:
logger.exception(
"[AgentTask] Failed to convert result for correlation_id: %s",
self._correlation_id,
)
self.set_value(is_error=True, value=e)
else:
# If error not handled by the parent, set it explicitly.
if self._first_error is None:
self._first_error = child.result
self.set_value(is_error=True, value=self._first_error)
class AzureFunctionsAgentExecutor(DurableAgentExecutor[AgentTask]):
"""Executor that executes durable agents inside Azure Functions orchestrations."""
def __init__(self, context: AgentOrchestrationContextType):
self.context = context
def generate_unique_id(self) -> str:
return str(self.context.new_uuid())
def get_run_request(
self,
message: str,
*,
options: dict[str, Any] | None = None,
) -> RunRequest:
"""Get the current run request from the orchestration context.
Args:
message: The message to send to the agent
options: Optional options dictionary. Supported keys include
``response_format``, ``enable_tool_calls``, and ``wait_for_response``.
Additional keys are forwarded to the agent execution.
Returns:
RunRequest: The current run request
Raises:
ValueError: If wait_for_response=False (not supported in orchestrations)
"""
# Create a copy to avoid modifying the caller's dict
request = super().get_run_request(message, options=options)
request.orchestration_id = self.context.instance_id
return request
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
session: AgentSession | None = None,
) -> AgentTask:
# Resolve session
session_id = self._create_session_id(agent_name, session)
entity_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
logger.debug(
"[AzureFunctionsAgentProvider] correlation_id: %s entity_id: %s session_id: %s",
run_request.correlation_id,
entity_id,
session_id,
)
# Branch based on wait_for_response
if not run_request.wait_for_response:
# Fire-and-forget mode: signal entity and return pre-completed task
logger.debug(
"[AzureFunctionsAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)",
run_request.correlation_id,
)
self.context.signal_entity(entity_id, "run", run_request.to_dict())
# Create acceptance response using base class helper
acceptance_response = self._create_acceptance_response(run_request.correlation_id)
# Create a pre-completed task with the acceptance response
entity_task = PreCompletedTask(acceptance_response)
else:
# Blocking mode: call entity and wait for response
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
return AgentTask(
entity_task=entity_task,
response_format=run_request.response_format,
correlation_id=run_request.correlation_id,
)
@@ -0,0 +1,83 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow Execution for Durable Functions.
This module provides the Azure Functions entry point for workflow orchestration.
The actual orchestration logic lives in the shared module
``agent_framework_durabletask._workflows.orchestrator`` and is host-agnostic.
This module re-exports the public API and provides the AF-specific
``run_workflow_orchestrator`` wrapper that creates an
:class:`AzureFunctionsWorkflowContext` before delegating.
"""
from __future__ import annotations
import logging
from collections.abc import Generator
from typing import Any
from agent_framework import Workflow
from agent_framework_durabletask._workflows.orchestrator import (
SOURCE_HITL_RESPONSE,
SOURCE_ORCHESTRATOR,
SOURCE_WORKFLOW_START,
ExecutorResult,
PendingHITLRequest,
TaskMetadata,
TaskType,
_extract_message_content, # pyright: ignore[reportPrivateUsage]
build_agent_executor_response,
execute_hitl_response_handler,
route_message_through_edge_groups,
)
from agent_framework_durabletask._workflows.orchestrator import (
run_workflow_orchestrator as _run_workflow_orchestrator_shared,
)
from azure.durable_functions import DurableOrchestrationContext
from ._workflow_af_context import AzureFunctionsWorkflowContext
logger = logging.getLogger(__name__)
# Re-export shared symbols for backward compatibility
__all__ = [
"SOURCE_HITL_RESPONSE",
"SOURCE_ORCHESTRATOR",
"SOURCE_WORKFLOW_START",
"ExecutorResult",
"PendingHITLRequest",
"TaskMetadata",
"TaskType",
"_extract_message_content",
"build_agent_executor_response",
"execute_hitl_response_handler",
"route_message_through_edge_groups",
"run_workflow_orchestrator",
]
def run_workflow_orchestrator(
context: DurableOrchestrationContext,
workflow: Workflow,
initial_message: Any,
shared_state: dict[str, Any] | None = None,
) -> Generator[Any, Any, list[Any] | dict[str, Any]]:
"""Azure Functions wrapper around the shared workflow orchestrator.
Creates an :class:`AzureFunctionsWorkflowContext` and delegates to the
host-agnostic :func:`run_workflow_orchestrator` in the durabletask package.
Args:
context: The Azure Functions ``DurableOrchestrationContext``.
workflow: The MAF Workflow instance to execute.
initial_message: Initial message to send to the start executor.
shared_state: Optional dict for cross-executor state sharing.
Returns:
For a top-level run, the list of workflow outputs collected from executor
activities. For a sub-workflow run, a result envelope ``{"outputs": [...],
"events": [...]}`` so the parent can bubble nested progress (see the shared
``run_workflow_orchestrator`` in the durabletask package).
"""
af_ctx = AzureFunctionsWorkflowContext(context)
return _run_workflow_orchestrator_shared(af_ctx, workflow, initial_message, shared_state)
@@ -0,0 +1,105 @@
# Copyright (c) Microsoft. All rights reserved.
"""Azure Functions adapter for WorkflowOrchestrationContext.
Wraps ``azure.durable_functions.DurableOrchestrationContext`` to satisfy the
:class:`~agent_framework_durabletask.WorkflowOrchestrationContext` protocol.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from agent_framework_durabletask import AgentSessionId, DurableAgentSession, DurableAIAgent
from azure.durable_functions import DurableOrchestrationContext
from ._orchestration import AzureFunctionsAgentExecutor
logger = logging.getLogger(__name__)
class AzureFunctionsWorkflowContext:
"""Adapter that maps ``DurableOrchestrationContext`` to ``WorkflowOrchestrationContext``."""
def __init__(self, context: DurableOrchestrationContext) -> None:
self._context = context
# -- Properties -----------------------------------------------------------
@property
def instance_id(self) -> str:
# Typed local (not cast): mypy sees the untyped context as Any, while
# pyright sees a concrete str - the annotation satisfies both.
instance_id: str = self._context.instance_id
return instance_id
@property
def is_replaying(self) -> bool:
is_replaying: bool = self._context.is_replaying
return is_replaying
@property
def supports_event_streaming(self) -> bool:
# The Azure Functions host has no workflow event-streaming endpoint, and its
# Durable Functions custom status is capped at 16 KB by the WebJobs extension.
# Publishing the accumulating event log would overflow that cap and fail the
# orchestrator, so events are omitted; state, pending HITL requests, and the
# final output remain available via the workflow status endpoint.
return False
@property
def current_utc_datetime(self) -> datetime:
current: datetime = self._context.current_utc_datetime
return current
# -- Agent / Activity dispatch --------------------------------------------
def prepare_agent_task(self, executor_id: str, message: str, orchestration_instance_id: str) -> Any:
session_id = AgentSessionId(name=executor_id, key=orchestration_instance_id)
session = DurableAgentSession(durable_session_id=session_id)
az_executor = AzureFunctionsAgentExecutor(self._context)
agent = DurableAIAgent(az_executor, executor_id)
return agent.run(message, session=session)
def prepare_activity_task(self, activity_name: str, input_json: str) -> Any:
orchestration_context: Any = self._context
return orchestration_context.call_activity(activity_name, input_json)
def call_sub_orchestrator(self, name: str, input: Any, instance_id: str | None = None) -> Any:
orchestration_context: Any = self._context
return orchestration_context.call_sub_orchestrator(name, input_=input, instance_id=instance_id)
# -- Composite tasks ------------------------------------------------------
def task_all(self, tasks: list[Any]) -> Any:
return self._context.task_all(tasks)
def task_any(self, tasks: list[Any]) -> Any:
return self._context.task_any(tasks)
# -- External events / timers ---------------------------------------------
def wait_for_external_event(self, name: str) -> Any:
return self._context.wait_for_external_event(name)
def create_timer(self, fire_at: datetime) -> Any:
return self._context.create_timer(fire_at)
# -- Status / utility -----------------------------------------------------
def set_custom_status(self, status: Any) -> None:
self._context.set_custom_status(status)
def new_uuid(self) -> str:
new_uuid: str = self._context.new_uuid()
return new_uuid
def cancel_task(self, task: Any) -> None:
cancel_fn = getattr(task, "cancel", None)
if callable(cancel_fn):
cancel_fn()
def get_task_result(self, task: Any) -> Any:
return getattr(task, "result", None)
@@ -0,0 +1,101 @@
[project]
name = "agent-framework-azurefunctions"
description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260709"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
"agent-framework-durabletask>=1.0.0b260709,<2",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
pythonpath = ["tests/integration_tests"]
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 300
markers = [
"integration: marks tests as integration tests (require running function app)",
"orchestration: marks tests that use orchestrations (require Azurite)",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_azurefunctions"]
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_azurefunctions"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,10 @@
# Azure OpenAI Configuration
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_MODEL=your-deployment-name
FUNCTIONS_WORKER_RUNTIME=python
# Azure Functions Configuration
AzureWebJobsStorage=UseDevelopmentStorage=true
DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=http://localhost:8080;Authentication=None
# Note: TASKHUB_NAME is not required for integration tests; it is auto-generated per test run.
@@ -0,0 +1,81 @@
# Sample Integration Tests
Integration tests that validate the Durable Agent Framework samples by running them as Azure Functions.
## Setup
### 1. Create `.env` file
Copy `.env.example` to `.env` and fill in your Azure credentials:
```bash
cp .env.example .env
```
Required variables:
- `AZURE_OPENAI_ENDPOINT`
- `AZURE_OPENAI_MODEL`
- `AZURE_OPENAI_API_KEY`
- `AzureWebJobsStorage`
- `DURABLE_TASK_SCHEDULER_CONNECTION_STRING`
- `FUNCTIONS_WORKER_RUNTIME`
### 2. Start required services
**Azurite (for orchestration tests):**
```bash
docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite
```
**Durable Task Scheduler:**
```bash
docker run -d -p 8080:8080 -p 8082:8082 -e DTS_USE_DYNAMIC_TASK_HUBS=true mcr.microsoft.com/dts/dts-emulator:latest
```
## Running Tests
The tests automatically start and stop the Azure Functions app for each sample.
### Run all sample tests
```bash
uv run pytest packages/azurefunctions/tests/integration_tests -v
```
### Run specific sample
```bash
uv run pytest packages/azurefunctions/tests/integration_tests/test_01_single_agent.py -v
```
### Run with verbose output
```bash
uv run pytest packages/azurefunctions/tests/integration_tests -sv
```
## How It Works
Each test file uses pytest markers to automatically configure and start the function app:
```python
pytestmark = [
pytest.mark.sample("01_single_agent"),
pytest.mark.usefixtures("function_app_for_test"),
skip_if_azure_functions_integration_tests_disabled,
]
```
The `function_app_for_test` fixture:
1. Loads environment variables from `.env`
2. Validates required variables are present
3. Starts the function app on a dynamically allocated port
4. Waits for the app to be ready
5. Runs your tests
6. Tears down the function app
## Troubleshooting
**Missing environment variables:**
Ensure your `.env` file contains all required variables from `.env.example`.
**Tests timeout:**
Check that Azure OpenAI credentials are valid and the service is accessible.
@@ -0,0 +1,613 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Pytest configuration for Azure Functions integration tests.
This module provides fixtures, configuration, and test utilities for pytest.
"""
import os
import shutil
import socket
import subprocess
import sys
import time
import uuid
from collections.abc import Iterator, Mapping
from contextlib import suppress
from pathlib import Path
from typing import Any
import pytest
import requests
# =============================================================================
# Configuration Constants
# =============================================================================
TIMEOUT = 30 # seconds
ORCHESTRATION_TIMEOUT = 180 # seconds for orchestrations
_DEFAULT_HOST = "localhost"
# Emulator ports (match CI workflow configuration)
_AZURITE_BLOB_PORT = 10000
_DTS_EMULATOR_PORT = 8080
# =============================================================================
# Exceptions
# =============================================================================
class FunctionAppStartupError(RuntimeError):
"""Raised when the Azure Functions host fails to start reliably."""
pass
# =============================================================================
# Environment and Service Checks
# =============================================================================
def _load_env_file_if_present() -> None:
"""Load environment variables from the local .env file when available."""
env_file = Path(__file__).parent / ".env"
if not env_file.exists():
return
try:
from dotenv import load_dotenv
load_dotenv(env_file)
except ImportError:
# python-dotenv not available; rely on existing environment
pass
def _check_func_cli_available() -> bool:
"""Check if Azure Functions Core Tools (func) is installed and available."""
return shutil.which("func") is not None
def _check_port_listening(port: int, host: str = _DEFAULT_HOST) -> bool:
"""Check if a service is listening on the given port."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1)
return sock.connect_ex((host, port)) == 0
def _check_azurite_available() -> bool:
"""Check if Azurite (Azure Storage emulator) is available on the expected port."""
return _check_port_listening(_AZURITE_BLOB_PORT)
def _check_dts_emulator_available() -> bool:
"""Check if Durable Task Scheduler emulator is available on the expected port."""
return _check_port_listening(_DTS_EMULATOR_PORT)
def _should_skip_azure_functions_integration_tests() -> tuple[bool, str]:
"""Determine whether Azure Functions integration tests should be skipped."""
_load_env_file_if_present()
# Check for Azure Functions Core Tools
if not _check_func_cli_available():
return (
True,
"Azure Functions Core Tools (func) not installed. Install with: npm install -g azure-functions-core-tools@4", # noqa: E501
)
# Check for Azurite (Azure Storage emulator)
if not _check_azurite_available():
return (
True,
f"Azurite not running on port {_AZURITE_BLOB_PORT}. Start with: docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite", # noqa: E501
)
# Check for Durable Task Scheduler emulator
if not _check_dts_emulator_available():
return (
True,
f"Durable Task Scheduler emulator not running on port {_DTS_EMULATOR_PORT}. Start with: docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest", # noqa: E501
)
has_foundry_config = bool(os.getenv("FOUNDRY_PROJECT_ENDPOINT", "").strip()) and bool(
os.getenv("FOUNDRY_MODEL", "").strip()
)
has_azure_openai_config = bool(os.getenv("AZURE_OPENAI_ENDPOINT", "").strip()) and bool(
os.getenv("AZURE_OPENAI_MODEL", "").strip()
)
if not has_foundry_config and not has_azure_openai_config:
return (
True,
"No real FOUNDRY_* or AZURE_OPENAI_* configuration provided; skipping integration tests.",
)
return False, "Integration tests enabled."
_SKIP_AZURE_FUNCTIONS_INTEGRATION_TESTS, _AZURE_FUNCTIONS_SKIP_REASON = _should_skip_azure_functions_integration_tests()
skip_if_azure_functions_integration_tests_disabled = pytest.mark.skipif(
_SKIP_AZURE_FUNCTIONS_INTEGRATION_TESTS,
reason=_AZURE_FUNCTIONS_SKIP_REASON,
)
# =============================================================================
# Test Helper Class
# =============================================================================
class SampleTestHelper:
"""Helper class for testing samples."""
@staticmethod
def post_json(url: str, data: dict[str, Any], timeout: int = TIMEOUT) -> requests.Response:
"""POST JSON data to a URL."""
return requests.post(url, json=data, headers={"Content-Type": "application/json"}, timeout=timeout)
@staticmethod
def post_text(url: str, text: str, timeout: int = TIMEOUT) -> requests.Response:
"""POST plain text to a URL."""
return requests.post(url, data=text, headers={"Content-Type": "text/plain"}, timeout=timeout)
@staticmethod
def get(url: str, timeout: int = TIMEOUT) -> requests.Response:
"""GET request to a URL."""
return requests.get(url, timeout=timeout)
@staticmethod
def wait_for_orchestration(
status_url: str, max_wait: int = ORCHESTRATION_TIMEOUT, poll_interval: int = 2
) -> dict[str, Any]:
"""Wait for an orchestration to complete.
Args:
status_url: URL to poll for orchestration status
max_wait: Maximum seconds to wait
poll_interval: Seconds between polls
Returns:
Final orchestration status
Raises:
TimeoutError: If orchestration doesn't complete in time
"""
start_time = time.time()
while time.time() - start_time < max_wait:
response = requests.get(status_url, timeout=TIMEOUT)
response.raise_for_status()
status = response.json()
runtime_status = status.get("runtimeStatus", "")
if runtime_status in ["Completed", "Failed", "Terminated"]:
return status
time.sleep(poll_interval)
raise TimeoutError(f"Orchestration did not complete within {max_wait} seconds")
@staticmethod
def wait_for_orchestration_with_output(
status_url: str, max_wait: int = ORCHESTRATION_TIMEOUT, poll_interval: int = 2
) -> dict[str, Any]:
"""Wait for an orchestration to complete and have output available.
This is a specialized version of wait_for_orchestration that also
ensures the output field is present, handling timing race conditions.
Args:
status_url: URL to poll for orchestration status
max_wait: Maximum seconds to wait
poll_interval: Seconds between polls
Returns:
Final orchestration status with output
Raises:
TimeoutError: If orchestration doesn't complete with output in time
"""
start_time = time.time()
while time.time() - start_time < max_wait:
response = requests.get(status_url, timeout=TIMEOUT)
response.raise_for_status()
status = response.json()
runtime_status = status.get("runtimeStatus", "")
if runtime_status in ["Failed", "Terminated"]:
return status
if runtime_status == "Completed" and status.get("output"):
return status
# If completed but no output, continue polling for a bit more to
# handle the race condition where output has not been persisted yet.
time.sleep(poll_interval)
# Provide detailed error message based on final status
final_response = requests.get(status_url, timeout=TIMEOUT)
final_response.raise_for_status()
final_status = final_response.json()
final_runtime_status = final_status.get("runtimeStatus", "Unknown")
if final_runtime_status == "Completed":
if "output" not in final_status:
raise TimeoutError(
"Orchestration completed but 'output' field is missing after "
f"{max_wait} seconds. Final status: {final_status}"
)
if not final_status["output"]:
raise TimeoutError(
"Orchestration completed but output is empty after "
f"{max_wait} seconds. Final status: {final_status}"
)
raise TimeoutError(
"Orchestration completed with output but validation failed after "
f"{max_wait} seconds. Final status: {final_status}"
)
raise TimeoutError(
"Orchestration did not complete within "
f"{max_wait} seconds. Final status: {final_runtime_status}, "
f"Full status: {final_status}"
)
# =============================================================================
# Function App Lifecycle Management
# =============================================================================
def _resolve_repo_root() -> Path:
"""Resolve the repository root, preferring GITHUB_WORKSPACE when available."""
workspace = os.getenv("GITHUB_WORKSPACE")
if workspace:
candidate = Path(workspace).expanduser()
if not (candidate / "samples").exists() and (candidate / "python" / "samples").exists():
return (candidate / "python").resolve()
return candidate.resolve()
# If `GITHUB_WORKSPACE` is not set,
# go up from conftest.py -> integration_tests -> tests -> azurefunctions -> packages -> python
return Path(__file__).resolve().parents[4]
def _get_sample_path_from_marker(request: pytest.FixtureRequest) -> tuple[Path | None, str | None]:
"""Get sample path from @pytest.mark.sample() marker.
Returns a tuple of (sample_path, error_message).
If successful, error_message is None.
If failed, sample_path is None and error_message contains the reason.
"""
marker = request.node.get_closest_marker("sample")
if not marker:
return (
None,
(
"No @pytest.mark.sample() marker found on test. Add pytestmark with "
"@pytest.mark.sample('sample_name') to the test module."
),
)
if not marker.args:
return (
None,
"@pytest.mark.sample() marker found but no sample name provided. Use @pytest.mark.sample('sample_name').",
)
sample_name = marker.args[0]
repo_root = _resolve_repo_root()
sample_path = repo_root / "samples" / "04-hosting" / "azure_functions" / sample_name
if not sample_path.exists():
return None, f"Sample directory does not exist: {sample_path}"
return sample_path, None
def _find_available_port(host: str = _DEFAULT_HOST) -> int:
"""Find an available TCP port on the given host."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((host, 0))
return sock.getsockname()[1]
def _build_base_url(port: int, host: str = _DEFAULT_HOST) -> str:
"""Construct a base URL for the Azure Functions host."""
return f"http://{host}:{port}"
def _is_port_in_use(port: int, host: str = _DEFAULT_HOST) -> bool:
"""Check if a port is already in use.
Returns True if the port is in use, False otherwise.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
return sock.connect_ex((host, port)) == 0
def _load_and_validate_env(sample_path: Path) -> None:
"""Load .env file from current directory if it exists, then validate required environment variables.
Raises pytest.fail if required environment variables are missing.
"""
_load_env_file_if_present()
# Required environment variables for Azure Functions samples
required_env_vars = [
"AzureWebJobsStorage",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
"FUNCTIONS_WORKER_RUNTIME",
]
# Samples that host no AI agents need no model credentials (only the DTS emulator
# and Azurite). The suite-level gate still requires *some* LLM config to be present.
no_llm_samples = {"13_subworkflow_hitl"}
if sample_path.name in no_llm_samples:
pass
elif sample_path.name == "11_workflow_parallel":
required_env_vars.extend(["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_MODEL"])
else:
required_env_vars.extend(["FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_MODEL"])
# Check if required env vars are set
missing_vars = [var for var in required_env_vars if not os.environ.get(var)]
if missing_vars:
pytest.fail(
f"Missing required environment variables: {', '.join(missing_vars)}. "
"Please create a .env file in tests/integration_tests/ based on .env.example or "
"set these variables in your environment."
)
def _start_function_app(sample_path: Path, port: int) -> subprocess.Popen[Any]:
"""Start a function app in the specified sample directory.
Returns the subprocess.Popen object for the running process.
"""
env = os.environ.copy()
# Use a unique TASKHUB_NAME for each test run to ensure test isolation.
# This prevents conflicts between parallel or repeated test runs, as Durable Functions
# use the task hub name to separate orchestration state.
env["TASKHUB_NAME"] = f"test{uuid.uuid4().hex[:8]}"
# The Azure Functions Python worker's dependency isolation mechanism crashes
# on Python 3.13 with a SIGSEGV in the protobuf C extension (google._upb).
# Disabling isolation lets the worker load dependencies from the app's own
# environment, which avoids the crash.
# See: https://github.com/Azure/azure-functions-python-worker/issues/1797
if sys.version_info >= (3, 13):
env.setdefault("PYTHON_ISOLATE_WORKER_DEPENDENCIES", "0")
# On Windows, use CREATE_NEW_PROCESS_GROUP to allow proper termination
# shell=True only on Windows to handle PATH resolution
if sys.platform == "win32":
return subprocess.Popen(
["func", "start", "--port", str(port)],
cwd=str(sample_path),
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
shell=True,
env=env,
)
# On Unix, use start_new_session=True to isolate the process group from the
# pytest-xdist worker. Without this, signals (e.g. from test-timeout) can
# propagate to the func host and vice-versa, potentially killing the worker.
return subprocess.Popen(
["func", "start", "--port", str(port)],
cwd=str(sample_path),
env=env,
start_new_session=True,
)
def _wait_for_function_app_ready(func_process: subprocess.Popen[Any], port: int, max_wait: int = 60) -> None:
"""Block until the Azure Functions host responds healthy or fail fast."""
start_time = time.time()
health_url = f"{_build_base_url(port)}/api/health"
last_error: Exception | None = None
while time.time() - start_time < max_wait:
# If the process exited early, capture any previously seen error and fail fast.
if func_process.poll() is not None:
raise FunctionAppStartupError(
f"Function app process exited with code {func_process.returncode} before becoming healthy"
) from last_error
if _is_port_in_use(port):
try:
response = requests.get(health_url, timeout=5)
if response.status_code == 200:
return
last_error = RuntimeError(f"Health check returned {response.status_code}")
except requests.RequestException as exc:
last_error = exc
time.sleep(1)
raise FunctionAppStartupError(
f"Function app did not become healthy on port {port} within {max_wait} seconds"
) from last_error
def _cleanup_function_app(func_process: subprocess.Popen[Any]) -> None:
"""Clean up the function app process and all its children.
Uses psutil if available for more thorough cleanup, falls back to basic termination.
"""
try:
import psutil
if func_process.poll() is None: # Process still running
# Get parent process
parent = psutil.Process(func_process.pid)
# Get all child processes recursively
children = parent.children(recursive=True)
# Kill children first
for child in children:
with suppress(psutil.NoSuchProcess, psutil.AccessDenied):
child.kill()
# Kill parent
with suppress(psutil.NoSuchProcess, psutil.AccessDenied):
parent.kill()
# Wait for all to terminate
_gone, alive = psutil.wait_procs(children + [parent], timeout=3)
# Force kill any remaining
for proc in alive:
with suppress(psutil.NoSuchProcess, psutil.AccessDenied):
proc.kill()
except ImportError:
# Fallback if psutil not available
try:
if func_process.poll() is None:
func_process.kill()
func_process.wait()
except Exception:
# Ignore all exceptions during fallback cleanup; best effort to terminate process.
pass
except Exception:
pass # Best effort cleanup
# Give the port time to be released
time.sleep(2)
# =============================================================================
# Pytest Configuration
# =============================================================================
def pytest_configure(config: pytest.Config) -> None:
"""Register custom markers."""
config.addinivalue_line("markers", "orchestration: marks tests that use orchestrations (require Azurite)")
config.addinivalue_line(
"markers",
"sample(path): specify the sample directory path for the test (e.g., @pytest.mark.sample('01_single_agent'))",
)
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
"""Skip integration tests in this directory if prerequisites are not met."""
should_skip, reason = _should_skip_azure_functions_integration_tests()
if should_skip:
skip_marker = pytest.mark.skip(reason=reason)
for item in items:
# Only skip items that are in this integration_tests directory
if "integration_tests" in str(item.fspath):
item.add_marker(skip_marker)
# =============================================================================
# Pytest Fixtures
# =============================================================================
@pytest.fixture(scope="session")
def function_app_running() -> bool:
"""Check if the function app is running on localhost:7071.
This fixture can be used to skip tests if the function app is not available.
"""
try:
response = requests.get("http://localhost:7071/api/health", timeout=2)
return response.status_code == 200
except requests.exceptions.RequestException:
return False
@pytest.fixture(scope="session")
def skip_if_no_function_app(function_app_running: bool) -> None:
"""Skip test if function app is not running."""
if not function_app_running:
pytest.skip("Function app is not running on http://localhost:7071")
@pytest.fixture(scope="module")
def function_app_for_test(request: pytest.FixtureRequest) -> Iterator[dict[str, int | str]]:
"""Start the function app for the corresponding sample based on marker.
This fixture:
1. Determines which sample to run from @pytest.mark.sample()
2. Validates environment variables
3. Starts the function app using 'func start'
4. Waits for the app to be ready
5. Tears down the app after tests complete
Usage:
@pytest.mark.sample("01_single_agent")
@pytest.mark.usefixtures("function_app_for_test")
class TestSample01SingleAgent:
...
"""
# Get sample path from marker
sample_path, error_message = _get_sample_path_from_marker(request)
if error_message:
pytest.fail(error_message)
assert sample_path is not None, "Sample path must be resolved before starting the function app"
# Load .env file if it exists and validate required env vars
_load_and_validate_env(sample_path)
max_attempts = 3
# The overall budget MUST be shorter than the pytest-timeout value
# (--timeout=120 by default) so that the fixture finishes cleanly instead
# of being killed by os._exit() which crashes the xdist worker.
overall_budget = 100 # seconds leaves headroom below the 120 s test timeout
last_error: Exception | None = None
func_process: subprocess.Popen[Any] | None = None
base_url = ""
port = 0
overall_start = time.monotonic()
attempts_made = 0
for _ in range(max_attempts):
remaining = overall_budget - (time.monotonic() - overall_start)
if remaining < 10:
# Not enough time for another attempt; bail out.
break
attempts_made += 1
port = _find_available_port()
base_url = _build_base_url(port)
func_process = _start_function_app(sample_path, port)
try:
# Cap each attempt's wait to the remaining budget minus a small
# buffer for cleanup.
per_attempt_wait = min(60, int(remaining) - 5)
_wait_for_function_app_ready(func_process, port, max_wait=max(per_attempt_wait, 10))
last_error = None
break
except FunctionAppStartupError as exc:
last_error = exc
_cleanup_function_app(func_process)
func_process = None
if func_process is None:
elapsed = int(time.monotonic() - overall_start)
error_message = f"Function app failed to start after {attempts_made} attempt(s) ({elapsed}s elapsed)."
if last_error is not None:
error_message += f" Last error: {last_error}"
pytest.fail(error_message)
try:
yield {"base_url": base_url, "port": port}
finally:
if func_process is not None:
_cleanup_function_app(func_process)
@pytest.fixture(scope="module")
def base_url(function_app_for_test: Mapping[str, int | str]) -> str:
"""Expose the function app's base URL to tests."""
return str(function_app_for_test["base_url"])
@pytest.fixture(scope="session")
def sample_helper() -> type[SampleTestHelper]:
"""Provide the SampleTestHelper class for tests."""
return SampleTestHelper
@@ -0,0 +1,115 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Single Agent Sample
Tests the single agent sample with various message formats and session management.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite or Azure Storage account configured
Usage:
uv run pytest packages/azurefunctions/tests/integration_tests/test_01_single_agent.py -v
"""
import pytest
from agent_framework_durabletask import THREAD_ID_HEADER
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("01_single_agent"),
pytest.mark.usefixtures("function_app_for_test"),
]
class TestSampleSingleAgent:
"""Tests for 01_single_agent sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide agent-specific base URL and helper for the tests."""
self.base_url = f"{base_url}/api/agents/Joker"
self.helper = sample_helper
def test_health_check(self, base_url: str, sample_helper) -> None:
"""Test health check endpoint."""
response = sample_helper.get(f"{base_url}/api/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
def test_simple_message_json(self) -> None:
"""Test sending a simple message with JSON payload."""
response = self.helper.post_json(
f"{self.base_url}/run",
{"message": "Tell me a short joke about cloud computing.", "thread_id": "test-simple-json"},
)
# Agent can return 200 (immediate) or 202 (async with wait_for_response=false)
assert response.status_code in [200, 202]
data = response.json()
if response.status_code == 200:
# Synchronous response - check result directly
assert data["status"] == "success"
assert "response" in data
assert data["message_count"] >= 1
else:
# Async response - check we got correlation info
assert "correlation_id" in data or "thread_id" in data
def test_simple_message_plain_text(self) -> None:
"""Test sending a message with plain text payload."""
response = self.helper.post_text(f"{self.base_url}/run", "Tell me a short joke about networking.")
assert response.status_code in [200, 202]
# Agent responded with plain text when the request body was text/plain.
assert response.text.strip()
assert response.headers.get(THREAD_ID_HEADER) is not None
def test_thread_id_in_query(self) -> None:
"""Test using thread_id in query parameter."""
response = self.helper.post_text(
f"{self.base_url}/run?thread_id=test-query-thread", "Tell me a short joke about weather in Texas."
)
assert response.status_code in [200, 202]
assert response.text.strip()
assert response.headers.get(THREAD_ID_HEADER) == "test-query-thread"
def test_conversation_continuity(self) -> None:
"""Test conversation context is maintained across requests."""
thread_id = "test-continuity"
# First message
response1 = self.helper.post_json(
f"{self.base_url}/run",
{"message": "Tell me a short joke about weather in Seattle.", "thread_id": thread_id},
)
assert response1.status_code in [200, 202]
if response1.status_code == 200:
data1 = response1.json()
assert data1["message_count"] == 2 # Initial + reply
# Second message in same session
response2 = self.helper.post_json(
f"{self.base_url}/run", {"message": "What about San Francisco?", "thread_id": thread_id}
)
assert response2.status_code == 200
data2 = response2.json()
assert data2["message_count"] == 4
else:
# In async mode, we can't easily test message count
# Just verify we can make multiple calls
response2 = self.helper.post_json(
f"{self.base_url}/run", {"message": "What about Texas?", "thread_id": thread_id}
)
assert response2.status_code == 202
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Multi-Agent Sample
Tests the multi-agent sample with different agent endpoints.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite or Azure Storage account configured
Usage:
uv run pytest packages/azurefunctions/tests/integration_tests/test_02_multi_agent.py -v
"""
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("02_multi_agent"),
pytest.mark.usefixtures("function_app_for_test"),
]
class TestSampleMultiAgent:
"""Tests for 02_multi_agent sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Configure base URLs for Weather and Math agents."""
self.weather_base_url = f"{base_url}/api/agents/WeatherAgent"
self.math_base_url = f"{base_url}/api/agents/MathAgent"
self.helper = sample_helper
@pytest.mark.skip(reason="Flaky in CI: times out / crashes the xdist runner; temporarily disabled.")
def test_weather_agent(self) -> None:
"""Test WeatherAgent endpoint."""
response = self.helper.post_json(
f"{self.weather_base_url}/run",
{"message": "What is the weather in Seattle?"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert "response" in data
def test_math_agent(self) -> None:
"""Test MathAgent endpoint."""
response = self.helper.post_json(
f"{self.math_base_url}/run",
{"message": "Calculate a 20% tip on a $50 bill", "wait_for_response": False},
)
assert response.status_code == 202
data = response.json()
assert data["status"] == "accepted"
assert "correlation_id" in data
assert "thread_id" in data
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,121 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Reliable Streaming Sample
Tests the reliable streaming sample using Redis Streams for persistent message delivery.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite or Azure Storage account configured
- Redis running (docker run -d --name redis -p 6379:6379 redis:latest)
Usage:
uv run pytest packages/azurefunctions/tests/integration_tests/test_03_reliable_streaming.py -v
"""
import time
import pytest
import requests
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("03_reliable_streaming"),
pytest.mark.usefixtures("function_app_for_test"),
]
class TestSampleReliableStreaming:
"""Tests for 03_reliable_streaming sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the base URL and helper for each test."""
self.base_url = base_url
self.agent_url = f"{base_url}/api/agents/TravelPlanner"
self.stream_url = f"{base_url}/api/agent/stream"
self.helper = sample_helper
def test_agent_run_and_stream(self) -> None:
"""Test agent execution with Redis streaming."""
# Start agent run
response = self.helper.post_json(
f"{self.agent_url}/run",
{"message": "Plan a 1-day trip to Seattle in 1 sentence", "wait_for_response": False},
)
assert response.status_code == 202
data = response.json()
thread_id = data.get("thread_id")
# Wait a moment for the agent to start writing to Redis
time.sleep(2)
# Stream response from Redis with longer timeout to account for LLM latency
stream_response = requests.get(
f"{self.stream_url}/{thread_id}",
headers={"Accept": "text/plain"},
timeout=60,
)
assert stream_response.status_code == 200
def test_stream_with_sse_format(self) -> None:
"""Test streaming with Server-Sent Events format."""
# Start agent run
response = self.helper.post_json(
f"{self.agent_url}/run",
{"message": "What's the weather like?", "wait_for_response": False},
)
assert response.status_code == 202
data = response.json()
thread_id = data.get("thread_id")
# Wait for agent to start writing
time.sleep(2)
# Stream with SSE format
stream_response = requests.get(
f"{self.stream_url}/{thread_id}",
headers={"Accept": "text/event-stream"},
timeout=60,
)
assert stream_response.status_code == 200
content_type = stream_response.headers.get("content-type", "")
assert "text/event-stream" in content_type
# Check for SSE event markers if we got content
content = stream_response.text
if content:
assert "event:" in content or "data:" in content
def test_stream_nonexistent_conversation(self) -> None:
"""Test streaming from a non-existent conversation.
The endpoint will wait for data in Redis, but since the conversation
doesn't exist, it will timeout. This is expected behavior.
"""
fake_id = "nonexistent-conversation-12345"
# Should timeout since the conversation doesn't exist
with pytest.raises(requests.exceptions.ReadTimeout):
requests.get(
f"{self.stream_url}/{fake_id}",
headers={"Accept": "text/plain"},
timeout=10, # Short timeout for non-existent ID
)
def test_health_endpoint(self) -> None:
"""Test health check endpoint."""
response = self.helper.get(f"{self.base_url}/api/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "agents" in data
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Orchestration Chaining Sample
Tests the orchestration chaining sample for sequential agent execution.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_04_single_agent_orchestration_chaining.py -v
"""
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("04_single_agent_orchestration_chaining"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestSampleOrchestrationChaining:
"""Tests for 04_single_agent_orchestration_chaining sample."""
@pytest.fixture(autouse=True)
def _setup(self, sample_helper) -> None:
"""Provide the helper for each test."""
self.helper = sample_helper
def test_orchestration_chaining(self, base_url: str) -> None:
"""Test sequential agent calls in orchestration."""
# Start orchestration
response = self.helper.post_json(f"{base_url}/api/singleagent/run", {})
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion with output available
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,59 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for MultiAgent Concurrency Sample
Tests the multi-agent concurrency sample for parallel agent execution.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_05_multi_agent_orchestration_concurrency.py -v
"""
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.orchestration,
pytest.mark.sample("05_multi_agent_orchestration_concurrency"),
pytest.mark.usefixtures("function_app_for_test"),
]
class TestSampleMultiAgentConcurrency:
"""Tests for 05_multi_agent_orchestration_concurrency sample."""
@pytest.fixture(autouse=True)
def _setup(self, sample_helper) -> None:
"""Provide the helper for each test."""
self.helper = sample_helper
def test_concurrent_agents(self, base_url: str) -> None:
"""Test multiple agents running concurrently."""
# Start orchestration
response = self.helper.post_text(f"{base_url}/api/multiagent/run", "What is temperature?")
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
output = status["output"]
assert "physicist" in output
assert "chemist" in output
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,77 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for MultiAgent Conditionals Sample
Tests the multi-agent conditionals sample for conditional orchestration logic.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_06_multi_agent_orchestration_conditionals.py -v
"""
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.orchestration,
pytest.mark.sample("06_multi_agent_orchestration_conditionals"),
pytest.mark.usefixtures("function_app_for_test"),
]
class TestSampleMultiAgentConditionals:
"""Tests for 06_multi_agent_orchestration_conditionals sample."""
@pytest.fixture(autouse=True)
def _setup(self, sample_helper) -> None:
"""Provide the helper for each test."""
self.helper = sample_helper
def test_legitimate_email(self, base_url: str) -> None:
"""Test conditional logic with legitimate email."""
response = self.helper.post_json(
f"{base_url}/api/spamdetection/run",
{
"email_id": "email-test-001",
"email_content": "Hi John, I hope you are doing well. Can you send me the report?",
},
)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "Email sent:" in status["output"]
def test_spam_email(self, base_url: str) -> None:
"""Test conditional logic with spam email."""
response = self.helper.post_json(
f"{base_url}/api/spamdetection/run",
{"email_id": "email-test-002", "email_content": "URGENT! You have won $1,000,000! Click here now!"},
)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
# Wait for completion
status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "Email marked as spam:" in status["output"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,185 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Human-in-the-Loop (HITL) Orchestration Sample
Tests the HITL orchestration sample for content generation with human approval workflow.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_07_single_agent_orchestration_hitl.py -v
"""
import time
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("07_single_agent_orchestration_hitl"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestSampleHITLOrchestration:
"""Tests for 07_single_agent_orchestration_hitl sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.hitl_base_url = f"{base_url}/api/hitl"
self.helper = sample_helper
def test_hitl_orchestration_approval(self) -> None:
"""Test HITL orchestration with human approval."""
# Start orchestration
response = self.helper.post_json(
f"{self.hitl_base_url}/run",
{"topic": "artificial intelligence", "max_review_attempts": 3, "approval_timeout_hours": 1.0},
)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
assert data["topic"] == "artificial intelligence"
instance_id = data["instanceId"]
# Wait a bit for the orchestration to generate initial content
time.sleep(5)
# Check status to ensure it's waiting for approval
status_response = self.helper.get(data["statusQueryGetUri"])
assert status_response.status_code == 200
status = status_response.json()
assert status["runtimeStatus"] in ["Running", "Pending"]
# Send approval
approval_response = self.helper.post_json(
f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""}
)
assert approval_response.status_code == 200
approval_data = approval_response.json()
assert approval_data["approved"] is True
# Wait for orchestration to complete
status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
assert "content" in status["output"]
def test_hitl_orchestration_rejection_with_feedback(self) -> None:
"""Test HITL orchestration with rejection and subsequent approval."""
# Start orchestration
response = self.helper.post_json(
f"{self.hitl_base_url}/run",
{"topic": "machine learning", "max_review_attempts": 3, "approval_timeout_hours": 1.0},
)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Wait for initial content generation
time.sleep(5)
# Send rejection with feedback
rejection_response = self.helper.post_json(
f"{self.hitl_base_url}/approve/{instance_id}",
{"approved": False, "feedback": "Please make it more concise and focus on practical applications."},
)
assert rejection_response.status_code == 200
# Wait for regeneration
time.sleep(5)
# Check status - should still be running
status_response = self.helper.get(data["statusQueryGetUri"])
assert status_response.status_code == 200
status = status_response.json()
assert status["runtimeStatus"] in ["Running", "Pending"]
# Now approve the revised content
approval_response = self.helper.post_json(
f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""}
)
assert approval_response.status_code == 200
# Wait for completion
status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_hitl_orchestration_missing_topic(self) -> None:
"""Test HITL orchestration with missing topic."""
response = self.helper.post_json(f"{self.hitl_base_url}/run", {"max_review_attempts": 3})
assert response.status_code == 400
data = response.json()
assert "error" in data
def test_hitl_get_status(self) -> None:
"""Test getting orchestration status."""
# Start orchestration
response = self.helper.post_json(
f"{self.hitl_base_url}/run",
{"topic": "quantum computing", "max_review_attempts": 2, "approval_timeout_hours": 1.0},
)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Get status
status_response = self.helper.get(f"{self.hitl_base_url}/status/{instance_id}")
assert status_response.status_code == 200
status = status_response.json()
assert "instanceId" in status
assert "runtimeStatus" in status
assert status["instanceId"] == instance_id
# Cleanup: approve to complete orchestration
time.sleep(5)
self.helper.post_json(f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""})
def test_hitl_approval_invalid_payload(self) -> None:
"""Test sending approval with invalid payload."""
# Start orchestration first
response = self.helper.post_json(
f"{self.hitl_base_url}/run",
{"topic": "test topic", "max_review_attempts": 1, "approval_timeout_hours": 1.0},
)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
time.sleep(3)
# Send approval without 'approved' field
approval_response = self.helper.post_json(
f"{self.hitl_base_url}/approve/{instance_id}", {"feedback": "Some feedback"}
)
assert approval_response.status_code == 400
error_data = approval_response.json()
assert "error" in error_data
# Cleanup
self.helper.post_json(f"{self.hitl_base_url}/approve/{instance_id}", {"approved": True, "feedback": ""})
def test_hitl_status_invalid_instance(self) -> None:
"""Test getting status for non-existent instance."""
response = self.helper.get(f"{self.hitl_base_url}/status/invalid-instance-id")
assert response.status_code == 404
data = response.json()
assert "error" in data
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,100 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Workflow Shared State Sample
Tests the workflow shared state sample for conditional email processing
with shared state management.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_09_workflow_shared_state.py -v
"""
import pytest
# Must match the workflow name in samples/04-hosting/azure_functions/09_workflow_shared_state/function_app.py
WORKFLOW_NAME = "email_triage_shared_state"
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("09_workflow_shared_state"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestWorkflowSharedState:
"""Tests for 09_workflow_shared_state sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
def test_workflow_with_spam_email(self) -> None:
"""Test workflow with spam email content - should be detected and handled as spam."""
spam_content = "URGENT! You have won $1,000,000! Click here to claim your prize now before it expires!"
# Start orchestration with spam email
response = self.helper.post_text(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", spam_content)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_workflow_with_legitimate_email(self) -> None:
"""Test workflow with legitimate email content - should generate response."""
legitimate_content = (
"Hi team, just a reminder about the sprint planning meeting tomorrow at 10 AM. "
"Please review the agenda items in Jira before the call."
)
# Start orchestration with legitimate email
response = self.helper.post_text(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", legitimate_content)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_workflow_with_phishing_email(self) -> None:
"""Test workflow with phishing email - should be detected as spam."""
phishing_content = (
"Dear Customer, Your account has been compromised! "
"Click this link immediately to secure your account: http://totallylegit.suspicious.com/secure"
)
# Start orchestration with phishing email
response = self.helper.post_text(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", phishing_content)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Workflow No Shared State Sample
Tests the workflow sample that runs without shared state,
demonstrating conditional routing with spam detection and email response.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_10_workflow_no_shared_state.py -v
"""
import pytest
# Must match the workflow name in samples/04-hosting/azure_functions/10_workflow_no_shared_state/function_app.py
WORKFLOW_NAME = "email_triage"
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("10_workflow_no_shared_state"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestWorkflowNoSharedState:
"""Tests for 10_workflow_no_shared_state sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
def test_workflow_with_spam_email(self) -> None:
"""Test workflow with spam email - should detect and handle as spam."""
payload = {
"email_id": "email-test-001",
"email_content": (
"URGENT! You've won $1,000,000! Click here immediately to claim your prize! "
"Limited time offer - act now!"
),
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_workflow_with_legitimate_email(self) -> None:
"""Test workflow with legitimate email - should draft a response."""
payload = {
"email_id": "email-test-002",
"email_content": (
"Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. "
"Please review the agenda in Jira."
),
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_workflow_status_endpoint(self) -> None:
"""Test that the status endpoint works correctly."""
payload = {
"email_id": "email-test-003",
"email_content": "Quick question: When is the next team meeting scheduled?",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Check status using the workflow status endpoint
status_response = self.helper.get(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/status/{instance_id}")
assert status_response.status_code == 200
status = status_response.json()
assert "instanceId" in status
assert status["instanceId"] == instance_id
assert "runtimeStatus" in status
# Wait for completion to clean up
self.helper.wait_for_orchestration(data["statusQueryGetUri"])
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,87 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Parallel Workflow Sample
Tests the parallel workflow execution sample demonstrating:
- Two executors running concurrently (fan-out to activities)
- Two agents running concurrently (fan-out to entities)
- Mixed agent + executor running concurrently
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_11_workflow_parallel.py -v
"""
import pytest
# Must match the workflow name in samples/04-hosting/azure_functions/11_workflow_parallel/function_app.py
WORKFLOW_NAME = "parallel_review"
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("11_workflow_parallel"),
pytest.mark.usefixtures("function_app_for_test"),
]
@pytest.mark.orchestration
class TestWorkflowParallel:
"""Tests for 11_workflow_parallel sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
@pytest.mark.skip(reason="Flaky in CI: times out / crashes the xdist runner; temporarily disabled.")
def test_parallel_workflow_end_to_end(self) -> None:
"""Run the parallel workflow end-to-end: start, check status, verify completion.
Consolidated into a single test on purpose: the work-stealing xdist scheduler
distributes tests (not modules) across workers, and the module-scoped
``function_app_for_test`` fixture is created per worker -- so multiple tests in
this module would each spawn a separate ``func`` host for this resource-heavy
parallel sample. One test keeps it to a single host while still covering the
fan-out path end-to-end.
"""
payload = {
"document_id": "doc-test-001",
"content": (
"The quarterly earnings report shows strong growth in our cloud services division. "
"Revenue increased by 25% compared to last year, driven by enterprise adoption. "
"Customer satisfaction remains high at 92%."
),
}
# Start the orchestration.
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
assert "statusQueryGetUri" in data
# The status endpoint reflects the started instance.
status_response = self.helper.get(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/status/{instance_id}")
assert status_response.status_code == 200
assert status_response.json()["instanceId"] == instance_id
# Fan-out to parallel processors and agents completes with an aggregated output.
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"], max_wait=300)
assert status["runtimeStatus"] == "Completed"
assert "output" in status
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,219 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Workflow Human-in-the-Loop (HITL) Sample
Tests the workflow HITL sample demonstrating content moderation with human approval
using the MAF request_info / @response_handler pattern.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite running for durable orchestrations (or Azure Storage account configured)
Usage:
# Start Azurite (if not already running)
azurite &
# Run tests
uv run pytest packages/azurefunctions/tests/integration_tests/test_12_workflow_hitl.py -v
"""
import time
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("12_workflow_hitl"),
pytest.mark.usefixtures("function_app_for_test"),
]
# Must match the workflow name in samples/04-hosting/azure_functions/12_workflow_hitl/function_app.py
WORKFLOW_NAME = "content_moderation"
@pytest.mark.orchestration
class TestWorkflowHITL:
"""Tests for 12_workflow_hitl sample."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
def _wait_for_hitl_request(self, instance_id: str, timeout: int = 40) -> dict:
"""Polls for a pending HITL request."""
start_time = time.time()
while time.time() - start_time < timeout:
status_response = self.helper.get(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/status/{instance_id}")
if status_response.status_code == 200:
status = status_response.json()
pending_requests = status.get("pendingHumanInputRequests", [])
if pending_requests:
return status
time.sleep(2)
raise AssertionError(f"Timed out waiting for HITL request for instance {instance_id}")
def test_hitl_workflow_approval(self) -> None:
"""Test HITL workflow with human approval."""
payload = {
"content_id": "article-test-001",
"title": "Introduction to AI in Healthcare",
"body": (
"Artificial intelligence is revolutionizing healthcare by enabling faster diagnosis, "
"personalized treatment plans, and improved patient outcomes. Machine learning algorithms "
"can analyze medical images with remarkable accuracy."
),
"author": "Dr. Jane Smith",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
instance_id = data["instanceId"]
# Wait for the workflow to reach the HITL pause point
status = self._wait_for_hitl_request(instance_id)
# Confirm status is valid
assert status["runtimeStatus"] in ["Running", "Pending"]
# Get the request ID from pending requests
pending_requests = status.get("pendingHumanInputRequests", [])
assert len(pending_requests) > 0, "Expected pending HITL request"
request_id = pending_requests[0]["requestId"]
# Send approval
approval_response = self.helper.post_json(
f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}",
{"approved": True, "reviewer_notes": "Content is appropriate and well-written."},
)
assert approval_response.status_code == 200
# Wait for orchestration to complete
final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert final_status["runtimeStatus"] == "Completed"
assert "output" in final_status
def test_hitl_workflow_rejection(self) -> None:
"""Test HITL workflow with human rejection."""
payload = {
"content_id": "article-test-002",
"title": "Get Rich Quick Scheme",
"body": (
"Click here NOW to make $10,000 overnight! This SECRET method is GUARANTEED to work! "
"Limited time offer - act NOW before it's too late!"
),
"author": "Definitely Not Spam",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Wait for the workflow to reach the HITL pause point
status = self._wait_for_hitl_request(instance_id)
# Get the request ID from pending requests
pending_requests = status.get("pendingHumanInputRequests", [])
assert len(pending_requests) > 0, "Expected pending HITL request"
request_id = pending_requests[0]["requestId"]
# Send rejection
rejection_response = self.helper.post_json(
f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}",
{"approved": False, "reviewer_notes": "Content appears to be spam/scam material."},
)
assert rejection_response.status_code == 200
# Wait for orchestration to complete
final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert final_status["runtimeStatus"] == "Completed"
assert "output" in final_status
# The output should indicate rejection
output = final_status["output"]
assert "rejected" in str(output).lower()
def test_hitl_workflow_status_endpoint(self) -> None:
"""Test that the workflow status endpoint shows pending HITL requests."""
payload = {
"content_id": "article-test-003",
"title": "Test Article",
"body": "This is a test article for checking status endpoint functionality.",
"author": "Test Author",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Wait for HITL pause
status = self._wait_for_hitl_request(instance_id)
# Check status
assert "instanceId" in status
assert status["instanceId"] == instance_id
assert "runtimeStatus" in status
assert "pendingHumanInputRequests" in status
# Clean up: approve to complete
pending_requests = status.get("pendingHumanInputRequests", [])
if pending_requests:
request_id = pending_requests[0]["requestId"]
self.helper.post_json(
f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}",
{"approved": True, "reviewer_notes": ""},
)
# Wait for completion
self.helper.wait_for_orchestration(data["statusQueryGetUri"])
def test_hitl_workflow_with_neutral_content(self) -> None:
"""Test HITL workflow with neutral content that should get medium risk."""
payload = {
"content_id": "article-test-004",
"title": "Product Review",
"body": (
"This product works as advertised. The build quality is average and the price "
"is reasonable. I would recommend it for basic use cases but not for professional work."
),
"author": "Regular User",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Wait for HITL pause
status = self._wait_for_hitl_request(instance_id)
pending_requests = status.get("pendingHumanInputRequests", [])
assert len(pending_requests) > 0
request_id = pending_requests[0]["requestId"]
# Approve
self.helper.post_json(
f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}",
{"approved": True, "reviewer_notes": "Approved after review."},
)
# Wait for completion
final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert final_status["runtimeStatus"] == "Completed"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,150 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for the Sub-workflow HITL Sample (13_subworkflow_hitl)
Tests nested human-in-the-loop through the Azure Functions host: the HITL pause
lives inside an inner workflow embedded via ``WorkflowExecutor``, so the pending
request surfaces at the top-level instance with a **qualified** request id
(``review_sub~0~{requestId}``). The caller responds against the top-level instance
and the host routes it to the owning child orchestration.
This sample hosts no AI agents, so it exercises the AF nested-HITL plumbing
deterministically (no model latency / variability).
The function app is automatically started by the test fixture.
Prerequisites:
- Azurite running for durable orchestrations
- Durable Task Scheduler emulator running on localhost:8080
Usage:
uv run pytest packages/azurefunctions/tests/integration_tests/test_13_workflow_subworkflow_hitl.py -v
"""
import time
import pytest
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.flaky,
pytest.mark.integration,
pytest.mark.sample("13_subworkflow_hitl"),
pytest.mark.usefixtures("function_app_for_test"),
]
# Must match the outer workflow name in samples/.../13_subworkflow_hitl/function_app.py
WORKFLOW_NAME = "moderation_pipeline"
# The WorkflowExecutor node id that embeds the inner HITL workflow.
SUBWORKFLOW_NODE_ID = "review_sub"
@pytest.mark.orchestration
class TestSubworkflowHITL:
"""Tests for the 13_subworkflow_hitl sample (nested HITL behind one surface)."""
@pytest.fixture(autouse=True)
def _setup(self, base_url: str, sample_helper) -> None:
"""Provide the helper and base URL for each test."""
self.base_url = base_url
self.helper = sample_helper
def _wait_for_hitl_request(self, instance_id: str, timeout: int = 40) -> dict:
"""Poll the top-level status endpoint until a (nested) HITL request appears."""
start_time = time.time()
while time.time() - start_time < timeout:
status_response = self.helper.get(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/status/{instance_id}")
if status_response.status_code == 200:
status = status_response.json()
if status.get("pendingHumanInputRequests"):
return status
time.sleep(2)
raise AssertionError(f"Timed out waiting for a nested HITL request for instance {instance_id}")
def _start(self, payload: dict) -> dict:
"""Start the outer workflow and return the run response JSON."""
response = self.helper.post_json(f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/run", payload)
assert response.status_code == 202
return response.json()
def test_nested_request_surfaces_with_qualified_id(self) -> None:
"""The nested pending request is surfaced with a ``review_sub~0~{id}`` qualified id."""
data = self._start({
"content_id": "article-100",
"title": "Quarterly Roadmap",
"body": "A summary of the upcoming features planned for the next quarter.",
})
instance_id = data["instanceId"]
status = self._wait_for_hitl_request(instance_id)
pending = status.get("pendingHumanInputRequests", [])
assert len(pending) == 1
request_id = pending[0]["requestId"]
# The qualifier carries the node id and the child's ordinal (0 for the single
# dispatch), then the inner bare request id: ``review_sub~0~{requestId}``.
expected_prefix = f"{SUBWORKFLOW_NODE_ID}~0~"
assert request_id.startswith(expected_prefix), request_id
assert request_id[len(expected_prefix) :] # non-empty inner id
# The respondUrl always targets the top-level instance.
assert f"/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/" in pending[0]["respondUrl"]
# Drain the pause so the instance does not hang.
approve = self.helper.post_json(
f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}",
{"approved": True, "reviewer_notes": "ok"},
)
assert approve.status_code == 200
self.helper.wait_for_orchestration(data["statusQueryGetUri"])
def test_nested_hitl_approval(self) -> None:
"""Responding 'approved' to the nested request resumes the outer workflow to APPROVED."""
data = self._start({
"content_id": "article-001",
"title": "Introduction to AI in Healthcare",
"body": (
"Artificial intelligence is improving healthcare by enabling faster diagnosis, "
"personalized treatment plans, and better patient outcomes."
),
})
instance_id = data["instanceId"]
status = self._wait_for_hitl_request(instance_id)
request_id = status["pendingHumanInputRequests"][0]["requestId"]
approval = self.helper.post_json(
f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}",
{"approved": True, "reviewer_notes": "Looks good."},
)
assert approval.status_code == 200
final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert final_status["runtimeStatus"] == "Completed"
assert "APPROVED" in str(final_status.get("output")).upper()
def test_nested_hitl_rejection(self) -> None:
"""Responding 'rejected' to the nested request resumes the outer workflow to REJECTED."""
data = self._start({
"content_id": "article-002",
"title": "Get Rich Quick",
"body": "Click here NOW to make $10,000 overnight! GUARANTEED! Limited time offer!",
})
instance_id = data["instanceId"]
status = self._wait_for_hitl_request(instance_id)
request_id = status["pendingHumanInputRequests"][0]["requestId"]
rejection = self.helper.post_json(
f"{self.base_url}/api/workflow/{WORKFLOW_NAME}/respond/{instance_id}/{request_id}",
{"approved": False, "reviewer_notes": "Violates content policy."},
)
assert rejection.status_code == 200
final_status = self.helper.wait_for_orchestration(data["statusQueryGetUri"])
assert final_status["runtimeStatus"] == "Completed"
assert "REJECTED" in str(final_status.get("output")).upper()
if __name__ == "__main__":
pytest.main([__file__, "-v"])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,293 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for create_agent_entity factory function.
Run with: pytest tests/test_entities.py -v
"""
from collections.abc import Callable
from typing import Any, TypeVar
from unittest.mock import AsyncMock, Mock
import pytest
from agent_framework import AgentResponse, Message
from agent_framework_azurefunctions._entities import create_agent_entity
FuncT = TypeVar("FuncT", bound=Callable[..., Any])
def _agent_response(text: str | None) -> AgentResponse:
"""Create an AgentResponse with a single assistant message."""
message = (
Message(role="assistant", contents=[text]) if text is not None else Message(role="assistant", contents=[""])
)
return AgentResponse(messages=[message])
class TestCreateAgentEntity:
"""Test suite for the create_agent_entity factory function."""
def test_create_agent_entity_returns_callable(self) -> None:
"""Test that create_agent_entity returns a callable."""
mock_agent = Mock()
entity_function = create_agent_entity(mock_agent)
assert callable(entity_function)
def test_entity_function_handles_run_agent(self) -> None:
"""Test that the entity function handles the run_agent operation."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity_function = create_agent_entity(mock_agent)
# Mock context
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.entity_key = "conv-123"
mock_context.get_input.return_value = {
"message": "Test message",
"correlationId": "corr-entity-factory",
}
mock_context.get_state.return_value = None
# Execute
entity_function(mock_context)
# Verify result and state were set
assert mock_context.set_result.called
assert mock_context.set_state.called
def test_entity_function_handles_reset(self) -> None:
"""Test that the entity function handles the reset operation."""
mock_agent = Mock()
entity_function = create_agent_entity(mock_agent)
# Mock context with existing state
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = {
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "test-correlation-id",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [{"$type": "text", "text": "test"}],
}
],
}
]
},
}
# Execute
entity_function(mock_context)
# Verify reset result
assert mock_context.set_result.called
result = mock_context.set_result.call_args[0][0]
assert result["status"] == "reset"
# Verify state was cleared
assert mock_context.set_state.called
state = mock_context.set_state.call_args[0][0]
assert state["data"]["conversationHistory"] == []
def test_entity_function_handles_unknown_operation(self) -> None:
"""Test that the entity function handles unknown operations."""
mock_agent = Mock()
entity_function = create_agent_entity(mock_agent)
mock_context = Mock()
mock_context.operation_name = "invalid_operation"
mock_context.get_state.return_value = None
# Execute
entity_function(mock_context)
# Verify error result
assert mock_context.set_result.called
result = mock_context.set_result.call_args[0][0]
assert "error" in result
assert "invalid_operation" in result["error"].lower()
def test_entity_function_creates_new_entity_on_first_call(self) -> None:
"""Test that the entity function creates a new entity when no state exists."""
mock_agent = Mock()
mock_agent.__class__.__name__ = "Agent"
entity_function = create_agent_entity(mock_agent)
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = None # No existing state
# Execute
entity_function(mock_context)
# Verify new entity state was created
assert mock_context.set_result.called
result = mock_context.set_result.call_args[0][0]
assert result["status"] == "reset"
assert mock_context.set_state.called
state = mock_context.set_state.call_args[0][0]
assert state["data"] == {"conversationHistory": []}
def test_entity_function_restores_existing_state(self) -> None:
"""Test that the entity function can operate when existing state is present."""
mock_agent = Mock()
entity_function = create_agent_entity(mock_agent)
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 = "reset"
mock_context.get_state.return_value = existing_state
entity_function(mock_context)
assert mock_context.set_result.called
# Reset should clear history and persist via set_state
assert mock_context.set_state.called
persisted_state = mock_context.set_state.call_args[0][0]
assert persisted_state["data"]["conversationHistory"] == []
def test_entity_function_handles_string_input(self) -> None:
"""Test that the entity function handles non-dict input by converting to string."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("String response"))
entity_function = create_agent_entity(mock_agent)
# Mock context with non-dict input (like a number)
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.entity_key = "conv-456"
# Use a number to test the str() conversion path
mock_context.get_input.return_value = 12345
mock_context.get_state.return_value = None
# Execute - entity will convert non-dict input to string
entity_function(mock_context)
# Verify the result was set
assert mock_context.set_result.called
def test_entity_function_handles_none_input(self) -> None:
"""Test that the entity function handles None input by converting to empty string."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Empty response"))
entity_function = create_agent_entity(mock_agent)
# Mock context with None input
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.entity_key = "conv-789"
mock_context.get_input.return_value = None
mock_context.get_state.return_value = None
# Execute - should hit error path since entity expects dict or valid JSON string
entity_function(mock_context)
# Verify the result was set (likely error result)
assert mock_context.set_result.called
def test_entity_function_runs_on_persistent_loop(self) -> None:
"""Entity coroutines run on the shared persistent loop and set a result."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity_function = create_agent_entity(mock_agent)
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.entity_key = "conv-loop-test"
mock_context.get_input.return_value = {"message": "Test", "correlationId": "corr-loop-test"}
mock_context.get_state.return_value = None
# Execute - the persistent loop should run the coroutine and set a result.
entity_function(mock_context)
assert mock_context.set_result.called
mock_agent.run.assert_awaited()
def test_entity_function_runs_across_threads_without_hang(self) -> None:
"""Successive entity invocations from different threads must not hang.
This reproduces the cross-loop scenario that previously deadlocked: a
shared async resource bound to one loop being awaited from a different
worker thread. The persistent loop keeps every invocation on one loop.
"""
from concurrent.futures import ThreadPoolExecutor
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity_function = create_agent_entity(mock_agent)
def invoke(i: int) -> bool:
ctx = Mock()
ctx.operation_name = "run"
ctx.entity_key = f"conv-{i}"
ctx.get_input.return_value = {"message": f"Test {i}", "correlationId": f"corr-{i}"}
ctx.get_state.return_value = None
entity_function(ctx)
return ctx.set_result.called
with ThreadPoolExecutor(max_workers=8) as ex:
results = list(ex.map(invoke, range(16)))
assert all(results)
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for custom exception types."""
import pytest
from agent_framework_azurefunctions._errors import IncomingRequestError
class TestIncomingRequestError:
"""Test suite for IncomingRequestError exception."""
def test_incoming_request_error_default_status_code(self) -> None:
"""Test that IncomingRequestError has a default status code of 400."""
error = IncomingRequestError("Invalid request")
assert str(error) == "Invalid request"
assert error.status_code == 400
def test_incoming_request_error_custom_status_code(self) -> None:
"""Test that IncomingRequestError can have a custom status code."""
error = IncomingRequestError("Unauthorized", status_code=401)
assert str(error) == "Unauthorized"
assert error.status_code == 401
def test_incoming_request_error_is_value_error(self) -> None:
"""Test that IncomingRequestError inherits from ValueError."""
error = IncomingRequestError("Test error")
assert isinstance(error, ValueError)
def test_incoming_request_error_can_be_raised_and_caught(self) -> None:
"""Test that IncomingRequestError can be raised and caught."""
with pytest.raises(IncomingRequestError) as exc_info:
raise IncomingRequestError("Bad request", status_code=400)
assert exc_info.value.status_code == 400
@@ -0,0 +1,162 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for workflow utility functions."""
from unittest.mock import Mock
import pytest
from agent_framework import WorkflowEvent, WorkflowMessage
from agent_framework_azurefunctions._context import CapturingRunnerContext
class TestCapturingRunnerContext:
"""Test suite for CapturingRunnerContext."""
@pytest.fixture
def context(self) -> CapturingRunnerContext:
"""Create a fresh CapturingRunnerContext for each test."""
return CapturingRunnerContext()
@pytest.mark.asyncio
async def test_send_message_captures_message(self, context: CapturingRunnerContext) -> None:
"""Test that send_message captures messages correctly."""
message = WorkflowMessage(data="test data", target_id="target_1", source_id="source_1")
await context.send_message(message)
messages = await context.drain_messages()
assert "source_1" in messages
assert len(messages["source_1"]) == 1
assert messages["source_1"][0].data == "test data"
@pytest.mark.asyncio
async def test_send_multiple_messages_groups_by_source(self, context: CapturingRunnerContext) -> None:
"""Test that messages are grouped by source_id."""
msg1 = WorkflowMessage(data="msg1", target_id="target", source_id="source_a")
msg2 = WorkflowMessage(data="msg2", target_id="target", source_id="source_a")
msg3 = WorkflowMessage(data="msg3", target_id="target", source_id="source_b")
await context.send_message(msg1)
await context.send_message(msg2)
await context.send_message(msg3)
messages = await context.drain_messages()
assert len(messages["source_a"]) == 2
assert len(messages["source_b"]) == 1
@pytest.mark.asyncio
async def test_drain_messages_clears_messages(self, context: CapturingRunnerContext) -> None:
"""Test that drain_messages clears the message store."""
message = WorkflowMessage(data="test", target_id="t", source_id="s")
await context.send_message(message)
await context.drain_messages() # First drain
messages = await context.drain_messages() # Second drain
assert messages == {}
@pytest.mark.asyncio
async def test_has_messages_returns_correct_status(self, context: CapturingRunnerContext) -> None:
"""Test has_messages returns correct boolean."""
assert await context.has_messages() is False
await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s"))
assert await context.has_messages() is True
@pytest.mark.asyncio
async def test_add_event_queues_event(self, context: CapturingRunnerContext) -> None:
"""Test that add_event queues events correctly."""
event = WorkflowEvent("output", executor_id="exec_1", data="output")
await context.add_event(event)
events = await context.drain_events()
assert len(events) == 1
assert isinstance(events[0], WorkflowEvent)
assert events[0].type == "output"
assert events[0].data == "output"
@pytest.mark.asyncio
async def test_drain_events_clears_queue(self, context: CapturingRunnerContext) -> None:
"""Test that drain_events clears the event queue."""
await context.add_event(WorkflowEvent("output", executor_id="e", data="test"))
await context.drain_events() # First drain
events = await context.drain_events() # Second drain
assert events == []
@pytest.mark.asyncio
async def test_has_events_returns_correct_status(self, context: CapturingRunnerContext) -> None:
"""Test has_events returns correct boolean."""
assert await context.has_events() is False
await context.add_event(WorkflowEvent("output", executor_id="e", data="test"))
assert await context.has_events() is True
@pytest.mark.asyncio
async def test_next_event_waits_for_event(self, context: CapturingRunnerContext) -> None:
"""Test that next_event returns queued events."""
event = WorkflowEvent("output", executor_id="e", data="waited")
await context.add_event(event)
result = await context.next_event()
assert result.data == "waited"
def test_has_checkpointing_returns_false(self, context: CapturingRunnerContext) -> None:
"""Test that checkpointing is not supported."""
assert context.has_checkpointing() is False
def test_is_streaming_returns_false_by_default(self, context: CapturingRunnerContext) -> None:
"""Test streaming is disabled by default."""
assert context.is_streaming() is False
def test_set_streaming(self, context: CapturingRunnerContext) -> None:
"""Test setting streaming mode."""
context.set_streaming(True)
assert context.is_streaming() is True
context.set_streaming(False)
assert context.is_streaming() is False
def test_set_workflow_id(self, context: CapturingRunnerContext) -> None:
"""Test setting workflow ID."""
context.set_workflow_id("workflow-123")
assert context._workflow_id == "workflow-123"
@pytest.mark.asyncio
async def test_reset_for_new_run_clears_state(self, context: CapturingRunnerContext) -> None:
"""Test that reset_for_new_run clears all state."""
await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s"))
await context.add_event(WorkflowEvent("output", executor_id="e", data="event"))
context.set_streaming(True)
context.reset_for_new_run()
assert await context.has_messages() is False
assert await context.has_events() is False
assert context.is_streaming() is False
@pytest.mark.asyncio
async def test_create_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None:
"""Test that checkpointing methods raise NotImplementedError."""
from agent_framework._workflows._state import State
with pytest.raises(NotImplementedError):
await context.create_checkpoint("test_workflow", "abc123", State(), None, 1)
@pytest.mark.asyncio
async def test_load_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None:
"""Test that load_checkpoint raises NotImplementedError."""
with pytest.raises(NotImplementedError):
await context.load_checkpoint("some-id")
@pytest.mark.asyncio
async def test_apply_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None:
"""Test that apply_checkpoint raises NotImplementedError."""
with pytest.raises(NotImplementedError):
await context.apply_checkpoint(Mock())
@@ -0,0 +1,155 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for multi-agent support in AgentFunctionApp."""
from unittest.mock import Mock
import pytest
from agent_framework_azurefunctions import AgentFunctionApp
class TestMultiAgentInit:
"""Test suite for multi-agent initialization."""
def test_init_with_agents_list(self) -> None:
"""Test initialization with list of agents."""
agent1 = Mock()
agent1.name = "Agent1"
agent2 = Mock()
agent2.name = "Agent2"
app = AgentFunctionApp(agents=[agent1, agent2])
assert len(app.agents) == 2
assert "Agent1" in app.agents
assert "Agent2" in app.agents
assert app.agents["Agent1"] == agent1
assert app.agents["Agent2"] == agent2
def test_init_with_empty_agents_list(self) -> None:
"""Test initialization with empty list of agents."""
app = AgentFunctionApp(agents=[])
assert len(app.agents) == 0
def test_init_with_no_agents(self) -> None:
"""Test initialization without any agents."""
app = AgentFunctionApp()
assert len(app.agents) == 0
def test_init_with_duplicate_agent_names(self) -> None:
"""Test initialization with duplicate agent names deduplicates with warning."""
agent1 = Mock()
agent1.name = "TestAgent"
agent2 = Mock()
agent2.name = "TestAgent"
app = AgentFunctionApp(agents=[agent1, agent2])
# Duplicate is skipped, only the first agent is registered
assert len(app.agents) == 1
assert "TestAgent" in app.agents
def test_init_with_agent_without_name(self) -> None:
"""Test initialization with agent missing name attribute raises error."""
agent1 = Mock()
agent1.name = "Agent1"
agent2 = Mock(spec=[]) # Mock without name attribute
with pytest.raises(ValueError, match="does not have a 'name' attribute"):
AgentFunctionApp(agents=[agent1, agent2])
class TestAddAgentMethod:
"""Test suite for add_agent() method."""
def test_add_agent_to_empty_app(self) -> None:
"""Test adding agent to app initialized without agents."""
app = AgentFunctionApp()
agent = Mock()
agent.name = "NewAgent"
app.add_agent(agent)
assert len(app.agents) == 1
assert "NewAgent" in app.agents
assert app.agents["NewAgent"] == agent
def test_add_multiple_agents(self) -> None:
"""Test adding multiple agents sequentially."""
app = AgentFunctionApp()
agent1 = Mock()
agent1.name = "Agent1"
agent2 = Mock()
agent2.name = "Agent2"
app.add_agent(agent1)
app.add_agent(agent2)
assert len(app.agents) == 2
assert "Agent1" in app.agents
assert "Agent2" in app.agents
def test_add_agent_with_duplicate_name_skips(self) -> None:
"""Test that adding agent with duplicate name logs warning and skips."""
agent1 = Mock()
agent1.name = "MyAgent"
agent2 = Mock()
agent2.name = "MyAgent"
app = AgentFunctionApp(agents=[agent1])
# Duplicate is silently skipped with a warning
app.add_agent(agent2)
# Only the original agent remains
assert len(app.agents) == 1
def test_add_agent_to_app_with_existing_agents(self) -> None:
"""Test adding agent to app that already has agents."""
agent1 = Mock()
agent1.name = "Agent1"
agent2 = Mock()
agent2.name = "Agent2"
app = AgentFunctionApp(agents=[agent1])
app.add_agent(agent2)
assert len(app.agents) == 2
assert "Agent1" in app.agents
assert "Agent2" in app.agents
def test_add_agent_without_name_raises_error(self) -> None:
"""Test that adding agent without name attribute raises error."""
app = AgentFunctionApp()
agent = Mock(spec=[]) # Mock without name attribute
with pytest.raises(ValueError, match="does not have a 'name' attribute"):
app.add_agent(agent)
class TestHealthCheckWithMultipleAgents:
"""Test suite for health check with multiple agents."""
def test_health_check_returns_all_agents(self) -> None:
"""Test that health check returns information about all agents."""
agent1 = Mock()
agent1.name = "Agent1"
agent2 = Mock()
agent2.name = "Agent2"
app = AgentFunctionApp(agents=[agent1, agent2])
# Note: We can't easily test the actual health check endpoint without running the app
# But we can verify the agents dictionary is properly populated
assert len(app.agents) == 2
assert app.enable_health_check is True
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,399 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for orchestration support (DurableAIAgent)."""
from typing import Any
from unittest.mock import Mock
import pytest
from agent_framework import AgentResponse, Message
from agent_framework_durabletask import DurableAIAgent
from azure.durable_functions.models.Task import TaskBase, TaskState
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions._orchestration import AgentTask
def _app_with_registered_agents(*agent_names: str) -> AgentFunctionApp:
app = AgentFunctionApp(enable_health_check=False, enable_http_endpoints=False)
for name in agent_names:
agent = Mock()
agent.name = name
app.add_agent(agent)
return app
class _FakeTask(TaskBase):
"""Concrete TaskBase for testing AgentTask wiring."""
def __init__(self, task_id: int = 1):
super().__init__(task_id, [])
self._set_is_scheduled(False)
self.action_repr: list[Any] = [] # pyrefly: ignore[bad-override-mutable-attribute]
self.state = TaskState.RUNNING
def _create_entity_task(task_id: int = 1) -> TaskBase:
"""Create a minimal TaskBase instance for AgentTask tests."""
return _FakeTask(task_id)
@pytest.fixture
def mock_context():
"""Create a mock orchestration context with UUID support."""
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
return context
@pytest.fixture
def mock_context_with_uuid() -> tuple[Mock, str]:
"""Create a mock context with a single UUID."""
from uuid import UUID
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
test_uuid = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
context.new_uuid = Mock(return_value=test_uuid)
return context, test_uuid.hex
@pytest.fixture
def mock_context_with_multiple_uuids() -> tuple[Mock, list[str]]:
"""Create a mock context with multiple UUIDs via side_effect."""
from uuid import UUID
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
uuids = [
UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"),
]
context.new_uuid = Mock(side_effect=uuids)
# Return the hex versions for assertion checking
hex_uuids = [uuid.hex for uuid in uuids]
return context, hex_uuids
@pytest.fixture
def executor_with_uuid() -> tuple[Any, Mock, str]:
"""Create an executor with a mocked generate_unique_id method."""
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
executor = AzureFunctionsAgentExecutor(context)
test_uuid_hex = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
executor.generate_unique_id = Mock(return_value=test_uuid_hex) # type: ignore[method-assign]
return executor, context, test_uuid_hex
@pytest.fixture
def executor_with_multiple_uuids() -> tuple[Any, Mock, list[str]]:
"""Create an executor with multiple mocked UUIDs."""
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
executor = AzureFunctionsAgentExecutor(context)
uuid_hexes = [
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"cccccccc-cccc-cccc-cccc-cccccccccccc",
"dddddddd-dddd-dddd-dddd-dddddddddddd",
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
]
executor.generate_unique_id = Mock(side_effect=uuid_hexes) # type: ignore[method-assign]
return executor, context, uuid_hexes
@pytest.fixture
def executor_with_context(mock_context_with_uuid: tuple[Mock, str]) -> tuple[Any, Mock]:
"""Create an executor with a mocked context."""
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
context, _ = mock_context_with_uuid
return AzureFunctionsAgentExecutor(context), context
class TestAgentResponseHelpers:
"""Tests for response handling through public AgentTask API."""
def test_try_set_value_exception_handling(self) -> None:
"""Test try_set_value handles exceptions raised when converting a successful task result to AgentResponse."""
entity_task = _create_entity_task()
task = AgentTask(entity_task, None, "correlation-id")
# Simulate successful entity task with invalid result that causes exception
entity_task.state = TaskState.SUCCEEDED
entity_task.result = {"invalid": "format"} # Missing required fields for AgentResponse
# Clear pending_tasks to simulate that parent has processed the child
task.pending_tasks.clear()
# Call try_set_value - should catch exception and set error
task.try_set_value(entity_task)
# Verify task failed due to conversion exception
assert task.state == TaskState.FAILED
assert isinstance(task.result, Exception)
def test_try_set_value_success(self) -> None:
"""Test try_set_value correctly processes successful task completion."""
entity_task = _create_entity_task()
task = AgentTask(entity_task, None, "correlation-id")
# Simulate successful entity task completion
entity_task.state = TaskState.SUCCEEDED
entity_task.result = AgentResponse(messages=[Message(role="assistant", contents=["Test response"])]).to_dict()
# Clear pending_tasks to simulate that parent has processed the child
task.pending_tasks.clear()
# Call try_set_value
task.try_set_value(entity_task)
# Verify task completed successfully with AgentResponse
assert task.state == TaskState.SUCCEEDED
assert isinstance(task.result, AgentResponse)
assert task.result.text == "Test response"
def test_try_set_value_failure(self) -> None:
"""Test try_set_value correctly handles failed task completion."""
entity_task = _create_entity_task()
task = AgentTask(entity_task, None, "correlation-id")
# Simulate failed entity task
entity_task.state = TaskState.FAILED
entity_task.result = Exception("Entity call failed")
# Call try_set_value
task.try_set_value(entity_task)
# Verify task failed with the error
assert task.state == TaskState.FAILED
assert isinstance(task.result, Exception)
assert str(task.result) == "Entity call failed"
def test_try_set_value_with_response_format(self) -> None:
"""Test try_set_value parses structured output when response_format is provided."""
from pydantic import BaseModel
class TestSchema(BaseModel):
answer: str
entity_task = _create_entity_task()
task = AgentTask(entity_task, TestSchema, "correlation-id")
# Simulate successful entity task with JSON response
entity_task.state = TaskState.SUCCEEDED
entity_task.result = AgentResponse(
messages=[Message(role="assistant", contents=['{"answer": "42"}'])]
).to_dict()
# Clear pending_tasks to simulate that parent has processed the child
task.pending_tasks.clear()
# Call try_set_value
task.try_set_value(entity_task)
# Verify task completed and value was parsed
assert task.state == TaskState.SUCCEEDED
assert isinstance(task.result, AgentResponse)
assert isinstance(task.result.value, TestSchema)
assert task.result.value.answer == "42"
class TestAgentFunctionAppGetAgent:
"""Test suite for AgentFunctionApp.get_agent."""
def test_get_agent_raises_for_unregistered_agent(self) -> None:
"""Test get_agent raises ValueError when agent is not registered."""
app = _app_with_registered_agents("KnownAgent")
with pytest.raises(ValueError, match=r"Agent 'MissingAgent' is not registered with this app\."):
app.get_agent(Mock(), "MissingAgent")
class TestAzureFunctionsFireAndForget:
"""Test fire-and-forget mode for AzureFunctionsAgentExecutor."""
def test_fire_and_forget_calls_signal_entity(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
"""Verify wait_for_response=False calls signal_entity instead of call_entity."""
executor, context, _ = executor_with_uuid
context.signal_entity = Mock()
context.call_entity = Mock(return_value=_create_entity_task())
agent = DurableAIAgent(executor, "TestAgent")
session = agent.create_session()
# Run with wait_for_response=False
result = agent.run("Test message", session=session, options={"wait_for_response": False})
# Verify signal_entity was called and call_entity was not
assert context.signal_entity.call_count == 1
assert context.call_entity.call_count == 0
# Should still return an AgentTask
assert isinstance(result, AgentTask)
def test_fire_and_forget_returns_completed_task(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
"""Verify wait_for_response=False returns pre-completed AgentTask."""
executor, context, _ = executor_with_uuid
context.signal_entity = Mock()
agent = DurableAIAgent(executor, "TestAgent")
session = agent.create_session()
result = agent.run("Test message", session=session, options={"wait_for_response": False})
# Task should be immediately complete
assert isinstance(result, AgentTask)
assert result.is_completed
def test_fire_and_forget_returns_acceptance_response(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
"""Verify wait_for_response=False returns acceptance response."""
executor, context, _ = executor_with_uuid
context.signal_entity = Mock()
agent = DurableAIAgent(executor, "TestAgent")
session = agent.create_session()
result = agent.run("Test message", session=session, options={"wait_for_response": False})
# Get the result
response = result.result
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
assert response.messages[0].role == "system"
# Check message contains key information
message_text = response.messages[0].text
assert "accepted" in message_text.lower()
assert "background" in message_text.lower()
def test_blocking_mode_still_works(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
"""Verify wait_for_response=True uses call_entity as before."""
executor, context, _ = executor_with_uuid
context.signal_entity = Mock()
context.call_entity = Mock(return_value=_create_entity_task())
agent = DurableAIAgent(executor, "TestAgent")
session = agent.create_session()
result = agent.run("Test message", session=session, options={"wait_for_response": True})
# Verify call_entity was called and signal_entity was not
assert context.call_entity.call_count == 1
assert context.signal_entity.call_count == 0
# Should return an AgentTask
assert isinstance(result, AgentTask)
class TestAzureFunctionsAgentExecutor:
"""Tests for AzureFunctionsAgentExecutor."""
def test_generate_unique_id(self, mock_context_with_uuid: tuple[Mock, str]) -> None:
"""Test generate_unique_id method returns UUID from orchestration context."""
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
context, _ = mock_context_with_uuid
executor = AzureFunctionsAgentExecutor(context)
# Call generate_unique_id
unique_id = executor.generate_unique_id()
# Verify it returns the UUID from context (as string with dashes)
# The UUID is returned in standard format with dashes
context.new_uuid.assert_called_once()
# Just verify it's a string representation of UUID
assert isinstance(unique_id, str)
assert len(unique_id) > 0
class TestOrchestrationIntegration:
"""Integration tests for orchestration scenarios."""
def test_sequential_agent_calls_simulation(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
"""Simulate sequential agent calls in an orchestration."""
executor, context, uuid_hexes = executor_with_multiple_uuids
# Track entity calls
entity_calls: list[dict[str, Any]] = []
def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> TaskBase:
entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data})
return _create_entity_task()
context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
# Create agent directly with executor (not via app.get_agent)
agent = DurableAIAgent(executor, "WriterAgent")
# Create session
session = agent.create_session()
# First call - returns AgentTask
task1 = agent.run("Write something", session=session)
assert isinstance(task1, AgentTask)
# Second call - returns AgentTask
task2 = agent.run("Improve: something", session=session)
assert isinstance(task2, AgentTask)
# Verify both calls used the same entity (same session key)
assert len(entity_calls) == 2
assert entity_calls[0]["entity_id"] == entity_calls[1]["entity_id"]
# EntityId format is @dafx-writeragent@<uuid_hex>
expected_entity_id = f"@dafx-writeragent@{uuid_hexes[0]}"
assert entity_calls[0]["entity_id"] == expected_entity_id
# generate_unique_id called 3 times: session + 2 correlation IDs
assert executor.generate_unique_id.call_count == 3
def test_multiple_agents_in_orchestration(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
"""Test using multiple different agents in one orchestration."""
executor, context, uuid_hexes = executor_with_multiple_uuids
entity_calls: list[str] = []
def mock_call_entity_side_effect(entity_id: Any, operation: str, input_data: dict[str, Any]) -> TaskBase:
entity_calls.append(str(entity_id))
return _create_entity_task()
context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
# Create agents directly with executor (not via app.get_agent)
writer = DurableAIAgent(executor, "WriterAgent")
editor = DurableAIAgent(executor, "EditorAgent")
writer_session = writer.create_session()
editor_session = editor.create_session()
# Call both agents - returns AgentTasks
writer_task = writer.run("Write", session=writer_session)
editor_task = editor.run("Edit", session=editor_session)
assert isinstance(writer_task, AgentTask)
assert isinstance(editor_task, AgentTask)
# Verify different entity IDs were used
assert len(entity_calls) == 2
# EntityId format is @dafx-agentname@uuid_hex (lowercased agent name with dafx- prefix)
expected_writer_id = f"@dafx-writeragent@{uuid_hexes[0]}"
expected_editor_id = f"@dafx-editoragent@{uuid_hexes[1]}"
assert entity_calls[0] == expected_writer_id
assert entity_calls[1] == expected_editor_id
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,328 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for workflow orchestration functions."""
import json
from dataclasses import dataclass
from typing import Any
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
Message,
)
from agent_framework._workflows._edge import (
FanInEdgeGroup,
FanOutEdgeGroup,
SingleEdgeGroup,
SwitchCaseEdgeGroup,
SwitchCaseEdgeGroupCase,
SwitchCaseEdgeGroupDefault,
)
from agent_framework_azurefunctions._workflow import (
_extract_message_content,
build_agent_executor_response,
route_message_through_edge_groups,
)
class TestRouteMessageThroughEdgeGroups:
"""Test suite for route_message_through_edge_groups function."""
def test_single_edge_group_routes_when_condition_matches(self) -> None:
"""Test SingleEdgeGroup routes when condition is satisfied."""
group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: True)
targets = route_message_through_edge_groups([group], "src", "any message")
assert targets == ["tgt"]
def test_single_edge_group_does_not_route_when_condition_fails(self) -> None:
"""Test SingleEdgeGroup does not route when condition fails."""
group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: False)
targets = route_message_through_edge_groups([group], "src", "any message")
assert targets == []
def test_single_edge_group_ignores_different_source(self) -> None:
"""Test SingleEdgeGroup ignores messages from different sources."""
group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: True)
targets = route_message_through_edge_groups([group], "other_src", "any message")
assert targets == []
def test_switch_case_with_selection_func(self) -> None:
"""Test SwitchCaseEdgeGroup uses selection_func."""
def select_first_target(msg: Any, targets: list[str]) -> list[str]:
return [targets[0]]
group = SwitchCaseEdgeGroup(
source_id="src",
cases=[
SwitchCaseEdgeGroupCase(condition=lambda m: True, target_id="target_a"),
SwitchCaseEdgeGroupDefault(target_id="target_b"),
],
)
# Manually set the selection function
group._selection_func = select_first_target
targets = route_message_through_edge_groups([group], "src", "test")
assert targets == ["target_a"]
def test_switch_case_without_selection_func_broadcasts(self) -> None:
"""Test SwitchCaseEdgeGroup without selection_func broadcasts to all."""
group = SwitchCaseEdgeGroup(
source_id="src",
cases=[
SwitchCaseEdgeGroupCase(condition=lambda m: True, target_id="target_a"),
SwitchCaseEdgeGroupDefault(target_id="target_b"),
],
)
group._selection_func = None
targets = route_message_through_edge_groups([group], "src", "test")
assert set(targets) == {"target_a", "target_b"}
def test_fan_out_with_selection_func(self) -> None:
"""Test FanOutEdgeGroup uses selection_func."""
def select_all(msg: Any, targets: list[str]) -> list[str]:
return targets
group = FanOutEdgeGroup(
source_id="src",
target_ids=["fan_a", "fan_b", "fan_c"],
selection_func=select_all,
)
targets = route_message_through_edge_groups([group], "src", "broadcast")
assert set(targets) == {"fan_a", "fan_b", "fan_c"}
def test_fan_in_is_not_routed_directly(self) -> None:
"""Test FanInEdgeGroup is handled separately (not routed here)."""
group = FanInEdgeGroup(
source_ids=["src_a", "src_b"],
target_id="aggregator",
)
# Fan-in should not add targets through this function
targets = route_message_through_edge_groups([group], "src_a", "message")
assert targets == []
def test_multiple_edge_groups_aggregated(self) -> None:
"""Test that targets from multiple edge groups are aggregated."""
group1 = SingleEdgeGroup(source_id="src", target_id="t1", condition=lambda m: True)
group2 = SingleEdgeGroup(source_id="src", target_id="t2", condition=lambda m: True)
targets = route_message_through_edge_groups([group1, group2], "src", "msg")
assert set(targets) == {"t1", "t2"}
class TestBuildAgentExecutorResponse:
"""Test suite for build_agent_executor_response function."""
def test_builds_response_with_text(self) -> None:
"""Test building response with plain text."""
response = build_agent_executor_response(
executor_id="my_executor",
response_text="Hello, world!",
structured_response=None,
previous_message="User input",
)
assert response.executor_id == "my_executor"
assert response.agent_response.text == "Hello, world!"
assert len(response.full_conversation) == 2 # User + Assistant
def test_builds_response_with_structured_response(self) -> None:
"""Test building response with structured JSON response."""
structured = {"answer": 42, "reason": "because"}
response = build_agent_executor_response(
executor_id="calc",
response_text="Original text",
structured_response=structured,
previous_message="Calculate",
)
# Structured response overrides text
assert response.agent_response.text == json.dumps(structured)
def test_conversation_includes_previous_string_message(self) -> None:
"""Test that string previous_message is included in conversation."""
response = build_agent_executor_response(
executor_id="exec",
response_text="Response",
structured_response=None,
previous_message="User said this",
)
assert len(response.full_conversation) == 2
assert response.full_conversation[0].role == "user"
assert response.full_conversation[0].text == "User said this"
assert response.full_conversation[1].role == "assistant"
def test_conversation_extends_previous_agent_executor_response(self) -> None:
"""Test that previous AgentExecutorResponse's conversation is extended."""
# Create a previous response with conversation history
previous = AgentExecutorResponse(
executor_id="prev",
agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Previous"])]),
full_conversation=[
Message(role="user", contents=["First"]),
Message(role="assistant", contents=["Previous"]),
],
)
response = build_agent_executor_response(
executor_id="current",
response_text="Current response",
structured_response=None,
previous_message=previous,
)
# Should have 3 messages: First + Previous + Current
assert len(response.full_conversation) == 3
assert response.full_conversation[0].text == "First"
assert response.full_conversation[1].text == "Previous"
assert response.full_conversation[2].text == "Current response"
class TestExtractMessageContent:
"""Test suite for _extract_message_content function."""
def test_extract_from_string(self) -> None:
"""Test extracting content from plain string."""
result = _extract_message_content("Hello, world!")
assert result == "Hello, world!"
def test_extract_from_agent_executor_response_with_text(self) -> None:
"""Test extracting from AgentExecutorResponse with text."""
response = AgentExecutorResponse(
executor_id="exec",
agent_response=AgentResponse(messages=[Message(role="assistant", contents=["Response text"])]),
full_conversation=[Message(role="assistant", contents=["Response text"])],
)
result = _extract_message_content(response)
assert result == "Response text"
def test_extract_from_agent_executor_response_with_messages(self) -> None:
"""Test extracting from AgentExecutorResponse with messages."""
response = AgentExecutorResponse(
executor_id="exec",
agent_response=AgentResponse(
messages=[
Message(role="user", contents=["First"]),
Message(role="assistant", contents=["Last message"]),
]
),
full_conversation=[
Message(role="user", contents=["First"]),
Message(role="assistant", contents=["Last message"]),
],
)
result = _extract_message_content(response)
# AgentResponse.text concatenates all message texts
assert result == "FirstLast message"
def test_extract_from_agent_executor_request(self) -> None:
"""Test extracting from AgentExecutorRequest."""
request = AgentExecutorRequest(
messages=[
Message(role="user", contents=["First"]),
Message(role="user", contents=["Last request"]),
]
)
result = _extract_message_content(request)
assert result == "Last request"
def test_extract_from_dict_returns_empty(self) -> None:
"""Test that dict messages return empty string (unexpected input)."""
msg_dict = {"messages": [{"text": "Hello"}]}
result = _extract_message_content(msg_dict)
assert result == ""
def test_extract_returns_empty_for_unknown_type(self) -> None:
"""Test that unknown types return empty string."""
result = _extract_message_content(12345)
assert result == ""
class TestEdgeGroupIntegration:
"""Integration tests for edge group routing with realistic scenarios."""
def test_conditional_routing_by_message_type(self) -> None:
"""Test routing based on message content/type."""
@dataclass
class SpamResult:
is_spam: bool
reason: str
def is_spam_condition(msg: Any) -> bool:
if isinstance(msg, SpamResult):
return msg.is_spam
return False
def is_not_spam_condition(msg: Any) -> bool:
if isinstance(msg, SpamResult):
return not msg.is_spam
return False
spam_group = SingleEdgeGroup(
source_id="detector",
target_id="spam_handler",
condition=is_spam_condition,
)
legit_group = SingleEdgeGroup(
source_id="detector",
target_id="email_handler",
condition=is_not_spam_condition,
)
# Test spam message
spam_msg = SpamResult(is_spam=True, reason="Suspicious content")
targets = route_message_through_edge_groups([spam_group, legit_group], "detector", spam_msg)
assert targets == ["spam_handler"]
# Test legitimate message
legit_msg = SpamResult(is_spam=False, reason="Clean")
targets = route_message_through_edge_groups([spam_group, legit_group], "detector", legit_msg)
assert targets == ["email_handler"]
def test_fan_out_to_multiple_workers(self) -> None:
"""Test fan-out to multiple parallel workers."""
def select_all_workers(msg: Any, targets: list[str]) -> list[str]:
return targets
group = FanOutEdgeGroup(
source_id="coordinator",
target_ids=["worker_1", "worker_2", "worker_3"],
selection_func=select_all_workers,
)
targets = route_message_through_edge_groups([group], "coordinator", {"task": "process"})
assert len(targets) == 3
assert set(targets) == {"worker_1", "worker_2", "worker_3"}