diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 1480a56cd23..85343bb28ff 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -3,14 +3,14 @@ from __future__ import annotations import asyncio -from collections import defaultdict from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass import functools import logging from pathlib import Path import re -from typing import IO, Any +import time +from typing import IO, Any, cast from hassil.expression import Expression, ListReference, Sequence from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList @@ -36,7 +36,7 @@ from homeassistant.helpers import ( entity_registry as er, floor_registry as fr, intent, - start, + start as ha_start, template, translation, ) @@ -60,6 +60,7 @@ METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_FILE = "hass_custom_file" DATA_DEFAULT_ENTITY = "conversation_default_entity" +ERROR_SENTINEL = object() @core.callback @@ -140,7 +141,7 @@ async def async_setup_default_agent( async_should_expose(hass, DOMAIN, state.entity_id) async_track_state_added_domain(hass, MATCH_ALL, async_entity_state_listener) - start.async_at_started(hass, async_hass_started) + ha_start.async_at_started(hass, async_hass_started) class DefaultAgent(ConversationEntity): @@ -154,8 +155,7 @@ class DefaultAgent(ConversationEntity): ) -> None: """Initialize the default agent.""" self.hass = hass - self._lang_intents: dict[str, LanguageIntents] = {} - self._lang_lock: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self._lang_intents: dict[str, LanguageIntents | object] = {} # intent -> [sentences] self._config_intents: dict[str, Any] = config_intents @@ -220,12 +220,7 @@ class DefaultAgent(ConversationEntity): return trigger_result language = user_input.language or self.hass.config.language - lang_intents = self._lang_intents.get(language) - - # Reload intents if missing or new components - if lang_intents is None: - # Load intents in executor - lang_intents = await self.async_get_or_load_intents(language) + lang_intents = await self.async_get_or_load_intents(language) if lang_intents is None: # No intents loaded @@ -235,7 +230,9 @@ class DefaultAgent(ConversationEntity): slot_lists = self._make_slot_lists() intent_context = self._make_intent_context(user_input) - return await self.hass.async_add_executor_job( + start = time.monotonic() + + result = await self.hass.async_add_executor_job( self._recognize, user_input, lang_intents, @@ -244,6 +241,13 @@ class DefaultAgent(ConversationEntity): language, ) + _LOGGER.debug( + "Recognize done in %.2f seconds", + time.monotonic() - start, + ) + + return result + async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" language = user_input.language or self.hass.config.language @@ -297,7 +301,7 @@ class DefaultAgent(ConversationEntity): return ConversationResult(response=response) # Intent match or failure - lang_intents = self._lang_intents.get(language) + lang_intents = await self.async_get_or_load_intents(language) if result is None: # Intent was not recognized @@ -534,19 +538,16 @@ class DefaultAgent(ConversationEntity): recognize_result: RecognizeResult, ) -> str: # Make copies of the states here so we can add translated names for responses. - matched: list[core.State] = [] - - for state in intent_response.matched_states: - state_copy = core.State.from_dict(state.as_dict()) - if state_copy is not None: - matched.append(state_copy) - - unmatched: list[core.State] = [] - for state in intent_response.unmatched_states: - state_copy = core.State.from_dict(state.as_dict()) - if state_copy is not None: - unmatched.append(state_copy) - + matched = [ + state_copy + for state in intent_response.matched_states + if (state_copy := core.State.from_dict(state.as_dict())) + ] + unmatched = [ + state_copy + for state in intent_response.unmatched_states + if (state_copy := core.State.from_dict(state.as_dict())) + ] all_states = matched + unmatched domains = {state.domain for state in all_states} translations = await translation.async_get_translations( @@ -620,159 +621,149 @@ class DefaultAgent(ConversationEntity): lang_intents = await self.async_get_or_load_intents(language) + # No intents loaded if lang_intents is None: - # No intents loaded - _LOGGER.warning("No intents were loaded for language: %s", language) return self._make_slot_lists() async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None: """Load all intents of a language with lock.""" - hass_components = set(self.hass.config.components) - async with self._lang_lock[language]: - return await self.hass.async_add_executor_job( - self._get_or_load_intents, language, hass_components + if lang_intents := self._lang_intents.get(language): + return ( + None + if lang_intents is ERROR_SENTINEL + else cast(LanguageIntents, lang_intents) ) - def _get_or_load_intents( - self, language: str, hass_components: set[str] - ) -> LanguageIntents | None: - """Load all intents for language (run inside executor).""" - lang_intents = self._lang_intents.get(language) + start = time.monotonic() - if lang_intents is None: - intents_dict: dict[str, Any] = {} - language_variant: str | None = None + result = await self.hass.async_add_executor_job(self._load_intents, language) + + if result is None: + self._lang_intents[language] = ERROR_SENTINEL else: - intents_dict = lang_intents.intents_dict - language_variant = lang_intents.language_variant + self._lang_intents[language] = result + _LOGGER.debug( + "Full intents load completed for language=%s in %.2f seconds", + language, + time.monotonic() - start, + ) + + return result + + def _load_intents(self, language: str) -> LanguageIntents | None: + """Load all intents for language (run inside executor).""" + intents_dict: dict[str, Any] = {} + language_variant: str | None = None supported_langs = set(get_languages()) - intents_changed = False + + # Choose a language variant upfront and commit to it for custom + # sentences, etc. + all_language_variants = {lang.lower(): lang for lang in supported_langs} + + # en-US, en_US, en, ... + for maybe_variant in _get_language_variations(language): + matching_variant = all_language_variants.get(maybe_variant.lower()) + if matching_variant: + language_variant = matching_variant + break if not language_variant: - # Choose a language variant upfront and commit to it for custom - # sentences, etc. - all_language_variants = {lang.lower(): lang for lang in supported_langs} + _LOGGER.warning( + "Unable to find supported language variant for %s", language + ) + return None - # en-US, en_US, en, ... - for maybe_variant in _get_language_variations(language): - matching_variant = all_language_variants.get(maybe_variant.lower()) - if matching_variant: - language_variant = matching_variant - break + # Load intents for this language variant + lang_variant_intents = get_intents(language_variant, json_load=json_load) - if not language_variant: - _LOGGER.warning( - "Unable to find supported language variant for %s", language - ) - return None + if lang_variant_intents: + # Merge sentences into existing dictionary + # Overriding because source dict is empty + intents_dict = lang_variant_intents - # Load intents for this language variant - lang_variant_intents = get_intents(language_variant, json_load=json_load) - - if lang_variant_intents: - # Merge sentences into existing dictionary - merge_dict(intents_dict, lang_variant_intents) - - # Will need to recreate graph - intents_changed = True - _LOGGER.debug( - "Loaded intents language=%s (%s)", - language, - language_variant, - ) + _LOGGER.debug( + "Loaded built-in intents for language=%s (%s)", + language, + language_variant, + ) # Check for custom sentences in /custom_sentences// - if lang_intents is None: - # Only load custom sentences once, otherwise they will be re-loaded - # when components change. - custom_sentences_dir = Path( - self.hass.config.path("custom_sentences", language_variant) - ) - if custom_sentences_dir.is_dir(): - for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): - with custom_sentences_path.open( - encoding="utf-8" - ) as custom_sentences_file: - # Merge custom sentences - if isinstance( - custom_sentences_yaml := yaml.safe_load( - custom_sentences_file - ), - dict, - ): - # Add metadata so we can identify custom sentences in the debugger - custom_intents_dict = custom_sentences_yaml.get( - "intents", {} + custom_sentences_dir = Path( + self.hass.config.path("custom_sentences", language_variant) + ) + if custom_sentences_dir.is_dir(): + for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"): + with custom_sentences_path.open( + encoding="utf-8" + ) as custom_sentences_file: + # Merge custom sentences + if not isinstance( + custom_sentences_yaml := yaml.safe_load(custom_sentences_file), + dict, + ): + _LOGGER.warning( + "Custom sentences file does not match expected format path=%s", + custom_sentences_file.name, + ) + continue + + # Add metadata so we can identify custom sentences in the debugger + custom_intents_dict = custom_sentences_yaml.get("intents", {}) + for intent_dict in custom_intents_dict.values(): + intent_data_list = intent_dict.get("data", []) + for intent_data in intent_data_list: + sentence_metadata = intent_data.get("metadata", {}) + sentence_metadata[METADATA_CUSTOM_SENTENCE] = True + sentence_metadata[METADATA_CUSTOM_FILE] = str( + custom_sentences_path.relative_to( + custom_sentences_dir.parent + ) ) - for intent_dict in custom_intents_dict.values(): - intent_data_list = intent_dict.get("data", []) - for intent_data in intent_data_list: - sentence_metadata = intent_data.get("metadata", {}) - sentence_metadata[METADATA_CUSTOM_SENTENCE] = True - sentence_metadata[METADATA_CUSTOM_FILE] = str( - custom_sentences_path.relative_to( - custom_sentences_dir.parent - ) - ) - intent_data["metadata"] = sentence_metadata + intent_data["metadata"] = sentence_metadata - merge_dict(intents_dict, custom_sentences_yaml) - else: - _LOGGER.warning( - "Custom sentences file does not match expected format path=%s", - custom_sentences_file.name, - ) + merge_dict(intents_dict, custom_sentences_yaml) - # Will need to recreate graph - intents_changed = True - _LOGGER.debug( - "Loaded custom sentences language=%s (%s), path=%s", - language, - language_variant, - custom_sentences_path, - ) - - # Load sentences from HA config for default language only - if self._config_intents and ( - self.hass.config.language in (language, language_variant) - ): - hass_config_path = self.hass.config.path() - merge_dict( - intents_dict, - { - "intents": { - intent_name: { - "data": [ - { - "sentences": sentences, - "metadata": { - METADATA_CUSTOM_SENTENCE: True, - METADATA_CUSTOM_FILE: hass_config_path, - }, - } - ] - } - for intent_name, sentences in self._config_intents.items() - } - }, - ) - intents_changed = True _LOGGER.debug( - "Loaded intents from configuration.yaml", + "Loaded custom sentences language=%s (%s), path=%s", + language, + language_variant, + custom_sentences_path, ) + # Load sentences from HA config for default language only + if self._config_intents and ( + self.hass.config.language in (language, language_variant) + ): + hass_config_path = self.hass.config.path() + merge_dict( + intents_dict, + { + "intents": { + intent_name: { + "data": [ + { + "sentences": sentences, + "metadata": { + METADATA_CUSTOM_SENTENCE: True, + METADATA_CUSTOM_FILE: hass_config_path, + }, + } + ] + } + for intent_name, sentences in self._config_intents.items() + } + }, + ) + _LOGGER.debug( + "Loaded intents from configuration.yaml", + ) + if not intents_dict: return None - if not intents_changed and lang_intents is not None: - return lang_intents - - # This can be made faster by not re-parsing existing sentences. - # But it will likely only be called once anyways, unless new - # components with sentences are often being loaded. intents = Intents.from_dict(intents_dict) # Load responses @@ -780,27 +771,22 @@ class DefaultAgent(ConversationEntity): intent_responses = responses_dict.get("intents", {}) error_responses = responses_dict.get("errors", {}) - if lang_intents is None: - lang_intents = LanguageIntents( - intents, - intents_dict, - intent_responses, - error_responses, - language_variant, - ) - self._lang_intents[language] = lang_intents - else: - lang_intents.intents = intents - lang_intents.intent_responses = intent_responses - lang_intents.error_responses = error_responses - - return lang_intents + return LanguageIntents( + intents, + intents_dict, + intent_responses, + error_responses, + language_variant, + ) @core.callback def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None: """Clear slot lists when a registry has changed.""" + # Two subscribers can be scheduled at same time + _LOGGER.debug("Clearing slot lists") + if self._unsub_clear_slot_list is None: + return self._slot_lists = None - assert self._unsub_clear_slot_list is not None for unsub in self._unsub_clear_slot_list: unsub() self._unsub_clear_slot_list = None @@ -811,6 +797,8 @@ class DefaultAgent(ConversationEntity): if self._slot_lists is not None: return self._slot_lists + start = time.monotonic() + entity_registry = er.async_get(self.hass) states = [ state @@ -891,6 +879,12 @@ class DefaultAgent(ConversationEntity): } self._listen_clear_slot_list() + + _LOGGER.debug( + "Created slot lists in %.2f seconds", + time.monotonic() - start, + ) + return self._slot_lists def _make_intent_context( @@ -934,6 +928,7 @@ class DefaultAgent(ConversationEntity): return response_template.async_render(response_args) + @core.callback def register_trigger( self, sentences: list[str], @@ -948,6 +943,7 @@ class DefaultAgent(ConversationEntity): return functools.partial(self._unregister_trigger, trigger_data) + @core.callback def _rebuild_trigger_intents(self) -> None: """Rebuild the HassIL intents object from the current trigger sentences.""" intents_dict = { @@ -961,20 +957,23 @@ class DefaultAgent(ConversationEntity): }, } - self._trigger_intents = Intents.from_dict(intents_dict) + trigger_intents = Intents.from_dict(intents_dict) # Assume slot list references are wildcards wildcard_names: set[str] = set() - for trigger_intent in self._trigger_intents.intents.values(): + for trigger_intent in trigger_intents.intents.values(): for intent_data in trigger_intent.data: for sentence in intent_data.sentences: _collect_list_references(sentence, wildcard_names) for wildcard_name in wildcard_names: - self._trigger_intents.slot_lists[wildcard_name] = WildcardSlotList() + trigger_intents.slot_lists[wildcard_name] = WildcardSlotList() + + self._trigger_intents = trigger_intents _LOGGER.debug("Rebuilt trigger intents: %s", intents_dict) + @core.callback def _unregister_trigger(self, trigger_data: TriggerData) -> None: """Unregister a set of trigger sentences.""" self._trigger_sentences.remove(trigger_data) diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr new file mode 100644 index 00000000000..7cdc239363d --- /dev/null +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -0,0 +1,606 @@ +# serializer version: 1 +# name: test_custom_sentences + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en-us', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'You ordered a stout', + }), + }), + }), + }) +# --- +# name: test_custom_sentences.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en-us', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'You ordered a lager', + }), + }), + }), + }) +# --- +# name: test_custom_sentences_config + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Stealth mode engaged', + }), + }), + }), + }) +# --- +# name: test_intent_alias_added_removed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_alias_added_removed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_alias_added_removed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I am not aware of any device called late added alias', + }), + }), + }), + }) +# --- +# name: test_intent_conversion_not_expose_new + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I am not aware of any device called kitchen light', + }), + }), + }), + }) +# --- +# name: test_intent_conversion_not_expose_new.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_added_removed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_added_removed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.late', + 'name': 'friendly light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_added_removed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.late', + 'name': 'friendly light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_added_removed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I am not aware of any device called late added light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_exposed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_exposed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_exposed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I am not aware of any device called kitchen light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_exposed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I am not aware of any device called my cool light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_exposed.4 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_exposed.5 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_renamed + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_renamed.1 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'renamed light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_renamed.2 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I am not aware of any device called kitchen light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_renamed.3 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen light', + 'type': , + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_intent_entity_renamed.4 + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Sorry, I am not aware of any device called renamed light', + }), + }), + }), + }) +# --- diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr new file mode 100644 index 00000000000..fd02646df48 --- /dev/null +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -0,0 +1,711 @@ +# serializer version: 1 +# name: test_get_agent_list + dict({ + 'agents': list([ + dict({ + 'id': 'conversation.home_assistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'af', + 'ar', + 'bg', + 'bn', + 'ca', + 'cs', + 'da', + 'de', + 'de-CH', + 'el', + 'en', + 'es', + 'et', + 'eu', + 'fa', + 'fi', + 'fr', + 'fr-CA', + 'gl', + 'gu', + 'he', + 'hi', + 'hr', + 'hu', + 'id', + 'is', + 'it', + 'ka', + 'kn', + 'ko', + 'lb', + 'lt', + 'lv', + 'ml', + 'mn', + 'ms', + 'nb', + 'nl', + 'pl', + 'pt', + 'pt-br', + 'ro', + 'ru', + 'sk', + 'sl', + 'sr', + 'sv', + 'sw', + 'te', + 'tr', + 'uk', + 'ur', + 'vi', + 'zh-cn', + 'zh-hk', + 'zh-tw', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + 'smurfish', + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.1 + dict({ + 'agents': list([ + dict({ + 'id': 'conversation.home_assistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + 'smurfish', + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.2 + dict({ + 'agents': list([ + dict({ + 'id': 'conversation.home_assistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'en', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.3 + dict({ + 'agents': list([ + dict({ + 'id': 'conversation.home_assistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'en', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.4 + dict({ + 'agents': list([ + dict({ + 'id': 'conversation.home_assistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'de', + 'de-CH', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_get_agent_list.5 + dict({ + 'agents': list([ + dict({ + 'id': 'conversation.home_assistant', + 'name': 'Home Assistant', + 'supported_languages': list([ + 'de-CH', + 'de', + ]), + }), + dict({ + 'id': 'mock-entry', + 'name': 'Mock Title', + 'supported_languages': list([ + ]), + }), + dict({ + 'id': 'mock-entry-support-all', + 'name': 'Mock Title', + 'supported_languages': '*', + }), + ]), + }) +# --- +# name: test_http_api_handle_failure + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'failed_to_handle', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'An unexpected error occurred', + }), + }), + }), + }) +# --- +# name: test_http_api_no_match + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_http_api_unexpected_failure + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'unknown', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'An unexpected error occurred', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent[None] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent[conversation.home_assistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_http_processing_intent[homeassistant] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + dict({ + 'id': 'light.kitchen', + 'name': 'kitchen', + 'type': 'entity', + }), + ]), + 'targets': list([ + ]), + }), + 'language': 'en', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Turned on the light', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload0] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload1] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'test-language', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload2] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload3] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload4] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'test-language', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_api[payload5] + dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }) +# --- +# name: test_ws_hass_agent_debug + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'name': dict({ + 'name': 'name', + 'text': 'my cool light', + 'value': 'my cool light', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOn', + }), + 'match': True, + 'sentence_template': ' on ( | [in ])', + 'slots': dict({ + 'name': 'my cool light', + }), + 'source': 'builtin', + 'targets': dict({ + 'light.kitchen': dict({ + 'matched': True, + }), + }), + 'unmatched_slots': dict({ + }), + }), + dict({ + 'details': dict({ + 'name': dict({ + 'name': 'name', + 'text': 'my cool light', + 'value': 'my cool light', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOff', + }), + 'match': True, + 'sentence_template': '[] ( | [in ]) [to] off', + 'slots': dict({ + 'name': 'my cool light', + }), + 'source': 'builtin', + 'targets': dict({ + 'light.kitchen': dict({ + 'matched': True, + }), + }), + 'unmatched_slots': dict({ + }), + }), + dict({ + 'details': dict({ + 'area': dict({ + 'name': 'area', + 'text': 'kitchen', + 'value': 'kitchen', + }), + 'domain': dict({ + 'name': 'domain', + 'text': '', + 'value': 'light', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOn', + }), + 'match': True, + 'sentence_template': ' on [all] in ', + 'slots': dict({ + 'area': 'kitchen', + 'domain': 'light', + }), + 'source': 'builtin', + 'targets': dict({ + 'light.kitchen': dict({ + 'matched': True, + }), + }), + 'unmatched_slots': dict({ + }), + }), + dict({ + 'details': dict({ + 'area': dict({ + 'name': 'area', + 'text': 'kitchen', + 'value': 'kitchen', + }), + 'domain': dict({ + 'name': 'domain', + 'text': 'lights', + 'value': 'light', + }), + 'state': dict({ + 'name': 'state', + 'text': 'on', + 'value': 'on', + }), + }), + 'intent': dict({ + 'name': 'HassGetState', + }), + 'match': True, + 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in ]', + 'slots': dict({ + 'area': 'kitchen', + 'domain': 'lights', + 'state': 'on', + }), + 'source': 'builtin', + 'targets': dict({ + 'light.kitchen': dict({ + 'matched': False, + }), + }), + 'unmatched_slots': dict({ + }), + }), + None, + ]), + }) +# --- +# name: test_ws_hass_agent_debug_custom_sentence + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'beer_style': dict({ + 'name': 'beer_style', + 'text': 'lager', + 'value': 'lager', + }), + }), + 'file': 'en/beer.yaml', + 'intent': dict({ + 'name': 'OrderBeer', + }), + 'match': True, + 'sentence_template': "I'd like to order a {beer_style} [please]", + 'slots': dict({ + 'beer_style': 'lager', + }), + 'source': 'custom', + 'targets': dict({ + }), + 'unmatched_slots': dict({ + }), + }), + ]), + }) +# --- +# name: test_ws_hass_agent_debug_null_result + dict({ + 'results': list([ + None, + ]), + }) +# --- +# name: test_ws_hass_agent_debug_out_of_range + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'brightness': dict({ + 'name': 'brightness', + 'text': '100%', + 'value': 100, + }), + 'name': dict({ + 'name': 'name', + 'text': 'test light', + 'value': 'test light', + }), + }), + 'intent': dict({ + 'name': 'HassLightSet', + }), + 'match': True, + 'sentence_template': '[] brightness [to] ', + 'slots': dict({ + 'brightness': '100%', + 'name': 'test light', + }), + 'source': 'builtin', + 'targets': dict({ + 'light.demo_1234': dict({ + 'matched': True, + }), + }), + 'unmatched_slots': dict({ + }), + }), + ]), + }) +# --- +# name: test_ws_hass_agent_debug_out_of_range.1 + dict({ + 'results': list([ + dict({ + 'details': dict({ + 'name': dict({ + 'name': 'name', + 'text': 'test light', + 'value': 'test light', + }), + }), + 'intent': dict({ + 'name': 'HassLightSet', + }), + 'match': False, + 'sentence_template': '[] brightness [to] ', + 'slots': dict({ + 'name': 'test light', + }), + 'source': 'builtin', + 'targets': dict({ + }), + 'unmatched_slots': dict({ + 'brightness': 1001, + }), + }), + ]), + }) +# --- +# name: test_ws_hass_agent_debug_sentence_trigger + dict({ + 'results': list([ + dict({ + 'match': True, + 'sentence_template': 'hello[ world]', + 'source': 'trigger', + }), + ]), + }) +# --- diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 6264e61863f..0327be064d4 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -24,81 +24,6 @@ }), }) # --- -# name: test_custom_sentences - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - 'targets': list([ - ]), - }), - 'language': 'en-us', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'You ordered a stout', - }), - }), - }), - }) -# --- -# name: test_custom_sentences.1 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - 'targets': list([ - ]), - }), - 'language': 'en-us', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'You ordered a lager', - }), - }), - }), - }) -# --- -# name: test_custom_sentences_config - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Stealth mode engaged', - }), - }), - }), - }) -# --- # name: test_get_agent_info dict({ 'id': 'conversation.home_assistant', @@ -117,918 +42,6 @@ 'name': 'Home Assistant', }) # --- -# name: test_get_agent_list - dict({ - 'agents': list([ - dict({ - 'id': 'conversation.home_assistant', - 'name': 'Home Assistant', - 'supported_languages': list([ - 'af', - 'ar', - 'bg', - 'bn', - 'ca', - 'cs', - 'da', - 'de', - 'de-CH', - 'el', - 'en', - 'es', - 'et', - 'eu', - 'fa', - 'fi', - 'fr', - 'fr-CA', - 'gl', - 'gu', - 'he', - 'hi', - 'hr', - 'hu', - 'id', - 'is', - 'it', - 'ka', - 'kn', - 'ko', - 'lb', - 'lt', - 'lv', - 'ml', - 'mn', - 'ms', - 'nb', - 'nl', - 'pl', - 'pt', - 'pt-br', - 'ro', - 'ru', - 'sk', - 'sl', - 'sr', - 'sv', - 'sw', - 'te', - 'tr', - 'uk', - 'ur', - 'vi', - 'zh-cn', - 'zh-hk', - 'zh-tw', - ]), - }), - dict({ - 'id': 'mock-entry', - 'name': 'Mock Title', - 'supported_languages': list([ - 'smurfish', - ]), - }), - dict({ - 'id': 'mock-entry-support-all', - 'name': 'Mock Title', - 'supported_languages': '*', - }), - ]), - }) -# --- -# name: test_get_agent_list.1 - dict({ - 'agents': list([ - dict({ - 'id': 'conversation.home_assistant', - 'name': 'Home Assistant', - 'supported_languages': list([ - ]), - }), - dict({ - 'id': 'mock-entry', - 'name': 'Mock Title', - 'supported_languages': list([ - 'smurfish', - ]), - }), - dict({ - 'id': 'mock-entry-support-all', - 'name': 'Mock Title', - 'supported_languages': '*', - }), - ]), - }) -# --- -# name: test_get_agent_list.2 - dict({ - 'agents': list([ - dict({ - 'id': 'conversation.home_assistant', - 'name': 'Home Assistant', - 'supported_languages': list([ - 'en', - ]), - }), - dict({ - 'id': 'mock-entry', - 'name': 'Mock Title', - 'supported_languages': list([ - ]), - }), - dict({ - 'id': 'mock-entry-support-all', - 'name': 'Mock Title', - 'supported_languages': '*', - }), - ]), - }) -# --- -# name: test_get_agent_list.3 - dict({ - 'agents': list([ - dict({ - 'id': 'conversation.home_assistant', - 'name': 'Home Assistant', - 'supported_languages': list([ - 'en', - ]), - }), - dict({ - 'id': 'mock-entry', - 'name': 'Mock Title', - 'supported_languages': list([ - ]), - }), - dict({ - 'id': 'mock-entry-support-all', - 'name': 'Mock Title', - 'supported_languages': '*', - }), - ]), - }) -# --- -# name: test_get_agent_list.4 - dict({ - 'agents': list([ - dict({ - 'id': 'conversation.home_assistant', - 'name': 'Home Assistant', - 'supported_languages': list([ - 'de', - 'de-CH', - ]), - }), - dict({ - 'id': 'mock-entry', - 'name': 'Mock Title', - 'supported_languages': list([ - ]), - }), - dict({ - 'id': 'mock-entry-support-all', - 'name': 'Mock Title', - 'supported_languages': '*', - }), - ]), - }) -# --- -# name: test_get_agent_list.5 - dict({ - 'agents': list([ - dict({ - 'id': 'conversation.home_assistant', - 'name': 'Home Assistant', - 'supported_languages': list([ - 'de-CH', - 'de', - ]), - }), - dict({ - 'id': 'mock-entry', - 'name': 'Mock Title', - 'supported_languages': list([ - ]), - }), - dict({ - 'id': 'mock-entry-support-all', - 'name': 'Mock Title', - 'supported_languages': '*', - }), - ]), - }) -# --- -# name: test_http_api_handle_failure - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'failed_to_handle', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'An unexpected error occurred', - }), - }), - }), - }) -# --- -# name: test_http_api_no_match - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_intent_match', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", - }), - }), - }), - }) -# --- -# name: test_http_api_unexpected_failure - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'unknown', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'An unexpected error occurred', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent[None] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent[conversation.home_assistant] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent[homeassistant] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_alias_added_removed - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_alias_added_removed.1 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_alias_added_removed.2 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_valid_targets', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called late added alias', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_conversion_not_expose_new - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_valid_targets', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_conversion_not_expose_new.1 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_added_removed - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_added_removed.1 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.late', - 'name': 'friendly light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_added_removed.2 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.late', - 'name': 'friendly light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_added_removed.3 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_valid_targets', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called late added light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_exposed - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_exposed.1 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_exposed.2 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_valid_targets', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_exposed.3 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_valid_targets', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called my cool light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_exposed.4 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_exposed.5 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_renamed - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_renamed.1 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'renamed light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_renamed.2 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_valid_targets', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_renamed.3 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen light', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_entity_renamed.4 - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_valid_targets', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called renamed light', - }), - }), - }), - }) -# --- -# name: test_http_processing_intent_target_ha_agent - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ 'conversation_id': None, @@ -1389,361 +402,3 @@ }), }) # --- -# name: test_ws_api[payload0] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_intent_match', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", - }), - }), - }), - }) -# --- -# name: test_ws_api[payload1] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_intent_match', - }), - 'language': 'test-language', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", - }), - }), - }), - }) -# --- -# name: test_ws_api[payload2] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_intent_match', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", - }), - }), - }), - }) -# --- -# name: test_ws_api[payload3] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_intent_match', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", - }), - }), - }), - }) -# --- -# name: test_ws_api[payload4] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_intent_match', - }), - 'language': 'test-language', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", - }), - }), - }), - }) -# --- -# name: test_ws_api[payload5] - dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_intent_match', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", - }), - }), - }), - }) -# --- -# name: test_ws_hass_agent_debug - dict({ - 'results': list([ - dict({ - 'details': dict({ - 'name': dict({ - 'name': 'name', - 'text': 'my cool light', - 'value': 'my cool light', - }), - }), - 'intent': dict({ - 'name': 'HassTurnOn', - }), - 'match': True, - 'sentence_template': ' on ( | [in ])', - 'slots': dict({ - 'name': 'my cool light', - }), - 'source': 'builtin', - 'targets': dict({ - 'light.kitchen': dict({ - 'matched': True, - }), - }), - 'unmatched_slots': dict({ - }), - }), - dict({ - 'details': dict({ - 'name': dict({ - 'name': 'name', - 'text': 'my cool light', - 'value': 'my cool light', - }), - }), - 'intent': dict({ - 'name': 'HassTurnOff', - }), - 'match': True, - 'sentence_template': '[] ( | [in ]) [to] off', - 'slots': dict({ - 'name': 'my cool light', - }), - 'source': 'builtin', - 'targets': dict({ - 'light.kitchen': dict({ - 'matched': True, - }), - }), - 'unmatched_slots': dict({ - }), - }), - dict({ - 'details': dict({ - 'area': dict({ - 'name': 'area', - 'text': 'kitchen', - 'value': 'kitchen', - }), - 'domain': dict({ - 'name': 'domain', - 'text': '', - 'value': 'light', - }), - }), - 'intent': dict({ - 'name': 'HassTurnOn', - }), - 'match': True, - 'sentence_template': ' on [all] in ', - 'slots': dict({ - 'area': 'kitchen', - 'domain': 'light', - }), - 'source': 'builtin', - 'targets': dict({ - 'light.kitchen': dict({ - 'matched': True, - }), - }), - 'unmatched_slots': dict({ - }), - }), - dict({ - 'details': dict({ - 'area': dict({ - 'name': 'area', - 'text': 'kitchen', - 'value': 'kitchen', - }), - 'domain': dict({ - 'name': 'domain', - 'text': 'lights', - 'value': 'light', - }), - 'state': dict({ - 'name': 'state', - 'text': 'on', - 'value': 'on', - }), - }), - 'intent': dict({ - 'name': 'HassGetState', - }), - 'match': True, - 'sentence_template': '[tell me] how many {on_off_domains:domain} (is|are) {on_off_states:state} [in ]', - 'slots': dict({ - 'area': 'kitchen', - 'domain': 'lights', - 'state': 'on', - }), - 'source': 'builtin', - 'targets': dict({ - 'light.kitchen': dict({ - 'matched': False, - }), - }), - 'unmatched_slots': dict({ - }), - }), - None, - ]), - }) -# --- -# name: test_ws_hass_agent_debug_custom_sentence - dict({ - 'results': list([ - dict({ - 'details': dict({ - 'beer_style': dict({ - 'name': 'beer_style', - 'text': 'lager', - 'value': 'lager', - }), - }), - 'file': 'en/beer.yaml', - 'intent': dict({ - 'name': 'OrderBeer', - }), - 'match': True, - 'sentence_template': "I'd like to order a {beer_style} [please]", - 'slots': dict({ - 'beer_style': 'lager', - }), - 'source': 'custom', - 'targets': dict({ - }), - 'unmatched_slots': dict({ - }), - }), - ]), - }) -# --- -# name: test_ws_hass_agent_debug_null_result - dict({ - 'results': list([ - None, - ]), - }) -# --- -# name: test_ws_hass_agent_debug_out_of_range - dict({ - 'results': list([ - dict({ - 'details': dict({ - 'brightness': dict({ - 'name': 'brightness', - 'text': '100%', - 'value': 100, - }), - 'name': dict({ - 'name': 'name', - 'text': 'test light', - 'value': 'test light', - }), - }), - 'intent': dict({ - 'name': 'HassLightSet', - }), - 'match': True, - 'sentence_template': '[] brightness [to] ', - 'slots': dict({ - 'brightness': '100%', - 'name': 'test light', - }), - 'source': 'builtin', - 'targets': dict({ - 'light.demo_1234': dict({ - 'matched': True, - }), - }), - 'unmatched_slots': dict({ - }), - }), - ]), - }) -# --- -# name: test_ws_hass_agent_debug_out_of_range.1 - dict({ - 'results': list([ - dict({ - 'details': dict({ - 'name': dict({ - 'name': 'name', - 'text': 'test light', - 'value': 'test light', - }), - }), - 'intent': dict({ - 'name': 'HassLightSet', - }), - 'match': False, - 'sentence_template': '[] brightness [to] ', - 'slots': dict({ - 'name': 'test light', - }), - 'source': 'builtin', - 'targets': dict({ - }), - 'unmatched_slots': dict({ - 'brightness': 1001, - }), - }), - ]), - }) -# --- -# name: test_ws_hass_agent_debug_sentence_trigger - dict({ - 'results': list([ - dict({ - 'match': True, - 'sentence_template': 'hello[ world]', - 'source': 'trigger', - }), - ]), - }) -# --- diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 1d1c7078e3d..0d547e98641 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -1,14 +1,20 @@ """Test for the default agent.""" from collections import defaultdict +import os +import tempfile from typing import Any from unittest.mock import AsyncMock, patch from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult import pytest +from syrupy import SnapshotAssertion +import yaml from homeassistant.components import conversation, cover, media_player from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation.models import ConversationInput +from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.homeassistant.exposed_entities import ( async_get_assistant_settings, ) @@ -17,10 +23,12 @@ from homeassistant.components.intent import ( TimerInfo, async_register_timer_handler, ) +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, STATE_CLOSED, + STATE_ON, STATE_UNKNOWN, ) from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant, callback @@ -34,9 +42,28 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from . import expose_entity +from . import expose_entity, expose_new -from tests.common import MockConfigEntry, async_mock_service +from tests.common import ( + MockConfigEntry, + MockUser, + async_mock_service, + setup_test_component_platform, +) +from tests.components.light.common import MockLight + + +class OrderBeerIntentHandler(intent.IntentHandler): + """Handle OrderBeer intent.""" + + intent_type = "OrderBeer" + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Return speech response.""" + beer_style = intent_obj.slots["beer_style"]["value"] + response = intent_obj.create_response() + response.async_set_speech(f"You ordered a {beer_style}") + return response @pytest.fixture @@ -1363,3 +1390,671 @@ async def test_name_wildcard_lower_priority(hass: HomeAssistant) -> None: assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert not beer_handler.triggered assert food_handler.triggered + + +async def test_intent_entity_added_removed( + hass: HomeAssistant, + init_components, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test processing intent via HTTP API with entities added later. + + We want to ensure that adding an entity later busts the cache + so that the new entity is available as well as any aliases. + """ + context = Context() + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) + await hass.async_block_till_done() + hass.states.async_set("light.kitchen", "off") + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + result = await conversation.async_converse( + hass, "turn on my cool light", None, context + ) + + assert len(calls) == 1 + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + # Add an entity + entity_registry.async_get_or_create( + "light", "demo", "5678", suggested_object_id="late" + ) + hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"}) + + result = await conversation.async_converse( + hass, "turn on friendly light", None, context + ) + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + # Now add an alias + entity_registry.async_update_entity("light.late", aliases={"late added light"}) + + result = await conversation.async_converse( + hass, "turn on late added light", None, context + ) + + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + # Now delete the entity + hass.states.async_remove("light.late") + + result = await conversation.async_converse( + hass, "turn on late added light", None, context + ) + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "error" + + +async def test_intent_alias_added_removed( + hass: HomeAssistant, + init_components, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test processing intent via HTTP API with aliases added later. + + We want to ensure that adding an alias later busts the cache + so that the new alias is available. + """ + context = Context() + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + hass.states.async_set("light.kitchen", "off", {"friendly_name": "kitchen light"}) + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + assert len(calls) == 1 + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + # Add an alias + entity_registry.async_update_entity("light.kitchen", aliases={"late added alias"}) + + result = await conversation.async_converse( + hass, "turn on late added alias", None, context + ) + + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + # Now remove the alieas + entity_registry.async_update_entity("light.kitchen", aliases={}) + + result = await conversation.async_converse( + hass, "turn on late added alias", None, context + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "error" + + +async def test_intent_entity_renamed( + hass: HomeAssistant, + init_components, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test processing intent via HTTP API with entities renamed later. + + We want to ensure that renaming an entity later busts the cache + so that the new name is used. + """ + context = Context() + entity = MockLight("kitchen light", STATE_ON) + entity._attr_unique_id = "1234" + entity.entity_id = "light.kitchen" + setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + {LIGHT_DOMAIN: [{"platform": "test"}]}, + ) + await hass.async_block_till_done() + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + + assert len(calls) == 1 + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + # Rename the entity + entity_registry.async_update_entity("light.kitchen", name="renamed light") + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "turn on renamed light", None, context + ) + + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "error" + + # Now clear the custom name + entity_registry.async_update_entity("light.kitchen", name=None) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + result = await conversation.async_converse( + hass, "turn on renamed light", None, context + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "error" + + +async def test_intent_entity_exposed( + hass: HomeAssistant, + init_components, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test processing intent via HTTP API with manual expose. + + We want to ensure that manually exposing an entity later busts the cache + so that the new setting is used. + """ + context = Context() + entity = MockLight("kitchen light", STATE_ON) + entity._attr_unique_id = "1234" + entity.entity_id = "light.kitchen" + setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + {LIGHT_DOMAIN: [{"platform": "test"}]}, + ) + await hass.async_block_till_done() + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) + await hass.async_block_till_done() + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + + assert len(calls) == 1 + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + calls.clear() + result = await conversation.async_converse( + hass, "turn on my cool light", None, context + ) + + assert len(calls) == 1 + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + # Unexpose the entity + expose_entity(hass, "light.kitchen", False) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "error" + + result = await conversation.async_converse( + hass, "turn on my cool light", None, context + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "error" + + # Now expose the entity + expose_entity(hass, "light.kitchen", True) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + result = await conversation.async_converse( + hass, "turn on my cool light", None, context + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + +async def test_intent_conversion_not_expose_new( + hass: HomeAssistant, + init_components, + hass_admin_user: MockUser, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test processing intent via HTTP API when not exposing new entities.""" + # Disable exposing new entities to the default agent + expose_new(hass, False) + + context = Context() + entity = MockLight("kitchen light", STATE_ON) + entity._attr_unique_id = "1234" + entity.entity_id = "light.kitchen" + setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) + + assert await async_setup_component( + hass, + LIGHT_DOMAIN, + {LIGHT_DOMAIN: [{"platform": "test"}]}, + ) + await hass.async_block_till_done() + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "error" + + # Expose the entity + expose_entity(hass, "light.kitchen", True) + await hass.async_block_till_done() + + result = await conversation.async_converse( + hass, "turn on kitchen light", None, context + ) + + assert len(calls) == 1 + data = result.as_dict() + + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + + +async def test_custom_sentences( + hass: HomeAssistant, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test custom sentences with a custom intent.""" + # Expecting testing_config/custom_sentences/en/beer.yaml + intent.async_register(hass, OrderBeerIntentHandler()) + + # Don't use "en" to test loading custom sentences with language variants. + language = "en-us" + + # Invoke intent via HTTP API + for beer_style in ("stout", "lager"): + result = await conversation.async_converse( + hass, + f"I'd like to order a {beer_style}, please", + None, + Context(), + language=language, + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert ( + data["response"]["speech"]["plain"]["speech"] + == f"You ordered a {beer_style}" + ) + + +async def test_custom_sentences_config( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test custom sentences with a custom intent in config.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component( + hass, + "conversation", + {"conversation": {"intents": {"StealthMode": ["engage stealth mode"]}}}, + ) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "StealthMode": {"speech": {"text": "Stealth mode engaged"}} + } + }, + ) + + # Invoke intent via HTTP API + result = await conversation.async_converse( + hass, "engage stealth mode", None, Context(), None + ) + + data = result.as_dict() + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "Stealth mode engaged" + + +async def test_language_region(hass: HomeAssistant, init_components) -> None: + """Test regional languages.""" + hass.states.async_set("light.kitchen", "off") + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + # Add fake region + language = f"{hass.config.language}-YZ" + await hass.services.async_call( + "conversation", + "process", + { + conversation.ATTR_TEXT: "turn on the kitchen", + conversation.ATTR_LANGUAGE: language, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == LIGHT_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": ["light.kitchen"]} + + +async def test_non_default_response(hass: HomeAssistant, init_components) -> None: + """Test intent response that is not the default.""" + hass.states.async_set("cover.front_door", "closed") + calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) + + agent = default_agent.async_get_default_agent(hass) + assert isinstance(agent, default_agent.DefaultAgent) + + result = await agent.async_process( + ConversationInput( + text="open the front door", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + ) + assert len(calls) == 1 + assert result.response.speech["plain"]["speech"] == "Opened" + + +async def test_turn_on_area( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test turning on an area.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + kitchen_area = area_registry.async_create("kitchen") + device_registry.async_update_device(device.id, area_id=kitchen_area.id) + + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="stove" + ) + entity_registry.async_update_entity( + "light.stove", aliases={"my stove light"}, area_id=kitchen_area.id + ) + hass.states.async_set("light.stove", "off") + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + await hass.services.async_call( + "conversation", + "process", + {conversation.ATTR_TEXT: "turn on lights in the kitchen"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == LIGHT_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": ["light.stove"]} + + basement_area = area_registry.async_create("basement") + device_registry.async_update_device(device.id, area_id=basement_area.id) + entity_registry.async_update_entity("light.stove", area_id=basement_area.id) + calls.clear() + + # Test that the area is updated + await hass.services.async_call( + "conversation", + "process", + {conversation.ATTR_TEXT: "turn on lights in the kitchen"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + # Test the new area works + await hass.services.async_call( + "conversation", + "process", + {conversation.ATTR_TEXT: "turn on lights in the basement"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == LIGHT_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": ["light.stove"]} + + +async def test_light_area_same_name( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test turning on a light with the same name as an area.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + kitchen_area = area_registry.async_create("kitchen") + device_registry.async_update_device(device.id, area_id=kitchen_area.id) + + kitchen_light = entity_registry.async_get_or_create( + "light", "demo", "1234", original_name="kitchen light" + ) + entity_registry.async_update_entity( + kitchen_light.entity_id, area_id=kitchen_area.id + ) + hass.states.async_set( + kitchen_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + + ceiling_light = entity_registry.async_get_or_create( + "light", "demo", "5678", original_name="ceiling light" + ) + entity_registry.async_update_entity( + ceiling_light.entity_id, area_id=kitchen_area.id + ) + hass.states.async_set( + ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"} + ) + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + + await hass.services.async_call( + "conversation", + "process", + {conversation.ATTR_TEXT: "turn on kitchen light"}, + ) + await hass.async_block_till_done() + + # Should only turn on one light instead of all lights in the kitchen + assert len(calls) == 1 + call = calls[0] + assert call.domain == LIGHT_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": [kitchen_light.entity_id]} + + +async def test_custom_sentences_priority( + hass: HomeAssistant, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, +) -> None: + """Test that user intents from custom_sentences have priority over builtin intents/sentences.""" + with tempfile.NamedTemporaryFile( + mode="w+", + encoding="utf-8", + suffix=".yaml", + dir=os.path.join(hass.config.config_dir, "custom_sentences", "en"), + ) as custom_sentences_file: + # Add a custom sentence that would match a builtin sentence. + # Custom sentences have priority. + yaml.dump( + { + "language": "en", + "intents": { + "CustomIntent": {"data": [{"sentences": ["turn on the lamp"]}]} + }, + }, + custom_sentences_file, + ) + custom_sentences_file.flush() + custom_sentences_file.seek(0) + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "light", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "CustomIntent": {"speech": {"text": "custom response"}} + } + }, + ) + + # Ensure that a "lamp" exists so that we can verify the custom intent + # overrides the builtin sentence. + hass.states.async_set("light.lamp", "off") + + result = await conversation.async_converse( + hass, + "turn on the lamp", + None, + Context(), + language=hass.config.language, + ) + + data = result.as_dict() + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "custom response" + + +async def test_config_sentences_priority( + hass: HomeAssistant, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, +) -> None: + """Test that user intents from configuration.yaml have priority over builtin intents/sentences.""" + # Add a custom sentence that would match a builtin sentence. + # Custom sentences have priority. + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component( + hass, + "conversation", + {"conversation": {"intents": {"CustomIntent": ["turn on the lamp"]}}}, + ) + assert await async_setup_component(hass, "light", {}) + assert await async_setup_component( + hass, + "intent_script", + {"intent_script": {"CustomIntent": {"speech": {"text": "custom response"}}}}, + ) + + # Ensure that a "lamp" exists so that we can verify the custom intent + # overrides the builtin sentence. + hass.states.async_set("light.lamp", "off") + + result = await conversation.async_converse( + hass, + "turn on the lamp", + None, + Context(), + language=hass.config.language, + ) + data = result.as_dict() + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "custom response" diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py new file mode 100644 index 00000000000..1431fd6c17b --- /dev/null +++ b/tests/components/conversation/test_http.py @@ -0,0 +1,524 @@ +"""The tests for the HTTP API of the Conversation component.""" + +from http import HTTPStatus +from typing import Any +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.conversation import default_agent +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.setup import async_setup_component + +from . import MockAgent + +from tests.common import async_mock_service +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +AGENT_ID_OPTIONS = [ + None, + # Old value of conversation.HOME_ASSISTANT_AGENT, + "homeassistant", + # Current value of conversation.HOME_ASSISTANT_AGENT, + "conversation.home_assistant", +] + + +class OrderBeerIntentHandler(intent.IntentHandler): + """Handle OrderBeer intent.""" + + intent_type = "OrderBeer" + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Return speech response.""" + beer_style = intent_obj.slots["beer_style"]["value"] + response = intent_obj.create_response() + response.async_set_speech(f"You ordered a {beer_style}") + return response + + +@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) +async def test_http_processing_intent( + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + agent_id, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test processing intent via HTTP API.""" + # Add an alias + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) + hass.states.async_set("light.kitchen", "off") + + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + client = await hass_client() + data: dict[str, Any] = {"text": "turn on my cool light"} + if agent_id: + data["agent_id"] = agent_id + resp = await client.post("/api/conversation/process", json=data) + + assert resp.status == HTTPStatus.OK + assert len(calls) == 1 + data = await resp.json() + + assert data == snapshot + + +async def test_http_api_no_match( + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the HTTP conversation API with an intent match failure.""" + client = await hass_client() + + # Shouldn't match any intents + resp = await client.post("/api/conversation/process", json={"text": "do something"}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data == snapshot + assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "no_intent_match" + + +async def test_http_api_handle_failure( + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the HTTP conversation API with an error during handling.""" + client = await hass_client() + + hass.states.async_set("light.kitchen", "off") + + # Raise an error during intent handling + def async_handle_error(*args, **kwargs): + raise intent.IntentHandleError + + with patch("homeassistant.helpers.intent.async_handle", new=async_handle_error): + resp = await client.post( + "/api/conversation/process", json={"text": "turn on the kitchen"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data == snapshot + assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "failed_to_handle" + + +async def test_http_api_unexpected_failure( + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the HTTP conversation API with an unexpected error during handling.""" + client = await hass_client() + + hass.states.async_set("light.kitchen", "off") + + # Raise an "unexpected" error during intent handling + def async_handle_error(*args, **kwargs): + raise intent.IntentUnexpectedError + + with patch("homeassistant.helpers.intent.async_handle", new=async_handle_error): + resp = await client.post( + "/api/conversation/process", json={"text": "turn on the kitchen"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data == snapshot + assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "unknown" + + +async def test_http_api_wrong_data( + hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator +) -> None: + """Test the HTTP conversation API.""" + client = await hass_client() + + resp = await client.post("/api/conversation/process", json={"text": 123}) + assert resp.status == HTTPStatus.BAD_REQUEST + + resp = await client.post("/api/conversation/process", json={}) + assert resp.status == HTTPStatus.BAD_REQUEST + + +@pytest.mark.parametrize( + "payload", + [ + { + "text": "Test Text", + }, + { + "text": "Test Text", + "language": "test-language", + }, + { + "text": "Test Text", + "conversation_id": "test-conv-id", + }, + { + "text": "Test Text", + "conversation_id": None, + }, + { + "text": "Test Text", + "conversation_id": "test-conv-id", + "language": "test-language", + }, + { + "text": "Test Text", + "agent_id": "homeassistant", + }, + ], +) +async def test_ws_api( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + payload, + snapshot: SnapshotAssertion, +) -> None: + """Test the Websocket conversation API.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "conversation/process", **payload}) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + assert msg["result"]["response"]["data"]["code"] == "no_intent_match" + + +@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) +async def test_ws_prepare( + hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator, agent_id +) -> None: + """Test the Websocket prepare conversation API.""" + agent = default_agent.async_get_default_agent(hass) + assert isinstance(agent, default_agent.DefaultAgent) + + # No intents should be loaded yet + assert not agent._lang_intents.get(hass.config.language) + + client = await hass_ws_client(hass) + + msg = {"type": "conversation/prepare"} + if agent_id is not None: + msg["agent_id"] = agent_id + await client.send_json_auto_id(msg) + + msg = await client.receive_json() + + assert msg["success"] + + # Intents should now be load + assert agent._lang_intents.get(hass.config.language) + + +async def test_get_agent_list( + hass: HomeAssistant, + init_components, + mock_conversation_agent: MockAgent, + mock_agent_support_all: MockAgent, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test getting agent info.""" + client = await hass_ws_client(hass) + + await client.send_json_auto_id({"type": "conversation/agent/list"}) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "smurfish"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "en"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "en-UK"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "de"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + await client.send_json_auto_id( + {"type": "conversation/agent/list", "language": "de", "country": "ch"} + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + assert msg["result"] == snapshot + + +async def test_ws_hass_agent_debug( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test homeassistant agent debug websocket command.""" + client = await hass_ws_client(hass) + + kitchen_area = area_registry.async_create("kitchen") + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity( + "light.kitchen", + aliases={"my cool light"}, + area_id=kitchen_area.id, + ) + await hass.async_block_till_done() + hass.states.async_set("light.kitchen", "off") + + on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + off_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off") + + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "turn on my cool light", + "turn my cool light off", + "turn on all lights in the kitchen", + "how many lights are on in the kitchen?", + "this will not match anything", # None in results + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + # Last sentence should be a failed match + assert msg["result"]["results"][-1] is None + + # Light state should not have been changed + assert len(on_calls) == 0 + assert len(off_calls) == 0 + + +async def test_ws_hass_agent_debug_null_result( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test homeassistant agent debug websocket command with a null result.""" + client = await hass_ws_client(hass) + + async def async_recognize(self, user_input, *args, **kwargs): + if user_input.text == "bad sentence": + return None + + return await self.async_recognize(user_input, *args, **kwargs) + + with patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize", + async_recognize, + ): + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "bad sentence", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + assert msg["result"]["results"] == [None] + + +async def test_ws_hass_agent_debug_out_of_range( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test homeassistant agent debug websocket command with an out of range entity.""" + test_light = entity_registry.async_get_or_create("light", "demo", "1234") + hass.states.async_set( + test_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "test light"} + ) + + client = await hass_ws_client(hass) + + # Brightness is in range (0-100) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "set test light brightness to 100%", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + results = msg["result"]["results"] + assert len(results) == 1 + assert results[0]["match"] + + # Brightness is out of range + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "set test light brightness to 1001%", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + results = msg["result"]["results"] + assert len(results) == 1 + assert not results[0]["match"] + + # Name matched, but brightness didn't + assert results[0]["slots"] == {"name": "test light"} + assert results[0]["unmatched_slots"] == {"brightness": 1001} + + +async def test_ws_hass_agent_debug_custom_sentence( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test homeassistant agent debug websocket command with a custom sentence.""" + # Expecting testing_config/custom_sentences/en/beer.yaml + intent.async_register(hass, OrderBeerIntentHandler()) + + client = await hass_ws_client(hass) + + # Brightness is in range (0-100) + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": [ + "I'd like to order a lager, please.", + ], + } + ) + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + debug_results = msg["result"].get("results", []) + assert len(debug_results) == 1 + assert debug_results[0].get("match") + assert debug_results[0].get("source") == "custom" + assert debug_results[0].get("file") == "en/beer.yaml" + + +async def test_ws_hass_agent_debug_sentence_trigger( + hass: HomeAssistant, + init_components, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test homeassistant agent debug websocket command with a sentence trigger.""" + calls = async_mock_service(hass, "test", "automation") + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["hello", "hello[ world]"], + }, + "action": { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + } + }, + ) + + client = await hass_ws_client(hass) + + # Use trigger sentence + await client.send_json_auto_id( + { + "type": "conversation/agent/homeassistant/debug", + "sentences": ["hello world"], + } + ) + await hass.async_block_till_done() + + msg = await client.receive_json() + + assert msg["success"] + assert msg["result"] == snapshot + + debug_results = msg["result"].get("results", []) + assert len(debug_results) == 1 + assert debug_results[0].get("match") + assert debug_results[0].get("source") == "trigger" + assert debug_results[0].get("sentence_template") == "hello[ world]" + + # Trigger should not have been executed + assert len(calls) == 0 diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 170a03a4566..14826401605 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1,42 +1,24 @@ """The tests for the Conversation component.""" from http import HTTPStatus -import os -import tempfile -from typing import Any from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -import yaml from homeassistant.components import conversation from homeassistant.components.conversation import default_agent -from homeassistant.components.conversation.models import ConversationInput -from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_ON -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - area_registry as ar, - device_registry as dr, - entity_registry as er, - intent, -) +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -from . import MockAgent, expose_entity, expose_new +from . import MockAgent -from tests.common import ( - MockConfigEntry, - MockUser, - async_mock_service, - setup_test_component_platform, -) -from tests.components.light.common import MockLight -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.common import MockUser, async_mock_service +from tests.typing import ClientSessionGenerator AGENT_ID_OPTIONS = [ None, @@ -47,449 +29,6 @@ AGENT_ID_OPTIONS = [ ] -class OrderBeerIntentHandler(intent.IntentHandler): - """Handle OrderBeer intent.""" - - intent_type = "OrderBeer" - - async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Return speech response.""" - beer_style = intent_obj.slots["beer_style"]["value"] - response = intent_obj.create_response() - response.async_set_speech(f"You ordered a {beer_style}") - return response - - -@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) -async def test_http_processing_intent( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - agent_id, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test processing intent via HTTP API.""" - # Add an alias - entity_registry.async_get_or_create( - "light", "demo", "1234", suggested_object_id="kitchen" - ) - entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) - hass.states.async_set("light.kitchen", "off") - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - client = await hass_client() - data: dict[str, Any] = {"text": "turn on my cool light"} - if agent_id: - data["agent_id"] = agent_id - resp = await client.post("/api/conversation/process", json=data) - - assert resp.status == HTTPStatus.OK - assert len(calls) == 1 - data = await resp.json() - - assert data == snapshot - - -async def test_http_processing_intent_target_ha_agent( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - mock_conversation_agent: MockAgent, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test processing intent can be processed via HTTP API with picking agent.""" - # Add an alias - entity_registry.async_get_or_create( - "light", "demo", "1234", suggested_object_id="kitchen" - ) - entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) - hass.states.async_set("light.kitchen", "off") - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - client = await hass_client() - resp = await client.post( - "/api/conversation/process", - json={"text": "turn on my cool light", "agent_id": "homeassistant"}, - ) - - assert resp.status == HTTPStatus.OK - assert len(calls) == 1 - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - -async def test_http_processing_intent_entity_added_removed( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test processing intent via HTTP API with entities added later. - - We want to ensure that adding an entity later busts the cache - so that the new entity is available as well as any aliases. - """ - entity_registry.async_get_or_create( - "light", "demo", "1234", suggested_object_id="kitchen" - ) - entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) - await hass.async_block_till_done() - hass.states.async_set("light.kitchen", "off") - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on my cool light"} - ) - - assert resp.status == HTTPStatus.OK - assert len(calls) == 1 - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - # Add an entity - entity_registry.async_get_or_create( - "light", "demo", "5678", suggested_object_id="late" - ) - hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"}) - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on friendly light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - # Now add an alias - entity_registry.async_update_entity("light.late", aliases={"late added light"}) - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on late added light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - # Now delete the entity - hass.states.async_remove("light.late") - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on late added light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "error" - - -async def test_http_processing_intent_alias_added_removed( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test processing intent via HTTP API with aliases added later. - - We want to ensure that adding an alias later busts the cache - so that the new alias is available. - """ - entity_registry.async_get_or_create( - "light", "demo", "1234", suggested_object_id="kitchen" - ) - hass.states.async_set("light.kitchen", "off", {"friendly_name": "kitchen light"}) - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - assert len(calls) == 1 - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - # Add an alias - entity_registry.async_update_entity("light.kitchen", aliases={"late added alias"}) - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on late added alias"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - # Now remove the alieas - entity_registry.async_update_entity("light.kitchen", aliases={}) - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on late added alias"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "error" - - -async def test_http_processing_intent_entity_renamed( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test processing intent via HTTP API with entities renamed later. - - We want to ensure that renaming an entity later busts the cache - so that the new name is used. - """ - entity = MockLight("kitchen light", STATE_ON) - entity._attr_unique_id = "1234" - entity.entity_id = "light.kitchen" - setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) - - assert await async_setup_component( - hass, - LIGHT_DOMAIN, - {LIGHT_DOMAIN: [{"platform": "test"}]}, - ) - await hass.async_block_till_done() - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - assert len(calls) == 1 - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - # Rename the entity - entity_registry.async_update_entity("light.kitchen", name="renamed light") - await hass.async_block_till_done() - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on renamed light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "error" - - # Now clear the custom name - entity_registry.async_update_entity("light.kitchen", name=None) - await hass.async_block_till_done() - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on renamed light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "error" - - -async def test_http_processing_intent_entity_exposed( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test processing intent via HTTP API with manual expose. - - We want to ensure that manually exposing an entity later busts the cache - so that the new setting is used. - """ - entity = MockLight("kitchen light", STATE_ON) - entity._attr_unique_id = "1234" - entity.entity_id = "light.kitchen" - setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) - - assert await async_setup_component( - hass, - LIGHT_DOMAIN, - {LIGHT_DOMAIN: [{"platform": "test"}]}, - ) - await hass.async_block_till_done() - entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) - await hass.async_block_till_done() - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - client = await hass_client() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - assert len(calls) == 1 - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - calls.clear() - resp = await client.post( - "/api/conversation/process", json={"text": "turn on my cool light"} - ) - - assert resp.status == HTTPStatus.OK - assert len(calls) == 1 - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - # Unexpose the entity - expose_entity(hass, "light.kitchen", False) - await hass.async_block_till_done(wait_background_tasks=True) - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "error" - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on my cool light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "error" - - # Now expose the entity - expose_entity(hass, "light.kitchen", True) - await hass.async_block_till_done() - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on my cool light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - -async def test_http_processing_intent_conversion_not_expose_new( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - hass_admin_user: MockUser, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test processing intent via HTTP API when not exposing new entities.""" - # Disable exposing new entities to the default agent - expose_new(hass, False) - - entity = MockLight("kitchen light", STATE_ON) - entity._attr_unique_id = "1234" - entity.entity_id = "light.kitchen" - setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) - - assert await async_setup_component( - hass, - LIGHT_DOMAIN, - {LIGHT_DOMAIN: [{"platform": "test"}]}, - ) - await hass.async_block_till_done() - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - client = await hass_client() - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "error" - - # Expose the entity - expose_entity(hass, "light.kitchen", True) - await hass.async_block_till_done() - - resp = await client.post( - "/api/conversation/process", json={"text": "turn on kitchen light"} - ) - - assert resp.status == HTTPStatus.OK - assert len(calls) == 1 - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - - @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @pytest.mark.parametrize("sentence", ["turn on kitchen", "turn kitchen on"]) @pytest.mark.parametrize("conversation_id", ["my_new_conversation", None]) @@ -562,95 +101,6 @@ async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) - assert call.data == {"entity_id": ["light.kitchen"]} -async def test_http_api_no_match( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test the HTTP conversation API with an intent match failure.""" - client = await hass_client() - - # Shouldn't match any intents - resp = await client.post("/api/conversation/process", json={"text": "do something"}) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "error" - assert data["response"]["data"]["code"] == "no_intent_match" - - -async def test_http_api_handle_failure( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test the HTTP conversation API with an error during handling.""" - client = await hass_client() - - hass.states.async_set("light.kitchen", "off") - - # Raise an error during intent handling - def async_handle_error(*args, **kwargs): - raise intent.IntentHandleError - - with patch("homeassistant.helpers.intent.async_handle", new=async_handle_error): - resp = await client.post( - "/api/conversation/process", json={"text": "turn on the kitchen"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "error" - assert data["response"]["data"]["code"] == "failed_to_handle" - - -async def test_http_api_unexpected_failure( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test the HTTP conversation API with an unexpected error during handling.""" - client = await hass_client() - - hass.states.async_set("light.kitchen", "off") - - # Raise an "unexpected" error during intent handling - def async_handle_error(*args, **kwargs): - raise intent.IntentUnexpectedError - - with patch("homeassistant.helpers.intent.async_handle", new=async_handle_error): - resp = await client.post( - "/api/conversation/process", json={"text": "turn on the kitchen"} - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data == snapshot - assert data["response"]["response_type"] == "error" - assert data["response"]["data"]["code"] == "unknown" - - -async def test_http_api_wrong_data( - hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator -) -> None: - """Test the HTTP conversation API.""" - client = await hass_client() - - resp = await client.post("/api/conversation/process", json={"text": 123}) - assert resp.status == HTTPStatus.BAD_REQUEST - - resp = await client.post("/api/conversation/process", json={}) - assert resp.status == HTTPStatus.BAD_REQUEST - - @pytest.mark.usefixtures("init_components") async def test_custom_agent( hass: HomeAssistant, @@ -688,149 +138,6 @@ async def test_custom_agent( ) -@pytest.mark.parametrize( - "payload", - [ - { - "text": "Test Text", - }, - { - "text": "Test Text", - "language": "test-language", - }, - { - "text": "Test Text", - "conversation_id": "test-conv-id", - }, - { - "text": "Test Text", - "conversation_id": None, - }, - { - "text": "Test Text", - "conversation_id": "test-conv-id", - "language": "test-language", - }, - { - "text": "Test Text", - "agent_id": "homeassistant", - }, - ], -) -async def test_ws_api( - hass: HomeAssistant, - init_components, - hass_ws_client: WebSocketGenerator, - payload, - snapshot: SnapshotAssertion, -) -> None: - """Test the Websocket conversation API.""" - client = await hass_ws_client(hass) - - await client.send_json_auto_id({"type": "conversation/process", **payload}) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["result"] == snapshot - assert msg["result"]["response"]["data"]["code"] == "no_intent_match" - - -@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) -async def test_ws_prepare( - hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator, agent_id -) -> None: - """Test the Websocket prepare conversation API.""" - agent = default_agent.async_get_default_agent(hass) - assert isinstance(agent, default_agent.DefaultAgent) - - # No intents should be loaded yet - assert not agent._lang_intents.get(hass.config.language) - - client = await hass_ws_client(hass) - - msg = {"type": "conversation/prepare"} - if agent_id is not None: - msg["agent_id"] = agent_id - await client.send_json_auto_id(msg) - - msg = await client.receive_json() - - assert msg["success"] - - # Intents should now be load - assert agent._lang_intents.get(hass.config.language) - - -async def test_custom_sentences( - hass: HomeAssistant, - init_components, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test custom sentences with a custom intent.""" - # Expecting testing_config/custom_sentences/en/beer.yaml - intent.async_register(hass, OrderBeerIntentHandler()) - - # Don't use "en" to test loading custom sentences with language variants. - language = "en-us" - - # Invoke intent via HTTP API - client = await hass_client() - for beer_style in ("stout", "lager"): - resp = await client.post( - "/api/conversation/process", - json={ - "text": f"I'd like to order a {beer_style}, please", - "language": language, - }, - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - assert ( - data["response"]["speech"]["plain"]["speech"] - == f"You ordered a {beer_style}" - ) - - -async def test_custom_sentences_config( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test custom sentences with a custom intent in config.""" - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component( - hass, - "conversation", - {"conversation": {"intents": {"StealthMode": ["engage stealth mode"]}}}, - ) - assert await async_setup_component(hass, "intent", {}) - assert await async_setup_component( - hass, - "intent_script", - { - "intent_script": { - "StealthMode": {"speech": {"text": "Stealth mode engaged"}} - } - }, - ) - - # Invoke intent via HTTP API - client = await hass_client() - resp = await client.post( - "/api/conversation/process", - json={"text": "engage stealth mode"}, - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data == snapshot - assert data["response"]["response_type"] == "action_done" - assert data["response"]["speech"]["plain"]["speech"] == "Stealth mode engaged" - - async def test_prepare_reload(hass: HomeAssistant, init_components) -> None: """Test calling the reload service.""" language = hass.config.language @@ -869,181 +176,7 @@ async def test_prepare_fail(hass: HomeAssistant) -> None: await agent.async_prepare("not-a-language") # Confirm no intents were loaded - assert not agent._lang_intents.get("not-a-language") - - -async def test_language_region(hass: HomeAssistant, init_components) -> None: - """Test calling the turn on intent.""" - hass.states.async_set("light.kitchen", "off") - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - - # Add fake region - language = f"{hass.config.language}-YZ" - await hass.services.async_call( - "conversation", - "process", - { - conversation.ATTR_TEXT: "turn on the kitchen", - conversation.ATTR_LANGUAGE: language, - }, - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - call = calls[0] - assert call.domain == LIGHT_DOMAIN - assert call.service == "turn_on" - assert call.data == {"entity_id": ["light.kitchen"]} - - -async def test_non_default_response(hass: HomeAssistant, init_components) -> None: - """Test intent response that is not the default.""" - hass.states.async_set("cover.front_door", "closed") - calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) - - agent = default_agent.async_get_default_agent(hass) - assert isinstance(agent, default_agent.DefaultAgent) - - result = await agent.async_process( - ConversationInput( - text="open the front door", - context=Context(), - conversation_id=None, - device_id=None, - language=hass.config.language, - agent_id=None, - ) - ) - assert len(calls) == 1 - assert result.response.speech["plain"]["speech"] == "Opened" - - -async def test_turn_on_area( - hass: HomeAssistant, - init_components, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test turning on an area.""" - entry = MockConfigEntry(domain="test") - entry.add_to_hass(hass) - - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - - kitchen_area = area_registry.async_create("kitchen") - device_registry.async_update_device(device.id, area_id=kitchen_area.id) - - entity_registry.async_get_or_create( - "light", "demo", "1234", suggested_object_id="stove" - ) - entity_registry.async_update_entity( - "light.stove", aliases={"my stove light"}, area_id=kitchen_area.id - ) - hass.states.async_set("light.stove", "off") - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - - await hass.services.async_call( - "conversation", - "process", - {conversation.ATTR_TEXT: "turn on lights in the kitchen"}, - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - call = calls[0] - assert call.domain == LIGHT_DOMAIN - assert call.service == "turn_on" - assert call.data == {"entity_id": ["light.stove"]} - - basement_area = area_registry.async_create("basement") - device_registry.async_update_device(device.id, area_id=basement_area.id) - entity_registry.async_update_entity("light.stove", area_id=basement_area.id) - calls.clear() - - # Test that the area is updated - await hass.services.async_call( - "conversation", - "process", - {conversation.ATTR_TEXT: "turn on lights in the kitchen"}, - ) - await hass.async_block_till_done() - - assert len(calls) == 0 - - # Test the new area works - await hass.services.async_call( - "conversation", - "process", - {conversation.ATTR_TEXT: "turn on lights in the basement"}, - ) - await hass.async_block_till_done() - - assert len(calls) == 1 - call = calls[0] - assert call.domain == LIGHT_DOMAIN - assert call.service == "turn_on" - assert call.data == {"entity_id": ["light.stove"]} - - -async def test_light_area_same_name( - hass: HomeAssistant, - init_components, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test turning on a light with the same name as an area.""" - entry = MockConfigEntry(domain="test") - entry.add_to_hass(hass) - - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - - kitchen_area = area_registry.async_create("kitchen") - device_registry.async_update_device(device.id, area_id=kitchen_area.id) - - kitchen_light = entity_registry.async_get_or_create( - "light", "demo", "1234", original_name="kitchen light" - ) - entity_registry.async_update_entity( - kitchen_light.entity_id, area_id=kitchen_area.id - ) - hass.states.async_set( - kitchen_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} - ) - - ceiling_light = entity_registry.async_get_or_create( - "light", "demo", "5678", original_name="ceiling light" - ) - entity_registry.async_update_entity( - ceiling_light.entity_id, area_id=kitchen_area.id - ) - hass.states.async_set( - ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"} - ) - - calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - - await hass.services.async_call( - "conversation", - "process", - {conversation.ATTR_TEXT: "turn on kitchen light"}, - ) - await hass.async_block_till_done() - - # Should only turn on one light instead of all lights in the kitchen - assert len(calls) == 1 - call = calls[0] - assert call.domain == LIGHT_DOMAIN - assert call.service == "turn_on" - assert call.data == {"entity_id": [kitchen_light.entity_id]} + assert agent._lang_intents.get("not-a-language") is default_agent.ERROR_SENTINEL async def test_agent_id_validator_invalid_agent( @@ -1057,64 +190,6 @@ async def test_agent_id_validator_invalid_agent( conversation.agent_id_validator("conversation.home_assistant") -async def test_get_agent_list( - hass: HomeAssistant, - init_components, - mock_conversation_agent: MockAgent, - mock_agent_support_all: MockAgent, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test getting agent info.""" - client = await hass_ws_client(hass) - - await client.send_json_auto_id({"type": "conversation/agent/list"}) - msg = await client.receive_json() - assert msg["type"] == "result" - assert msg["success"] - assert msg["result"] == snapshot - - await client.send_json_auto_id( - {"type": "conversation/agent/list", "language": "smurfish"} - ) - msg = await client.receive_json() - assert msg["type"] == "result" - assert msg["success"] - assert msg["result"] == snapshot - - await client.send_json_auto_id( - {"type": "conversation/agent/list", "language": "en"} - ) - msg = await client.receive_json() - assert msg["type"] == "result" - assert msg["success"] - assert msg["result"] == snapshot - - await client.send_json_auto_id( - {"type": "conversation/agent/list", "language": "en-UK"} - ) - msg = await client.receive_json() - assert msg["type"] == "result" - assert msg["success"] - assert msg["result"] == snapshot - - await client.send_json_auto_id( - {"type": "conversation/agent/list", "language": "de"} - ) - msg = await client.receive_json() - assert msg["type"] == "result" - assert msg["success"] - assert msg["result"] == snapshot - - await client.send_json_auto_id( - {"type": "conversation/agent/list", "language": "de", "country": "ch"} - ) - msg = await client.receive_json() - assert msg["type"] == "result" - assert msg["success"] - assert msg["result"] == snapshot - - async def test_get_agent_info( hass: HomeAssistant, init_components, @@ -1138,334 +213,3 @@ async def test_get_agent_info( agent_info = conversation.async_get_agent_info(hass) assert agent_info == snapshot - - -async def test_ws_hass_agent_debug( - hass: HomeAssistant, - init_components, - hass_ws_client: WebSocketGenerator, - area_registry: ar.AreaRegistry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test homeassistant agent debug websocket command.""" - client = await hass_ws_client(hass) - - kitchen_area = area_registry.async_create("kitchen") - entity_registry.async_get_or_create( - "light", "demo", "1234", suggested_object_id="kitchen" - ) - entity_registry.async_update_entity( - "light.kitchen", - aliases={"my cool light"}, - area_id=kitchen_area.id, - ) - await hass.async_block_till_done() - hass.states.async_set("light.kitchen", "off") - - on_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") - off_calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off") - - await client.send_json_auto_id( - { - "type": "conversation/agent/homeassistant/debug", - "sentences": [ - "turn on my cool light", - "turn my cool light off", - "turn on all lights in the kitchen", - "how many lights are on in the kitchen?", - "this will not match anything", # None in results - ], - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["result"] == snapshot - - # Last sentence should be a failed match - assert msg["result"]["results"][-1] is None - - # Light state should not have been changed - assert len(on_calls) == 0 - assert len(off_calls) == 0 - - -async def test_ws_hass_agent_debug_null_result( - hass: HomeAssistant, - init_components, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test homeassistant agent debug websocket command with a null result.""" - client = await hass_ws_client(hass) - - async def async_recognize(self, user_input, *args, **kwargs): - if user_input.text == "bad sentence": - return None - - return await self.async_recognize(user_input, *args, **kwargs) - - with patch( - "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize", - async_recognize, - ): - await client.send_json_auto_id( - { - "type": "conversation/agent/homeassistant/debug", - "sentences": [ - "bad sentence", - ], - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["result"] == snapshot - assert msg["result"]["results"] == [None] - - -async def test_ws_hass_agent_debug_out_of_range( - hass: HomeAssistant, - init_components, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, -) -> None: - """Test homeassistant agent debug websocket command with an out of range entity.""" - test_light = entity_registry.async_get_or_create("light", "demo", "1234") - hass.states.async_set( - test_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "test light"} - ) - - client = await hass_ws_client(hass) - - # Brightness is in range (0-100) - await client.send_json_auto_id( - { - "type": "conversation/agent/homeassistant/debug", - "sentences": [ - "set test light brightness to 100%", - ], - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["result"] == snapshot - - results = msg["result"]["results"] - assert len(results) == 1 - assert results[0]["match"] - - # Brightness is out of range - await client.send_json_auto_id( - { - "type": "conversation/agent/homeassistant/debug", - "sentences": [ - "set test light brightness to 1001%", - ], - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["result"] == snapshot - - results = msg["result"]["results"] - assert len(results) == 1 - assert not results[0]["match"] - - # Name matched, but brightness didn't - assert results[0]["slots"] == {"name": "test light"} - assert results[0]["unmatched_slots"] == {"brightness": 1001} - - -async def test_ws_hass_agent_debug_custom_sentence( - hass: HomeAssistant, - init_components, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, -) -> None: - """Test homeassistant agent debug websocket command with a custom sentence.""" - # Expecting testing_config/custom_sentences/en/beer.yaml - intent.async_register(hass, OrderBeerIntentHandler()) - - client = await hass_ws_client(hass) - - # Brightness is in range (0-100) - await client.send_json_auto_id( - { - "type": "conversation/agent/homeassistant/debug", - "sentences": [ - "I'd like to order a lager, please.", - ], - } - ) - - msg = await client.receive_json() - - assert msg["success"] - assert msg["result"] == snapshot - - debug_results = msg["result"].get("results", []) - assert len(debug_results) == 1 - assert debug_results[0].get("match") - assert debug_results[0].get("source") == "custom" - assert debug_results[0].get("file") == "en/beer.yaml" - - -async def test_ws_hass_agent_debug_sentence_trigger( - hass: HomeAssistant, - init_components, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test homeassistant agent debug websocket command with a sentence trigger.""" - calls = async_mock_service(hass, "test", "automation") - assert await async_setup_component( - hass, - "automation", - { - "automation": { - "trigger": { - "platform": "conversation", - "command": ["hello", "hello[ world]"], - }, - "action": { - "service": "test.automation", - "data_template": {"data": "{{ trigger }}"}, - }, - } - }, - ) - - client = await hass_ws_client(hass) - - # Use trigger sentence - await client.send_json_auto_id( - { - "type": "conversation/agent/homeassistant/debug", - "sentences": ["hello world"], - } - ) - await hass.async_block_till_done() - - msg = await client.receive_json() - - assert msg["success"] - assert msg["result"] == snapshot - - debug_results = msg["result"].get("results", []) - assert len(debug_results) == 1 - assert debug_results[0].get("match") - assert debug_results[0].get("source") == "trigger" - assert debug_results[0].get("sentence_template") == "hello[ world]" - - # Trigger should not have been executed - assert len(calls) == 0 - - -async def test_custom_sentences_priority( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_admin_user: MockUser, - snapshot: SnapshotAssertion, -) -> None: - """Test that user intents from custom_sentences have priority over builtin intents/sentences.""" - with tempfile.NamedTemporaryFile( - mode="w+", - encoding="utf-8", - suffix=".yaml", - dir=os.path.join(hass.config.config_dir, "custom_sentences", "en"), - ) as custom_sentences_file: - # Add a custom sentence that would match a builtin sentence. - # Custom sentences have priority. - yaml.dump( - { - "language": "en", - "intents": { - "CustomIntent": {"data": [{"sentences": ["turn on the lamp"]}]} - }, - }, - custom_sentences_file, - ) - custom_sentences_file.flush() - custom_sentences_file.seek(0) - - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "conversation", {}) - assert await async_setup_component(hass, "light", {}) - assert await async_setup_component(hass, "intent", {}) - assert await async_setup_component( - hass, - "intent_script", - { - "intent_script": { - "CustomIntent": {"speech": {"text": "custom response"}} - } - }, - ) - - # Ensure that a "lamp" exists so that we can verify the custom intent - # overrides the builtin sentence. - hass.states.async_set("light.lamp", "off") - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", - json={ - "text": "turn on the lamp", - "language": hass.config.language, - }, - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data["response"]["response_type"] == "action_done" - assert data["response"]["speech"]["plain"]["speech"] == "custom response" - - -async def test_config_sentences_priority( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_admin_user: MockUser, - snapshot: SnapshotAssertion, -) -> None: - """Test that user intents from configuration.yaml have priority over builtin intents/sentences.""" - # Add a custom sentence that would match a builtin sentence. - # Custom sentences have priority. - assert await async_setup_component(hass, "homeassistant", {}) - assert await async_setup_component(hass, "intent", {}) - assert await async_setup_component( - hass, - "conversation", - {"conversation": {"intents": {"CustomIntent": ["turn on the lamp"]}}}, - ) - assert await async_setup_component(hass, "light", {}) - assert await async_setup_component( - hass, - "intent_script", - {"intent_script": {"CustomIntent": {"speech": {"text": "custom response"}}}}, - ) - - # Ensure that a "lamp" exists so that we can verify the custom intent - # overrides the builtin sentence. - hass.states.async_set("light.lamp", "off") - - client = await hass_client() - resp = await client.post( - "/api/conversation/process", - json={ - "text": "turn on the lamp", - "language": hass.config.language, - }, - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data["response"]["response_type"] == "action_done" - assert data["response"]["speech"]["plain"]["speech"] == "custom response"