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
@@ -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")