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
@@ -1032,24 +1032,27 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
Returns:
The prepared chat messages for a request.
"""
call_id_to_id: dict[str, str] = {}
for message in chat_messages:
for content in message.contents:
if (
content.type == "function_call"
and content.additional_properties
and "fc_id" in content.additional_properties
and content.additional_properties["fc_id"]
):
call_id_to_id[content.call_id] = content.additional_properties["fc_id"] # type: ignore[attr-defined, index]
list_of_list = [self._prepare_message_for_openai(message, call_id_to_id) for message in chat_messages]
list_of_list = [self._prepare_message_for_openai(message) for message in chat_messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(list_of_list))
@staticmethod
def _message_replays_provider_context(message: Message) -> bool:
"""Return whether the message came from provider-attributed replay context.
Responses ``fc_id`` values are response-scoped and only valid while replaying
the same live tool loop. Once a message comes back through a context provider
(for example, loaded session history), that message is historical input and
must not reuse the original response-scoped ``fc_id``.
"""
additional_properties = getattr(message, "additional_properties", None)
if not additional_properties:
return False
return "_attribution" in additional_properties
def _prepare_message_for_openai(
self,
message: Message,
call_id_to_id: dict[str, str],
) -> list[dict[str, Any]]:
"""Prepare a chat message for the OpenAI Responses API format."""
all_messages: list[dict[str, Any]] = []
@@ -1067,39 +1070,41 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
case "text_reasoning":
if not has_function_call:
continue # reasoning not followed by a function_call is invalid in input
reasoning = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type]
reasoning = self._prepare_content_for_openai(message.role, content, message=message)
if reasoning:
all_messages.append(reasoning)
case "function_result":
new_args: dict[str, Any] = {}
new_args.update(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore[arg-type]
new_args.update(self._prepare_content_for_openai(message.role, content, message=message))
if new_args:
all_messages.append(new_args)
case "function_call":
function_call = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type]
function_call = self._prepare_content_for_openai(message.role, content, message=message)
if function_call:
all_messages.append(function_call) # type: ignore
all_messages.append(function_call)
case "function_approval_response" | "function_approval_request":
prepared = self._prepare_content_for_openai(Role(message.role), content, call_id_to_id)
prepared = self._prepare_content_for_openai(message.role, content, message=message)
if prepared:
all_messages.append(prepared) # type: ignore
all_messages.append(prepared)
case _:
prepared_content = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore
prepared_content = self._prepare_content_for_openai(message.role, content, message=message)
if prepared_content:
if "content" not in args:
args["content"] = []
args["content"].append(prepared_content) # type: ignore
args["content"].append(prepared_content) # type: ignore[reportUnknownMemberType]
if "content" in args or "tool_calls" in args:
all_messages.append(args)
return all_messages
def _prepare_content_for_openai(
self,
role: Role,
role: Role | str,
content: Content,
call_id_to_id: dict[str, str],
*,
message: Message | None = None,
) -> dict[str, Any]:
"""Prepare content for the OpenAI Responses API format."""
role = Role(role)
match content.type:
case "text":
if role == "assistant":
@@ -1174,8 +1179,15 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
if not content.call_id:
logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'")
return {}
# Use fc_id from additional_properties if available, otherwise fallback to call_id
fc_id = call_id_to_id.get(content.call_id, content.call_id)
fc_id = content.call_id
if (
message is not None
and not self._message_replays_provider_context(message)
and content.additional_properties
):
live_fc_id = content.additional_properties.get("fc_id")
if isinstance(live_fc_id, str) and live_fc_id:
fc_id = live_fc_id
# OpenAI Responses API requires IDs to start with `fc_`
if not fc_id.startswith("fc_"):
fc_id = f"fc_{fc_id}"
@@ -1221,7 +1233,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
if item.type == "text":
output_parts.append({"type": "input_text", "text": item.text or ""})
else:
part = self._prepare_content_for_openai("user", item, call_id_to_id) # type: ignore[arg-type]
part = self._prepare_content_for_openai("user", item)
if part:
output_parts.append(part)
if output_parts:
+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")
+29
View File
@@ -174,6 +174,35 @@ typeCheckingMode = "strict"
reportUnnecessaryIsInstance = false
reportMissingTypeStubs = false
reportUnnecessaryCast = "error"
# Tests intentionally probe internal implementation details.
executionEnvironments = [
{ root = "packages/a2a/tests", reportPrivateUsage = "none" },
{ root = "packages/ag-ui/tests", reportPrivateUsage = "none" },
{ root = "packages/anthropic/tests", reportPrivateUsage = "none" },
{ root = "packages/azure-ai-search/tests", reportPrivateUsage = "none" },
{ root = "packages/azure-ai/tests", reportPrivateUsage = "none" },
{ root = "packages/azure-cosmos/tests", reportPrivateUsage = "none" },
{ root = "packages/azurefunctions/tests", reportPrivateUsage = "none" },
{ root = "packages/bedrock/tests", reportPrivateUsage = "none" },
{ root = "packages/chatkit/tests", reportPrivateUsage = "none" },
{ root = "packages/claude/tests", reportPrivateUsage = "none" },
{ root = "packages/copilotstudio/tests", reportPrivateUsage = "none" },
{ root = "packages/core/tests", reportPrivateUsage = "none" },
{ root = "packages/declarative/tests", reportPrivateUsage = "none" },
{ root = "packages/devui/tests", reportPrivateUsage = "none" },
{ root = "packages/durabletask/tests", reportPrivateUsage = "none" },
{ root = "packages/foundry_local/tests", reportPrivateUsage = "none" },
{ root = "packages/github_copilot/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/gaia/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/lightning/tests", reportPrivateUsage = "none" },
{ root = "packages/lab/tau2/tests", reportPrivateUsage = "none" },
{ root = "packages/mem0/tests", reportPrivateUsage = "none" },
{ root = "packages/ollama/tests", reportPrivateUsage = "none" },
{ root = "packages/orchestrations/tests", reportPrivateUsage = "none" },
{ root = "packages/purview/tests", reportPrivateUsage = "none" },
{ root = "packages/redis/tests", reportPrivateUsage = "none" },
{ root = "tests", reportPrivateUsage = "none" },
]
[tool.mypy]
plugins = ['pydantic.mypy']