diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 145986fb9a..46ec070d6e 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -1032,24 +1032,27 @@ def _prepare_messages_for_openai(self, chat_messages: Sequence[Message]) -> list 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 @@ def _prepare_message_for_openai( 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 @@ def _prepare_content_for_openai( 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 @@ def _prepare_content_for_openai( 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: diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 8e6faa37c4..cab55196f8 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -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 diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 696dd77772..27ce380583 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -28,6 +28,7 @@ from pytest import param from agent_framework import ( + Agent, ChatOptions, ChatResponse, ChatResponseUpdate, @@ -37,6 +38,11 @@ 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" @@ -2246,7 +2247,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" @@ -2254,7 +2255,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 @@ -2266,14 +2267,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" @@ -2284,12 +2285,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 == {} @@ -2303,7 +2304,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" @@ -2325,7 +2326,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" @@ -2349,7 +2350,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 @@ -2372,7 +2373,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" @@ -2401,7 +2402,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" @@ -2430,7 +2431,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..." @@ -2452,7 +2453,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" @@ -3228,6 +3229,53 @@ def get_test_image() -> Content: 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 @@ -3529,6 +3577,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") diff --git a/python/pyproject.toml b/python/pyproject.toml index 82e113c811..72dd812f43 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -188,6 +188,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']