mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
cbcdb2d29e
commit
cdb51e6a41
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user