diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 19992e63dad..e66c246dc44 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -19,7 +19,12 @@ from hassil.intents import ( TextSlotList, WildcardSlotList, ) -from hassil.recognize import RecognizeResult, recognize_all +from hassil.recognize import ( + RecognizeResult, + UnmatchedEntity, + UnmatchedTextEntity, + recognize_all, +) from hassil.util import merge_dict from home_assistant_intents import get_domains_and_languages, get_intents import yaml @@ -213,6 +218,7 @@ class DefaultAgent(AbstractConversationAgent): lang_intents = self._lang_intents.get(language) if result is None: + # Intent was not recognized _LOGGER.debug("No intent was matched for '%s'", user_input.text) return _make_error_result( language, @@ -221,6 +227,28 @@ class DefaultAgent(AbstractConversationAgent): conversation_id, ) + if result.unmatched_entities: + # Intent was recognized, but not entity/area names, etc. + _LOGGER.debug( + "Recognized intent '%s' for template '%s' but had unmatched: %s", + result.intent.name, + result.intent_sentence.text + if result.intent_sentence is not None + else "", + result.unmatched_entities_list, + ) + error_response_type, error_response_args = _get_unmatched_response( + result.unmatched_entities + ) + return _make_error_result( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), + conversation_id, + ) + # Will never happen because result will be None when no intents are # loaded in async_recognize. assert lang_intents is not None @@ -302,7 +330,35 @@ class DefaultAgent(AbstractConversationAgent): # Keep looking in case an entity has the same name maybe_result = result - return maybe_result + if maybe_result is not None: + # Successful strict match + return maybe_result + + # Try again with missing entities enabled + for result in recognize_all( + user_input.text, + lang_intents.intents, + slot_lists=slot_lists, + intent_context=intent_context, + allow_unmatched_entities=True, + ): + if maybe_result is None: + # First result + maybe_result = result + elif len(result.unmatched_entities) < len(maybe_result.unmatched_entities): + # Fewer unmatched entities + maybe_result = result + elif len(result.unmatched_entities) == len(maybe_result.unmatched_entities): + if result.text_chunks_matched > maybe_result.text_chunks_matched: + # More literal text chunks matched + maybe_result = result + + if (maybe_result is not None) and maybe_result.unmatched_entities: + # Failed to match, but we have more information about why in unmatched_entities + return maybe_result + + # Complete match failure + return None async def _build_speech( self, @@ -655,15 +711,22 @@ class DefaultAgent(AbstractConversationAgent): return {"area": device_area.id} def _get_error_text( - self, response_type: ResponseType, lang_intents: LanguageIntents | None + self, + response_type: ResponseType, + lang_intents: LanguageIntents | None, + **response_args, ) -> str: """Get response error text by type.""" if lang_intents is None: return _DEFAULT_ERROR_TEXT response_key = response_type.value - response_str = lang_intents.error_responses.get(response_key) - return response_str or _DEFAULT_ERROR_TEXT + response_str = ( + lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT + ) + response_template = template.Template(response_str, self.hass) + + return response_template.async_render(response_args) def register_trigger( self, @@ -783,6 +846,27 @@ def _make_error_result( return ConversationResult(response, conversation_id) +def _get_unmatched_response( + unmatched_entities: dict[str, UnmatchedEntity], +) -> tuple[ResponseType, dict[str, Any]]: + error_response_type = ResponseType.NO_INTENT + error_response_args: dict[str, Any] = {} + + if unmatched_name := unmatched_entities.get("name"): + # Unmatched device or entity + assert isinstance(unmatched_name, UnmatchedTextEntity) + error_response_type = ResponseType.NO_ENTITY + error_response_args["entity"] = unmatched_name.text + + elif unmatched_area := unmatched_entities.get("area"): + # Unmatched area + assert isinstance(unmatched_area, UnmatchedTextEntity) + error_response_type = ResponseType.NO_AREA + error_response_args["area"] = unmatched_area.text + + return error_response_type, error_response_args + + def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Sequence): diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 5f0c7b171ae..5de11d7a41a 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2024.1.2"] + "requirements": ["hassil==1.5.2", "home-assistant-intents==2024.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ac82851adc1..a392cb492ff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ha-av==10.1.1 ha-ffmpeg==3.1.0 habluetooth==2.0.2 hass-nabucasa==0.75.1 -hassil==1.5.1 +hassil==1.5.2 home-assistant-bluetooth==1.11.0 home-assistant-frontend==20240104.0 home-assistant-intents==2024.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index eaf70a24fa7..d1bf48801c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hass-nabucasa==0.75.1 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.5.1 +hassil==1.5.2 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 52610995ff6..15385290abb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -809,7 +809,7 @@ habluetooth==2.0.2 hass-nabucasa==0.75.1 # homeassistant.components.conversation -hassil==1.5.1 +hassil==1.5.2 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index e822759d208..128f5479077 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -48,14 +48,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -67,7 +67,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }), 'type': , @@ -75,9 +75,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }), 'type': , @@ -137,14 +137,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -156,7 +156,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'Arnold Schwarzenegger', }), 'type': , @@ -164,9 +164,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=Arnold+Schwarzenegger', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_2657c1a8ee_test.mp3', }), }), 'type': , @@ -226,14 +226,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en-US', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -245,7 +245,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'Arnold Schwarzenegger', }), 'type': , @@ -253,9 +253,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=Arnold+Schwarzenegger', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_2657c1a8ee_test.mp3', }), }), 'type': , @@ -338,14 +338,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -357,7 +357,7 @@ 'data': dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }), 'type': , @@ -365,9 +365,9 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }), 'type': , diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index c165675a6ff..31b1c44e67e 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -46,14 +46,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -64,16 +64,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -127,14 +127,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -145,16 +145,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_debug.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -220,14 +220,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -238,16 +238,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_with_enhancements.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -421,14 +421,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No device or entity named test transcript', }), }), }), @@ -439,16 +439,16 @@ dict({ 'engine': 'test', 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", + 'tts_input': 'No device or entity named test transcript', 'voice': 'james_earl_jones', }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.8 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': 'media-source://tts/test?message=No+device+or+entity+named+test+transcript&language=en-US&voice=james_earl_jones', 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + 'url': '/api/tts_proxy/e5e8e318b536f0a5455f993243a34521e7ad4d6d_en-us_031e2ec052_test.mp3', }), }) # --- @@ -771,14 +771,14 @@ 'card': dict({ }), 'data': dict({ - 'code': 'no_intent_match', + 'code': 'no_valid_targets', }), 'language': 'en', 'response_type': 'error', 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", + 'speech': 'No area named are', }), }), }), diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 7f928224aba..b68f2fb8701 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,4 +1,104 @@ # serializer version: 1 +# name: test_custom_agent + dict({ + 'conversation_id': 'test-conv-id', + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + 'targets': list([ + ]), + }), + 'language': 'test-language', + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Test response', + }), + }), + }), + }) +# --- +# 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': 'homeassistant', @@ -225,6 +325,686 @@ ]), }) # --- +# 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 while handling the intent', + }), + }), + }), + }) +# --- +# name: test_http_api_no_match + 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': 'No device or entity named do something', + }), + }), + }), + }) +# --- +# 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 while handling the intent', + }), + }), + }), + }) +# --- +# 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[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': 'No device or entity named 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': 'No area named kitchen', + }), + }), + }), + }) +# --- +# 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': 'No area named late added', + }), + }), + }), + }) +# --- +# 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': 'No area named kitchen', + }), + }), + }), + }) +# --- +# 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': 'No area named my cool', + }), + }), + }), + }) +# --- +# 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': 'No area named kitchen', + }), + }), + }), + }) +# --- +# 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': 'No area named renamed', + }), + }), + }), + }) +# --- +# 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, @@ -339,7 +1119,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -369,7 +1149,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -399,7 +1179,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -429,7 +1209,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -465,6 +1245,126 @@ }), }) # --- +# name: test_ws_api[payload0] + 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': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# 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_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# name: test_ws_api[payload3] + 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': 'No device or entity named test text', + }), + }), + }), + }) +# --- +# 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_valid_targets', + }), + 'language': 'en', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'No device or entity named test text', + }), + }), + }), + }) +# --- # name: test_ws_get_agent_info dict({ 'attribution': None, @@ -590,7 +1490,23 @@ }), }), }), - None, + dict({ + 'details': dict({ + 'domain': dict({ + 'name': 'domain', + 'text': '', + 'value': 'script', + }), + }), + 'intent': dict({ + 'name': 'HassTurnOn', + }), + 'slots': dict({ + 'domain': 'script', + }), + 'targets': dict({ + }), + }), ]), }) # --- diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index c68ec301280..4c1d395a2cc 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -57,7 +57,7 @@ async def test_hidden_entities_skipped( assert len(calls) == 0 assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: @@ -70,10 +70,10 @@ async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: hass, "turn on test media player", None, Context(), None ) - # This is an intent match failure instead of a handle failure because the - # media player domain is not exposed. + # This is a match failure instead of a handle failure because the media + # player domain is not exposed. assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_exposed_areas( @@ -127,9 +127,9 @@ async def test_exposed_areas( hass, "turn on lights in the bedroom", None, Context(), None ) - # This should be an intent match failure because the area isn't in the slot list + # This should be a match failure because the area isn't in the slot list assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS async def test_conversation_agent( @@ -417,6 +417,48 @@ async def test_device_area_context( result = await conversation.async_converse( hass, f"turn {command} all lights", None, Context(), None ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert ( + result.response.error_code + == intent.IntentResponseErrorCode.NO_VALID_TARGETS + ) + + +async def test_error_missing_entity(hass: HomeAssistant, init_components) -> None: + """Test error message when entity is missing.""" + result = await conversation.async_converse( + hass, "turn on missing entity", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "No device or entity named missing entity" + ) + + +async def test_error_missing_area(hass: HomeAssistant, init_components) -> None: + """Test error message when area is missing.""" + result = await conversation.async_converse( + hass, "turn on the lights in missing area", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert result.response.speech["plain"]["speech"] == "No area named missing area" + + +async def test_error_match_failure(hass: HomeAssistant, init_components) -> None: + """Test response with complete match failure.""" + with patch( + "homeassistant.components.conversation.default_agent.recognize_all", + return_value=[], + ): + result = await conversation.async_converse( + hass, "do something", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR assert ( result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 820734901ad..b3167d979d5 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -58,6 +58,7 @@ async def test_http_processing_intent( hass_admin_user: MockUser, agent_id, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API.""" # Add an alias @@ -78,27 +79,7 @@ async def test_http_processing_intent( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot async def test_http_processing_intent_target_ha_agent( @@ -108,6 +89,7 @@ async def test_http_processing_intent_target_ha_agent( hass_admin_user: MockUser, mock_agent, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent can be processed via HTTP API with picking agent.""" # Add an alias @@ -127,28 +109,8 @@ async def test_http_processing_intent_target_ha_agent( assert resp.status == HTTPStatus.OK assert len(calls) == 1 data = await resp.json() - - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" async def test_http_processing_intent_entity_added_removed( @@ -157,6 +119,7 @@ async def test_http_processing_intent_entity_added_removed( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with entities added later. @@ -179,27 +142,8 @@ async def test_http_processing_intent_entity_added_removed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Add an entity entity_registry.async_get_or_create( @@ -215,27 +159,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.late", "name": "friendly light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + 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"}) @@ -248,27 +173,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.late", "name": "friendly light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Now delete the entity hass.states.async_remove("light.late") @@ -280,21 +186,8 @@ async def test_http_processing_intent_entity_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_alias_added_removed( @@ -303,6 +196,7 @@ async def test_http_processing_intent_alias_added_removed( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with aliases added later. @@ -324,27 +218,8 @@ async def test_http_processing_intent_alias_added_removed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Add an alias entity_registry.async_update_entity("light.kitchen", aliases={"late added alias"}) @@ -357,27 +232,8 @@ async def test_http_processing_intent_alias_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Now remove the alieas entity_registry.async_update_entity("light.kitchen", aliases={}) @@ -389,21 +245,8 @@ async def test_http_processing_intent_alias_added_removed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_entity_renamed( @@ -413,6 +256,7 @@ async def test_http_processing_intent_entity_renamed( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with entities renamed later. @@ -442,27 +286,8 @@ async def test_http_processing_intent_entity_renamed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Rename the entity entity_registry.async_update_entity("light.kitchen", name="renamed light") @@ -476,27 +301,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "renamed light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -505,21 +311,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Now clear the custom name entity_registry.async_update_entity("light.kitchen", name=None) @@ -533,27 +326,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -562,21 +336,8 @@ async def test_http_processing_intent_entity_renamed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_processing_intent_entity_exposed( @@ -586,6 +347,7 @@ async def test_http_processing_intent_entity_exposed( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API with manual expose. @@ -617,27 +379,8 @@ async def test_http_processing_intent_entity_exposed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") client = await hass_client() @@ -649,27 +392,8 @@ async def test_http_processing_intent_entity_exposed( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" # Unexpose the entity expose_entity(hass, "light.kitchen", False) @@ -682,21 +406,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" client = await hass_client() resp = await client.post( @@ -705,21 +416,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Now expose the entity expose_entity(hass, "light.kitchen", True) @@ -733,27 +431,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" client = await hass_client() resp = await client.post( @@ -762,27 +441,8 @@ async def test_http_processing_intent_entity_exposed( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" async def test_http_processing_intent_conversion_not_expose_new( @@ -792,6 +452,7 @@ async def test_http_processing_intent_conversion_not_expose_new( hass_admin_user: MockUser, entity_registry: er.EntityRegistry, enable_custom_integrations: None, + snapshot: SnapshotAssertion, ) -> None: """Test processing intent via HTTP API when not exposing new entities.""" # Disable exposing new entities to the default agent @@ -820,21 +481,8 @@ async def test_http_processing_intent_conversion_not_expose_new( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "conversation_id": None, - "response": { - "card": {}, - "data": {"code": "no_intent_match"}, - "language": hass.config.language, - "response_type": "error", - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - }, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" # Expose the entity expose_entity(hass, "light.kitchen", True) @@ -848,27 +496,8 @@ async def test_http_processing_intent_conversion_not_expose_new( assert len(calls) == 1 data = await resp.json() - assert data == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Turned on the light", - } - }, - "language": hass.config.language, - "data": { - "targets": [], - "success": [ - {"id": "light.kitchen", "name": "kitchen light", "type": "entity"} - ], - "failed": [], - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "action_done" @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @@ -936,7 +565,10 @@ async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) - async def test_http_api_no_match( - hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the HTTP conversation API with an intent match failure.""" client = await hass_client() @@ -947,25 +579,15 @@ async def test_http_api_no_match( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - }, - }, - "language": hass.config.language, - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" async def test_http_api_handle_failure( - hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator + hass: HomeAssistant, + init_components, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the HTTP conversation API with an error during handling.""" client = await hass_client() @@ -984,29 +606,16 @@ async def test_http_api_handle_failure( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "An unexpected error occurred while handling the intent", - } - }, - "language": hass.config.language, - "data": { - "code": "failed_to_handle", - }, - }, - "conversation_id": None, - } + 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() @@ -1025,23 +634,9 @@ async def test_http_api_unexpected_failure( assert resp.status == HTTPStatus.OK data = await resp.json() - assert data == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "An unexpected error occurred while handling the intent", - } - }, - "language": hass.config.language, - "data": { - "code": "unknown", - }, - }, - "conversation_id": None, - } + assert data == snapshot + assert data["response"]["response_type"] == "error" + assert data["response"]["data"]["code"] == "unknown" async def test_http_api_wrong_data( @@ -1062,6 +657,7 @@ async def test_custom_agent( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, mock_agent, + snapshot: SnapshotAssertion, ) -> None: """Test a custom conversation agent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1079,21 +675,11 @@ async def test_custom_agent( resp = await client.post("/api/conversation/process", json=data) assert resp.status == HTTPStatus.OK - assert await resp.json() == { - "response": { - "response_type": "action_done", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Test response", - } - }, - "language": "test-language", - "data": {"targets": [], "success": [], "failed": []}, - }, - "conversation_id": "test-conv-id", - } + data = await resp.json() + assert data == snapshot + assert data["response"]["response_type"] == "action_done" + assert data["response"]["speech"]["plain"]["speech"] == "Test response" + assert data["conversation_id"] == "test-conv-id" assert len(mock_agent.calls) == 1 assert mock_agent.calls[0].text == "Test Text" @@ -1136,7 +722,10 @@ async def test_custom_agent( ], ) async def test_ws_api( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, payload + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + payload, + snapshot: SnapshotAssertion, ) -> None: """Test the Websocket conversation API.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1148,21 +737,7 @@ async def test_ws_api( msg = await client.receive_json() assert msg["success"] - assert msg["result"] == { - "response": { - "response_type": "error", - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Sorry, I couldn't understand that", - } - }, - "language": payload.get("language", hass.config.language), - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } + assert msg["result"] == snapshot @pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS) @@ -1198,7 +773,10 @@ async def test_ws_prepare( async def test_custom_sentences( - hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, ) -> None: """Test custom sentences with a custom intent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1223,30 +801,19 @@ async def test_custom_sentences( ) assert resp.status == HTTPStatus.OK data = await resp.json() - - assert data == { - "response": { - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": f"You ordered a {beer_style}", - } - }, - "language": language, - "response_type": "action_done", - "data": { - "targets": [], - "success": [], - "failed": [], - }, - }, - "conversation_id": None, - } + 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, hass_admin_user: MockUser + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + snapshot: SnapshotAssertion, ) -> None: """Test custom sentences with a custom intent in config.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -1274,26 +841,9 @@ async def test_custom_sentences_config( ) assert resp.status == HTTPStatus.OK data = await resp.json() - - assert data == { - "response": { - "card": {}, - "speech": { - "plain": { - "extra_data": None, - "speech": "Stealth mode engaged", - } - }, - "language": hass.config.language, - "response_type": "action_done", - "data": { - "targets": [], - "success": [], - "failed": [], - }, - }, - "conversation_id": None, - } + 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) -> None: diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 8d473338058..0486211417c 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -192,7 +192,7 @@ async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: ) assert result.response.response_type == intent.IntentResponseType.ERROR - assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS def test_async_register(hass: HomeAssistant) -> None: