Python: Fix OpenAI Responses API 400 error on multi-turn conversations with previous_response_id#3800
Python: Fix OpenAI Responses API 400 error on multi-turn conversations with previous_response_id#3800
Conversation
- Added logic to filter assistant messages when using previous_response_id - Updated _prepare_options to determine conversation type before message preparation - Modified _prepare_messages_for_openai to accept filter_for_continuation parameter - When using previous_response_id (resp_*), only NEW user messages after last assistant are sent - Added comprehensive tests for message filtering behavior - All existing tests pass Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
…nse_id - Updated filtering logic to preserve system/developer messages (API accepts these roles) - System messages are collected from before last assistant and combined with new user messages - Updated test to verify system message preservation - All 92 tests pass Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
Python Test Coverage Report •
Python Unit Test Overview
|
||||||||||||||||||||||||||||||
|
@copilot run the pre-commit hooks and add any files that are modified |
Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes multi-turn continuation failures with the OpenAI Responses API by adjusting how message history is prepared when continuing via previous_response_id (resp_*), aligning client behavior with the API’s expected schema.
Changes:
- Detect
resp_*continuation early in_prepare_options()and enable message filtering accordingly. - Add continuation-aware filtering behavior to
_prepare_messages_for_openai(...). - Add unit tests validating filtering behavior for
resp_*vsconv_*vs no conversation id.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| python/packages/core/agent_framework/openai/_responses_client.py | Adds continuation-aware message preparation for resp_* IDs and wires it into option preparation. |
| python/packages/core/tests/openai/test_openai_responses_client.py | Adds tests verifying message filtering behavior across conversation id modes. |
| async def test_message_filtering_with_previous_response_id() -> None: | ||
| """Test that assistant messages are filtered when using previous_response_id.""" | ||
| client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") | ||
|
|
||
| # Create a multi-turn conversation with history | ||
| messages = [ | ||
| ChatMessage(role="system", text="You are a helpful assistant"), | ||
| ChatMessage(role="user", text="My name is Alice"), | ||
| ChatMessage(role="assistant", text="Nice to meet you, Alice!"), | ||
| ChatMessage(role="user", text="What's my name?"), | ||
| ] | ||
|
|
||
| # When using previous_response_id, assistant messages should be filtered but system messages preserved | ||
| options = await client._prepare_options( | ||
| messages, | ||
| {"conversation_id": "resp_12345"}, # Using resp_ prefix | ||
| ) # type: ignore | ||
|
|
||
| # Should include: system message + last user message | ||
| assert "input" in options | ||
| input_messages = options["input"] | ||
| assert len(input_messages) == 2, f"Expected 2 messages (system + user), got {len(input_messages)}" | ||
| assert input_messages[0]["role"] == "system" | ||
| assert input_messages[1]["role"] == "user" | ||
| assert "What's my name?" in str(input_messages[1]) | ||
|
|
||
| # Verify previous_response_id is set | ||
| assert options["previous_response_id"] == "resp_12345" | ||
|
|
||
|
|
||
| async def test_message_filtering_without_previous_response_id() -> None: | ||
| """Test that all messages are included when NOT using previous_response_id.""" | ||
| client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") | ||
|
|
||
| # Same conversation as above | ||
| messages = [ | ||
| ChatMessage(role="system", text="You are a helpful assistant"), | ||
| ChatMessage(role="user", text="My name is Alice"), | ||
| ChatMessage(role="assistant", text="Nice to meet you, Alice!"), | ||
| ChatMessage(role="user", text="What's my name?"), | ||
| ] | ||
|
|
||
| # Without conversation_id, all messages should be included | ||
| options = await client._prepare_options(messages, {}) # type: ignore | ||
|
|
||
| # Should include all messages | ||
| assert "input" in options | ||
| input_messages = options["input"] | ||
| # System (1) + User (1) + Assistant (1) + User (1) = 4 messages | ||
| assert len(input_messages) == 4 | ||
|
|
||
| # Verify previous_response_id is NOT set | ||
| assert "previous_response_id" not in options | ||
|
|
||
|
|
||
| async def test_message_filtering_with_conv_prefix() -> None: | ||
| """Test that messages are NOT filtered when using conv_ prefix (conversation ID).""" | ||
| client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") | ||
|
|
||
| messages = [ | ||
| ChatMessage(role="system", text="You are a helpful assistant"), | ||
| ChatMessage(role="user", text="My name is Alice"), | ||
| ChatMessage(role="assistant", text="Nice to meet you, Alice!"), | ||
| ChatMessage(role="user", text="What's my name?"), | ||
| ] | ||
|
|
||
| # When using conv_ prefix, should use conversation parameter, not previous_response_id | ||
| options = await client._prepare_options( | ||
| messages, | ||
| {"conversation_id": "conv_abc123"}, # Using conv_ prefix | ||
| ) # type: ignore | ||
|
|
||
| # All messages should be included (no filtering for conversation IDs) | ||
| assert "input" in options | ||
| input_messages = options["input"] | ||
| assert len(input_messages) == 4 | ||
|
|
||
| # Verify conversation is set, not previous_response_id | ||
| assert options.get("conversation") == "conv_abc123" | ||
| assert "previous_response_id" not in options | ||
|
|
There was a problem hiding this comment.
The new tests cover filtering assistant messages for resp_* continuation, but don’t cover the case where a tool message appears after the last assistant turn (which the client intends to filter out for previous_response_id). Adding a test that includes a ChatMessage(role="tool", ...) after the last assistant message and asserting it is excluded from options["input"] would better lock in the intended fix and prevent regressions.
| # Get all messages after the last assistant (new user messages) | ||
| new_messages = chat_messages[last_assistant_idx + 1 :] | ||
| # Combine: system messages + new messages | ||
| chat_messages = system_messages + list(new_messages) |
There was a problem hiding this comment.
In the continuation filtering path, new_messages = chat_messages[last_assistant_idx + 1:] is appended without restricting roles. If a tool message (e.g., function_result/tool output) exists after the last assistant turn (possible in tool-loop error/retry scenarios), it will be sent to the Responses API even though this block’s comment/docstring says assistant messages and function results should be filtered out. Consider explicitly filtering the retained messages to roles the Responses API accepts for previous_response_id continuation (e.g., keep system/developer plus only user messages after the last assistant; drop tool and any other roles).
| # Get all messages after the last assistant (new user messages) | |
| new_messages = chat_messages[last_assistant_idx + 1 :] | |
| # Combine: system messages + new messages | |
| chat_messages = system_messages + list(new_messages) | |
| # Get all messages after the last assistant, but keep only supported roles | |
| # (system/developer/user) for continuation. | |
| new_messages = [ | |
| msg | |
| for msg in chat_messages[last_assistant_idx + 1 :] | |
| if msg.role in ("system", "developer", "user") | |
| ] | |
| # Combine: system messages + filtered new messages | |
| chat_messages = system_messages + new_messages |
Motivation and Context
OpenAI Responses API rejects assistant messages when using
previous_response_idfor conversation continuation. The API expects only system/developer/user messages in theinputarray - assistant responses are already stored server-side.Description
Schema mismatch:
OpenAIResponsesClientwas sending all conversation history regardless of continuation method. The Responses API has different requirements:previous_response_id(resp_*): Expects only system/developer/user messagesconversation(conv_*): Expects full conversation historyChanges:
_prepare_options(): Determine conversation type before message preparation_prepare_messages_for_openai(): Addfilter_for_continuationparameterprevious_response_idis used:Example:
Azure clients inherit fix through
RawOpenAIResponsesClientbase class.Contribution Checklist
Original prompt
This section details on the original issue you should resolve
<issue_title>Python: [Bug]: OpenAIResponsesClient + Responses API 400 invalid_prompt when using messages array input (schema / validation mismatch with latest Responses API)</issue_title>
<issue_description>### Description
When using
agent_framework’sOpenAIResponsesClientwith the OpenAI Responses API, the request fails with a 400invalid_prompterror on a multi-turn conversation.The error payload suggests that the request body built by
OpenAIResponsesClient(and/or the underlyingopenaiSDK types) is no longer compatible with the current Responses API schema as documented here:https://developers.openai.com/api/reference/resources/responses/methods/create
Code Sample
import asyncio from typing import Any, MutableMapping, Sequence from agent_framework import ChatMessage, ChatMessageStore, ChatMessageStoreProtocol from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient async def multi_turn_example(): OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_API_KEY = "sk-...redacted..." # real key in my local file model_id = "openrouter/aurora-alpha" chat_client = OpenAIResponsesClient( api_key=OPENROUTER_API_KEY, base_url=OPENROUTER_BASE_URL, model_id=model_id, ) agent = chat_client.create_agent( name="ChatBot", instructions="You are a helpful assistant", store=False, chat_message_store_factory=lambda: ChatMessageStore(), ) # Create a thread for persistent conversation thread = agent.get_new_thread() # First interaction response1 = await agent.run("My name is Alice", thread=thread) print(f"Agent: {response1.text}") # Second interaction – the agent should remember the name response2 = await agent.run("What's my name?", thread=thread) print(f"Agent: {response2.text}") # Expected to mention "Alice" # Serialize thread for storage serialized = await thread.serialize() # Later, deserialize and continue conversation new_thread = await agent.deserialize_thread(serialized) response3 = await agent.run("What did we talk about?", thread=new_thread) print(f"Agent: {response3.text}") # Expected to remember previous context if __name__ == "__main__": asyncio.run(multi_turn_example())Error Messages / Stack Traces
Agent: Nice to meet you, Alice! How can I assist you today? request content: {"input":[{"role":"system","content":[{"type":"input_text","text":"You are a helpful assistant"}]},{"role":"user","content":[{"type":"input_text","text":"My name is Alice"}]},{"role":"assistant","content":[{"type":"output_text","text":"Nice to meet you, Alice! How can I assist you today?"}]},{"role":"user","content":[{"type":"input_text","text":"What's my name?"}]}],"model":"openrouter/aurora-alpha","store":false,"stream":false} Traceback (most recent call last): File "/root/workspace/ofnil-agentic-rag/.venv/lib/python3.12/site-packages/agent_framework/openai/_responses_client.py", line 100, in _inner_get_response response = await client.responses.create(stream=False, **run_options) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/root/workspace/ofnil-agentic-rag/.venv/lib/python3.12/site-packages/openai/resources/responses/responses.py", line 2259, in create return await self._post( ^^^^^^^^^^^^^^^^^ File "/root/workspace/ofnil-agentic-rag/.venv/lib/python3.12/site-packages/openai/_base_client.py", line 1795, in post return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/root/workspace/ofnil-agentic-rag/.venv/lib/python3.12/site-packages/openai/_base_client.py", line 1595, in request raise self._make_status_error_from_response(err.response) from None openai.BadRequestError: Error code: 400 - {'error': {'code': 'invalid_prompt', 'message': 'Invalid Responses API request'}, 'metadata': {'raw': '[\n {\n "code": "invalid_union",\n "errors": [\n [\n {\n "expected": "string",\n "code": "invalid_type",\n "path": [],\n "message": "Invalid input: expected string, received array"\n }\n ],\n [\n {\n "code": "invalid_union",\n "errors": [\n [\n {\n "code": "invalid_value",\n "values": [\n "reasoning"\n ],\n "path": [\n "type"\n ],\n "message": "Invalid input: expected \\"reasoning\\""\n },\n {\n "expected": "string",\n "code": "invalid_type",\n "path": [\n "id"\n ],\n "message": "Invalid input: expected str... </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes microsoft/agent-framework#3795 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.