Python: fix thread serialization for multi-turn tool calls (#4684)

* Python: strip fc_id from loaded history

* Move fc_id replay handling into Responses client

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove unnecessary pytest asyncio marker

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Responses integration test for fc_id replay

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* removed old arg

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-03-17 11:00:04 +01:00
committed by GitHub
Unverified
parent cbcdb2d29e
commit cdb51e6a41
4 changed files with 373 additions and 56 deletions
+124 -1
View File
@@ -2,9 +2,10 @@
import contextlib
import inspect
import json
from collections.abc import AsyncIterable, MutableSequence
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
@@ -1943,6 +1944,128 @@ async def test_stores_by_default_with_store_false_in_default_options_injects_inm
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_shared_local_storage_cross_provider_responses_history_does_not_leak_fc_id() -> None:
"""Responses-specific replay metadata should stay local to Responses when session storage is shared."""
from openai.types.chat.chat_completion import ChatCompletion, Choice
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from agent_framework._sessions import InMemoryHistoryProvider
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
@tool(approval_mode="never_require")
def search_hotels(city: str) -> str:
return f"Found 3 hotels in {city}"
responses_client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
responses_agent = Agent(
client=responses_client,
tools=[search_hotels],
default_options={"store": False},
)
session = responses_agent.create_session()
responses_tool_call = MagicMock()
responses_tool_call.type = "function_call"
responses_tool_call.id = "fc_provider123"
responses_tool_call.call_id = "call_1"
responses_tool_call.name = "search_hotels"
responses_tool_call.arguments = '{"city": "Paris"}'
responses_tool_call.status = "completed"
responses_first = MagicMock()
responses_first.output_parsed = None
responses_first.metadata = {}
responses_first.usage = None
responses_first.id = "resp_1"
responses_first.model = "test-model"
responses_first.created_at = 1000000000
responses_first.status = "completed"
responses_first.finish_reason = "tool_calls"
responses_first.incomplete = None
responses_first.output = [responses_tool_call]
responses_text_item = MagicMock()
responses_text_item.type = "message"
responses_text_content = MagicMock()
responses_text_content.type = "output_text"
responses_text_content.text = "Hotel Lutetia is the cheapest option."
responses_text_item.content = [responses_text_content]
responses_second = MagicMock()
responses_second.output_parsed = None
responses_second.metadata = {}
responses_second.usage = None
responses_second.id = "resp_2"
responses_second.model = "test-model"
responses_second.created_at = 1000000001
responses_second.status = "completed"
responses_second.finish_reason = "stop"
responses_second.incomplete = None
responses_second.output = [responses_text_item]
with patch.object(
responses_client.client.responses,
"create",
side_effect=[responses_first, responses_second],
) as mock_responses_create:
responses_result = await responses_agent.run("Find me a hotel in Paris", session=session)
assert responses_result.text == "Hotel Lutetia is the cheapest option."
assert any(isinstance(provider, InMemoryHistoryProvider) for provider in responses_agent.context_providers)
shared_messages = session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"]
shared_function_call = next(
content for message in shared_messages for content in message.contents if content.type == "function_call"
)
assert shared_function_call.additional_properties is not None
assert shared_function_call.additional_properties.get("fc_id") == "fc_provider123"
responses_replay_input = mock_responses_create.call_args_list[1].kwargs["input"]
responses_replay_call = next(item for item in responses_replay_input if item.get("type") == "function_call")
assert responses_replay_call["id"] == "fc_provider123"
chat_client = OpenAIChatClient(model_id="test-model", api_key="test-key")
chat_agent = Agent(client=chat_client)
chat_response = ChatCompletion(
id="chatcmpl-test",
object="chat.completion",
created=1234567890,
model="gpt-4o-mini",
choices=[
Choice(
index=0,
message=ChatCompletionMessage(role="assistant", content="The cheapest option is still Hotel Lutetia."),
finish_reason="stop",
)
],
)
with patch.object(
chat_client.client.chat.completions,
"create",
new=AsyncMock(return_value=chat_response),
) as mock_chat_create:
chat_result = await chat_agent.run("Which option is cheapest?", session=session)
assert chat_result.text == "The cheapest option is still Hotel Lutetia."
chat_request_messages = mock_chat_create.call_args.kwargs["messages"]
assistant_tool_call_message = next(
message for message in chat_request_messages if message.get("role") == "assistant" and message.get("tool_calls")
)
assert assistant_tool_call_message["tool_calls"][0]["id"] == "call_1"
assert assistant_tool_call_message["tool_calls"][0]["function"]["name"] == "search_hotels"
tool_result_message = next(
message
for message in chat_request_messages
if message.get("role") == "tool" and message.get("tool_call_id") == "call_1"
)
assert tool_result_message["content"] == "Found 3 hotels in Paris"
assert "fc_provider123" not in json.dumps(chat_request_messages)
# region as_tool user_input_request propagation
@@ -28,6 +28,7 @@ from pydantic import BaseModel
from pytest import param
from agent_framework import (
Agent,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
@@ -37,6 +38,11 @@ from agent_framework import (
SupportsChatGetResponse,
tool,
)
from agent_framework._sessions import (
AgentSession,
InMemoryHistoryProvider,
SessionContext,
)
from agent_framework.exceptions import (
ChatClientException,
ChatClientInvalidRequestException,
@@ -1050,7 +1056,7 @@ def test_prepare_content_for_opentool_approval_response() -> None:
function_call=function_call,
)
result = client._prepare_content_for_openai("assistant", approval_response, {})
result = client._prepare_content_for_openai("assistant", approval_response)
assert result["type"] == "mcp_approval_response"
assert result["approval_request_id"] == "approval_001"
@@ -1067,7 +1073,7 @@ def test_prepare_content_for_openai_error_content() -> None:
error_details="Invalid parameter",
)
result = client._prepare_content_for_openai("assistant", error_content, {})
result = client._prepare_content_for_openai("assistant", error_content)
# ErrorContent should return empty dict (logged but not sent)
assert result == {}
@@ -1085,7 +1091,7 @@ def test_prepare_content_for_openai_usage_content() -> None:
}
)
result = client._prepare_content_for_openai("assistant", usage_content, {})
result = client._prepare_content_for_openai("assistant", usage_content)
# UsageContent should return empty dict (logged but not sent)
assert result == {}
@@ -1099,7 +1105,7 @@ def test_prepare_content_for_openai_hosted_vector_store_content() -> None:
vector_store_id="vs_123",
)
result = client._prepare_content_for_openai("assistant", vector_store_content, {})
result = client._prepare_content_for_openai("assistant", vector_store_content)
# HostedVectorStoreContent should return empty dict (logged but not sent)
assert result == {}
@@ -1111,8 +1117,8 @@ def test_prepare_content_for_openai_text_uses_role_specific_type() -> None:
text_content = Content.from_text(text="hello")
user_result = client._prepare_content_for_openai("user", text_content, {})
assistant_result = client._prepare_content_for_openai("assistant", text_content, {})
user_result = client._prepare_content_for_openai("user", text_content)
assistant_result = client._prepare_content_for_openai("assistant", text_content)
assert user_result["type"] == "input_text"
assert assistant_result["type"] == "output_text"
@@ -1234,9 +1240,8 @@ def test_prepare_message_for_openai_with_function_approval_response() -> None:
)
message = Message(role="user", contents=[approval_response])
call_id_to_id: dict[str, str] = {}
result = client._prepare_message_for_openai(message, call_id_to_id)
result = client._prepare_message_for_openai(message)
# FunctionApprovalResponseContent is added directly, not nested in args with role
assert len(result) == 1
@@ -1267,9 +1272,8 @@ def test_prepare_message_for_openai_includes_reasoning_with_function_call() -> N
)
message = Message(role="assistant", contents=[reasoning, function_call])
call_id_to_id: dict[str, str] = {}
result = client._prepare_message_for_openai(message, call_id_to_id)
result = client._prepare_message_for_openai(message)
# Both reasoning and function_call should be present as top-level items
types = [item["type"] for item in result]
@@ -1355,9 +1359,8 @@ def test_prepare_message_for_openai_filters_error_content() -> None:
)
message = Message(role="assistant", contents=[error_content])
call_id_to_id: dict[str, str] = {}
result = client._prepare_message_for_openai(message, call_id_to_id)
result = client._prepare_message_for_openai(message)
# Message should be empty since ErrorContent is filtered out
assert len(result) == 0
@@ -1376,9 +1379,8 @@ def test_chat_message_with_usage_content() -> None:
)
message = Message(role="assistant", contents=[usage_content])
call_id_to_id: dict[str, str] = {}
result = client._prepare_message_for_openai(message, call_id_to_id)
result = client._prepare_message_for_openai(message)
# Message should be empty since UsageContent is filtered out
assert len(result) == 0
@@ -1394,8 +1396,7 @@ def test_hosted_file_content_preparation() -> None:
name="document.pdf",
)
result = client._prepare_content_for_openai("user", hosted_file, {})
result = client._prepare_content_for_openai("user", hosted_file)
assert result["type"] == "input_file"
assert result["file_id"] == "file_abc123"
@@ -1417,7 +1418,7 @@ def test_function_approval_response_with_mcp_tool_call() -> None:
function_call=mcp_call,
)
result = client._prepare_content_for_openai("assistant", approval_response, {})
result = client._prepare_content_for_openai("assistant", approval_response)
assert result["type"] == "mcp_approval_response"
assert result["approval_request_id"] == "approval_mcp_001"
@@ -2259,7 +2260,7 @@ def test_prepare_content_for_openai_image_content() -> None:
media_type="image/jpeg",
additional_properties={"detail": "high", "file_id": "file_123"},
)
result = client._prepare_content_for_openai("user", image_content_with_detail, {}) # type: ignore
result = client._prepare_content_for_openai("user", image_content_with_detail)
assert result["type"] == "input_image"
assert result["image_url"] == "https://example.com/image.jpg"
assert result["detail"] == "high"
@@ -2267,7 +2268,7 @@ def test_prepare_content_for_openai_image_content() -> None:
# Test image content without additional properties (defaults)
image_content_basic = Content.from_uri(uri="https://example.com/basic.png", media_type="image/png")
result = client._prepare_content_for_openai("user", image_content_basic, {}) # type: ignore
result = client._prepare_content_for_openai("user", image_content_basic)
assert result["type"] == "input_image"
assert result["detail"] == "auto"
assert result["file_id"] is None
@@ -2279,14 +2280,14 @@ def test_prepare_content_for_openai_audio_content() -> None:
# Test WAV audio content
wav_content = Content.from_uri(uri="data:audio/wav;base64,abc123", media_type="audio/wav")
result = client._prepare_content_for_openai("user", wav_content, {}) # type: ignore
result = client._prepare_content_for_openai("user", wav_content)
assert result["type"] == "input_audio"
assert result["input_audio"]["data"] == "data:audio/wav;base64,abc123"
assert result["input_audio"]["format"] == "wav"
# Test MP3 audio content
mp3_content = Content.from_uri(uri="data:audio/mp3;base64,def456", media_type="audio/mp3")
result = client._prepare_content_for_openai("user", mp3_content, {}) # type: ignore
result = client._prepare_content_for_openai("user", mp3_content)
assert result["type"] == "input_audio"
assert result["input_audio"]["format"] == "mp3"
@@ -2297,12 +2298,12 @@ def test_prepare_content_for_openai_unsupported_content() -> None:
# Test unsupported audio format
unsupported_audio = Content.from_uri(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg")
result = client._prepare_content_for_openai("user", unsupported_audio, {}) # type: ignore
result = client._prepare_content_for_openai("user", unsupported_audio)
assert result == {}
# Test non-media content
text_uri_content = Content.from_uri(uri="https://example.com/document.txt", media_type="text/plain")
result = client._prepare_content_for_openai("user", text_uri_content, {}) # type: ignore
result = client._prepare_content_for_openai("user", text_uri_content)
assert result == {}
@@ -2316,7 +2317,7 @@ def test_prepare_content_for_openai_function_result_with_rich_items() -> None:
result=[Content.from_text("Result text"), image_content],
)
result = client._prepare_content_for_openai("user", content, {}) # type: ignore
result = client._prepare_content_for_openai("user", content)
assert result["type"] == "function_call_output"
assert result["call_id"] == "call_rich"
@@ -2338,7 +2339,7 @@ def test_prepare_content_for_openai_function_result_without_items() -> None:
result="Simple result",
)
result = client._prepare_content_for_openai("user", content, {}) # type: ignore
result = client._prepare_content_for_openai("user", content)
assert result["type"] == "function_call_output"
assert result["call_id"] == "call_plain"
@@ -2362,7 +2363,7 @@ def test_parse_chunk_from_openai_code_interpreter() -> None:
mock_item_image.code = None
mock_event_image.item = mock_item_image
result = client._parse_chunk_from_openai(mock_event_image, chat_options, function_call_ids) # type: ignore
result = client._parse_chunk_from_openai(mock_event_image, chat_options, function_call_ids)
assert len(result.contents) == 1
assert result.contents[0].type == "code_interpreter_tool_result"
assert result.contents[0].outputs
@@ -2385,7 +2386,7 @@ def test_parse_chunk_from_openai_code_interpreter_delta() -> None:
mock_delta_event.call_id = None # Ensure fallback to item_id
mock_delta_event.id = None
result = client._parse_chunk_from_openai(mock_delta_event, chat_options, function_call_ids) # type: ignore
result = client._parse_chunk_from_openai(mock_delta_event, chat_options, function_call_ids)
assert len(result.contents) == 1
assert result.contents[0].type == "code_interpreter_tool_call"
assert result.contents[0].call_id == "ci_123"
@@ -2414,7 +2415,7 @@ def test_parse_chunk_from_openai_code_interpreter_done() -> None:
mock_done_event.call_id = None # Ensure fallback to item_id
mock_done_event.id = None
result = client._parse_chunk_from_openai(mock_done_event, chat_options, function_call_ids) # type: ignore
result = client._parse_chunk_from_openai(mock_done_event, chat_options, function_call_ids)
assert len(result.contents) == 1
assert result.contents[0].type == "code_interpreter_tool_call"
assert result.contents[0].call_id == "ci_456"
@@ -2443,7 +2444,7 @@ def test_parse_chunk_from_openai_reasoning() -> None:
mock_item_reasoning.summary = ["Problem analysis summary"]
mock_event_reasoning.item = mock_item_reasoning
result = client._parse_chunk_from_openai(mock_event_reasoning, chat_options, function_call_ids) # type: ignore
result = client._parse_chunk_from_openai(mock_event_reasoning, chat_options, function_call_ids)
assert len(result.contents) == 1
assert result.contents[0].type == "text_reasoning"
assert result.contents[0].text == "Analyzing the problem step by step..."
@@ -2465,7 +2466,7 @@ def test_prepare_content_for_openai_text_reasoning_comprehensive() -> None:
"encrypted_content": "secure_data_456",
},
)
result = client._prepare_content_for_openai("assistant", comprehensive_reasoning, {}) # type: ignore
result = client._prepare_content_for_openai("assistant", comprehensive_reasoning)
assert result["type"] == "reasoning"
assert result["id"] == "rs_comprehensive"
assert result["summary"][0]["text"] == "Comprehensive reasoning summary"
@@ -3241,6 +3242,53 @@ async def test_integration_tool_rich_content_image() -> None:
assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}"
@pytest.mark.timeout(300)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_openai_integration_tests_disabled
async def test_integration_agent_replays_local_tool_history_without_stale_fc_id() -> None:
"""Integration test: persisted local Responses tool history can be replayed on a later turn."""
hotel_code = "HOTEL-PERSIST-4672"
@tool(name="search_hotels", approval_mode="never_require")
async def search_hotels(city: Annotated[str, "The city to search for hotels in"]) -> str:
return f"The only hotel option in {city} is {hotel_code}."
client = OpenAIResponsesClient()
client.function_invocation_configuration["max_iterations"] = 2
agent = Agent(
client=client,
tools=[search_hotels],
default_options={"store": False},
)
session = agent.create_session()
first_response = await agent.run(
"Call the search_hotels tool for Paris and answer with the hotel code you found.",
session=session,
options={"tool_choice": {"mode": "required", "required_function_name": "search_hotels"}},
)
assert first_response.text is not None
assert hotel_code in first_response.text
shared_messages = session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"]
shared_function_call = next(
content for message in shared_messages for content in message.contents if content.type == "function_call"
)
assert shared_function_call.additional_properties is not None
assert isinstance(shared_function_call.additional_properties.get("fc_id"), str)
assert shared_function_call.additional_properties["fc_id"]
second_response = await agent.run(
"What hotel code did you already find for Paris? Answer with the exact code only.",
session=session,
options={"tool_choice": "none"},
)
assert second_response.text is not None
assert hotel_code in second_response.text
def test_continuation_token_json_serializable() -> None:
"""Test that OpenAIContinuationToken is a plain dict and JSON-serializable."""
from agent_framework.openai import OpenAIContinuationToken
@@ -3542,6 +3590,111 @@ def test_parse_response_from_openai_function_call_includes_status() -> None:
assert function_call.raw_representation is mock_function_call_item
async def test_prepare_messages_for_openai_does_not_replay_fc_id_when_loaded_from_history() -> None:
"""Loaded history must not replay provider-ephemeral Responses function call IDs."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
provider = InMemoryHistoryProvider()
session = AgentSession(session_id="thread-1")
session.state[provider.source_id] = {
"messages": [
Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_1",
name="search_hotels",
arguments='{"city": "Paris"}',
additional_properties={"fc_id": "fc_provider123", "status": "completed"},
),
],
),
Message(
role="tool",
contents=[
Content.from_function_result(
call_id="call_1",
result="Found 3 hotels in Paris",
),
],
),
]
}
next_turn_input = Message(role="user", contents=[Content.from_text(text="Book the cheapest one")])
live_result = client._prepare_messages_for_openai([*session.state[provider.source_id]["messages"], next_turn_input])
live_function_call = next(item for item in live_result if item.get("type") == "function_call")
assert live_function_call["id"] == "fc_provider123"
context = SessionContext(session_id=session.session_id, input_messages=[next_turn_input])
await provider.before_run(
agent=None,
session=session,
context=context,
state=session.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
loaded_result = client._prepare_messages_for_openai(
context.get_messages(sources={provider.source_id}, include_input=True)
)
loaded_function_call = next(item for item in loaded_result if item.get("type") == "function_call")
assert loaded_function_call["id"] == "fc_call_1"
stored_function_call = session.state[provider.source_id]["messages"][0].contents[0]
assert stored_function_call.additional_properties is not None
assert stored_function_call.additional_properties.get("fc_id") == "fc_provider123"
restored = AgentSession.from_dict(json.loads(json.dumps(session.to_dict())))
restored_context = SessionContext(session_id=restored.session_id, input_messages=[next_turn_input])
await provider.before_run(
agent=None,
session=restored,
context=restored_context,
state=restored.state.setdefault(provider.source_id, {}),
) # type: ignore[arg-type]
restored_result = client._prepare_messages_for_openai(
restored_context.get_messages(sources={provider.source_id}, include_input=True)
)
restored_function_call = next(item for item in restored_result if item.get("type") == "function_call")
assert restored_function_call["id"] == "fc_call_1"
def test_prepare_messages_for_openai_keeps_live_fc_id_separate_from_replayed_history() -> None:
"""Replayed history must not borrow a live Responses function call ID with the same call_id."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
history_message = Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_1",
name="search_hotels",
arguments='{"city": "Paris"}',
additional_properties={"fc_id": "fc_history123"},
)
],
additional_properties={"_attribution": {"source_id": "history", "source_type": "InMemoryHistoryProvider"}},
)
live_message = Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_1",
name="search_hotels",
arguments='{"city": "London"}',
additional_properties={"fc_id": "fc_live123"},
)
],
)
result = client._prepare_messages_for_openai([history_message, live_message])
function_calls = [item for item in result if item.get("type") == "function_call"]
assert [item["id"] for item in function_calls] == ["fc_call_1", "fc_live123"]
def test_prepare_messages_for_openai_filters_empty_fc_id() -> None:
"""Test _prepare_messages_for_openai correctly filters empty fc_id values from call_id_to_id mapping."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")