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 <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-03-18 09:08:44 +01:00
committed by GitHub
Unverified
parent 192a283c9a
commit 705ed47a0b
3 changed files with 166 additions and 1 deletions
@@ -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
@@ -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"])