From 458d97216f8baca219bfaad605d9eba0401937a6 Mon Sep 17 00:00:00 2001 From: Jokasa7 <42598207+Jokasa7@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:17:36 +0800 Subject: [PATCH] Python: Preserve tool call IDs in chat history XML --- .../contents/function_call_content.py | 9 ++++- .../contents/function_result_content.py | 9 ++++- .../test_openai_responses_thread_actions.py | 34 +++++++++++++++++++ .../tests/unit/contents/test_chat_history.py | 28 +++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/contents/function_call_content.py b/python/semantic_kernel/contents/function_call_content.py index e4ca2338111b..80c5b8597e2c 100644 --- a/python/semantic_kernel/contents/function_call_content.py +++ b/python/semantic_kernel/contents/function_call_content.py @@ -205,6 +205,8 @@ def to_element(self) -> Element: element = Element(self.tag) if self.id: element.set("id", self.id) + if self.call_id: + element.set("call_id", self.call_id) if self.name: element.set("name", self.name) if self.arguments: @@ -217,7 +219,12 @@ def from_element(cls: type[_T], element: Element) -> _T: if element.tag != cls.tag: raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover - return cls(name=element.get("name"), id=element.get("id"), arguments=element.text or "") + return cls( + name=element.get("name"), + id=element.get("id"), + call_id=element.get("call_id"), + arguments=element.text or "", + ) def to_dict(self) -> dict[str, str | Any]: """Convert the instance to a dictionary.""" diff --git a/python/semantic_kernel/contents/function_result_content.py b/python/semantic_kernel/contents/function_result_content.py index b881371a398f..e9f60756a07e 100644 --- a/python/semantic_kernel/contents/function_result_content.py +++ b/python/semantic_kernel/contents/function_result_content.py @@ -106,6 +106,8 @@ def to_element(self) -> Element: element = Element(self.tag) if self.id: element.set("id", self.id) + if self.call_id: + element.set("call_id", self.call_id) if self.name: element.set("name", self.name) element.text = str(self.result) @@ -116,7 +118,12 @@ def from_element(cls: type[_T], element: Element) -> _T: """Create an instance from an Element.""" if element.tag != cls.tag: raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover - return cls(id=element.get("id", ""), result=element.text, name=element.get("name", None)) + return cls( + id=element.get("id", ""), + call_id=element.get("call_id"), + result=element.text, + name=element.get("name", None), + ) @classmethod def from_function_call_content_and_result( diff --git a/python/tests/unit/agents/openai_responses/test_openai_responses_thread_actions.py b/python/tests/unit/agents/openai_responses/test_openai_responses_thread_actions.py index ba0473efe8d7..7f4ae460e4ac 100644 --- a/python/tests/unit/agents/openai_responses/test_openai_responses_thread_actions.py +++ b/python/tests/unit/agents/openai_responses/test_openai_responses_thread_actions.py @@ -23,6 +23,40 @@ from semantic_kernel.functions import KernelArguments +@pytest.mark.parametrize("store_enabled", [False, True]) +def test_prepare_request_preserves_call_ids_after_xml_roundtrip(store_enabled): + from semantic_kernel.contents import ChatHistory, FunctionCallContent, FunctionResultContent + + function_call = FunctionCallContent( + id="fc_item_123", call_id="call_correlation_456", name="weather", arguments='{"city":"Seattle"}' + ) + history = ChatHistory( + messages=[ + ChatMessageContent(role=AuthorRole.ASSISTANT, items=[function_call]), + ChatMessageContent( + role=AuthorRole.TOOL, + items=[FunctionResultContent.from_function_call_content_and_result(function_call, "Sunny")], + ), + ] + ) + + restored = ChatHistory.from_rendered_prompt(history.to_prompt()) + request = ResponsesAgentThreadActions._prepare_chat_history_for_request(restored, store_enabled=store_enabled) + + expected = [{"type": "function_call_output", "call_id": "call_correlation_456", "output": "Sunny"}] + if not store_enabled: + expected.insert( + 0, + { + "type": "function_call", + "call_id": "call_correlation_456", + "name": "weather", + "arguments": '{"city":"Seattle"}', + }, + ) + assert request == expected + + @pytest.fixture def mock_agent(): agent = AsyncMock(spec=OpenAIResponsesAgent) diff --git a/python/tests/unit/contents/test_chat_history.py b/python/tests/unit/contents/test_chat_history.py index 3c1b92945296..2c345b6e1347 100644 --- a/python/tests/unit/contents/test_chat_history.py +++ b/python/tests/unit/contents/test_chat_history.py @@ -46,6 +46,34 @@ def test_init_with_messages_only(): assert chat_history.messages == msgs, "Chat history should contain exactly the provided messages" +@pytest.mark.parametrize("call_id", [None, "call_correlation_456"]) +@pytest.mark.parametrize("serializer", [str, ChatHistory.to_prompt]) +def test_xml_roundtrip_preserves_function_call_ids(call_id, serializer): + function_call = FunctionCallContent( + id="fc_item_123", call_id=call_id, name="weather-get_weather", arguments='{"city":"Seattle"}' + ) + function_result = FunctionResultContent.from_function_call_content_and_result(function_call, "Sunny") + history = ChatHistory( + messages=[ + ChatMessageContent(role=AuthorRole.ASSISTANT, items=[function_call]), + ChatMessageContent(role=AuthorRole.TOOL, items=[function_result]), + ] + ) + + restored = ChatHistory.from_rendered_prompt(serializer(history)) + + assert len(restored.messages) == 2 + restored_call = restored.messages[0].items[0] + restored_result = restored.messages[1].items[0] + assert isinstance(restored_call, FunctionCallContent) + assert isinstance(restored_result, FunctionResultContent) + assert restored_call.id == restored_result.id == "fc_item_123" + assert restored_call.call_id == restored_result.call_id == call_id + assert restored_call.name == restored_result.name == "weather-get_weather" + assert restored_call.arguments == '{"city":"Seattle"}' + assert restored_result.result == "Sunny" + + def test_init_with_messages_and_system_message(): system_msg = "a test system prompt" msgs = [ChatMessageContent(role=AuthorRole.USER, content=f"Message {i}") for i in range(3)]