[BREAKING] Python: Schema changes for azure functions package (#2151)

* Python: Add Scaffolding for Durable AzureFunctions package to Agent Framework (#1823)

* Add scafolding

* update readme

* add code owners and label

* update owners

* .NET: Durable extension: initial src and unit tests (#1900)

* Python: Add Durable Agent Wrapper code (#1913)

* add initial changes

* Move code and add single sample

* Update logger

* Remove unused code

* address PR comments

* cleanup code and address comments

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>

* Azure Functions .NET samples (#1939)

* Python: Add Unit tests for Azurefunctions package (#1976)

* Add Unit tests for Azurefunctions

* remove duplicate import

* .NET: [Feature Branch] Migrate state schema updates and support for agents as MCP tools (#1979)

* Python: Add more samples for Azure Functions (#1980)

* Move all samples

* fix comments

* remove dead lines

* Make samples simpler

* .NET: [Feature Branch] Durable Task extension integration tests (#2017)

* .NET: [Feature Branch] Update OpenAI config for integration tests (#2063)

* Python: Add Integration tests for AzureFunctions  (#2020)

* Add Integration tests

* Remove DTS extension

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Add pyi file for type safety

* Add samples in readme

* Updated all readme instructions

* Address comments

* Update readmes

* Fix requirements

* Address comments

---------

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

* .NET: [Feature Branch] Update dotnet-build-and-test.yml to support integration tests (#2070)

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

* Fix DTS startup issue and improve logging (#2103)

* .NET: [Feature Branch] Introduce Azure OpenAI config for .NET pipeline (#2106)

Also fixes an issue where we were trying to start docker containers for integration tests on Windows, which doesn't work.

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

* Fix uv.lock after merge

* Python: Add README for Azure Functions samples setup (#2100)

* Add README for Azure Functions samples setup

Added setup instructions for Azure Functions samples, including environment setup, virtual environment creation, and running samples.

* Update python/samples/getting_started/azure_functions/README.md

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

* Apply suggestions from code review

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

* Apply suggestion from @Copilot

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

* Apply suggestions from code review

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

---------

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

* Fix or remove broken markdown file links (#2115)

* .NET: [Feature Branch] Update HTTP API to be consistent across languages (#2118)

* Python: Fix AzureFunctions Integration Tests (#2116)

* Add Identity Auth to samples

* Update python/samples/getting_started/azure_functions/README.md

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

* Update python/samples/getting_started/azure_functions/01_single_agent/function_app.py

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

* Update python/samples/getting_started/azure_functions/02_multi_agent/function_app.py

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

* Update python/samples/getting_started/azure_functions/06_multi_agent_orchestration_conditionals/README.md

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

---------

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

* Python: Fix Http Schema (#2112)

* Rename to threadid

* Respond in plain text

* Make snake-case

* Add http prefix

* rename to wait-for-response

* Add query param check

* address comments

* .NET: Remove IsPackable=false in preparation for nuget release (#2142)

* Python: Move `azurefunctions` to `azure` for import (#2141)

* Move import to Azure

* fix mypy

* Update python/packages/azurefunctions/README.md

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

* Add missing types

* Address comments

---------

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

* Update python/packages/azurefunctions/pyproject.toml

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

* Update python/packages/azurefunctions/agent_framework_azurefunctions/__init__.py

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

* Fix imports

* Address PR feedback from westey-m (#2150)

- Adds a link from the /dotnet/samples/README.md to /dotnet/samples/AzureFunctions
- Make DurableAgentThread deserialization internal for future-proofing
- Update JSON serialization logic to address recently discovered issues with source generator serialization

* Schema changes for azure functions

* Fixed serialization bug

* update to camel case

* Adding logs

* merge with main

* sync uv.lock

* Updated schema

* Fixed tests

* Addressed comments

* Fixed mypy errors

* Fixed bug in responsetype and authorName

* Addressed feedback

* Addressed more feedback

* Python: Addressing comments for #2151 (#2315)

* Initial fixes

* Address more comments

* Address remaining comments

* Fixed remaining snake_case properties

* Fixed remaining snake_case properties

* Fixed mypy errors

* Minor changes

* revert tool names

* Fixed mypy errors

---------

Co-authored-by: Laveesh Rohra <larohra@microsoft.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Chris Gillum <cgillum@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Anirudh Garg <anirudhg@microsoft.com>
Co-authored-by: Victoria Hall <victoriahall@microsoft.com>
This commit is contained in:
Gavin Aguiar
2025-11-20 16:24:34 +00:00
committed by GitHub
co-authored by Laveesh Rohra Dmytro Struk Chris Gillum Copilot Anirudh Garg Victoria Hall
parent 039e49f353
commit 6ae32f007d
15 changed files with 1700 additions and 563 deletions
@@ -16,6 +16,8 @@ Usage:
import pytest
from agent_framework_azurefunctions._constants import THREAD_ID_HEADER
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
# Module-level markers - applied to all tests in this file
@@ -67,7 +69,7 @@ class TestSampleSingleAgent:
# Agent responded with plain text when the request body was text/plain.
assert response.text.strip()
assert response.headers.get("x-ms-thread-id") is not None
assert response.headers.get(THREAD_ID_HEADER) is not None
def test_thread_id_in_query(self) -> None:
"""Test using thread_id in query parameter."""
@@ -77,7 +79,7 @@ class TestSampleSingleAgent:
assert response.status_code in [200, 202]
assert response.text.strip()
assert response.headers.get("x-ms-thread-id") == "test-query-thread"
assert response.headers.get(THREAD_ID_HEADER) == "test-query-thread"
def test_conversation_continuity(self) -> None:
"""Test conversation context is maintained across requests."""
@@ -92,7 +94,7 @@ class TestSampleSingleAgent:
if response1.status_code == 200:
data1 = response1.json()
assert data1["message_count"] == 1
assert data1["message_count"] == 2 # Initial + reply
# Second message in same session
response2 = SampleTestHelper.post_json(
@@ -100,7 +102,7 @@ class TestSampleSingleAgent:
)
assert response2.status_code == 200
data2 = response2.json()
assert data2["message_count"] == 2
assert data2["message_count"] == 4
else:
# In async mode, we can't easily test message count
# Just verify we can make multiple calls
+101 -38
View File
@@ -13,7 +13,13 @@ from agent_framework import AgentRunResponse, ChatMessage
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER
from agent_framework_azurefunctions._entities import AgentEntity, AgentState, create_agent_entity
from agent_framework_azurefunctions._constants import (
MIMETYPE_APPLICATION_JSON,
MIMETYPE_TEXT_PLAIN,
THREAD_ID_HEADER,
)
from agent_framework_azurefunctions._durable_agent_state import DurableAgentState
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
@@ -333,14 +339,14 @@ class TestAgentEntityOperations:
result = await entity.run_agent(
mock_context,
{"message": "Test message", "thread_id": "test-conv-123", "correlation_id": "corr-app-entity-1"},
{"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"},
)
assert result["status"] == "success"
assert result["response"] == "Test response"
assert result["message"] == "Test message"
assert result["thread_id"] == "test-conv-123"
assert entity.state.message_count == 1
assert entity.state.message_count == 2
async def test_entity_stores_conversation_history(self) -> None:
"""Test that the entity stores conversation history."""
@@ -354,18 +360,29 @@ class TestAgentEntityOperations:
# Send first message
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-2"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"}
)
history = entity.state.conversation_history
assert len(history) == 2 # User + assistant
# Each conversation turn creates 2 entries: request and response
history = entity.state.data.conversation_history[0].messages # Request entry
assert len(history) == 1 # Just the user message
# Send second message
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"}
)
# Now we have 4 entries total (2 requests + 2 responses)
# Access the first request entry
history2 = entity.state.data.conversation_history[2].messages # Second request entry
assert len(history2) == 1 # Just the user message
user_msg = history[0]
user_role = getattr(user_msg.role, "value", user_msg.role)
assert user_role == "user"
assert user_msg.text == "Message 1"
assistant_msg = history[1]
assistant_msg = entity.state.data.conversation_history[1].messages[0]
assistant_role = getattr(assistant_msg.role, "value", assistant_msg.role)
assert assistant_role == "assistant"
assert assistant_msg.text == "Response 1"
@@ -380,17 +397,17 @@ class TestAgentEntityOperations:
entity = AgentEntity(mock_agent)
mock_context = Mock()
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3a"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"}
)
assert entity.state.message_count == 1
assert len(entity.state.data.conversation_history) == 2
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-app-entity-3b"}
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"}
)
assert entity.state.message_count == 2
assert len(entity.state.data.conversation_history) == 4
def test_entity_reset(self) -> None:
"""Test that entity reset clears state."""
@@ -398,19 +415,13 @@ class TestAgentEntityOperations:
entity = AgentEntity(mock_agent)
# Set some state
entity.state.message_count = 10
entity.state.last_response = "Some response"
entity.state.conversation_history = [
ChatMessage(role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"})
]
entity.state = DurableAgentState()
# Reset
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.message_count == 0
assert entity.state.last_response is None
assert len(entity.state.conversation_history) == 0
assert len(entity.state.data.conversation_history) == 0
class TestAgentEntityFactory:
@@ -438,7 +449,7 @@ class TestAgentEntityFactory:
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
"correlation_id": "corr-app-factory-1",
"correlationId": "corr-app-factory-1",
}
mock_context.get_state.return_value = None
@@ -458,9 +469,27 @@ class TestAgentEntityFactory:
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = {
"message_count": 5,
"conversation_history": [{"role": "user", "content": "test"}],
"last_response": "Test",
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "corr-reset-test",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [
{
"$type": "text",
"text": "test",
}
],
}
],
}
],
},
}
# Execute entity function
@@ -497,19 +526,53 @@ class TestAgentEntityFactory:
# Mock context with existing state
existing_state = {
"message_count": 3,
"conversation_history": [{"role": "user", "content": "msg1"}, {"role": "assistant", "content": "resp1"}],
"last_response": "resp1",
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [
{
"$type": "text",
"text": "msg1",
}
],
}
],
},
{
"$type": "response",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:05:00Z",
"messages": [
{
"role": "assistant",
"contents": [
{
"$type": "text",
"text": "resp1",
}
],
}
],
},
],
},
}
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = existing_state
with patch.object(AgentState, "restore_state") as restore_state_mock:
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
entity_function(mock_context)
restore_state_mock.assert_called_once_with(existing_state)
from_dict_mock.assert_called_once_with(existing_state)
class TestErrorHandling:
@@ -524,7 +587,7 @@ class TestErrorHandling:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlation_id": "corr-app-error-1"}
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"}
)
assert result["status"] == "error"
@@ -600,7 +663,7 @@ class TestIncomingRequestParsing:
app = self._create_app()
request = Mock()
request.headers = {"accept": "application/json"}
request.headers = {"accept": MIMETYPE_APPLICATION_JSON}
request.params = {}
request.get_json.side_effect = ValueError("Invalid JSON")
request.get_body.return_value = b"Plain text message"
@@ -674,8 +737,8 @@ class TestHttpRunRoute:
response = await handler(request, client)
assert response.status_code == 202
assert response.mimetype == "text/plain"
assert response.headers.get("x-ms-thread-id") is not None
assert response.mimetype == MIMETYPE_TEXT_PLAIN
assert response.headers.get(THREAD_ID_HEADER) is not None
assert response.get_body().decode("utf-8") == "Agent request accepted"
signal_args = client.signal_entity.call_args[0]
@@ -693,7 +756,7 @@ class TestHttpRunRoute:
handler = self._get_run_handler(mock_agent)
request = Mock()
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false", "Accept": "application/json"}
request.headers = {WAIT_FOR_RESPONSE_HEADER: "false", "Accept": MIMETYPE_APPLICATION_JSON}
request.params = {}
request.route_params = {}
request.get_json.side_effect = ValueError("Invalid JSON")
@@ -704,8 +767,8 @@ class TestHttpRunRoute:
response = await handler(request, client)
assert response.status_code == 202
assert response.mimetype == "application/json"
assert response.headers.get("x-ms-thread-id") is None
assert response.mimetype == MIMETYPE_APPLICATION_JSON
assert response.headers.get(THREAD_ID_HEADER) is None
body = response.get_body().decode("utf-8")
assert '"status": "accepted"' in body
@@ -728,8 +791,8 @@ class TestHttpRunRoute:
response = await handler(request, client)
assert response.status_code == 400
assert response.mimetype == "text/plain"
assert response.headers.get("x-ms-thread-id") is not None
assert response.mimetype == MIMETYPE_TEXT_PLAIN
assert response.headers.get(THREAD_ID_HEADER) is not None
assert response.get_body().decode("utf-8") == "Message is required"
client.signal_entity.assert_not_called()
@@ -15,14 +15,20 @@ import pytest
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, Role
from pydantic import BaseModel
from agent_framework_azurefunctions._durable_agent_state import (
DurableAgentState,
DurableAgentStateData,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateTextContent,
)
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
from agent_framework_azurefunctions._models import RunRequest
from agent_framework_azurefunctions._state import AgentState
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
def _role_value(chat_message: ChatMessage) -> str:
def _role_value(chat_message: DurableAgentStateMessage) -> str:
"""Helper to extract the string role from a ChatMessage."""
role = getattr(chat_message, "role", None)
role_value = getattr(role, "value", role)
@@ -71,9 +77,9 @@ class TestAgentEntityInit:
entity = AgentEntity(mock_agent)
assert entity.agent == mock_agent
assert entity.state.conversation_history == []
assert entity.state.last_response is None
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
assert entity.state.data.extension_data is None
assert entity.state.schema_version == "1.0.0"
def test_init_stores_agent_reference(self) -> None:
"""Test that the agent reference is stored correctly."""
@@ -112,7 +118,7 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-1"}
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"}
)
# Verify agent.run was called
@@ -123,8 +129,8 @@ class TestAgentEntityRunAgent:
assert len(sent_messages) == 1
sent_message = sent_messages[0]
assert isinstance(sent_message, ChatMessage)
assert sent_message.text == "Test message"
assert _role_value(sent_message) == "user"
assert getattr(sent_message, "text", None) == "Test message"
assert getattr(sent_message.role, "value", sent_message.role) == "user"
# Verify result
assert result["status"] == "success"
@@ -158,7 +164,7 @@ class TestAgentEntityRunAgent:
{
"message": "Tell me something",
"thread_id": "session-1",
"correlation_id": "corr-stream-1",
"correlationId": "corr-stream-1",
},
)
@@ -205,7 +211,7 @@ class TestAgentEntityRunAgent:
{
"message": "Hi",
"thread_id": "session-2",
"correlation_id": "corr-final-1",
"correlationId": "corr-final-1",
},
)
@@ -233,19 +239,20 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
await entity.run_agent(
mock_context, {"message": "User message", "thread_id": "conv-1", "correlation_id": "corr-entity-2"}
mock_context, {"message": "User message", "thread_id": "conv-1", "correlationId": "corr-entity-2"}
)
# Should have 2 entries: user message + assistant response
history = entity.state.conversation_history
# Should have 1 entry: user message + assistant response
user_history = entity.state.data.conversation_history[0].messages
assistant_history = entity.state.data.conversation_history[1].messages
assert len(history) == 2
assert len(user_history) == 1
user_msg = history[0]
user_msg = user_history[0]
assert _role_value(user_msg) == "user"
assert user_msg.text == "User message"
assistant_msg = history[1]
assistant_msg = assistant_history[0]
assert _role_value(assistant_msg) == "assistant"
assert assistant_msg.text == "Agent response"
@@ -257,41 +264,22 @@ class TestAgentEntityRunAgent:
entity = AgentEntity(mock_agent)
mock_context = Mock()
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-3a"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-3a"}
)
assert entity.state.message_count == 1
assert len(entity.state.data.conversation_history) == 2
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-3b"}
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-3b"}
)
assert entity.state.message_count == 2
assert len(entity.state.data.conversation_history) == 4
await entity.run_agent(
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-3c"}
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-3c"}
)
assert entity.state.message_count == 3
async def test_run_agent_stores_last_response(self) -> None:
"""Test that run_agent stores the last response."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-4a"}
)
assert entity.state.last_response == "Response 1"
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-4b"}
)
assert entity.state.last_response == "Response 2"
assert len(entity.state.data.conversation_history) == 6
async def test_run_agent_with_none_thread_id(self) -> None:
"""Test run_agent with a None thread identifier."""
@@ -303,7 +291,7 @@ class TestAgentEntityRunAgent:
with pytest.raises(ValueError, match="thread_id"):
await entity.run_agent(
mock_context, {"message": "Message", "thread_id": None, "correlation_id": "corr-entity-5"}
mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"}
)
async def test_run_agent_handles_response_without_text_attribute(self) -> None:
@@ -322,7 +310,7 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-6"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-6"}
)
# Should handle gracefully
@@ -338,7 +326,7 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-7"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-7"}
)
assert result["status"] == "success"
@@ -354,18 +342,18 @@ class TestAgentEntityRunAgent:
# Send multiple messages
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-8a"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-8a"}
)
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-8b"}
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-8b"}
)
await entity.run_agent(
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-8c"}
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-8c"}
)
history = entity.state.conversation_history
history = entity.state.data.conversation_history
assert len(history) == 6
assert entity.state.message_count == 3
assert entity.state.message_count == 6
class TestAgentEntityReset:
@@ -376,40 +364,47 @@ class TestAgentEntityReset:
mock_agent = Mock()
entity = AgentEntity(mock_agent)
# Add some history
entity.state.conversation_history = [
ChatMessage(role="user", text="msg1"),
ChatMessage(role="assistant", text="resp1"),
# Add some history with proper DurableAgentStateEntry objects
entity.state.data.conversation_history = [
DurableAgentStateRequest(
correlation_id="test-1",
created_at=datetime.now(),
messages=[
DurableAgentStateMessage(
role="user",
contents=[DurableAgentStateTextContent(text="msg1")],
)
],
),
]
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.conversation_history == []
assert entity.state.data.conversation_history == []
def test_reset_clears_last_response(self) -> None:
"""Test that reset clears the last response."""
def test_reset_with_extension_data(self) -> None:
"""Test that reset works when entity has extension data."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
entity.state.last_response = "Some response"
# Set up some initial state with conversation history
entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"})
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.last_response is None
assert len(entity.state.data.conversation_history) == 0
def test_reset_clears_message_count(self) -> None:
"""Test that reset clears the message count."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
entity.state.message_count = 10
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
async def test_reset_after_conversation(self) -> None:
"""Test reset after a full conversation."""
@@ -421,23 +416,22 @@ class TestAgentEntityReset:
# Have a conversation
await entity.run_agent(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-10a"}
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-10a"}
)
await entity.run_agent(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-10b"}
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-10b"}
)
# Verify state before reset
assert entity.state.message_count == 2
assert len(entity.state.conversation_history) == 4
assert entity.state.message_count == 4
assert len(entity.state.data.conversation_history) == 4
# Reset
entity.reset(mock_context)
# Verify state after reset
assert entity.state.message_count == 0
assert len(entity.state.conversation_history) == 0
assert entity.state.last_response is None
assert len(entity.state.data.conversation_history) == 0
class TestCreateAgentEntity:
@@ -464,7 +458,7 @@ class TestCreateAgentEntity:
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
"correlation_id": "corr-entity-factory",
"correlationId": "corr-entity-factory",
}
mock_context.get_state.return_value = None
@@ -485,13 +479,22 @@ class TestCreateAgentEntity:
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = {
"message_count": 5,
"conversation_history": [
ChatMessage(
role="user", text="test", additional_properties={"timestamp": "2024-01-01T00:00:00Z"}
).to_dict()
],
"last_response": "Test",
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "test-correlation-id",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [{"$type": "text", "text": "test"}],
}
],
}
]
},
}
# Execute
@@ -505,9 +508,7 @@ class TestCreateAgentEntity:
# Verify state was cleared
assert mock_context.set_state.called
state = mock_context.set_state.call_args[0][0]
assert state["message_count"] == 0
assert state["conversation_history"] == []
assert state["last_response"] is None
assert state["data"]["conversationHistory"] == []
def test_entity_function_handles_unknown_operation(self) -> None:
"""Test that the entity function handles unknown operations."""
@@ -547,8 +548,7 @@ class TestCreateAgentEntity:
assert result["status"] == "reset"
assert mock_context.set_state.called
state = mock_context.set_state.call_args[0][0]
assert state["message_count"] == 0
assert state["conversation_history"] == []
assert state["data"] == {"conversationHistory": []}
def test_entity_function_restores_existing_state(self) -> None:
"""Test that the entity function restores existing state."""
@@ -557,26 +557,53 @@ class TestCreateAgentEntity:
entity_function = create_agent_entity(mock_agent)
existing_state = {
"message_count": 5,
"conversation_history": [
ChatMessage(
role="user", text="msg1", additional_properties={"timestamp": "2024-01-01T00:00:00Z"}
).to_dict(),
ChatMessage(
role="assistant", text="resp1", additional_properties={"timestamp": "2024-01-01T00:05:00Z"}
).to_dict(),
],
"last_response": "resp1",
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [
{
"$type": "text",
"text": "msg1",
}
],
}
],
},
{
"$type": "response",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:05:00Z",
"messages": [
{
"role": "assistant",
"contents": [
{
"$type": "text",
"text": "resp1",
}
],
}
],
},
],
},
}
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.get_state.return_value = existing_state
with patch.object(AgentState, "restore_state") as restore_state_mock:
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
entity_function(mock_context)
restore_state_mock.assert_called_once_with(existing_state)
from_dict_mock.assert_called_once_with(existing_state)
class TestErrorHandling:
@@ -591,7 +618,7 @@ class TestErrorHandling:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-1"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-1"}
)
assert result["status"] == "error"
@@ -608,7 +635,7 @@ class TestErrorHandling:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-2"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-2"}
)
assert result["status"] == "error"
@@ -624,7 +651,7 @@ class TestErrorHandling:
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-error-3"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-3"}
)
assert result["status"] == "error"
@@ -659,7 +686,7 @@ class TestErrorHandling:
result = await entity.run_agent(
mock_context,
{"message": "Test message", "thread_id": "conv-123", "correlation_id": "corr-entity-error-4"},
{"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-error-4"},
)
# Even on error, message info should be preserved
@@ -680,15 +707,15 @@ class TestConversationHistory:
mock_context = Mock()
await entity.run_agent(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlation_id": "corr-entity-history-1"}
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-history-1"}
)
# Check both user and assistant messages have timestamps
for entry in entity.state.conversation_history:
timestamp = entry.additional_properties.get("timestamp")
for entry in entity.state.data.conversation_history:
timestamp = entry.created_at
assert timestamp is not None
# Verify timestamp is in ISO format
datetime.fromisoformat(timestamp)
datetime.fromisoformat(str(timestamp))
async def test_conversation_history_ordering(self) -> None:
"""Test that conversation history maintains the correct order."""
@@ -701,29 +728,30 @@ class TestConversationHistory:
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
await entity.run_agent(
mock_context,
{"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2a"},
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-2a"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
await entity.run_agent(
mock_context,
{"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2b"},
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-2b"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
await entity.run_agent(
mock_context,
{"message": "Message 3", "thread_id": "conv-1", "correlation_id": "corr-entity-history-2c"},
{"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-history-2c"},
)
# Verify order
history = entity.state.conversation_history
assert history[0].text == "Message 1"
assert history[1].text == "Response 1"
assert history[2].text == "Message 2"
assert history[3].text == "Response 2"
assert history[4].text == "Message 3"
assert history[5].text == "Response 3"
history = entity.state.data.conversation_history
# Each conversation turn creates 2 entries: request and response
assert history[0].messages[0].text == "Message 1" # Request 1
assert history[1].messages[0].text == "Response 1" # Response 1
assert history[2].messages[0].text == "Message 2" # Request 2
assert history[3].messages[0].text == "Response 2" # Response 2
assert history[4].messages[0].text == "Message 3" # Request 3
assert history[5].messages[0].text == "Response 3" # Response 3
async def test_conversation_history_role_alternation(self) -> None:
"""Test that conversation history alternates between user and assistant roles."""
@@ -735,19 +763,20 @@ class TestConversationHistory:
await entity.run_agent(
mock_context,
{"message": "Message 1", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3a"},
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-3a"},
)
await entity.run_agent(
mock_context,
{"message": "Message 2", "thread_id": "conv-1", "correlation_id": "corr-entity-history-3b"},
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-3b"},
)
# Check role alternation
history = entity.state.conversation_history
assert _role_value(history[0]) == "user"
assert _role_value(history[1]) == "assistant"
assert _role_value(history[2]) == "user"
assert _role_value(history[3]) == "assistant"
history = entity.state.data.conversation_history
# Each conversation turn creates 2 entries: request and response
assert history[0].messages[0].role == "user" # Request 1
assert history[1].messages[0].role == "assistant" # Response 1
assert history[2].messages[0].role == "user" # Request 2
assert history[3].messages[0].role == "assistant" # Response 2
class TestRunRequestSupport:
@@ -789,7 +818,7 @@ class TestRunRequestSupport:
"thread_id": "conv-456",
"role": "system",
"enable_tool_calls": False,
"correlation_id": "corr-runreq-2",
"correlationId": "corr-runreq-2",
}
result = await entity.run_agent(mock_context, request_dict)
@@ -828,9 +857,9 @@ class TestRunRequestSupport:
await entity.run_agent(mock_context, request)
# Check that system role was stored
history = entity.state.conversation_history
assert _role_value(history[0]) == "system"
assert history[0].text == "System message"
history = entity.state.data.conversation_history
assert history[0].messages[0].role == "system"
assert history[0].messages[0].text == "System message"
async def test_run_agent_with_response_format(self) -> None:
"""Test run_agent with a JSON response format."""
@@ -887,7 +916,7 @@ class TestRunRequestSupport:
"thread_id": "conv-789",
"role": "user",
"enable_tool_calls": True,
"correlation_id": "corr-runreq-6",
"correlationId": "corr-runreq-6",
}
mock_context.get_state.return_value = None
@@ -295,32 +295,32 @@ class TestRunRequest:
restored = RunRequest.from_dict(data)
assert restored.response_format is ModuleStructuredResponse
def test_init_with_correlation_id(self) -> None:
"""Test RunRequest initialization with correlation_id."""
def test_init_with_correlationId(self) -> None:
"""Test RunRequest initialization with correlationId."""
request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123")
assert request.message == "Test message"
assert request.correlation_id == "corr-123"
def test_to_dict_with_correlation_id(self) -> None:
"""Test to_dict includes correlation_id."""
def test_to_dict_with_correlationId(self) -> None:
"""Test to_dict includes correlationId."""
request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456")
data = request.to_dict()
assert data["message"] == "Test"
assert data["correlation_id"] == "corr-456"
assert data["correlationId"] == "corr-456"
def test_from_dict_with_correlation_id(self) -> None:
"""Test from_dict with correlation_id."""
data = {"message": "Test", "correlation_id": "corr-789", "thread_id": "thread-corr-from-dict"}
def test_from_dict_with_correlationId(self) -> None:
"""Test from_dict with correlationId."""
data = {"message": "Test", "correlationId": "corr-789", "thread_id": "thread-corr-from-dict"}
request = RunRequest.from_dict(data)
assert request.message == "Test"
assert request.correlation_id == "corr-789"
assert request.thread_id == "thread-corr-from-dict"
def test_round_trip_with_correlation_id(self) -> None:
"""Test round-trip to_dict and from_dict with correlation_id."""
def test_round_trip_with_correlationId(self) -> None:
"""Test round-trip to_dict and from_dict with correlationId."""
original = RunRequest(
message="Test message",
thread_id="thread-123",
@@ -136,8 +136,8 @@ class TestDurableAIAgent:
assert operation == "run_agent"
assert request["message"] == "Test message"
assert request["enable_tool_calls"] is True
assert "correlation_id" in request
assert request["correlation_id"] == "correlation-guid"
assert "correlationId" in request
assert request["correlationId"] == "correlation-guid"
assert "thread_id" in request
assert request["thread_id"] == "thread-guid"
@@ -145,7 +145,7 @@ class TestDurableAIAgent:
"""Test that run() works without explicit thread (creates unique session key)."""
mock_context = Mock()
mock_context.instance_id = "test-instance-002"
# Two calls to new_uuid: one for session_key, one for correlation_id
# Two calls to new_uuid: one for session_key, one for correlationId
mock_context.new_uuid = Mock(side_effect=["auto-generated-guid", "correlation-guid"])
mock_task = Mock()
@@ -164,7 +164,7 @@ class TestDurableAIAgent:
entity_id = call_args[0][0]
assert entity_id.name == "dafx-TestAgent"
assert entity_id.key == "auto-generated-guid"
# Should be called twice: once for session_key, once for correlation_id
# Should be called twice: once for session_key, once for correlationId
assert mock_context.new_uuid.call_count == 2
def test_run_with_response_format(self) -> None:
@@ -307,8 +307,8 @@ class TestOrchestrationIntegration:
mock_context.instance_id = "test-orchestration-001"
# new_uuid will be called 3 times:
# 1. thread creation
# 2. correlation_id for first call
# 3. correlation_id for second call
# 2. correlationId for first call
# 3. correlationId for second call
mock_context.new_uuid = Mock(side_effect=["deterministic-guid-001", "corr-1", "corr-2"])
# Track entity calls
@@ -1,110 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for AgentState correlation ID tracking."""
from unittest.mock import Mock
import pytest
from agent_framework import AgentRunResponse
from agent_framework_azurefunctions._state import AgentState
class TestAgentStateCorrelationId:
"""Test suite for AgentState correlation ID tracking."""
def _create_mock_response(self, text: str = "Response") -> Mock:
"""Create a mock AgentRunResponse with the provided text."""
mock_response = Mock(spec=AgentRunResponse)
mock_response.to_dict.return_value = {"text": text, "messages": []}
return mock_response
def test_add_assistant_message_with_correlation_id(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-123-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
message_metadata = state.conversation_history[-1].additional_properties or {}
assert message_metadata.get("correlation_id") == "corr-123"
response_data = state.try_get_agent_response("corr-123")
assert response_data is not None
assert response_data["content"] == "Response"
assert response_data["agent_response"] == {"text": "Response", "messages": []}
def test_try_get_agent_response_returns_response(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-200-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-456")
response_data = state.try_get_agent_response("corr-456")
assert response_data is not None
assert response_data["content"] == "Response"
def test_try_get_agent_response_returns_none_for_missing_id(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-300-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
assert state.try_get_agent_response("non-existent") is None
def test_multiple_responses_tracked_separately(self) -> None:
state = AgentState()
for index in range(3):
state.add_user_message(f"Message {index}", correlation_id=f"corr-{index}-request")
state.add_assistant_message(
f"Response {index}",
self._create_mock_response(text=f"Response {index}"),
correlation_id=f"corr-{index}",
)
for index in range(3):
payload = state.try_get_agent_response(f"corr-{index}")
assert payload is not None
assert payload["content"] == f"Response {index}"
def test_add_assistant_message_without_correlation_id(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-400-request")
state.add_assistant_message("Response", self._create_mock_response())
assert state.try_get_agent_response("missing") is None
assert state.last_response == "Response"
def test_to_dict_does_not_duplicate_agent_responses(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-500-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
state_snapshot = state.to_dict()
assert "agent_responses" not in state_snapshot
metadata = state_snapshot["conversation_history"][-1]["additional_properties"]
assert metadata["correlation_id"] == "corr-123"
def test_restore_state_preserves_agent_response_lookup(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-600-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
restored_state = AgentState()
restored_state.restore_state(state.to_dict())
payload = restored_state.try_get_agent_response("corr-123")
assert payload is not None
assert payload["content"] == "Response"
def test_reset_clears_conversation_history(self) -> None:
state = AgentState()
state.add_user_message("Hello", correlation_id="corr-700-request")
state.add_assistant_message("Response", self._create_mock_response(), correlation_id="corr-123")
state.reset()
assert len(state.conversation_history) == 0
assert state.try_get_agent_response("corr-123") is None
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])