From 4550565c7a42933c6f215179b0beb97f1ac60ef7 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 20 Sep 2026 18:28:40 +0300 Subject: [PATCH] Preserve native code interpreter items in openai_conversation (#182715) --- .../components/openai_conversation/entity.py | 36 +++++- .../openai_conversation/__init__.py | 41 ++++--- .../snapshots/test_conversation.ambr | 110 ++++++++++++++++-- .../snapshots/test_entity.ambr | 74 ++++++++++++ .../openai_conversation/test_conversation.py | 29 ++++- .../openai_conversation/test_entity.py | 65 ++++++++++- 6 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 tests/components/openai_conversation/snapshots/test_entity.ambr diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index ff0affcaa55b..dfe6cf4e7fc4 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -14,6 +14,7 @@ from openai.types.responses import ( EasyInputMessageParam, FunctionToolParam, ResponseCodeInterpreterToolCall, + ResponseCodeInterpreterToolCallParam, ResponseCompletedEvent, ResponseErrorEvent, ResponseFailedEvent, @@ -41,6 +42,9 @@ from openai.types.responses import ( ToolParam, WebSearchToolParam, ) +from openai.types.responses.response_code_interpreter_tool_call_param import ( + Output as CodeInterpreterOutputParam, +) from openai.types.responses.response_create_params import ( Reasoning, ResponseCreateParamsStreaming, @@ -160,6 +164,7 @@ def _convert_content_to_param( messages: ResponseInputParam = [] reasoning_summary: list[str] = [] web_search_calls: dict[str, ResponseFunctionWebSearchParam] = {} + code_interpreter_calls: dict[str, llm.ToolInput] = {} for content in chat_content: if isinstance(content, conversation.ToolResultContent): @@ -172,6 +177,24 @@ def _convert_content_to_param( "status", "completed" ) messages.append(web_search_call) + elif ( + content.tool_name == "code_interpreter" + and content.tool_call_id in code_interpreter_calls + ): + tool_call = code_interpreter_calls.pop(content.tool_call_id) + messages.append( + ResponseCodeInterpreterToolCallParam( + type="code_interpreter_call", + id=tool_call.id, + code=tool_call.tool_args["code"], + container_id=cast(str, content.result.data["container_id"]), + outputs=cast( + list[CodeInterpreterOutputParam] | None, + content.result.data["output"], + ), + status=content.result.data["status"], # type: ignore[typeddict-item] + ) + ) else: messages.append( FunctionCallOutput( @@ -211,6 +234,10 @@ def _convert_content_to_param( action=tool_call.tool_args["action"], status="completed", ) + elif ( + tool_call.external and tool_call.tool_name == "code_interpreter" + ): + code_interpreter_calls[tool_call.id] = tool_call else: messages.append( ResponseFunctionToolCallParam( @@ -311,10 +338,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have llm.ToolInput( id=event.item.id, tool_name="code_interpreter", - tool_args={ - "code": event.item.code, - "container": event.item.container_id, - }, + tool_args={"code": event.item.code}, external=True, ) ] @@ -325,11 +349,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have "tool_name": "code_interpreter", "result": llm.ToolResult( data={ + "container_id": event.item.container_id, "output": ( [output.to_dict() for output in event.item.outputs] # type: ignore[misc] if event.item.outputs is not None else None - ) + ), + "status": event.item.status, }, error=event.item.status == "failed", ), diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index ccd41dffbdad..32d2d5946338 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1,5 +1,7 @@ """Tests for the OpenAI Conversation integration.""" +from typing import Literal + from openai.types.responses import ( ResponseCodeInterpreterCallCodeDeltaEvent, ResponseCodeInterpreterCallCodeDoneEvent, @@ -325,7 +327,11 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve def create_code_interpreter_item( - id: str, code: str | list[str], output_index: int, logs: str | None = None + id: str, + code: str | list[str], + output_index: int, + logs: str | None = None, + status: Literal["completed", "incomplete", "failed"] = "completed", ) -> list[ResponseStreamEvent]: """Create a message item.""" if isinstance(code, str): @@ -382,26 +388,31 @@ def create_code_interpreter_item( sequence_number=0, type="response.code_interpreter_call.interpreting", ), + ] + ) + if status == "completed": + events.append( ResponseCodeInterpreterCallCompletedEvent( item_id=id, output_index=output_index, sequence_number=0, type="response.code_interpreter_call.completed", + ) + ) + events.append( + ResponseOutputItemDoneEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code=code, + container_id=container_id, + outputs=[OutputLogs(type="logs", logs=logs)] if logs else None, + status=status, + type="code_interpreter_call", ), - ResponseOutputItemDoneEvent( - item=ResponseCodeInterpreterToolCall( - id=id, - code=code, - container_id=container_id, - outputs=[OutputLogs(type="logs", logs=logs)] if logs else None, - status="completed", - type="code_interpreter_call", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ) ) return events diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 8d152c2aec52..4d6708cbcdb6 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_code_interpreter +# name: test_code_interpreter[completed] list([ dict({ 'content': 'Please use the python tool to calculate square root of 55555', @@ -7,15 +7,109 @@ 'type': 'message', }), dict({ - 'arguments': '{"code":"import math\\nmath.sqrt(55555)","container":"cntr_A"}', - 'call_id': 'ci_A', - 'name': 'code_interpreter', - 'type': 'function_call', + 'code': ''' + import math + math.sqrt(55555) + ''', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'logs': ''' + 235.70108188126758 + + ''', + 'type': 'logs', + }), + ]), + 'status': 'completed', + 'type': 'code_interpreter_call', }), dict({ - 'call_id': 'ci_A', - 'output': '{"data":{"output":[{"logs":"235.70108188126758\\n","type":"logs"}]},"error":false}', - 'type': 'function_call_output', + 'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.', + 'role': 'assistant', + 'type': 'message', + }), + dict({ + 'content': 'Thank you!', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'content': 'You are welcome!', + 'role': 'assistant', + 'type': 'message', + }), + ]) +# --- +# name: test_code_interpreter[failed] + list([ + dict({ + 'content': 'Please use the python tool to calculate square root of 55555', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'code': ''' + import math + math.sqrt(55555) + ''', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'logs': ''' + 235.70108188126758 + + ''', + 'type': 'logs', + }), + ]), + 'status': 'failed', + 'type': 'code_interpreter_call', + }), + dict({ + 'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.', + 'role': 'assistant', + 'type': 'message', + }), + dict({ + 'content': 'Thank you!', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'content': 'You are welcome!', + 'role': 'assistant', + 'type': 'message', + }), + ]) +# --- +# name: test_code_interpreter[incomplete] + list([ + dict({ + 'content': 'Please use the python tool to calculate square root of 55555', + 'role': 'user', + 'type': 'message', + }), + dict({ + 'code': ''' + import math + math.sqrt(55555) + ''', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'logs': ''' + 235.70108188126758 + + ''', + 'type': 'logs', + }), + ]), + 'status': 'incomplete', + 'type': 'code_interpreter_call', }), dict({ 'content': 'I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758.', diff --git a/tests/components/openai_conversation/snapshots/test_entity.ambr b/tests/components/openai_conversation/snapshots/test_entity.ambr new file mode 100644 index 000000000000..388c230685c3 --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_entity.ambr @@ -0,0 +1,74 @@ +# serializer version: 1 +# name: test_convert_code_interpreter[custom_function] + list([ + dict({ + 'arguments': '{"code":"print(1)"}', + 'call_id': 'ci_A', + 'name': 'code_interpreter', + 'type': 'function_call', + }), + dict({ + 'call_id': 'ci_A', + 'output': '{"data":{"container_id":"cntr_A","output":null,"status":"completed"},"error":false}', + 'type': 'function_call_output', + }), + ]) +# --- +# name: test_convert_code_interpreter[failed] + list([ + dict({ + 'code': 'raise ValueError()', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'logs': 'ValueError', + 'type': 'logs', + }), + ]), + 'status': 'failed', + 'type': 'code_interpreter_call', + }), + ]) +# --- +# name: test_convert_code_interpreter[image] + list([ + dict({ + 'code': 'plt.show()', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': list([ + dict({ + 'type': 'image', + 'url': 'https://example.com/plot.png', + }), + ]), + 'status': 'completed', + 'type': 'code_interpreter_call', + }), + ]) +# --- +# name: test_convert_code_interpreter[incomplete] + list([ + dict({ + 'code': 'print(1)', + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': None, + 'status': 'incomplete', + 'type': 'code_interpreter_call', + }), + ]) +# --- +# name: test_convert_code_interpreter[no_output] + list([ + dict({ + 'code': None, + 'container_id': 'cntr_A', + 'id': 'ci_A', + 'outputs': None, + 'status': 'completed', + 'type': 'code_interpreter_call', + }), + ]) +# --- diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 515aab22c91e..2817c269ffe5 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,6 +1,7 @@ """Tests for the OpenAI integration.""" import datetime +from typing import Literal from unittest.mock import AsyncMock from freezegun import freeze_time @@ -713,13 +714,22 @@ async def test_web_search_remove_citations_gpt5( assert result.response.speech["plain"]["speech"] == "The match ended 0-2." +@pytest.mark.usefixtures("mock_init_component") +@pytest.mark.parametrize( + "status", + [ + pytest.param("completed", id="completed"), + pytest.param("incomplete", id="incomplete"), + pytest.param("failed", id="failed"), + ], +) async def test_code_interpreter( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_init_component, - mock_create_stream, + mock_create_stream: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 snapshot: SnapshotAssertion, + status: Literal["completed", "incomplete", "failed"], ) -> None: """Test code_interpreter tool.""" subentry = next(iter(mock_config_entry.subentries.values())) @@ -744,6 +754,7 @@ async def test_code_interpreter( code=["import", " math", "\n", "math", ".sqrt", "(", "555", "55", ")"], logs="235.70108188126758\n", output_index=0, + status=status, ), *create_message_item(id="msg_A", text=message, output_index=1), ) @@ -763,6 +774,16 @@ async def test_code_interpreter( assert result.response.response_type is intent.IntentResponseType.ACTION_DONE assert result.response.speech["plain"]["speech"] == message, result.response.speech + assistant_content = mock_chat_log.content[2] + assert isinstance(assistant_content, conversation.AssistantContent) + assert assistant_content.tool_calls + assert assistant_content.tool_calls[0].tool_args == { + "code": "import math\nmath.sqrt(55555)" + } + tool_result = mock_chat_log.content[3] + assert isinstance(tool_result, conversation.ToolResultContent) + assert tool_result.result.data["container_id"] == "cntr_A" + # Test follow-up message in multi-turn conversation mock_create_stream.return_value = [ (*create_message_item(id="msg_B", text="You are welcome!", output_index=1),) @@ -777,6 +798,10 @@ async def test_code_interpreter( ) assert mock_create_stream.mock_calls[1][2]["input"][1:] == snapshot + assert mock_create_stream.mock_calls[1][2]["tools"] == [ + {"type": "code_interpreter", "container": {"type": "auto"}} + ] + assert mock_create_stream.mock_calls[1][2]["input"][2]["status"] == status async def test_flex_tier_retry( diff --git a/tests/components/openai_conversation/test_entity.py b/tests/components/openai_conversation/test_entity.py index ddd787ea9f09..2df0dcaa4a2b 100644 --- a/tests/components/openai_conversation/test_entity.py +++ b/tests/components/openai_conversation/test_entity.py @@ -5,14 +5,77 @@ from unittest.mock import patch import probatio import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components import conversation from homeassistant.components.openai_conversation.entity import ( + _convert_content_to_param, _format_structured_output, async_prepare_files_for_prompt, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import selector +from homeassistant.helpers import llm, selector +from homeassistant.util.json import JsonObjectType + + +@pytest.mark.parametrize( + ("code", "outputs", "error", "external", "status"), + [ + pytest.param(None, None, False, True, "completed", id="no_output"), + pytest.param( + "raise ValueError()", + [{"type": "logs", "logs": "ValueError"}], + True, + True, + "failed", + id="failed", + ), + pytest.param( + "plt.show()", + [{"type": "image", "url": "https://example.com/plot.png"}], + False, + True, + "completed", + id="image", + ), + pytest.param("print(1)", None, False, False, "completed", id="custom_function"), + pytest.param("print(1)", None, False, True, "incomplete", id="incomplete"), + ], +) +def test_convert_code_interpreter( + code: str | None, + outputs: list[JsonObjectType] | None, + error: bool, + external: bool, + status: str, + snapshot: SnapshotAssertion, +) -> None: + """Restore native external calls while preserving custom function calls.""" + content = [ + conversation.AssistantContent( + agent_id="conversation.openai_conversation", + tool_calls=[ + llm.ToolInput( + id="ci_A", + tool_name="code_interpreter", + tool_args={"code": code}, + external=external, + ) + ], + ), + conversation.ToolResultContent( + agent_id="conversation.openai_conversation", + tool_call_id="ci_A", + tool_name="code_interpreter", + result=llm.ToolResult( + data={"container_id": "cntr_A", "output": outputs, "status": status}, + error=error, + ), + ), + ] + + assert _convert_content_to_param(content) == snapshot async def test_format_structured_output() -> None: