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 c5a4d04cb3..b9db16161f 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 @@ -995,7 +995,7 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An content_text = "" tool_calls: list[dict[str, Any]] = [] - tool_result_call_id: str | None = None + function_results: list[Any] = [] for content in msg.contents: if content.type == "text": @@ -1012,9 +1012,38 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An } ) elif content.type == "function_result": - # Tool result content - extract call_id and result - tool_result_call_id = content.call_id - content_text = content.result if content.result is not None else "" + function_results.append(content) + + # A single Agent Framework message can carry several function_result + # contents (parallel tool calls). Emit one AG-UI tool message per result so + # none are dropped and each keeps its own toolCallId. + if function_results: + # Preserve the source id for the first result; give every additional + # message an independent generated id. Deriving suffixes from the source + # id (e.g. f"{base_id}-1") risks colliding with a legitimate id elsewhere + # in the history, which would let id-keyed clients re-collapse results. + for idx, fr in enumerate(function_results): + result.append( + { + "id": msg.message_id if (idx == 0 and msg.message_id) else generate_event_id(), + "role": "tool", + "content": fr.result if fr.result is not None else "", + "toolCallId": fr.call_id, + } + ) + # A mixed message may also carry text / function_call contents alongside + # the tool results (e.g. a finalized assistant turn). Emit those as a + # separate, distinctly-identified message so they are not lost. + if content_text or tool_calls: + extra_msg: dict[str, Any] = { + "id": generate_event_id(), + "role": role, + "content": content_text, + } + if tool_calls: + extra_msg["tool_calls"] = tool_calls + result.append(extra_msg) + continue agui_msg: dict[str, Any] = { "id": msg.message_id if msg.message_id else generate_event_id(), # Always include id @@ -1025,12 +1054,6 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An if tool_calls: agui_msg["tool_calls"] = tool_calls - # If this is a tool result message, add toolCallId (using camelCase for Pydantic) - if tool_result_call_id: - agui_msg["toolCallId"] = tool_result_call_id - # Tool result messages should have role="tool" - agui_msg["role"] = "tool" - result.append(agui_msg) return result diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index c78bec3293..1749cafe72 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -896,6 +896,73 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents(): assert agui_msg["content"] == '["First result", "Second result"]' +def test_agent_framework_to_agui_parallel_function_results_not_collapsed(): + """A tool Message with several function_result contents emits one AG-UI tool message each.""" + msg = Message( + role="tool", + contents=[ + Content.from_function_result(call_id="call_a", result="result-a"), + Content.from_function_result(call_id="call_b", result="result-b"), + Content.from_function_result(call_id="call_c", result="result-c"), + ], + message_id="tool-batch-1", + ) + + messages = agent_framework_messages_to_agui([msg]) + + tool_msgs = [m for m in messages if m.get("role") == "tool"] + assert len(tool_msgs) == 3 + assert [m["toolCallId"] for m in tool_msgs] == ["call_a", "call_b", "call_c"] + assert [m["content"] for m in tool_msgs] == ["result-a", "result-b", "result-c"] + # Split messages must carry distinct ids so downstream clients keyed by id do not re-collapse them. + assert len({m["id"] for m in tool_msgs}) == 3 + # The first result keeps the source message id for continuity. + assert tool_msgs[0]["id"] == "tool-batch-1" + + +def test_agent_framework_to_agui_parallel_function_results_none_result(): + """A None result among parallel results maps to empty string without dropping the entry.""" + msg = Message( + role="tool", + contents=[ + Content.from_function_result(call_id="call_a", result="result-a"), + Content.from_function_result(call_id="call_b", result=None), + ], + message_id="tool-batch-2", + ) + + messages = agent_framework_messages_to_agui([msg]) + + tool_msgs = [m for m in messages if m.get("role") == "tool"] + assert len(tool_msgs) == 2 + assert tool_msgs[1]["toolCallId"] == "call_b" + assert tool_msgs[1]["content"] == "" + + +def test_agent_framework_to_agui_function_result_with_text_preserves_both(): + """A mixed message (function_result + text) emits a tool message and keeps the text.""" + msg = Message( + role="assistant", + contents=[ + Content.from_function_result(call_id="weather-call", result="Sunny in Seattle"), + Content.from_text("The weather is sunny."), + ], + message_id="mixed-1", + ) + + messages = agent_framework_messages_to_agui([msg]) + + assert len(messages) == 2 + tool_msg, text_msg = messages + assert tool_msg["role"] == "tool" + assert tool_msg["toolCallId"] == "weather-call" + assert tool_msg["content"] == "Sunny in Seattle" + # The assistant text is not swallowed by the tool result and keeps its own role/id. + assert text_msg["role"] == "assistant" + assert text_msg["content"] == "The weather is sunny." + assert text_msg["id"] != tool_msg["id"] + + # Additional tests for better coverage