From 6ad18a986b6c7762e737c4f033089bece2db3787 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 29 Jul 2026 14:08:12 +0200 Subject: [PATCH 1/2] Python: Preserve approval decisions under OpenAI continuation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1 --- docs/specs/004-python-function-calling-loop.md | 1 + python/packages/openai/AGENTS.md | 5 +++++ .../openai/agent_framework_openai/_chat_client.py | 10 +++++++++- .../openai/tests/openai/test_openai_chat_client.py | 12 +++++++----- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 398b7356e32..2c66a0fbce5 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -463,6 +463,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Pending placeholder history | An approval response remains replayable while its only result is `[APPROVAL_PENDING]`. | `packages/core/tests/core/test_sessions.py::test_filter_approval_controls_keeps_response_for_pending_placeholder` | | Pending hosted history replay | Stateless hosted approval requests remain replayable until a response is recorded, then both controls become inert. | `packages/openai/tests/openai/test_openai_chat_client.py::test_stateless_history_preserves_pending_hosted_approval_request_until_response` | | Non-history provider plus session | Local history is still auto-injected for approval resume. | `packages/core/tests/core/test_agents.py::test_non_history_context_provider_still_injects_inmemory` | +| Service-side approval decision | Stored request is skipped; current approved or rejected response is sent. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage` | | OpenAI approval serialization | Approval id and decision serialize to `mcp_approval_response`. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` | | OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` | | Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` | diff --git a/python/packages/openai/AGENTS.md b/python/packages/openai/AGENTS.md index 752919fa28e..1dd22ad0c97 100644 --- a/python/packages/openai/AGENTS.md +++ b/python/packages/openai/AGENTS.md @@ -24,6 +24,11 @@ agent_framework_openai/ All clients follow the Raw + Full-Featured pattern (e.g., `RawOpenAIChatClient` + `OpenAIChatClient`). +For Responses API continuation with service-side storage, a prior `function_approval_request` is server-issued and +must not be replayed inline, while the new `function_approval_response` is always serialized as +`mcp_approval_response` so the user's approved or rejected decision reaches the service. Applications that manually +replay message history must not send that same approval response again on later turns. + The generic OpenAI clients support both OpenAI and Azure OpenAI routing. Precedence is: explicit Azure inputs (`credential`, `azure_endpoint`, `api_version`) → OpenAI API key (`OPENAI_API_KEY`) → Azure environment fallback (`AZURE_OPENAI_*`). diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index e35ff3500a6..92cfd6671de 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1684,7 +1684,7 @@ def _prepare_message_for_openai( ) if function_call: all_messages.append(function_call) - case "function_approval_response" | "function_approval_request": + case "function_approval_request": if request_uses_service_side_storage: continue prepared = self._prepare_content_for_openai( @@ -1694,6 +1694,14 @@ def _prepare_message_for_openai( ) if prepared: all_messages.append(prepared) + case "function_approval_response": + prepared = self._prepare_content_for_openai( + message.role, + content, + replays_local_storage=replays_local_storage, + ) + if prepared: + all_messages.append(prepared) case "mcp_server_tool_call" | "mcp_server_tool_result": # Hosted MCP call/result contents serialize as a single # top-level mcp_call input item; the result side emits an diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 89b7867891d..ad0e0615608 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -7784,9 +7784,9 @@ def test_prepare_messages_keeps_function_call_without_storage() -> None: assert output_item["call_id"] == "call_1" -def test_prepare_messages_strips_approval_items_under_storage() -> None: - """Approval request/response items also carry server-issued IDs and must be stripped under - storage. Without storage they are kept (#3295).""" +@pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"]) +def test_prepare_messages_strips_approval_request_but_keeps_response_under_storage(approved: bool) -> None: + """Stored requests are not replayed, but the new approval decision must reach the service.""" client = OpenAIChatClient(model="test-model", api_key="test-key") function_call = Content.from_function_call( @@ -7799,7 +7799,7 @@ def test_prepare_messages_strips_approval_items_under_storage() -> None: function_call=function_call, ) approval_response = Content.from_function_approval_response( - approved=True, + approved=approved, id="approval_req_1", function_call=function_call, ) @@ -7811,7 +7811,9 @@ def test_prepare_messages_strips_approval_items_under_storage() -> None: storage_on = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=True) storage_on_types = [item.get("type") for item in storage_on] assert "mcp_approval_request" not in storage_on_types - assert "mcp_approval_response" not in storage_on_types + assert storage_on_types == ["mcp_approval_response"] + assert storage_on[0]["approval_request_id"] == "approval_req_1" + assert storage_on[0]["approve"] is approved storage_off = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False) storage_off_types = [item.get("type") for item in storage_off] From f96346bd299d79f7f35c9576ce932629d168a5ae Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 30 Jul 2026 07:34:25 +0200 Subject: [PATCH 2/2] Chore: retrigger PR checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1