Files
ha-openai-compatible/tests/test_entity.py
T
pc-heiniandClaude Opus 5.5 6aed494e44
validate / hassfest (push) Failing after 1m2s
validate / hacs (push) Failing after 1m11s
test / pytest (push) Successful in 1m36s
fix: describe local intent answers to the model instead of replaying them
With prefer_local_intents, Home Assistant puts requests its local intents
answered into the chat log as external calls of intents like
HassGetCurrentTime. The model does not have these tools, and replayed as
its own calls they make it apologise for using tools it does not have and
doubt answers it never gave. They are now passed as a developer note with
the intent, its arguments and its result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:35:26 +02:00

185 lines
6.0 KiB
Python

"""Tests for the fork's chat log conversion fixes in entity.py."""
from unittest.mock import MagicMock
from homeassistant.components import conversation
from homeassistant.core import Context
from homeassistant.helpers import llm
from custom_components.openai_compatible.const import (
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
)
from custom_components.openai_compatible.entity import (
_convert_content_to_param,
_transform_stream,
)
from . import create_function_tool_call_item, create_message_item
async def _stream(events):
for event in events:
yield event
async def test_tool_call_without_arguments() -> None:
"""Test an empty argument string means a call without parameters.
Some OpenAI-compatible endpoints send "" instead of "{}" for tools that
take no parameters, e.g. the timer status intent.
"""
events = create_function_tool_call_item(
id="fc_1",
arguments=[],
call_id="call_1",
name="HassTimerStatus",
output_index=0,
)
deltas = [delta async for delta in _transform_stream(MagicMock(), _stream(events))]
tool_calls = [call for delta in deltas for call in delta.get("tool_calls", [])]
assert len(tool_calls) == 1
assert tool_calls[0].tool_name == "HassTimerStatus"
assert tool_calls[0].tool_args == {}
def test_web_search_not_replayed() -> None:
"""Test earlier web searches are left out of the history.
The chat log keeps only a search's status, not its results. Replaying the
bare search makes the model disown the answer it based on that search.
"""
content = [
conversation.UserContent(content="How did the match go?"),
conversation.AssistantContent(
agent_id="conversation.test",
tool_calls=[
llm.ToolInput(
id="ws_1",
tool_name="web_search_call",
tool_args={"action": {"type": "search", "query": "match"}},
external=True,
)
],
),
conversation.ToolResultContent(
agent_id="conversation.test",
tool_call_id="ws_1",
tool_name="web_search_call",
result=llm.ToolResult(data={"status": "completed"}),
),
conversation.AssistantContent(
agent_id="conversation.test",
content="It ended 2 to 2.",
tool_calls=[
llm.ToolInput(id="call_1", tool_name="GetLiveContext", tool_args={})
],
),
conversation.ToolResultContent(
agent_id="conversation.test",
tool_call_id="call_1",
tool_name="GetLiveContext",
result=llm.ToolResult(data={"success": True}),
),
]
messages = _convert_content_to_param(content)
assert [message["type"] for message in messages] == [
"message",
"message",
"function_call",
"function_call_output",
]
assert messages[1]["content"] == "It ended 2 to 2."
assert messages[2]["call_id"] == "call_1"
async def test_web_search_location_leaves_out_empty_fields(
hass, mock_config_entry, mock_init_component, mock_create_stream
) -> None:
"""Test unknown location fields are left out of the web search tool.
A failed location lookup stores no city or region; endpoints in front of
Anthropic models reject empty strings there.
"""
subentry = next(iter(mock_config_entry.subentries.values()))
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={
**subentry.data,
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_USER_LOCATION: True,
CONF_WEB_SEARCH_COUNTRY: "DE",
CONF_WEB_SEARCH_TIMEZONE: "Europe/Berlin",
},
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Hi", output_index=0)
]
await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.openai_conversation"
)
tools = mock_create_stream.mock_calls[0][2]["tools"]
web_search = next(tool for tool in tools if tool["type"] == "web_search")
assert web_search["user_location"] == {
"type": "approximate",
"country": "DE",
"timezone": "Europe/Berlin",
}
def test_local_intent_replayed_as_note() -> None:
"""Test a request a local intent answered is described, not replayed as a call.
With prefer_local_intents, the chat log holds intents like
HassGetCurrentTime as external tool calls. They are not the model's tools,
and replayed as its own calls they make it apologise for using them.
"""
content = [
conversation.UserContent(content="What time is it?"),
conversation.AssistantContent(
agent_id="conversation.test",
tool_calls=[
llm.ToolInput(
id="intent_1",
tool_name="HassGetCurrentTime",
tool_args={},
external=True,
)
],
),
conversation.ToolResultContent(
agent_id="conversation.test",
tool_call_id="intent_1",
tool_name="HassGetCurrentTime",
result=llm.ToolResult(data={"speech": {"plain": {"speech": "10:29"}}}),
),
conversation.AssistantContent(
agent_id="conversation.test", content="It is 10:29"
),
conversation.UserContent(content="What is your name?"),
]
messages = _convert_content_to_param(content)
assert [message["type"] for message in messages] == ["message"] * 4
assert [message["role"] for message in messages] == [
"user",
"developer",
"assistant",
"user",
]
note = messages[1]["content"]
assert "HassGetCurrentTime" in note
assert "10:29" in note
assert "not one of your tools" in note