Python: Durable Support for Workflows (#3630)

* Add workflow support for Azure Functions

* fix compatability with latest framework changes and add integration tests

* refactor code

* remove white space

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* align help text with actual port used

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* replace instance id with a place holder

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* remove unused import

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* remove redundant typing import and fix SIM115

* fix latest breaking changes

* fix mypy issues

* clean up imports

* define source marker strings as constants

* fix json module name

* refactor _extract_message_content_from_dict

* refactor serialization

* add helper method for error response construction and remove _extract_message_content_from_dict since it is not needed

* use strict tpe checking for edges

* change how duplicate agent registrations are handled

* cancel approval_task on HITL timeout

* update docstring

* fix: align azurefunctions package with core API changes after rebase

- State.import_state/export_state are now sync (removed await)
- Add State.commit() before export_state() in activity execution
- Rename executor parameter shared_state -> state
- Rename ctx.set_shared_state/get_shared_state -> set_state/get_state (sync)
- WorkflowBuilder now takes start_executor as constructor kwarg
- Update WorkflowOutputEvent -> WorkflowEvent with type='output'
- Update RequestInfoEvent -> WorkflowEvent[Any]
- Update SharedState -> State in test imports
- Update duplicate agent name tests to match new warning behavior
- Update sample README API references

* fix sample check errors

* fix mypy issues

* fix trailing white spaces

* fix test imports

* feat: add durable workflow samples and adapt to main branch changes

- Add workflow samples 09-12 to 04-hosting/azure_functions/
- Adapt to ChatMessage -> Message rename from main
- Adapt to pickle-based checkpoint encoding from main
- Simplify _serialization.py to delegate to core encode/decode
- Fix Message -> WorkflowMessage disambiguation in _context.py
- Remove non-existent _checkpoint_summary import

* fix: update create_checkpoint signature to match superclass

* fix: correct relative link in HITL sample README

* fix: resolve import breakage after rebase (State, DurableAgentThread, get_logger)

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
Ahmed Muhsin
2026-02-17 16:11:33 -06:00
committed by GitHub
Unverified
parent 9a369c69c0
commit bb3d3c2efc
46 changed files with 5501 additions and 15 deletions
@@ -0,0 +1,95 @@
# 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
# Module-level markers - applied to all tests in this file
pytestmark = [
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_json(f"{self.base_url}/api/workflow/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_json(f"{self.base_url}/api/workflow/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_json(f"{self.base_url}/api/workflow/run", phishing_content)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
# Wait for completion
status = self.helper.wait_for_orchestration_with_output(data["statusQueryGetUri"])
assert status["runtimeStatus"] == "Completed"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,111 @@
# 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
# Module-level markers - applied to all tests in this file
pytestmark = [
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/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/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/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/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,138 @@
# 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
# Module-level markers - applied to all tests in this file
pytestmark = [
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
def test_parallel_workflow_document_analysis(self) -> None:
"""Test parallel workflow with a standard document."""
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%. However, we face challenges in the "
"mobile segment where competition is intense. Overall, the outlook is positive "
"with expected continued growth in the coming quarters."
),
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
assert "instanceId" in data
assert "statusQueryGetUri" in data
# Wait for completion - parallel workflows may take longer
status = self.helper.wait_for_orchestration_with_output(
data["statusQueryGetUri"],
max_wait=300, # 5 minutes for parallel execution
)
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_parallel_workflow_short_document(self) -> None:
"""Test parallel workflow with a short document."""
payload = {
"document_id": "doc-test-002",
"content": "Quick update: Project completed successfully. Team performance exceeded expectations.",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/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"], max_wait=300)
assert status["runtimeStatus"] == "Completed"
assert "output" in status
def test_parallel_workflow_technical_document(self) -> None:
"""Test parallel workflow with a technical document."""
payload = {
"document_id": "doc-test-003",
"content": (
"The new microservices architecture has been deployed to production. "
"Key improvements include: reduced latency by 40%, improved scalability "
"to handle 10x traffic spikes, and enhanced monitoring with distributed tracing. "
"The Kubernetes cluster is now running on version 1.28 with auto-scaling enabled. "
"Next steps include implementing service mesh and improving CI/CD pipelines."
),
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
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"], max_wait=300)
assert status["runtimeStatus"] == "Completed"
def test_workflow_status_endpoint(self) -> None:
"""Test that the workflow status endpoint works correctly."""
payload = {
"document_id": "doc-test-004",
"content": "Brief status update for testing purposes.",
}
# Start orchestration
response = self.helper.post_json(f"{self.base_url}/api/workflow/run", payload)
assert response.status_code == 202
data = response.json()
instance_id = data["instanceId"]
# Check status
status_response = self.helper.get(f"{self.base_url}/api/workflow/status/{instance_id}")
assert status_response.status_code == 200
status = status_response.json()
assert "instanceId" in status
assert status["instanceId"] == instance_id
# Wait for completion
self.helper.wait_for_orchestration(data["statusQueryGetUri"], max_wait=300)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,214 @@
# 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.sample("12_workflow_hitl"),
pytest.mark.usefixtures("function_app_for_test"),
]
@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/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/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/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/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/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/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/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/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/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"])
@@ -1317,5 +1317,129 @@ class TestAgentFunctionAppErrorPaths:
assert app._coerce_to_bool([]) is False
class TestAgentFunctionAppWorkflow:
"""Test suite for AgentFunctionApp workflow support."""
def test_init_with_workflow_stores_workflow(self) -> None:
"""Test that workflow is stored when provided."""
mock_workflow = Mock()
mock_workflow.executors = {}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
):
app = AgentFunctionApp(workflow=mock_workflow)
assert app.workflow is mock_workflow
def test_init_with_workflow_extracts_agents(self) -> None:
"""Test that agents are extracted from workflow executors."""
from agent_framework import AgentExecutor
mock_agent = Mock()
mock_agent.name = "WorkflowAgent"
mock_executor = Mock(spec=AgentExecutor)
mock_executor.agent = mock_agent
mock_workflow = Mock()
mock_workflow.executors = {"WorkflowAgent": mock_executor}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
patch.object(AgentFunctionApp, "_setup_agent_functions"),
):
app = AgentFunctionApp(workflow=mock_workflow)
assert "WorkflowAgent" in app.agents
def test_init_with_workflow_calls_setup_methods(self) -> None:
"""Test that workflow setup methods are called."""
mock_executor = Mock()
mock_executor.id = "TestExecutor"
mock_workflow = Mock()
# Include a non-AgentExecutor so _setup_executor_activity is called
mock_workflow.executors = {"TestExecutor": mock_executor}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec,
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
):
AgentFunctionApp(workflow=mock_workflow)
setup_exec.assert_called_once()
setup_orch.assert_called_once()
def test_init_without_workflow_does_not_call_workflow_setup(self) -> None:
"""Test that workflow setup is not called when no workflow provided."""
mock_agent = Mock()
mock_agent.name = "TestAgent"
with (
patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec,
patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch,
):
AgentFunctionApp(agents=[mock_agent])
setup_exec.assert_not_called()
setup_orch.assert_not_called()
def test_init_with_workflow_deduplicates_agents(self) -> None:
"""Test that agents in both 'agents' and workflow are not double-registered."""
from agent_framework import AgentExecutor
mock_agent = Mock()
mock_agent.name = "SharedAgent"
mock_executor = Mock(spec=AgentExecutor)
mock_executor.agent = mock_agent
mock_workflow = Mock()
mock_workflow.executors = {"SharedAgent": mock_executor}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
patch.object(AgentFunctionApp, "_setup_agent_functions"),
):
# Same agent passed explicitly AND present in workflow — should not raise
app = AgentFunctionApp(agents=[mock_agent], workflow=mock_workflow)
assert "SharedAgent" in app.agents
def test_build_status_url(self) -> None:
"""Test _build_status_url constructs correct URL."""
mock_workflow = Mock()
mock_workflow.executors = {}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
):
app = AgentFunctionApp(workflow=mock_workflow)
url = app._build_status_url("http://localhost:7071/api/workflow/run", "instance-123")
assert url == "http://localhost:7071/api/workflow/status/instance-123"
def test_build_status_url_handles_trailing_slash(self) -> None:
"""Test _build_status_url handles URLs without /api/ correctly."""
mock_workflow = Mock()
mock_workflow.executors = {}
with (
patch.object(AgentFunctionApp, "_setup_executor_activity"),
patch.object(AgentFunctionApp, "_setup_workflow_orchestration"),
):
app = AgentFunctionApp(workflow=mock_workflow)
url = app._build_status_url("http://localhost:7071/", "instance-456")
assert "instance-456" in url
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -40,14 +40,17 @@ class TestMultiAgentInit:
assert len(app.agents) == 0
def test_init_with_duplicate_agent_names(self) -> None:
"""Test initialization with agents having the same name raises error."""
"""Test initialization with duplicate agent names deduplicates with warning."""
agent1 = Mock()
agent1.name = "TestAgent"
agent2 = Mock()
agent2.name = "TestAgent"
with pytest.raises(ValueError, match="already registered"):
AgentFunctionApp(agents=[agent1, agent2])
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."""
@@ -91,8 +94,8 @@ class TestAddAgentMethod:
assert "Agent1" in app.agents
assert "Agent2" in app.agents
def test_add_agent_with_duplicate_name_raises_error(self) -> None:
"""Test that adding agent with duplicate name raises ValueError."""
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()
@@ -100,9 +103,11 @@ class TestAddAgentMethod:
app = AgentFunctionApp(agents=[agent1])
# Try to add another agent with the same name
with pytest.raises(ValueError, match="already registered"):
app.add_agent(agent2)
# 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."""
@@ -0,0 +1,374 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for workflow utility functions."""
from dataclasses import dataclass
from unittest.mock import Mock
import pytest
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
Message,
WorkflowEvent,
WorkflowMessage,
)
from pydantic import BaseModel
from agent_framework_azurefunctions._context import CapturingRunnerContext
from agent_framework_azurefunctions._serialization import (
deserialize_value,
reconstruct_to_type,
serialize_value,
)
# Module-level test types (must be importable for checkpoint encoding roundtrip)
@dataclass
class SampleData:
"""Sample dataclass for testing checkpoint encoding roundtrip."""
name: str
value: int
class SampleModel(BaseModel):
"""Sample Pydantic model for testing checkpoint encoding roundtrip."""
title: str
count: int
@dataclass
class DataclassWithPydanticField:
"""Dataclass containing a Pydantic model field for testing nested serialization."""
label: str
model: SampleModel
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 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())
class TestSerializationRoundtrip:
"""Test that serialization roundtrips correctly for types used in Azure Functions workflows."""
def test_roundtrip_chat_message(self) -> None:
"""Test Message survives encode → decode roundtrip."""
original = Message(role="user", text="Hello")
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, Message)
assert decoded.role == "user"
def test_roundtrip_agent_executor_request(self) -> None:
"""Test AgentExecutorRequest with nested Messages roundtrips."""
original = AgentExecutorRequest(
messages=[Message(role="user", text="Hi")],
should_respond=True,
)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, AgentExecutorRequest)
assert len(decoded.messages) == 1
assert isinstance(decoded.messages[0], Message)
assert decoded.should_respond is True
def test_roundtrip_agent_executor_response(self) -> None:
"""Test AgentExecutorResponse with nested AgentResponse roundtrips."""
original = AgentExecutorResponse(
executor_id="test_exec",
agent_response=AgentResponse(messages=[Message(role="assistant", text="Reply")]),
)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, AgentExecutorResponse)
assert decoded.executor_id == "test_exec"
assert isinstance(decoded.agent_response, AgentResponse)
def test_roundtrip_dataclass(self) -> None:
"""Test custom dataclass roundtrips."""
original = SampleData(name="test", value=42)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, SampleData)
assert decoded.name == "test"
assert decoded.value == 42
def test_roundtrip_pydantic_model(self) -> None:
"""Test Pydantic model roundtrips."""
original = SampleModel(title="Hello", count=5)
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, SampleModel)
assert decoded.title == "Hello"
assert decoded.count == 5
def test_roundtrip_primitives(self) -> None:
"""Test primitives pass through unchanged."""
assert serialize_value(None) is None
assert serialize_value("hello") == "hello"
assert serialize_value(42) == 42
assert serialize_value(3.14) == 3.14
assert serialize_value(True) is True
def test_roundtrip_list_of_objects(self) -> None:
"""Test list of typed objects roundtrips."""
original = [
Message(role="user", text="Q"),
Message(role="assistant", text="A"),
]
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, list)
assert len(decoded) == 2
assert all(isinstance(m, Message) for m in decoded)
def test_roundtrip_dict_of_objects(self) -> None:
"""Test dict with typed values roundtrips (used for shared state)."""
original = {"count": 42, "msg": Message(role="user", text="Hi")}
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert decoded["count"] == 42
assert isinstance(decoded["msg"], Message)
def test_roundtrip_dataclass_with_nested_pydantic(self) -> None:
"""Test dataclass containing a Pydantic model field roundtrips correctly.
This covers the HITL pattern where AnalysisWithSubmission (dataclass)
contains a ContentAnalysisResult (Pydantic BaseModel) field.
"""
original = DataclassWithPydanticField(label="test", model=SampleModel(title="Nested", count=99))
encoded = serialize_value(original)
decoded = deserialize_value(encoded)
assert isinstance(decoded, DataclassWithPydanticField)
assert decoded.label == "test"
assert isinstance(decoded.model, SampleModel)
assert decoded.model.title == "Nested"
assert decoded.model.count == 99
class TestReconstructToType:
"""Test suite for reconstruct_to_type function (used for HITL responses)."""
def test_none_returns_none(self) -> None:
"""Test that None input returns None."""
assert reconstruct_to_type(None, str) is None
def test_already_correct_type(self) -> None:
"""Test that values already of the correct type are returned as-is."""
assert reconstruct_to_type("hello", str) == "hello"
assert reconstruct_to_type(42, int) == 42
def test_non_dict_returns_original(self) -> None:
"""Test that non-dict values are returned as-is."""
assert reconstruct_to_type("hello", int) == "hello"
assert reconstruct_to_type([1, 2], dict) == [1, 2]
def test_reconstruct_pydantic_model(self) -> None:
"""Test reconstruction of Pydantic model from plain dict."""
class ApprovalResponse(BaseModel):
approved: bool
reason: str
data = {"approved": True, "reason": "Looks good"}
result = reconstruct_to_type(data, ApprovalResponse)
assert isinstance(result, ApprovalResponse)
assert result.approved is True
assert result.reason == "Looks good"
def test_reconstruct_dataclass(self) -> None:
"""Test reconstruction of dataclass from plain dict."""
@dataclass
class Feedback:
score: int
comment: str
data = {"score": 5, "comment": "Great"}
result = reconstruct_to_type(data, Feedback)
assert isinstance(result, Feedback)
assert result.score == 5
assert result.comment == "Great"
def test_reconstruct_from_checkpoint_markers(self) -> None:
"""Test that data with checkpoint markers is decoded via deserialize_value."""
original = SampleData(value=99, name="marker-test")
encoded = serialize_value(original)
result = reconstruct_to_type(encoded, SampleData)
assert isinstance(result, SampleData)
assert result.value == 99
def test_unrecognized_dict_returns_original(self) -> None:
"""Test that unrecognized dicts are returned as-is."""
@dataclass
class Unrelated:
completely_different: str
data = {"some_key": "some_value"}
result = reconstruct_to_type(data, Unrelated)
assert result == data
@@ -0,0 +1,323 @@
# 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", text="Previous")]),
full_conversation=[
Message(role="user", text="First"),
Message(role="assistant", text="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", text="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", text="First"),
Message(role="assistant", text="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", text="First"),
Message(role="user", text="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"}