From a6cac465c6da9f7f573e23cecec20e9fcfae444b Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 3 Mar 2026 17:45:48 +0900 Subject: [PATCH 1/4] Fix IndexError when reasoning models return no text content (#4384) In _prepare_message_for_openai(), the text_reasoning case unconditionally accessed all_messages[-1] to attach reasoning_details. When a reasoning model (e.g. gpt-5-mini) returns reasoning_details without text content, all_messages is empty, causing an IndexError. Guard the access by initializing all_messages with the current args dict when it is empty, so reasoning_details can be safely attached. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework/openai/_chat_client.py | 2 + .../tests/openai/test_openai_chat_client.py | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index f08d80e990..4e4dbf1efe 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -575,6 +575,8 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: # Functions returning None should still have a tool result message args["content"] = content.result if content.result is not None else "" case "text_reasoning" if (protected_data := content.protected_data) is not None: + if not all_messages: + all_messages.append(args) all_messages[-1]["reasoning_details"] = json.loads(protected_data) case _: if "content" not in args: diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index fae303ed22..ae99f40c98 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -643,6 +643,69 @@ def test_prepare_message_with_text_reasoning_content(openai_unit_test_env: dict[ assert prepared[0]["content"] == "The answer is 42." +def test_prepare_message_with_only_text_reasoning_content(openai_unit_test_env: dict[str, str]) -> None: + """Test that a message with only text_reasoning content does not raise IndexError. + + Regression test for https://github.com/microsoft/agent-framework/issues/4384 + Reasoning models (e.g. gpt-5-mini) may produce reasoning_details without text content, + which previously caused an IndexError when preparing messages. + """ + client = OpenAIChatClient() + + mock_reasoning_data = { + "effort": "high", + "summary": "Deep analysis of the problem", + } + + reasoning_content = Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data)) + + # Message with only reasoning content and no text + message = Message( + role="assistant", + contents=[reasoning_content], + ) + + prepared = client._prepare_message_for_openai(message) + + # Should have one message with reasoning_details + assert len(prepared) == 1 + assert prepared[0]["role"] == "assistant" + assert "reasoning_details" in prepared[0] + assert prepared[0]["reasoning_details"] == mock_reasoning_data + + +def test_prepare_message_with_text_reasoning_before_text(openai_unit_test_env: dict[str, str]) -> None: + """Test that text_reasoning content appearing before text content is handled correctly. + + Regression test for https://github.com/microsoft/agent-framework/issues/4384 + """ + client = OpenAIChatClient() + + mock_reasoning_data = { + "effort": "medium", + "summary": "Quick analysis", + } + + reasoning_content = Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data)) + + # Reasoning appears before text content + message = Message( + role="assistant", + contents=[ + reasoning_content, + Content.from_text(text="The answer is 42."), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + # Should produce messages without raising IndexError + assert len(prepared) >= 1 + # Reasoning details should be present on a message + has_reasoning = any("reasoning_details" in msg for msg in prepared) + assert has_reasoning + + def test_function_approval_content_is_skipped_in_preparation(openai_unit_test_env: dict[str, str]) -> None: """Test that function approval request and response content are skipped.""" client = OpenAIChatClient() From 8e8b1e26a5a4565baf47d7eb98b4e58544c20eb3 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 3 Mar 2026 17:52:19 +0900 Subject: [PATCH 2/4] Address review: buffer reasoning details for valid message payloads (#4384) - Buffer pending reasoning details and attach to the next message with content/tool_calls, avoiding standalone reasoning-only messages. - When reasoning is the only content, emit a message with empty content to satisfy Chat Completions schema requirements. - Strengthen test assertions to verify text+reasoning co-location and that all messages with reasoning_details also have content or tool_calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework/openai/_chat_client.py | 19 ++++++++++++++++--- .../tests/openai/test_openai_chat_client.py | 19 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 4e4dbf1efe..154494bddd 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -548,6 +548,7 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: return [] all_messages: list[dict[str, Any]] = [] + pending_reasoning: Any = None for content in message.contents: # Skip approval content - it's internal framework state, not for the LLM if content.type in ("function_approval_request", "function_approval_response"): @@ -575,17 +576,29 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: # Functions returning None should still have a tool result message args["content"] = content.result if content.result is not None else "" case "text_reasoning" if (protected_data := content.protected_data) is not None: - if not all_messages: - all_messages.append(args) - all_messages[-1]["reasoning_details"] = json.loads(protected_data) + if all_messages: + all_messages[-1]["reasoning_details"] = json.loads(protected_data) + else: + # Buffer reasoning to attach to the next message with content/tool_calls + pending_reasoning = json.loads(protected_data) case _: if "content" not in args: args["content"] = [] # this is a list to allow multi-modal content args["content"].append(self._prepare_content_for_openai(content)) # type: ignore if "content" in args or "tool_calls" in args: + if pending_reasoning is not None: + args["reasoning_details"] = pending_reasoning + pending_reasoning = None all_messages.append(args) + # If reasoning was the only content, emit a valid message with empty content + if pending_reasoning is not None: + pending_args: dict[str, Any] = {"role": message.role, "content": "", "reasoning_details": pending_reasoning} + if message.author_name and message.role != "tool": + pending_args["name"] = message.author_name + all_messages.append(pending_args) + # Flatten text-only content lists to plain strings for broader # compatibility with OpenAI-like endpoints (e.g. Foundry Local). # See https://github.com/microsoft/agent-framework/issues/4084 diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index ae99f40c98..789a739745 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -672,6 +672,9 @@ def test_prepare_message_with_only_text_reasoning_content(openai_unit_test_env: assert prepared[0]["role"] == "assistant" assert "reasoning_details" in prepared[0] assert prepared[0]["reasoning_details"] == mock_reasoning_data + # Message should also include a content field to be a valid Chat Completions payload + assert "content" in prepared[0] + assert prepared[0]["content"] in ("", None) def test_prepare_message_with_text_reasoning_before_text(openai_unit_test_env: dict[str, str]) -> None: @@ -701,9 +704,19 @@ def test_prepare_message_with_text_reasoning_before_text(openai_unit_test_env: d # Should produce messages without raising IndexError assert len(prepared) >= 1 - # Reasoning details should be present on a message - has_reasoning = any("reasoning_details" in msg for msg in prepared) - assert has_reasoning + + # There should be a message containing the expected text content + assert any(msg.get("content") == "The answer is 42." for msg in prepared) + + # The message containing the text should also carry the reasoning details + text_message = next(msg for msg in prepared if msg.get("content") == "The answer is 42.") + assert "reasoning_details" in text_message + assert text_message["role"] == "assistant" + + # Ensure we don't end up with a standalone reasoning-only message + for msg in prepared: + if "reasoning_details" in msg: + assert "content" in msg or "tool_calls" in msg def test_function_approval_content_is_skipped_in_preparation(openai_unit_test_env: dict[str, str]) -> None: From 9bf47f1a8eab21148134c39b5aaf8c5edf861cdb Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 3 Mar 2026 18:47:56 +0900 Subject: [PATCH 3/4] Fix text_reasoning handling: always buffer and tighten tests (#4384) - Always buffer reasoning into pending_reasoning instead of conditionally attaching to the previous message via fragile all_messages emptiness check - Attach buffered reasoning to last message at end-of-loop when no subsequent content consumed it - Assert exact content values (content == '' not in ('', None)) - Assert exact list lengths (== 1 not >= 1) for stronger regression guards - Add test for reasoning before FunctionCallContent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework/openai/_chat_client.py | 18 +++---- .../tests/openai/test_openai_chat_client.py | 54 ++++++++++++++----- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 154494bddd..c902d52887 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -576,11 +576,8 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: # Functions returning None should still have a tool result message args["content"] = content.result if content.result is not None else "" case "text_reasoning" if (protected_data := content.protected_data) is not None: - if all_messages: - all_messages[-1]["reasoning_details"] = json.loads(protected_data) - else: - # Buffer reasoning to attach to the next message with content/tool_calls - pending_reasoning = json.loads(protected_data) + # Buffer reasoning to attach to the next message with content/tool_calls + pending_reasoning = json.loads(protected_data) case _: if "content" not in args: args["content"] = [] @@ -594,10 +591,13 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: # If reasoning was the only content, emit a valid message with empty content if pending_reasoning is not None: - pending_args: dict[str, Any] = {"role": message.role, "content": "", "reasoning_details": pending_reasoning} - if message.author_name and message.role != "tool": - pending_args["name"] = message.author_name - all_messages.append(pending_args) + if all_messages: + all_messages[-1]["reasoning_details"] = pending_reasoning + else: + pending_args: dict[str, Any] = {"role": message.role, "content": "", "reasoning_details": pending_reasoning} + if message.author_name and message.role != "tool": + pending_args["name"] = message.author_name + all_messages.append(pending_args) # Flatten text-only content lists to plain strings for broader # compatibility with OpenAI-like endpoints (e.g. Foundry Local). diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index 789a739745..58faac42a3 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -674,7 +674,7 @@ def test_prepare_message_with_only_text_reasoning_content(openai_unit_test_env: assert prepared[0]["reasoning_details"] == mock_reasoning_data # Message should also include a content field to be a valid Chat Completions payload assert "content" in prepared[0] - assert prepared[0]["content"] in ("", None) + assert prepared[0]["content"] == "" def test_prepare_message_with_text_reasoning_before_text(openai_unit_test_env: dict[str, str]) -> None: @@ -702,21 +702,49 @@ def test_prepare_message_with_text_reasoning_before_text(openai_unit_test_env: d prepared = client._prepare_message_for_openai(message) - # Should produce messages without raising IndexError - assert len(prepared) >= 1 + # Should produce exactly one message without raising IndexError + assert len(prepared) == 1 + + # Reasoning details should be present on the message + assert "reasoning_details" in prepared[0] + assert prepared[0]["reasoning_details"] == mock_reasoning_data + assert prepared[0]["content"] == "The answer is 42." + + +def test_prepare_message_with_text_reasoning_before_function_call(openai_unit_test_env: dict[str, str]) -> None: + """Test that text_reasoning content appearing before a function call is handled correctly. + + Regression test for https://github.com/microsoft/agent-framework/issues/4384 + """ + client = OpenAIChatClient() + + mock_reasoning_data = { + "effort": "medium", + "summary": "Deciding to call a function", + } - # There should be a message containing the expected text content - assert any(msg.get("content") == "The answer is 42." for msg in prepared) + reasoning_content = Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data)) - # The message containing the text should also carry the reasoning details - text_message = next(msg for msg in prepared if msg.get("content") == "The answer is 42.") - assert "reasoning_details" in text_message - assert text_message["role"] == "assistant" + # Reasoning appears before function call content + message = Message( + role="assistant", + contents=[ + reasoning_content, + Content.from_function_call(call_id="call_abc", name="get_weather", arguments='{"city": "Seattle"}'), + ], + ) - # Ensure we don't end up with a standalone reasoning-only message - for msg in prepared: - if "reasoning_details" in msg: - assert "content" in msg or "tool_calls" in msg + prepared = client._prepare_message_for_openai(message) + + # Should produce exactly one message + assert len(prepared) == 1 + + # The message should carry the reasoning details and tool_calls + assert "reasoning_details" in prepared[0] + assert prepared[0]["reasoning_details"] == mock_reasoning_data + assert "tool_calls" in prepared[0] + assert prepared[0]["tool_calls"][0]["function"]["name"] == "get_weather" + assert prepared[0]["role"] == "assistant" def test_function_approval_content_is_skipped_in_preparation(openai_unit_test_env: dict[str, str]) -> None: From 7136c86830c12dacc4b2680f434ea8373e13791d Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 3 Mar 2026 18:48:33 +0900 Subject: [PATCH 4/4] Apply pre-commit auto-fixes --- python/packages/core/agent_framework/openai/_chat_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index c902d52887..0c3d346129 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -594,7 +594,11 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: if all_messages: all_messages[-1]["reasoning_details"] = pending_reasoning else: - pending_args: dict[str, Any] = {"role": message.role, "content": "", "reasoning_details": pending_reasoning} + pending_args: dict[str, Any] = { + "role": message.role, + "content": "", + "reasoning_details": pending_reasoning, + } if message.author_name and message.role != "tool": pending_args["name"] = message.author_name all_messages.append(pending_args)