From 3a168cddb821881c56b07e901079efe690134b7e Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan Date: Tue, 1 Sep 2026 09:26:33 +0530 Subject: [PATCH 1/3] Python: fix: preserve parallel function_result contents in AG-UI conversion agent_framework_messages_to_agui collapsed a tool Message carrying multiple function_result contents (parallel tool calls) into a single AG-UI tool message, keeping only the last call_id/result. Clients then saw the missing results as pending tool approvals. Emit one AG-UI tool message per function_result, each with its own toolCallId and content, with distinct ids so downstream clients keyed by id do not re-collapse them. Fixes #7979 --- .../_message_adapters.py | 28 +++++++----- .../tests/ag_ui/test_message_adapters.py | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) 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..30fa235db1 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,23 @@ 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 tool 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: + base_id = msg.message_id if msg.message_id else generate_event_id() + for idx, fr in enumerate(function_results): + result.append( + { + "id": base_id if idx == 0 else f"{base_id}-{idx}", + "role": "tool", + "content": fr.result if fr.result is not None else "", + "toolCallId": fr.call_id, + } + ) + continue agui_msg: dict[str, Any] = { "id": msg.message_id if msg.message_id else generate_event_id(), # Always include id @@ -1025,12 +1039,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..5a1b0729cc 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,49 @@ 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"] == "" + + # Additional tests for better coverage From e60e27d48b68b360660edd7eba2524c6cb512178 Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan Date: Tue, 1 Sep 2026 09:35:16 +0530 Subject: [PATCH 2/3] Python: fix: preserve non-result contents in mixed AG-UI tool messages A single Agent Framework message can carry function_result contents alongside text or function_call contents (e.g. a finalized assistant turn). The parallel-result fix emitted the tool messages then returned early, dropping that leftover content. Emit a separate, distinctly-identified AG-UI message for any remaining text/tool_calls after the split tool results, so mixed messages are preserved instead of silently losing the assistant output. --- .../_message_adapters.py | 14 ++++++++++- .../tests/ag_ui/test_message_adapters.py | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) 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 30fa235db1..f7d65d8fcb 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 @@ -1014,7 +1014,7 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An elif content.type == "function_result": function_results.append(content) - # A single Agent Framework tool message can carry several function_result + # 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: @@ -1028,6 +1028,18 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An "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": f"{base_id}-{len(function_results)}", + "role": role, + "content": content_text, + } + if tool_calls: + extra_msg["tool_calls"] = tool_calls + result.append(extra_msg) continue agui_msg: dict[str, Any] = { 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 5a1b0729cc..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 @@ -939,6 +939,30 @@ def test_agent_framework_to_agui_parallel_function_results_none_result(): 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 From dd3f09345dcedcbcec057b4f5750729a564dfa15 Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan Date: Tue, 1 Sep 2026 09:39:47 +0530 Subject: [PATCH 3/3] Python: fix: use independent ids for split AG-UI tool messages Deriving suffixes from the source id (f"{base_id}-{idx}") could collide with a legitimate message id elsewhere in the converted history, letting id-keyed clients re-collapse a result. Keep the source id for the first result and generate an independent id for each additional message. --- .../ag-ui/agent_framework_ag_ui/_message_adapters.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 f7d65d8fcb..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 @@ -1018,11 +1018,14 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An # 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: - base_id = msg.message_id if msg.message_id else generate_event_id() + # 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": base_id if idx == 0 else f"{base_id}-{idx}", + "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, @@ -1033,7 +1036,7 @@ def agent_framework_messages_to_agui(messages: list[Message] | list[dict[str, An # separate, distinctly-identified message so they are not lost. if content_text or tool_calls: extra_msg: dict[str, Any] = { - "id": f"{base_id}-{len(function_results)}", + "id": generate_event_id(), "role": role, "content": content_text, }