From b1866bd2797bfc038bad8bb9225ac7ec3ae694ab Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Wed, 11 Mar 2026 14:20:23 -0700 Subject: [PATCH] Python: Fix missing status input for OpenAI responses API (#4626) * Fix missing status input for OpenAI responses API * Fix mypy * Address comments * Remove raw_rep restore * Do not set status if it's None --- .../openai/_responses_client.py | 15 ++- .../azure/test_azure_responses_client.py | 67 ++++++++++ .../openai/test_openai_responses_client.py | 125 ++++++++++++++++++ 3 files changed, 202 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 44639909c7..03dc1cd5ed 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -1019,6 +1019,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc] 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] @@ -1158,13 +1159,17 @@ class RawOpenAIResponsesClient( # type: ignore[misc] # OpenAI Responses API requires IDs to start with `fc_` if not fc_id.startswith("fc_"): fc_id = f"fc_{fc_id}" - return { + + function_call_obj = { "call_id": content.call_id, "id": fc_id, "type": "function_call", "name": content.name, "arguments": content.arguments, } + if status := content.additional_properties.get("status"): + function_call_obj["status"] = status + return function_call_obj case "function_result": shell_output_type = ( content.additional_properties.get(OPENAI_SHELL_OUTPUT_TYPE_KEY) @@ -1472,10 +1477,10 @@ class RawOpenAIResponsesClient( # type: ignore[misc] case "function_call": # ResponseOutputFunctionCall contents.append( Content.from_function_call( - call_id=item.call_id if hasattr(item, "call_id") and item.call_id else "", - name=item.name if hasattr(item, "name") else "", - arguments=item.arguments if hasattr(item, "arguments") else "", - additional_properties={"fc_id": item.id} if hasattr(item, "id") else {}, + call_id=item.call_id, + name=item.name, + arguments=item.arguments, + additional_properties={"fc_id": item.id, "status": item.status}, raw_representation=item, ) ) diff --git a/python/packages/core/tests/azure/test_azure_responses_client.py b/python/packages/core/tests/azure/test_azure_responses_client.py index 37efff16ca..68ee066158 100644 --- a/python/packages/core/tests/azure/test_azure_responses_client.py +++ b/python/packages/core/tests/azure/test_azure_responses_client.py @@ -602,3 +602,70 @@ async def test_integration_client_agent_existing_session(): assert isinstance(second_response, AgentResponse) assert second_response.text is not None assert "photography" in second_response.text.lower() + + +# region Integration with Foundry V2 + + +skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif( + os.getenv("AZURE_AI_PROJECT_ENDPOINT", "") in ("", "https://test-project.cognitiveservices.azure.com/") + or os.getenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") == "", + reason="No real AZURE_AI_PROJECT_ENDPOINT or AZURE_AI_MODEL_DEPLOYMENT_NAME provided; skipping integration tests.", +) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_azure_ai_integration_tests_disabled +async def test_integration_function_call_roundtrip_preserves_fidelity(): + """Test that function calls roundtrip correctly with full fidelity preserved. + + This verifies the changes where: + 1. raw_representation is preserved when parsing function calls + 2. fc_id and status are included in additional_properties + 3. When re-sending messages, the full object fidelity is preserved + """ + call_count = 0 + + @tool(name="get_weather", approval_mode="never_require") + async def get_weather_tool(location: str) -> str: + """Get weather for a location.""" + nonlocal call_count + call_count += 1 + return f"Weather in {location} is sunny, 72F" + + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + async with Agent( + client=client, + name="WeatherAgent", + instructions="You help check weather. Use get_weather when asked about weather.", + tools=[get_weather_tool], + default_options={"store": False}, # Store messages locally to test fidelity across messages + ) as agent: + session = agent.create_session() + + # First request - should invoke the tool + response1 = await agent.run("What is the weather in Seattle?", session=session) + + assert response1 is not None + assert response1.text is not None + assert call_count >= 1 + + # Verify the response contains expected content + response_text = response1.text.lower() + assert "seattle" in response_text or "sunny" in response_text or "72" in response_text + + # Second request - should work correctly with the preserved conversation + response2 = await agent.run("And how about in Portland?", session=session) + + assert response2 is not None + assert response2.text is not None + assert call_count >= 2 + + +# endregion 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 d5a9903b93..78ff6ec17d 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -3363,3 +3363,128 @@ async def test_prepare_options_excludes_continuation_token() -> None: # endregion + + +# region Function Call Fidelity Tests + + +def test_parse_response_from_openai_function_call_includes_status() -> None: + """Test _parse_response_from_openai includes status in function call additional_properties.""" + from openai.types.responses import ResponseFunctionToolCall + + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Create a real ResponseFunctionToolCall object + mock_function_call_item = ResponseFunctionToolCall( + type="function_call", + call_id="call_123", + name="get_weather", + arguments='{"location": "Seattle"}', + id="fc_456", + status="completed", + ) + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + mock_response.output = [mock_function_call_item] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + assert len(response.messages[0].contents) == 1 + function_call = response.messages[0].contents[0] + assert function_call.type == "function_call" + assert function_call.call_id == "call_123" + assert function_call.name == "get_weather" + assert function_call.arguments == '{"location": "Seattle"}' + # Verify status is included in additional_properties + assert function_call.additional_properties is not None + assert function_call.additional_properties.get("status") == "completed" + assert function_call.additional_properties.get("fc_id") == "fc_456" + # Verify raw_representation is preserved + assert function_call.raw_representation is mock_function_call_item + + +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") + + messages = [ + Message(role="user", contents=[Content.from_text(text="check hotels")]), + Message( + role="assistant", + contents=[ + # Function call with empty fc_id - should NOT be added to call_id_to_id + Content.from_function_call( + call_id="call_empty", + name="search_hotels", + arguments='{"city": "Paris"}', + additional_properties={"fc_id": ""}, # Empty string + ), + ], + ), + Message( + role="assistant", + contents=[ + # Function call with valid fc_id - SHOULD be added to call_id_to_id + Content.from_function_call( + call_id="call_valid", + name="search_flights", + arguments='{"from": "NYC"}', + additional_properties={"fc_id": "fc_valid123"}, + ), + ], + ), + ] + + result = client._prepare_messages_for_openai(messages) + + # Find the function_call items in the result + fc_items = [item for item in result if item.get("type") == "function_call"] + assert len(fc_items) == 2 + + # The empty fc_id should result in an auto-generated id (starts with fc_) + empty_fc_item = next(item for item in fc_items if item.get("call_id") == "call_empty") + assert empty_fc_item["id"].startswith("fc_") + assert empty_fc_item["id"] != "" + + # The valid fc_id should be preserved + valid_fc_item = next(item for item in fc_items if item.get("call_id") == "call_valid") + assert valid_fc_item["id"] == "fc_valid123" + + +def test_prepare_messages_for_openai_filters_none_fc_id() -> None: + """Test _prepare_messages_for_openai correctly filters None fc_id values.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + messages = [ + Message( + role="assistant", + contents=[ + # Function call with None fc_id value + Content.from_function_call( + call_id="call_none", + name="get_info", + arguments="{}", + additional_properties={"fc_id": None}, # None value + ), + ], + ), + ] + + result = client._prepare_messages_for_openai(messages) + + # Find the function_call item + fc_items = [item for item in result if item.get("type") == "function_call"] + assert len(fc_items) == 1 + + # The None fc_id should result in an auto-generated id + fc_item = fc_items[0] + assert fc_item["id"].startswith("fc_") + + +# endregion