mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
9a369c69c0
commit
bb3d3c2efc
+95
@@ -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"])
|
||||
+111
@@ -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"}
|
||||
Reference in New Issue
Block a user