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
@@ -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"}