Improve Anthropic coverage (#163011)

This commit is contained in:
Denis Shulyaka
2026-02-14 21:33:53 +01:00
committed by GitHub
parent 8840d2f0ef
commit c67c19413b
6 changed files with 790 additions and 98 deletions
-83
View File
@@ -21,16 +21,6 @@ from anthropic.types.raw_message_delta_event import Delta
import pytest
from homeassistant.components.anthropic.const import (
CONF_CHAT_MODEL,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_MAX_USES,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
)
@@ -84,79 +74,6 @@ def mock_config_entry_with_assist(
return mock_config_entry
@pytest.fixture
def mock_config_entry_with_extended_thinking(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Mock a config entry with extended thinking."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-3-7-sonnet-latest",
CONF_THINKING_BUDGET: 1500,
},
)
return mock_config_entry
@pytest.fixture
def mock_config_entry_with_adaptive_thinking(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Mock a config entry with adaptive thinking."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-opus-4-6",
CONF_THINKING_EFFORT: "medium",
},
)
return mock_config_entry
@pytest.fixture
def mock_config_entry_with_web_search(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Mock a config entry with server tools enabled."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-sonnet-4-5",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_MAX_USES: 5,
CONF_WEB_SEARCH_USER_LOCATION: True,
CONF_WEB_SEARCH_CITY: "San Francisco",
CONF_WEB_SEARCH_REGION: "California",
CONF_WEB_SEARCH_COUNTRY: "US",
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
},
)
return mock_config_entry
@pytest.fixture
def mock_config_entry_with_no_structured_output(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
"""Mock a config entry with a model without structured outputs support."""
for subentry in mock_config_entry.subentries.values():
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={
CONF_CHAT_MODEL: "claude-sonnet-4-0",
},
)
return mock_config_entry
@pytest.fixture
async def mock_init_component(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
@@ -0,0 +1,252 @@
# serializer version: 1
# name: test_generate_structured_data
dict({
'max_tokens': 3000,
'messages': list([
dict({
'content': 'Generate test data',
'role': 'user',
}),
dict({
'content': list([
dict({
'text': '{"characters": ["Mario", "Luigi"]}',
'type': 'text',
}),
]),
'role': 'assistant',
}),
]),
'model': 'claude-haiku-4-5',
'output_config': dict({
'format': dict({
'schema': dict({
'additionalProperties': False,
'properties': dict({
'characters': dict({
'items': dict({
'type': 'string',
}),
'type': 'array',
}),
}),
'required': list([
'characters',
]),
'type': 'object',
}),
'type': 'json_schema',
}),
}),
'stream': True,
'system': list([
dict({
'cache_control': dict({
'type': 'ephemeral',
}),
'text': '''
You are a Home Assistant expert and help users with their tasks.
Current time is 04:00:00. Today's date is 2026-01-01.
''',
'type': 'text',
}),
]),
'temperature': 1.0,
'thinking': dict({
'type': 'disabled',
}),
})
# ---
# name: test_generate_structured_data_legacy
dict({
'max_tokens': 3000,
'messages': list([
dict({
'content': 'Generate test data',
'role': 'user',
}),
dict({
'content': list([
dict({
'text': '{"characters": ["Mario", "Luigi"]}',
'type': 'text',
}),
]),
'role': 'assistant',
}),
]),
'model': 'claude-sonnet-4-0',
'stream': True,
'system': list([
dict({
'cache_control': dict({
'type': 'ephemeral',
}),
'text': '''
You are a Home Assistant expert and help users with their tasks.
Current time is 04:00:00. Today's date is 2026-01-01.
''',
'type': 'text',
}),
]),
'temperature': 1.0,
'thinking': dict({
'type': 'disabled',
}),
'tool_choice': dict({
'name': 'test_task',
'type': 'tool',
}),
'tools': list([
dict({
'description': 'Use this tool to reply to the user',
'input_schema': dict({
'properties': dict({
'characters': dict({
'items': dict({
'type': 'string',
}),
'type': 'array',
}),
}),
'required': list([
'characters',
]),
'type': 'object',
}),
'name': 'test_task',
}),
]),
})
# ---
# name: test_generate_structured_data_legacy_extended_thinking
dict({
'max_tokens': 3000,
'messages': list([
dict({
'content': 'Generate test data',
'role': 'user',
}),
dict({
'content': list([
dict({
'text': '{"characters": ["Mario", "Luigi"]}',
'type': 'text',
}),
]),
'role': 'assistant',
}),
]),
'model': 'claude-sonnet-4-0',
'stream': True,
'system': list([
dict({
'cache_control': dict({
'type': 'ephemeral',
}),
'text': '''
You are a Home Assistant expert and help users with their tasks.
Current time is 04:00:00. Today's date is 2026-01-01.
''',
'type': 'text',
}),
dict({
'text': "Claude MUST use the 'test_task' tool to provide the final answer instead of plain text.",
'type': 'text',
}),
]),
'thinking': dict({
'budget_tokens': 1500,
'type': 'enabled',
}),
'tool_choice': dict({
'type': 'auto',
}),
'tools': list([
dict({
'description': 'Use this tool to reply to the user',
'input_schema': dict({
'properties': dict({
'characters': dict({
'items': dict({
'type': 'string',
}),
'type': 'array',
}),
}),
'required': list([
'characters',
]),
'type': 'object',
}),
'name': 'test_task',
}),
]),
})
# ---
# name: test_generate_structured_data_legacy_tools
dict({
'max_tokens': 3000,
'messages': list([
dict({
'content': 'Generate test data',
'role': 'user',
}),
dict({
'content': list([
dict({
'text': '{"characters": ["Mario", "Luigi"]}',
'type': 'text',
}),
]),
'role': 'assistant',
}),
]),
'model': 'claude-sonnet-4-0',
'stream': True,
'system': list([
dict({
'cache_control': dict({
'type': 'ephemeral',
}),
'text': '''
You are a Home Assistant expert and help users with their tasks.
Current time is 04:00:00. Today's date is 2026-01-01.
''',
'type': 'text',
}),
]),
'temperature': 1.0,
'thinking': dict({
'type': 'disabled',
}),
'tool_choice': dict({
'type': 'any',
}),
'tools': list([
dict({
'max_uses': None,
'name': 'web_search',
'type': 'web_search_20250305',
}),
dict({
'description': 'Use this tool to reply to the user',
'input_schema': dict({
'properties': dict({
'characters': dict({
'items': dict({
'type': 'string',
}),
'type': 'array',
}),
}),
'required': list([
'characters',
]),
'type': 'object',
}),
'name': 'test_task',
}),
]),
})
# ---
@@ -1,4 +1,118 @@
# serializer version: 1
# name: test_disabled_thinking
list([
dict({
'content': '''
You are a voice assistant for Home Assistant.
Answer questions about the world truthfully.
Answer in plain text. Keep it simple and to the point.
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
''',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'system',
}),
dict({
'attachments': None,
'content': 'hello',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': 'Hello, how can I help you today?',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
'tool_calls': None,
}),
])
# ---
# name: test_disabled_thinking.1
dict({
'max_tokens': 3000,
'messages': list([
dict({
'content': 'hello',
'role': 'user',
}),
dict({
'content': list([
dict({
'text': 'Hello, how can I help you today?',
'type': 'text',
}),
]),
'role': 'assistant',
}),
]),
'model': 'claude-opus-4-6',
'stream': True,
'system': list([
dict({
'cache_control': dict({
'type': 'ephemeral',
}),
'text': '''
You are a voice assistant for Home Assistant.
Answer questions about the world truthfully.
Answer in plain text. Keep it simple and to the point.
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
''',
'type': 'text',
}),
]),
'temperature': 1.0,
'thinking': dict({
'type': 'disabled',
}),
})
# ---
# name: test_extended_thinking
dict({
'max_tokens': 3000,
'messages': list([
dict({
'content': 'hello',
'role': 'user',
}),
dict({
'content': list([
dict({
'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
'thinking': 'The user has just greeted me with "Hi". This is a simple greeting and doesn\'t require any Home Assistant function calls. I should respond with a friendly greeting and let them know I\'m available to help with their smart home.',
'type': 'thinking',
}),
dict({
'text': 'Hello, how can I help you today?',
'type': 'text',
}),
]),
'role': 'assistant',
}),
]),
'model': 'claude-3-7-sonnet-latest',
'stream': True,
'system': list([
dict({
'cache_control': dict({
'type': 'ephemeral',
}),
'text': '''
You are a voice assistant for Home Assistant.
Answer questions about the world truthfully.
Answer in plain text. Keep it simple and to the point.
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
''',
'type': 'text',
}),
]),
'thinking': dict({
'budget_tokens': 1500,
'type': 'enabled',
}),
})
# ---
# name: test_extended_thinking_tool_call
list([
dict({
+221 -8
View File
@@ -3,10 +3,13 @@
from pathlib import Path
from unittest.mock import AsyncMock, patch
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
from homeassistant.components import ai_task, media_source
from homeassistant.components.anthropic.const import CONF_CHAT_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
@@ -51,13 +54,45 @@ async def test_generate_data(
assert result.data == "The test data"
async def test_generate_structured_data_legacy(
async def test_empty_data(
hass: HomeAssistant,
mock_config_entry_with_no_structured_output: MockConfigEntry,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task data generation but the data returned is empty."""
mock_create_stream.return_value = [create_content_block(0, [""])]
with pytest.raises(
HomeAssistantError, match="Last content in chat log is not an AssistantContent"
):
await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.claude_ai_task",
instructions="Generate test data",
)
@freeze_time("2026-01-01 12:00:00")
async def test_generate_structured_data_legacy(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test AI Task structured data generation with legacy method."""
for subentry in mock_config_entry.subentries.values():
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={
CONF_CHAT_MODEL: "claude-sonnet-4-0",
},
)
mock_create_stream.return_value = [
create_tool_use_block(
1,
@@ -86,15 +121,122 @@ async def test_generate_structured_data_legacy(
)
assert result.data == {"characters": ["Mario", "Luigi"]}
assert mock_create_stream.call_args.kwargs.copy() == snapshot
@freeze_time("2026-01-01 12:00:00")
async def test_generate_structured_data_legacy_tools(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test AI Task structured data generation with legacy method and tools enabled."""
mock_create_stream.return_value = [
create_tool_use_block(
1,
"toolu_0123456789AbCdEfGhIjKlM",
"test_task",
['{"charac', 'ters": ["Mario', '", "Luigi"]}'],
),
]
for subentry in mock_config_entry.subentries.values():
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={"chat_model": "claude-sonnet-4-0", "web_search": True},
)
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.claude_ai_task",
instructions="Generate test data",
structure=vol.Schema(
{
vol.Required("characters"): selector.selector(
{
"text": {
"multiple": True,
}
}
)
},
),
)
assert result.data == {"characters": ["Mario", "Luigi"]}
assert mock_create_stream.call_args.kwargs.copy() == snapshot
@freeze_time("2026-01-01 12:00:00")
async def test_generate_structured_data_legacy_extended_thinking(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test AI Task structured data generation with legacy method and extended_thinking."""
mock_create_stream.return_value = [
create_tool_use_block(
1,
"toolu_0123456789AbCdEfGhIjKlM",
"test_task",
['{"charac', 'ters": ["Mario', '", "Luigi"]}'],
),
]
for subentry in mock_config_entry.subentries.values():
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={
"chat_model": "claude-sonnet-4-0",
"thinking_budget": 1500,
},
)
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.claude_ai_task",
instructions="Generate test data",
structure=vol.Schema(
{
vol.Required("characters"): selector.selector(
{
"text": {
"multiple": True,
}
}
)
},
),
)
assert result.data == {"characters": ["Mario", "Luigi"]}
assert mock_create_stream.call_args.kwargs.copy() == snapshot
async def test_generate_invalid_structured_data_legacy(
hass: HomeAssistant,
mock_config_entry_with_no_structured_output: MockConfigEntry,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
) -> None:
"""Test AI Task with invalid JSON response with legacy method."""
for subentry in mock_config_entry.subentries.values():
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={
CONF_CHAT_MODEL: "claude-sonnet-4-0",
},
)
mock_create_stream.return_value = [
create_tool_use_block(
1,
@@ -126,11 +268,13 @@ async def test_generate_invalid_structured_data_legacy(
)
@freeze_time("2026-01-01 12:00:00")
async def test_generate_structured_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test AI Task structured data generation."""
mock_create_stream.return_value = [
@@ -156,6 +300,7 @@ async def test_generate_structured_data(
)
assert result.data == {"characters": ["Mario", "Luigi"]}
assert mock_create_stream.call_args.kwargs.copy() == snapshot
async def test_generate_data_with_attachments(
@@ -177,7 +322,7 @@ async def test_generate_data_with_attachments(
side_effect=[
media_source.PlayMedia(
url="http://example.com/doorbell_snapshot.jpg",
mime_type="image/jpeg",
mime_type="image/jpg",
path=Path("doorbell_snapshot.jpg"),
),
media_source.PlayMedia(
@@ -188,10 +333,6 @@ async def test_generate_data_with_attachments(
],
),
patch("pathlib.Path.exists", return_value=True),
patch(
"homeassistant.components.openai_conversation.entity.guess_file_type",
return_value=("image/jpeg", None),
),
patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"),
):
result = await ai_task.async_generate_data(
@@ -242,3 +383,75 @@ async def test_generate_data_with_attachments(
assert document_block["source"]["data"] == "ZmFrZV9pbWFnZV9kYXRh"
assert document_block["source"]["media_type"] == "application/pdf"
assert document_block["source"]["type"] == "base64"
async def test_generate_data_invalid_attachments(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task data generation with attachments of unsupported type."""
entity_id = "ai_task.claude_ai_task"
mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])]
# Test path that doesn't exist
with (
patch(
"homeassistant.components.media_source.async_resolve_media",
side_effect=[
media_source.PlayMedia(
url="http://example.com/doorbell_snapshot.jpg",
mime_type="image/jpeg",
path=Path("doorbell_snapshot.jpg"),
)
],
),
patch("pathlib.Path.exists", return_value=False),
pytest.raises(
HomeAssistantError, match="`doorbell_snapshot.jpg` does not exist"
),
):
await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id=entity_id,
instructions="Test prompt",
attachments=[
{"media_content_id": "media-source://media/doorbell_snapshot.jpg"},
],
)
# Test unsupported file type
with (
patch(
"homeassistant.components.media_source.async_resolve_media",
side_effect=[
media_source.PlayMedia(
url="http://example.com/doorbell_snapshot.txt",
mime_type=None,
path=Path("doorbell_snapshot.txt"),
)
],
),
patch("pathlib.Path.exists", return_value=True),
patch(
"homeassistant.components.anthropic.entity.guess_file_type",
return_value=("text/plain", None),
),
pytest.raises(
HomeAssistantError,
match="Only images and PDF are supported by the Anthropic API",
),
):
await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id=entity_id,
instructions="Test prompt",
attachments=[
{"media_content_id": "media-source://media/doorbell_snapshot.txt"},
],
)
@@ -18,6 +18,18 @@ from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
from homeassistant.components import conversation
from homeassistant.components.anthropic.const import (
CONF_CHAT_MODEL,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_MAX_USES,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
)
from homeassistant.components.anthropic.entity import CitationDetails, ContentDetails
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import Context, HomeAssistant
@@ -517,11 +529,22 @@ async def test_refusal(
async def test_extended_thinking(
hass: HomeAssistant,
mock_config_entry_with_extended_thinking: MockConfigEntry,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test extended thinking support."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-3-7-sonnet-latest",
CONF_THINKING_BUDGET: 1500,
},
)
mock_create_stream.return_value = [
(
*create_thinking_block(
@@ -550,12 +573,52 @@ async def test_extended_thinking(
assert len(chat_log.content) == 3
assert chat_log.content[1].content == "hello"
assert chat_log.content[2].content == "Hello, how can I help you today?"
call_args = mock_create_stream.call_args.kwargs.copy()
call_args.pop("tools", None)
assert call_args == snapshot
@freeze_time("2024-05-24 12:00:00")
async def test_disabled_thinking(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test conversation with thinking effort disabled."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: "assist",
CONF_CHAT_MODEL: "claude-opus-4-6",
CONF_THINKING_EFFORT: "none",
},
)
mock_create_stream.return_value = [
create_content_block(1, ["Hello, how can I help you today?"])
]
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
)
chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get(
result.conversation_id
)
assert len(chat_log.content) == 3
assert chat_log.content == snapshot
call_args = mock_create_stream.call_args.kwargs.copy()
call_args.pop("tools", None)
assert call_args == snapshot
@freeze_time("2024-05-24 12:00:00")
async def test_redacted_thinking(
hass: HomeAssistant,
mock_config_entry_with_extended_thinking: MockConfigEntry,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
@@ -590,12 +653,22 @@ async def test_redacted_thinking(
async def test_extended_thinking_tool_call(
mock_get_tools,
hass: HomeAssistant,
mock_config_entry_with_adaptive_thinking: MockConfigEntry,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test that thinking blocks and their order are preserved in with tool calls."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-opus-4-6",
CONF_THINKING_EFFORT: "medium",
},
)
agent_id = "conversation.claude_conversation"
context = Context()
@@ -658,12 +731,28 @@ async def test_extended_thinking_tool_call(
@freeze_time("2025-10-31 12:00:00")
async def test_web_search(
hass: HomeAssistant,
mock_config_entry_with_web_search: MockConfigEntry,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test web search."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-sonnet-4-5",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_MAX_USES: 5,
CONF_WEB_SEARCH_USER_LOCATION: True,
CONF_WEB_SEARCH_CITY: "San Francisco",
CONF_WEB_SEARCH_REGION: "California",
CONF_WEB_SEARCH_COUNTRY: "US",
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
},
)
web_search_results = [
WebSearchResultBlock(
type="web_search_result",
+110 -3
View File
@@ -12,11 +12,11 @@ from anthropic import (
from httpx import URL, Request, Response
import pytest
from homeassistant.components.anthropic.const import DOMAIN
from homeassistant.components.anthropic.const import DATA_REPAIR_DEFER_RELOAD, DOMAIN
from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData
from homeassistant.const import CONF_API_KEY
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er, llm
from homeassistant.helpers.device_registry import DeviceEntryDisabler
from homeassistant.helpers.entity_registry import RegistryEntryDisabler
from homeassistant.setup import async_setup_component
@@ -70,6 +70,113 @@ async def test_init_error(
assert error in caplog.text
async def test_deferred_update(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
) -> None:
"""Test that update is deferred."""
for subentry in mock_config_entry.subentries.values():
if subentry.subentry_type == "conversation":
conversation_subentry = subentry
elif subentry.subentry_type == "ai_task_data":
ai_task_subentry = subentry
old_client = mock_config_entry.runtime_data
# Set deferred update
defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(
DATA_REPAIR_DEFER_RELOAD, set()
)
defer_reload_entries.add(mock_config_entry.entry_id)
# Update the conversation subentry
hass.config_entries.async_update_subentry(
mock_config_entry,
conversation_subentry,
data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
)
await hass.async_block_till_done()
# Verify that the entry is not reloaded yet
assert mock_config_entry.runtime_data is old_client
# Clear deferred update
defer_reload_entries.discard(mock_config_entry.entry_id)
# Update the AI Task subentry
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_subentry,
data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
)
await hass.async_block_till_done()
# Verify that the entry is reloaded
assert mock_config_entry.runtime_data is not old_client
async def test_downgrade_from_v3_to_v2(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that migration from future versions is skipped."""
# Create a v3 config entry
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_API_KEY: "test-api-key"},
version=3,
minor_version=0,
subentries_data=[
{
"data": {
"recommended": True,
"llm_hass_api": ["assist"],
"prompt": "You are a helpful assistant",
"chat_model": "claude-3-haiku-20240307",
},
"subentry_id": "mock_id",
"subentry_type": "conversation",
"title": "Claude haiku",
"unique_id": None,
},
],
)
mock_config_entry.add_to_hass(hass)
conversation_device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
config_subentry_id="mock_id",
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="Anthropic",
model="Claude",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity_registry.async_get_or_create(
"conversation",
DOMAIN,
mock_config_entry.entry_id,
config_entry=mock_config_entry,
config_subentry_id="mock_id",
device_id=conversation_device.id,
suggested_object_id="claude",
)
# Run migration
with patch(
"homeassistant.components.anthropic.async_setup_entry",
return_value=True,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify migration was skipped and version was not updated
assert mock_config_entry.version == 3
assert mock_config_entry.minor_version == 0
async def test_migration_from_v1_to_v2(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,