Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions python/packages/core/tests/core/test_types.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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=[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
117 changes: 116 additions & 1 deletion python/packages/durabletask/tests/test_durable_agent_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Loading