From c67c19413bce8bdfaeca58a38ed7b2b85500dfb7 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 14 Feb 2026 23:33:53 +0300 Subject: [PATCH] Improve Anthropic coverage (#163011) --- tests/components/anthropic/conftest.py | 83 ------ .../anthropic/snapshots/test_ai_task.ambr | 252 ++++++++++++++++++ .../snapshots/test_conversation.ambr | 114 ++++++++ tests/components/anthropic/test_ai_task.py | 229 +++++++++++++++- .../components/anthropic/test_conversation.py | 97 ++++++- tests/components/anthropic/test_init.py | 113 +++++++- 6 files changed, 790 insertions(+), 98 deletions(-) create mode 100644 tests/components/anthropic/snapshots/test_ai_task.ambr diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 786d00f7bb93..61f1139f7549 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -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 diff --git a/tests/components/anthropic/snapshots/test_ai_task.ambr b/tests/components/anthropic/snapshots/test_ai_task.ambr new file mode 100644 index 000000000000..6bdc29ba8fe7 --- /dev/null +++ b/tests/components/anthropic/snapshots/test_ai_task.ambr @@ -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', + }), + ]), + }) +# --- diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index acb4ced2c36e..991997cb91a8 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -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({ diff --git a/tests/components/anthropic/test_ai_task.py b/tests/components/anthropic/test_ai_task.py index d9fda7d0aa96..0d1ea94540ef 100644 --- a/tests/components/anthropic/test_ai_task.py +++ b/tests/components/anthropic/test_ai_task.py @@ -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"}, + ], + ) diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 8c902a7840e0..417c19f0bfa3 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -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", diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 10626d24fc6f..89f8a6355b98 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -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,