From ce3dd2b6db253dd5f0c5d17d4d05f074204dafcb Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 7 Feb 2026 23:13:38 +0300 Subject: [PATCH] Fix JSON serialization of time objects in OpenAI tool results (#162490) --- .../components/openai_conversation/entity.py | 5 +- .../snapshots/test_conversation.ambr | 88 ++++++++++++++++++- .../openai_conversation/test_conversation.py | 40 +++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 0091f191a4c..0b25771f551 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -64,6 +64,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, issue_registry as ir, llm from homeassistant.helpers.entity import Entity +from homeassistant.helpers.json import json_dumps from homeassistant.util import slugify from .const import ( @@ -183,7 +184,7 @@ def _convert_content_to_param( FunctionCallOutput( type="function_call_output", call_id=content.tool_call_id, - output=json.dumps(content.tool_result), + output=json_dumps(content.tool_result), ) ) continue @@ -217,7 +218,7 @@ def _convert_content_to_param( ResponseFunctionToolCallParam( type="function_call", name=tool_call.tool_name, - arguments=json.dumps(tool_call.tool_args), + arguments=json_dumps(tool_call.tool_args), call_id=tool_call.id, ) ) diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 4351aae2bcf..087d2c469b3 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -7,14 +7,14 @@ 'type': 'message', }), dict({ - 'arguments': '{"code": "import math\\nmath.sqrt(55555)", "container": "cntr_A"}', + 'arguments': '{"code":"import math\\nmath.sqrt(55555)","container":"cntr_A"}', 'call_id': 'ci_A', 'name': 'code_interpreter', 'type': 'function_call', }), dict({ 'call_id': 'ci_A', - 'output': '{"output": [{"logs": "235.70108188126758\\n", "type": "logs"}]}', + 'output': '{"output":[{"logs":"235.70108188126758\\n","type":"logs"}]}', 'type': 'function_call_output', }), dict({ @@ -36,6 +36,65 @@ # --- # name: test_function_call list([ + dict({ + 'attachments': None, + 'content': 'What time is it?', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.openai_conversation', + 'content': None, + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': list([ + dict({ + 'external': True, + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + }), + 'tool_name': 'HassGetCurrentTime', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.openai_conversation', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'HassGetCurrentTime', + 'tool_result': dict({ + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), + }), + }), + }), + dict({ + 'agent_id': 'conversation.openai_conversation', + 'content': '12:00 PM', + 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'native': None, + 'role': 'assistant', + 'thinking_content': None, + 'tool_calls': None, + }), dict({ 'attachments': None, 'content': 'Please call the test function', @@ -125,6 +184,27 @@ # --- # name: test_function_call.1 list([ + dict({ + 'content': 'What time is it?', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'arguments': '{}', + 'call_id': 'mock-tool-call-id', + 'name': 'HassGetCurrentTime', + 'type': 'function_call', + }), + dict({ + 'call_id': 'mock-tool-call-id', + 'output': '{"speech":{"plain":{"speech":"12:00 PM","extra_data":null}},"response_type":"action_done","speech_slots":{"time":"12:00:00"},"data":{"targets":[],"success":[],"failed":[]}}', + 'type': 'function_call_output', + }), + dict({ + 'content': '12:00 PM', + 'role': 'assistant', + 'type': 'message', + }), dict({ 'content': 'Please call the test function', 'role': 'user', @@ -146,7 +226,7 @@ 'type': 'reasoning', }), dict({ - 'arguments': '{"param1": "call1"}', + 'arguments': '{"param1":"call1"}', 'call_id': 'call_call_1', 'name': 'test_tool', 'type': 'function_call', @@ -157,7 +237,7 @@ 'type': 'function_call_output', }), dict({ - 'arguments': '{"param1": "call2"}', + 'arguments': '{"param1":"call2"}', 'call_id': 'call_call_2', 'name': 'test_tool', 'type': 'function_call', diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index bbeaff0217a..6988cd9a55a 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,5 +1,6 @@ """Tests for the OpenAI integration.""" +import datetime from unittest.mock import AsyncMock, patch from freezegun import freeze_time @@ -30,6 +31,7 @@ from homeassistant.components.openai_conversation.const import ( from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent +from homeassistant.helpers.llm import ToolInput from homeassistant.setup import async_setup_component from . import ( @@ -251,6 +253,44 @@ async def test_function_call( snapshot: SnapshotAssertion, ) -> None: """Test function call from the assistant.""" + + # Add some pre-existing content from conversation.default_agent + mock_chat_log.async_add_user_content( + conversation.UserContent(content="What time is it?") + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id="conversation.openai_conversation", + tool_calls=[ + ToolInput( + tool_name="HassGetCurrentTime", + tool_args={}, + id="mock-tool-call-id", + external=True, + ) + ], + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.ToolResultContent( + agent_id="conversation.openai_conversation", + tool_call_id="mock-tool-call-id", + tool_name="HassGetCurrentTime", + tool_result={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0, 0, 0)}, + "data": {"targets": [], "success": [], "failed": []}, + }, + ) + ) + mock_chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id="conversation.openai_conversation", + content="12:00 PM", + ) + ) + mock_create_stream.return_value = [ # Initial conversation (