diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_events.py b/python/packages/ag-ui/agent_framework_ag_ui/_events.py index 184da0239e..449b7eac87 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_events.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_events.py @@ -31,6 +31,7 @@ FunctionCallContent, FunctionResultContent, TextContent, + prepare_function_call_results, ) from ._utils import generate_event_id @@ -391,12 +392,7 @@ def _handle_function_result_content(self, content: FunctionResultContent) -> lis self.state_delta_count = 0 result_message_id = generate_event_id() - if isinstance(content.result, dict): - result_content = json.dumps(content.result) # type: ignore[arg-type] - elif content.result is not None: - result_content = str(content.result) - else: - result_content = "" + result_content = prepare_function_call_results(content.result) result_event = ToolCallResultEvent( message_id=result_message_id, diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 11d2977f90..b87f3b1827 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -2,6 +2,7 @@ """Message format conversion between AG-UI and Agent Framework.""" +import json from typing import Any, cast from agent_framework import ( @@ -11,6 +12,7 @@ FunctionResultContent, Role, TextContent, + prepare_function_call_results, ) # Role mapping constants @@ -59,10 +61,8 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha # Distinguish approval payloads from actual tool results is_approval = False if isinstance(result_content, str) and result_content: - import json as _json - try: - parsed = _json.loads(result_content) + parsed = json.loads(result_content) is_approval = isinstance(parsed, dict) and "accepted" in parsed except Exception: is_approval = False @@ -237,13 +237,8 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str elif isinstance(content, FunctionResultContent): # Tool result content - extract call_id and result tool_result_call_id = content.call_id - # Serialize result to string - if isinstance(content.result, dict): - import json - - content_text = json.dumps(content.result) # type: ignore - elif content.result is not None: - content_text = str(content.result) + # Serialize result to string using core utility + content_text = prepare_function_call_results(content.result) agui_msg: dict[str, Any] = { "id": msg.message_id if msg.message_id else generate_event_id(), # Always include id diff --git a/python/packages/ag-ui/tests/test_events_comprehensive.py b/python/packages/ag-ui/tests/test_events_comprehensive.py index a51d1f382a..20b53cc18f 100644 --- a/python/packages/ag-ui/tests/test_events_comprehensive.py +++ b/python/packages/ag-ui/tests/test_events_comprehensive.py @@ -201,7 +201,8 @@ async def test_tool_result_with_none(): assert len(events) == 2 assert events[0].type == "TOOL_CALL_END" assert events[1].type == "TOOL_CALL_RESULT" - assert events[1].content == "" + # prepare_function_call_results serializes None as JSON "null" + assert events[1].content == "null" async def test_multiple_tool_results_in_sequence(): @@ -688,3 +689,97 @@ async def test_state_delta_count_logging(): # State delta count should have incremented (one per unique state update) assert bridge.state_delta_count >= 1 + + +# Tests for list type tool results (MCP tool serialization) + + +async def test_tool_result_with_empty_list(): + """Test FunctionResultContent with empty list result.""" + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=[])]) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[1].type == "TOOL_CALL_RESULT" + # Empty list serializes as JSON empty array + assert events[1].content == "[]" + + +async def test_tool_result_with_single_text_content(): + """Test FunctionResultContent with single TextContent-like item (MCP tool result).""" + from dataclasses import dataclass + + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + @dataclass + class MockTextContent: + text: str + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate( + contents=[FunctionResultContent(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])] + ) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[1].type == "TOOL_CALL_RESULT" + # TextContent text is extracted and serialized as JSON array + assert events[1].content == '["Hello from MCP tool!"]' + + +async def test_tool_result_with_multiple_text_contents(): + """Test FunctionResultContent with multiple TextContent-like items (MCP tool result).""" + from dataclasses import dataclass + + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + @dataclass + class MockTextContent: + text: str + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate( + contents=[ + FunctionResultContent( + call_id="call_123", + result=[MockTextContent("First result"), MockTextContent("Second result")], + ) + ] + ) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[1].type == "TOOL_CALL_RESULT" + # Multiple TextContent items should return JSON array + assert events[1].content == '["First result", "Second result"]' + + +async def test_tool_result_with_model_dump_objects(): + """Test FunctionResultContent with Pydantic BaseModel objects.""" + from pydantic import BaseModel + + from agent_framework_ag_ui._events import AgentFrameworkEventBridge + + class MockModel(BaseModel): + value: int + + bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread") + + update = AgentRunResponseUpdate( + contents=[FunctionResultContent(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])] + ) + events = await bridge.from_agent_run_update(update) + + assert len(events) == 2 + assert events[1].type == "TOOL_CALL_RESULT" + # Should be properly serialized JSON array without double escaping + assert events[1].content == '[{"value": 1}, {"value": 2}]' diff --git a/python/packages/ag-ui/tests/test_message_adapters.py b/python/packages/ag-ui/tests/test_message_adapters.py index a21375b87b..51a51c9fd4 100644 --- a/python/packages/ag-ui/tests/test_message_adapters.py +++ b/python/packages/ag-ui/tests/test_message_adapters.py @@ -3,7 +3,7 @@ """Tests for message adapters.""" import pytest -from agent_framework import ChatMessage, FunctionCallContent, Role, TextContent +from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent from agent_framework_ag_ui._message_adapters import ( agent_framework_messages_to_agui, @@ -278,3 +278,119 @@ def test_extract_text_from_custom_contents(): result = extract_text_from_contents(contents) assert result == "Custom Mixed" + + +# Tests for FunctionResultContent serialization in agent_framework_messages_to_agui + + +def test_agent_framework_to_agui_function_result_dict(): + """Test converting FunctionResultContent with dict result to AG-UI.""" + msg = ChatMessage( + role=Role.TOOL, + contents=[FunctionResultContent(call_id="call-123", result={"key": "value", "count": 42})], + message_id="msg-789", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + agui_msg = messages[0] + assert agui_msg["role"] == "tool" + assert agui_msg["toolCallId"] == "call-123" + assert agui_msg["content"] == '{"key": "value", "count": 42}' + + +def test_agent_framework_to_agui_function_result_none(): + """Test converting FunctionResultContent with None result to AG-UI.""" + msg = ChatMessage( + role=Role.TOOL, + contents=[FunctionResultContent(call_id="call-123", result=None)], + message_id="msg-789", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + agui_msg = messages[0] + # None serializes as JSON null + assert agui_msg["content"] == "null" + + +def test_agent_framework_to_agui_function_result_string(): + """Test converting FunctionResultContent with string result to AG-UI.""" + msg = ChatMessage( + role=Role.TOOL, + contents=[FunctionResultContent(call_id="call-123", result="plain text result")], + message_id="msg-789", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + agui_msg = messages[0] + assert agui_msg["content"] == "plain text result" + + +def test_agent_framework_to_agui_function_result_empty_list(): + """Test converting FunctionResultContent with empty list result to AG-UI.""" + msg = ChatMessage( + role=Role.TOOL, + contents=[FunctionResultContent(call_id="call-123", result=[])], + message_id="msg-789", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + agui_msg = messages[0] + # Empty list serializes as JSON empty array + assert agui_msg["content"] == "[]" + + +def test_agent_framework_to_agui_function_result_single_text_content(): + """Test converting FunctionResultContent with single TextContent-like item.""" + from dataclasses import dataclass + + @dataclass + class MockTextContent: + text: str + + msg = ChatMessage( + role=Role.TOOL, + contents=[FunctionResultContent(call_id="call-123", result=[MockTextContent("Hello from MCP!")])], + message_id="msg-789", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + agui_msg = messages[0] + # TextContent text is extracted and serialized as JSON array + assert agui_msg["content"] == '["Hello from MCP!"]' + + +def test_agent_framework_to_agui_function_result_multiple_text_contents(): + """Test converting FunctionResultContent with multiple TextContent-like items.""" + from dataclasses import dataclass + + @dataclass + class MockTextContent: + text: str + + msg = ChatMessage( + role=Role.TOOL, + contents=[ + FunctionResultContent( + call_id="call-123", + result=[MockTextContent("First result"), MockTextContent("Second result")], + ) + ], + message_id="msg-789", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 1 + agui_msg = messages[0] + # Multiple items should return JSON array + assert agui_msg["content"] == '["First result", "Second result"]' diff --git a/python/packages/ag-ui/tests/test_utils.py b/python/packages/ag-ui/tests/test_utils.py index 4a6d0360bd..b077468b81 100644 --- a/python/packages/ag-ui/tests/test_utils.py +++ b/python/packages/ag-ui/tests/test_utils.py @@ -5,7 +5,11 @@ from dataclasses import dataclass from datetime import date, datetime -from agent_framework_ag_ui._utils import generate_event_id, make_json_safe, merge_state +from agent_framework_ag_ui._utils import ( + generate_event_id, + make_json_safe, + merge_state, +) def test_generate_event_id(): diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index f804aae052..a99440a771 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1869,6 +1869,9 @@ def _prepare_function_call_results_as_dumpable(content: Contents | Any | list[Co return content.model_dump() if hasattr(content, "to_dict"): return content.to_dict(exclude={"raw_representation", "additional_properties"}) + # Handle objects with text attribute (e.g., MCP TextContent) + if hasattr(content, "text") and isinstance(content.text, str): + return content.text return content diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 81242147d2..cce1aea934 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -2085,3 +2085,55 @@ def test_prepare_function_call_results_nested_pydantic_model(): assert "Seattle" in json_result assert "rainy" in json_result assert "18.0" in json_result or "18" in json_result + + +# region prepare_function_call_results with MCP TextContent-like objects + + +def test_prepare_function_call_results_text_content_single(): + """Test that objects with text attribute (like MCP TextContent) are properly handled.""" + from dataclasses import dataclass + + @dataclass + class MockTextContent: + text: str + + result = [MockTextContent("Hello from MCP tool!")] + json_result = prepare_function_call_results(result) + + # Should extract text and serialize as JSON array of strings + assert isinstance(json_result, str) + assert json_result == '["Hello from MCP tool!"]' + + +def test_prepare_function_call_results_text_content_multiple(): + """Test that multiple TextContent-like objects are serialized correctly.""" + from dataclasses import dataclass + + @dataclass + class MockTextContent: + text: str + + result = [MockTextContent("First result"), MockTextContent("Second result")] + json_result = prepare_function_call_results(result) + + # Should extract text from each and serialize as JSON array + assert isinstance(json_result, str) + assert json_result == '["First result", "Second result"]' + + +def test_prepare_function_call_results_text_content_with_non_string_text(): + """Test that objects with non-string text attribute are not treated as TextContent.""" + + class BadTextContent: + def __init__(self): + self.text = 12345 # Not a string! + + result = [BadTextContent()] + json_result = prepare_function_call_results(result) + + # Should not extract text since it's not a string, will serialize the object + assert isinstance(json_result, str) + + +# endregion