From 705ed47a0b48d9fab001b5a53439bd49a92570fb Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 18 Mar 2026 09:08:44 +0100 Subject: [PATCH] Python: Fix missing methods on the `Content` class in durable tasks (#4738) * Fix Content serialization in DurableAgentStateUnknownContent (#4719) DurableAgentStateUnknownContent.from_unknown_content() stored raw Content objects without converting them to dicts, causing json.dumps to fail in Azure Durable Functions' entity state serialization. This affected content types not explicitly handled (e.g., mcp_server_tool_call/result). The fix converts Content objects to dicts via to_dict() when storing in DurableAgentStateUnknownContent, and restores them via Content.from_dict() in to_ai_content(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add to_json and from_json methods to Content class (#4719) Add to_json() and from_json() methods to the Content class to match the serialization interface provided by SerializationMixin on other model classes. Also fix pre-existing pyright type errors in durabletask's DurableAgentStateUnknownContent.to_ai_content(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: add type guard, remove to_json, add fallback, and tests - Remove Content.to_json() per reviewer request (comment 3) - Add type guard in Content.from_json() for non-dict JSON (comments 1, 4) - Wrap json.JSONDecodeError as ValueError for consistent exception contract - Add try/except fallback in to_ai_content() for invalid Content dicts (comment 5) - Add test_content_to_dict_exclude_none and test_content_to_dict_exclude_fields (comment 2) - Add test_unknown_content_to_ai_content_fallback_on_invalid_type_dict (comment 5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Address review feedback for #4719: review comment fixes * Remove Content.from_json, move logic to consuming code (#4719) Remove the from_json convenience method from Content class per review feedback. This is the same trivial json.loads + from_dict wrapper as to_json which was already removed. Consumers should call json.loads and Content.from_dict directly. Update tests to use Content.from_dict(json.loads(...)) pattern and remove from_json-specific error handling tests (those errors are already covered by json.loads and Content.from_dict). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/tests/core/test_types.py | 42 +++++++ .../_durable_agent_state.py | 8 ++ .../tests/test_durable_agent_state.py | 117 +++++++++++++++++- 3 files changed, 166 insertions(+), 1 deletion(-) 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 @@ class DurableAgentStateUnknownContent(DurableAgentStateContent): @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 @@ class TestDurableAgentStateUsage: 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"])