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"])