diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 5e9469c8bd..1cde898787 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import base64 +import json from collections.abc import AsyncIterable, Sequence from dataclasses import dataclass from datetime import datetime, timezone @@ -1710,6 +1711,47 @@ def test_content_roundtrip_preserves_compaction_annotation_dict() -> None: assert annotation[GROUP_TOKEN_COUNT_KEY] is None +def test_content_from_dict_via_json() -> None: + """Test Content.from_dict with data parsed from a JSON string.""" + data = json.loads(json.dumps({"type": "text", "text": "Hello world"})) + content = Content.from_dict(data) + assert content.type == "text" + assert content.text == "Hello world" + + +def test_content_from_dict_roundtrip_via_json() -> None: + """Test Content.from_dict roundtrip via to_dict and json.dumps.""" + original = Content.from_function_call(call_id="call1", name="my_func", arguments={"key": "value"}) + data = json.loads(json.dumps(original.to_dict())) + restored = Content.from_dict(data) + assert restored.type == "function_call" + assert restored.call_id == "call1" + assert restored.name == "my_func" + assert restored.arguments == {"key": "value"} + + +def test_content_to_dict_exclude_none() -> None: + """Test Content.to_dict excludes None fields by default.""" + content = Content.from_text("Hello") + d = content.to_dict() + parsed = json.loads(json.dumps(d)) + assert "uri" not in parsed + + d_with_none = content.to_dict(exclude_none=False) + parsed_with_none = json.loads(json.dumps(d_with_none)) + assert "uri" in parsed_with_none + assert parsed_with_none["uri"] is None + + +def test_content_to_dict_exclude_fields() -> None: + """Test Content.to_dict with explicit field exclusion.""" + content = Content.from_text("Hello") + d = content.to_dict(exclude={"text"}) + parsed = json.loads(json.dumps(d)) + assert "text" not in parsed + assert parsed["type"] == "text" + + def test_chat_response_roundtrip_preserves_compaction_annotation_dict() -> None: response = ChatResponse( messages=[ diff --git a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py index a10fceefa4..eb861a99ea 100644 --- a/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py +++ b/python/packages/durabletask/agent_framework_durabletask/_durable_agent_state.py @@ -1318,9 +1318,17 @@ def to_dict(self) -> dict[str, Any]: @staticmethod def from_unknown_content(content: Any) -> DurableAgentStateUnknownContent: + if isinstance(content, Content): + return DurableAgentStateUnknownContent(content=content.to_dict()) return DurableAgentStateUnknownContent(content=content) def to_ai_content(self) -> Content: if not self.content: raise Exception("The content is missing and cannot be converted to valid AI content.") + content_value: Any = self.content + if isinstance(content_value, dict) and "type" in content_value: + try: + return Content.from_dict(cast(dict[str, Any], content_value)) + except (ValueError, TypeError): + pass return Content(type=self.type, additional_properties={"content": self.content}) # type: ignore diff --git a/python/packages/durabletask/tests/test_durable_agent_state.py b/python/packages/durabletask/tests/test_durable_agent_state.py index 24b31a747e..81efe3b1db 100644 --- a/python/packages/durabletask/tests/test_durable_agent_state.py +++ b/python/packages/durabletask/tests/test_durable_agent_state.py @@ -2,16 +2,19 @@ """Unit tests for DurableAgentState and related classes.""" +import json from datetime import datetime import pytest -from agent_framework import UsageDetails +from agent_framework import Content, Message, UsageDetails from agent_framework_durabletask._durable_agent_state import ( DurableAgentState, + DurableAgentStateContent, DurableAgentStateMessage, DurableAgentStateRequest, DurableAgentStateTextContent, + DurableAgentStateUnknownContent, DurableAgentStateUsage, ) from agent_framework_durabletask._models import RunRequest @@ -373,5 +376,117 @@ def test_usage_round_trip(self) -> None: assert restored.get("total_token_count") == original.get("total_token_count") +class TestDurableAgentStateUnknownContent: + """Test suite for DurableAgentStateUnknownContent serialization.""" + + def test_unknown_content_from_content_object_produces_serializable_dict(self) -> None: + """Test that from_unknown_content serializes Content objects to dicts.""" + content = Content.from_mcp_server_tool_call( + call_id="call-1", + tool_name="search", + server_name="learn-mcp", + arguments={"query": "azure functions"}, + ) + + unknown = DurableAgentStateUnknownContent.from_unknown_content(content) + result = unknown.to_dict() + + # The content field should be a dict, not a Content object + assert isinstance(result["content"], dict) + assert result["content"]["type"] == "mcp_server_tool_call" + + def test_unknown_content_to_dict_is_json_serializable(self) -> None: + """Test that to_dict output can be passed to json.dumps without error.""" + content = Content.from_mcp_server_tool_result( + call_id="call-1", + output="Azure Functions documentation...", + ) + + unknown = DurableAgentStateUnknownContent.from_unknown_content(content) + result = unknown.to_dict() + + # This must not raise TypeError + serialized = json.dumps(result) + assert serialized is not None + + def test_unknown_content_round_trip_preserves_content(self) -> None: + """Test that Content objects survive serialization and deserialization.""" + original = Content.from_mcp_server_tool_call( + call_id="call-1", + tool_name="fetch", + server_name="learn-mcp", + arguments={"url": "https://example.com"}, + ) + + unknown = DurableAgentStateUnknownContent.from_unknown_content(original) + restored = unknown.to_ai_content() + + assert restored.type == "mcp_server_tool_call" + assert restored.tool_name == "fetch" + assert restored.server_name == "learn-mcp" + + def test_unknown_content_from_plain_dict_unchanged(self) -> None: + """Test that non-Content values are stored as-is.""" + plain = {"some": "data"} + + unknown = DurableAgentStateUnknownContent.from_unknown_content(plain) + + assert unknown.content == {"some": "data"} + + def test_unknown_content_to_ai_content_fallback_on_invalid_type_dict(self) -> None: + """Test that to_ai_content falls back when dict has 'type' but is not valid Content.""" + invalid = {"type": "bogus_not_a_real_content_type", "extra": "stuff"} + unknown = DurableAgentStateUnknownContent(content=invalid) + + result = unknown.to_ai_content() + + assert result.type == "unknown" + assert result.additional_properties == {"content": invalid} + + def test_from_ai_content_unknown_type_produces_serializable_state(self) -> None: + """Test that unknown content types in message conversion produce JSON-serializable state.""" + content = Content.from_mcp_server_tool_call( + call_id="call-1", + tool_name="search", + server_name="learn-mcp", + arguments={"query": "create function app"}, + ) + + durable_content = DurableAgentStateContent.from_ai_content(content) + data = durable_content.to_dict() + + # Must be fully JSON-serializable + serialized = json.dumps(data) + assert serialized is not None + + def test_state_with_mcp_content_is_json_serializable(self) -> None: + """Test that full DurableAgentState with MCP content can be serialized to JSON. + + This reproduces the scenario from issue #4719 where agent state containing + MCP tool content could not be serialized by Azure Durable Functions. + """ + state = DurableAgentState() + mcp_content = Content.from_mcp_server_tool_call( + call_id="call-1", + tool_name="search", + server_name="learn-mcp", + arguments={"query": "azure functions"}, + ) + message = DurableAgentStateMessage.from_chat_message(Message(role="assistant", contents=[mcp_content])) + state.data.conversation_history.append( + DurableAgentStateRequest( + correlation_id="test-mcp", + created_at=datetime.now(), + messages=[message], + ) + ) + + state_dict = state.to_dict() + + # This simulates what Azure Durable Functions does with entity state + serialized = json.dumps(state_dict) + assert serialized is not None + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"])