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
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:
@@ -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"])
|
||||
+57
@@ -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"])
|
||||
+59
@@ -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"])
|
||||
+77
@@ -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"])
|
||||
+185
@@ -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"])
|
||||
+100
@@ -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"])
|
||||
+116
@@ -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"])
|
||||
+150
@@ -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"}
|
||||
Reference in New Issue
Block a user