mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
db283cd396
* Fix MCP tool result serialization for list[TextContent] When MCP tools return results containing list[TextContent], they were incorrectly serialized to object repr strings like: '[<agent_framework._types.TextContent object at 0x...>]' This fix properly extracts text content from list items by: 1. Checking if items have a 'text' attribute (TextContent) 2. Using model_dump() for items that support it 3. Falling back to str() for other types 4. Joining single items as plain text, multiple items as JSON array Fixes #2509 * Address PR review feedback for MCP tool result serialization - Extract serialize_content_result() to shared _utils.py - Fix logic: use texts[0] instead of join for single item - Add type annotation: texts: list[str] = [] - Return empty string for empty list instead of '[]' - Move import json to file top level - Add comprehensive unit tests for serialization * Address PR review feedback: fix type checking and double serialization - Add isinstance(item.text, str) check to ensure text attribute is a string - Fix double-serialization issue by keeping model_dump results as dicts until final json.dumps (removes escaped JSON strings in arrays) - Improve docstring with detailed return value documentation - Add test for non-string text attribute handling - Add tests for list type tool results in _events.py path * Simplify PR: minimal changes to fix MCP tool result serialization Addresses reviewer feedback about excessive refactoring: - Reset _events.py to original structure - Only add import and use serialize_content_result in one location - All review comments addressed in serialize_content_result(): - Added isinstance(item.text, str) check - Use model_dump(mode="json") to avoid double-serialization - Improved docstring with explicit return value documentation - Empty list returns "" instead of "[]" * Refactor: Move MCP TextContent serialization to core prepare_function_call_results Per reviewer feedback, moved the TextContent serialization logic from ag-ui's serialize_content_result to the core package's prepare_function_call_results function. Changes: - Added handling for objects with 'text' attribute (like MCP TextContent) in _prepare_function_call_results_as_dumpable - Removed serialize_content_result from ag-ui/_utils.py - Updated _events.py and _message_adapters.py to use prepare_function_call_results from core package - Updated tests to match the core function's behavior * Fix failing tests for prepare_function_call_results behavior - test_tool_result_with_none: Update expected value to 'null' (JSON serialization of None) - test_tool_result_with_model_dump_objects: Use Pydantic BaseModel instead of plain class * Fix B903 linter error: Convert MockTextContent to dataclass The ruff linter was reporting B903 (class could be dataclass or namedtuple) for the MockTextContent test helper classes. This commit converts them to dataclasses to satisfy the linter check.
310 lines
8.1 KiB
Python
310 lines
8.1 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
"""Tests for utilities."""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime
|
|
|
|
from agent_framework_ag_ui._utils import (
|
|
generate_event_id,
|
|
make_json_safe,
|
|
merge_state,
|
|
)
|
|
|
|
|
|
def test_generate_event_id():
|
|
"""Test event ID generation."""
|
|
id1 = generate_event_id()
|
|
id2 = generate_event_id()
|
|
|
|
assert id1 != id2
|
|
assert isinstance(id1, str)
|
|
assert len(id1) > 0
|
|
|
|
|
|
def test_merge_state():
|
|
"""Test state merging."""
|
|
current: dict[str, int] = {"a": 1, "b": 2}
|
|
update: dict[str, int] = {"b": 3, "c": 4}
|
|
|
|
result = merge_state(current, update)
|
|
|
|
assert result["a"] == 1
|
|
assert result["b"] == 3
|
|
assert result["c"] == 4
|
|
|
|
|
|
def test_merge_state_empty_update():
|
|
"""Test merging with empty update."""
|
|
current: dict[str, int] = {"x": 10, "y": 20}
|
|
update: dict[str, int] = {}
|
|
|
|
result = merge_state(current, update)
|
|
|
|
assert result == current
|
|
assert result is not current
|
|
|
|
|
|
def test_merge_state_empty_current():
|
|
"""Test merging with empty current state."""
|
|
current: dict[str, int] = {}
|
|
update: dict[str, int] = {"a": 1, "b": 2}
|
|
|
|
result = merge_state(current, update)
|
|
|
|
assert result == update
|
|
|
|
|
|
def test_merge_state_deep_copy():
|
|
"""Test that merge_state creates a deep copy preventing mutation of original."""
|
|
current: dict[str, dict[str, object]] = {"recipe": {"name": "Cake", "ingredients": ["flour", "sugar"]}}
|
|
update: dict[str, str] = {"other": "value"}
|
|
|
|
result = merge_state(current, update)
|
|
|
|
result["recipe"]["ingredients"].append("eggs")
|
|
|
|
assert "eggs" not in current["recipe"]["ingredients"]
|
|
assert current["recipe"]["ingredients"] == ["flour", "sugar"]
|
|
assert result["recipe"]["ingredients"] == ["flour", "sugar", "eggs"]
|
|
|
|
|
|
def test_make_json_safe_basic():
|
|
"""Test JSON serialization of basic types."""
|
|
assert make_json_safe("text") == "text"
|
|
assert make_json_safe(123) == 123
|
|
assert make_json_safe(None) is None
|
|
assert make_json_safe(3.14) == 3.14
|
|
assert make_json_safe(True) is True
|
|
assert make_json_safe(False) is False
|
|
|
|
|
|
def test_make_json_safe_datetime():
|
|
"""Test datetime serialization."""
|
|
dt = datetime(2025, 10, 30, 12, 30, 45)
|
|
result = make_json_safe(dt)
|
|
assert result == "2025-10-30T12:30:45"
|
|
|
|
|
|
def test_make_json_safe_date():
|
|
"""Test date serialization."""
|
|
d = date(2025, 10, 30)
|
|
result = make_json_safe(d)
|
|
assert result == "2025-10-30"
|
|
|
|
|
|
@dataclass
|
|
class SampleDataclass:
|
|
"""Sample dataclass for testing."""
|
|
|
|
name: str
|
|
value: int
|
|
|
|
|
|
def test_make_json_safe_dataclass():
|
|
"""Test dataclass serialization."""
|
|
obj = SampleDataclass(name="test", value=42)
|
|
result = make_json_safe(obj)
|
|
assert result == {"name": "test", "value": 42}
|
|
|
|
|
|
class ModelDumpObject:
|
|
"""Object with model_dump method."""
|
|
|
|
def model_dump(self):
|
|
return {"type": "model", "data": "dump"}
|
|
|
|
|
|
def test_make_json_safe_model_dump():
|
|
"""Test object with model_dump method."""
|
|
obj = ModelDumpObject()
|
|
result = make_json_safe(obj)
|
|
assert result == {"type": "model", "data": "dump"}
|
|
|
|
|
|
class DictObject:
|
|
"""Object with dict method."""
|
|
|
|
def dict(self):
|
|
return {"type": "dict", "method": "call"}
|
|
|
|
|
|
def test_make_json_safe_dict_method():
|
|
"""Test object with dict method."""
|
|
obj = DictObject()
|
|
result = make_json_safe(obj)
|
|
assert result == {"type": "dict", "method": "call"}
|
|
|
|
|
|
class CustomObject:
|
|
"""Custom object with __dict__."""
|
|
|
|
def __init__(self):
|
|
self.field1 = "value1"
|
|
self.field2 = 123
|
|
|
|
|
|
def test_make_json_safe_dict_attribute():
|
|
"""Test object with __dict__ attribute."""
|
|
obj = CustomObject()
|
|
result = make_json_safe(obj)
|
|
assert result == {"field1": "value1", "field2": 123}
|
|
|
|
|
|
def test_make_json_safe_list():
|
|
"""Test list serialization."""
|
|
lst = [1, "text", None, {"key": "value"}]
|
|
result = make_json_safe(lst)
|
|
assert result == [1, "text", None, {"key": "value"}]
|
|
|
|
|
|
def test_make_json_safe_tuple():
|
|
"""Test tuple serialization."""
|
|
tpl = (1, 2, 3)
|
|
result = make_json_safe(tpl)
|
|
assert result == [1, 2, 3]
|
|
|
|
|
|
def test_make_json_safe_dict():
|
|
"""Test dict serialization."""
|
|
d = {"a": 1, "b": {"c": 2}}
|
|
result = make_json_safe(d)
|
|
assert result == {"a": 1, "b": {"c": 2}}
|
|
|
|
|
|
def test_make_json_safe_nested():
|
|
"""Test nested structure serialization."""
|
|
obj = {
|
|
"datetime": datetime(2025, 10, 30),
|
|
"list": [1, 2, CustomObject()],
|
|
"nested": {"value": SampleDataclass(name="nested", value=99)},
|
|
}
|
|
result = make_json_safe(obj)
|
|
|
|
assert result["datetime"] == "2025-10-30T00:00:00"
|
|
assert result["list"][0] == 1
|
|
assert result["list"][2] == {"field1": "value1", "field2": 123}
|
|
assert result["nested"]["value"] == {"name": "nested", "value": 99}
|
|
|
|
|
|
class UnserializableObject:
|
|
"""Object that can't be serialized by standard methods."""
|
|
|
|
def __init__(self):
|
|
# Add attribute to trigger __dict__ fallback path
|
|
pass
|
|
|
|
|
|
def test_make_json_safe_fallback():
|
|
"""Test fallback to dict for objects with __dict__."""
|
|
obj = UnserializableObject()
|
|
result = make_json_safe(obj)
|
|
# Objects with __dict__ return their __dict__ dict
|
|
assert isinstance(result, dict)
|
|
|
|
|
|
def test_convert_tools_to_agui_format_with_ai_function():
|
|
"""Test converting AIFunction to AG-UI format."""
|
|
from agent_framework import ai_function
|
|
|
|
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
|
|
|
|
@ai_function
|
|
def test_func(param: str, count: int = 5) -> str:
|
|
"""Test function."""
|
|
return f"{param} {count}"
|
|
|
|
result = convert_tools_to_agui_format([test_func])
|
|
|
|
assert result is not None
|
|
assert len(result) == 1
|
|
assert result[0]["name"] == "test_func"
|
|
assert result[0]["description"] == "Test function."
|
|
assert "parameters" in result[0]
|
|
assert "properties" in result[0]["parameters"]
|
|
|
|
|
|
def test_convert_tools_to_agui_format_with_callable():
|
|
"""Test converting plain callable to AG-UI format."""
|
|
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
|
|
|
|
def plain_func(x: int) -> int:
|
|
"""A plain function."""
|
|
return x * 2
|
|
|
|
result = convert_tools_to_agui_format([plain_func])
|
|
|
|
assert result is not None
|
|
assert len(result) == 1
|
|
assert result[0]["name"] == "plain_func"
|
|
assert result[0]["description"] == "A plain function."
|
|
assert "parameters" in result[0]
|
|
|
|
|
|
def test_convert_tools_to_agui_format_with_dict():
|
|
"""Test converting dict tool to AG-UI format."""
|
|
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
|
|
|
|
tool_dict = {
|
|
"name": "custom_tool",
|
|
"description": "Custom tool",
|
|
"parameters": {"type": "object"},
|
|
}
|
|
|
|
result = convert_tools_to_agui_format([tool_dict])
|
|
|
|
assert result is not None
|
|
assert len(result) == 1
|
|
assert result[0] == tool_dict
|
|
|
|
|
|
def test_convert_tools_to_agui_format_with_none():
|
|
"""Test converting None tools."""
|
|
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
|
|
|
|
result = convert_tools_to_agui_format(None)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_convert_tools_to_agui_format_with_single_tool():
|
|
"""Test converting single tool (not in list)."""
|
|
from agent_framework import ai_function
|
|
|
|
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
|
|
|
|
@ai_function
|
|
def single_tool(arg: str) -> str:
|
|
"""Single tool."""
|
|
return arg
|
|
|
|
result = convert_tools_to_agui_format(single_tool)
|
|
|
|
assert result is not None
|
|
assert len(result) == 1
|
|
assert result[0]["name"] == "single_tool"
|
|
|
|
|
|
def test_convert_tools_to_agui_format_with_multiple_tools():
|
|
"""Test converting multiple tools."""
|
|
from agent_framework import ai_function
|
|
|
|
from agent_framework_ag_ui._utils import convert_tools_to_agui_format
|
|
|
|
@ai_function
|
|
def tool1(x: int) -> int:
|
|
"""Tool 1."""
|
|
return x
|
|
|
|
@ai_function
|
|
def tool2(y: str) -> str:
|
|
"""Tool 2."""
|
|
return y
|
|
|
|
result = convert_tools_to_agui_format([tool1, tool2])
|
|
|
|
assert result is not None
|
|
assert len(result) == 2
|
|
assert result[0]["name"] == "tool1"
|
|
assert result[1]["name"] == "tool2"
|