From b9bfbc9e98e871418936c6b81e848cdc9b4f9db1 Mon Sep 17 00:00:00 2001 From: Elias Wernicke Date: Sun, 8 Feb 2026 19:56:07 +0100 Subject: [PATCH] Validate conversation_command in start timer intent (#149915) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- homeassistant/components/intent/timers.py | 60 ++++++++++++ tests/components/intent/test_timers.py | 107 +++++++++++++++++++++- 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 06be933ba6b..37188cb5a2e 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) TIMER_NOT_FOUND_RESPONSE = "timer_not_found" MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched" NO_TIMER_SUPPORT_RESPONSE = "no_timer_support" +NO_TIMER_COMMAND_RESPONSE = "no_timer_command" @dataclass @@ -192,6 +193,17 @@ class MultipleTimersMatchedError(intent.IntentHandleError): super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE) +class NoTimerCommandError(intent.IntentHandleError): + """Error when a conversation command does not match any intent.""" + + def __init__(self, command: str) -> None: + """Initialize error.""" + super().__init__( + f"Intent not recognized: {command}", + NO_TIMER_COMMAND_RESPONSE, + ) + + class TimersNotSupportedError(intent.IntentHandleError): """Error when a timer intent is used from a device that isn't registered to handle timer events.""" @@ -836,6 +848,12 @@ class StartTimerIntentHandler(intent.IntentHandler): # Fail early if this is not a delayed command raise TimersNotSupportedError(intent_obj.device_id) + # Validate conversation command if provided + if conversation_command and not await self._validate_conversation_command( + intent_obj, conversation_command + ): + raise NoTimerCommandError(conversation_command) + name: str | None = None if "name" in slots: name = slots["name"]["value"] @@ -865,6 +883,48 @@ class StartTimerIntentHandler(intent.IntentHandler): return intent_obj.create_response() + async def _validate_conversation_command( + self, intent_obj: intent.Intent, conversation_command: str + ) -> bool: + """Validate that a conversation command can be executed.""" + from homeassistant.components.conversation import ( # noqa: PLC0415 + ConversationInput, + async_get_agent, + default_agent, + ) + + # Only validate if using the default agent + conversation_agent = async_get_agent( + intent_obj.hass, intent_obj.conversation_agent_id + ) + + if conversation_agent is None or not isinstance( + conversation_agent, default_agent.DefaultAgent + ): + return True # Skip validation + + test_input = ConversationInput( + text=conversation_command, + context=intent_obj.context, + conversation_id=None, + device_id=intent_obj.device_id, + satellite_id=intent_obj.satellite_id, + language=intent_obj.language, + agent_id=conversation_agent.entity_id, + ) + + # check for sentence trigger + if ( + await conversation_agent.async_recognize_sentence_trigger(test_input) + ) is not None: + return True + + # check for intent + if (await conversation_agent.async_recognize_intent(test_input)) is not None: + return True + + return False + class CancelTimerIntentHandler(intent.IntentHandler): """Intent handler for cancelling a timer.""" diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index 1789e981e2d..dfeb69ab3e4 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -1,12 +1,15 @@ """Tests for intent timers.""" import asyncio -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components import conversation from homeassistant.components.intent.timers import ( + TIMER_DATA, MultipleTimersMatchedError, + NoTimerCommandError, TimerEventType, TimerInfo, TimerManager, @@ -32,6 +35,8 @@ from tests.common import MockConfigEntry @pytest.fixture async def init_components(hass: HomeAssistant) -> None: """Initialize required components for tests.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) assert await async_setup_component(hass, "intent", {}) @@ -1463,6 +1468,106 @@ async def test_start_timer_with_conversation_command( assert mock_converse.call_args.args[1] == test_command +async def test_start_timer_with_sentence_trigger_validation( + hass: HomeAssistant, init_components +) -> None: + """Test starting a timer with a conversation command validates against sentence triggers.""" + device_id = "test_device" + timer_name = "test timer" + test_command = "turn on the lights" + agent_id = None # Default agent + + with patch( + "homeassistant.components.conversation.async_get_agent" + ) as mock_get_agent: + mock_agent = MagicMock(spec=conversation.default_agent.DefaultAgent) + mock_agent.async_recognize_sentence_trigger = AsyncMock( + return_value=MagicMock(), + ) + mock_agent.async_recognize_intent = AsyncMock(return_value=None) + mock_get_agent.return_value = mock_agent + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "seconds": {"value": 5}, + "conversation_command": {"value": test_command}, + }, + device_id=device_id, + conversation_agent_id=agent_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Verify the sentence trigger was checked + mock_agent.async_recognize_sentence_trigger.assert_called_once() + + # Verify timer was created successfully + timer_manager = hass.data[TIMER_DATA] + assert len(timer_manager.timers) == 1 + + +async def test_start_timer_with_invalid_conversation_command( + hass: HomeAssistant, init_components +) -> None: + """Test starting a timer with an invalid conversation command fails validation.""" + device_id = "test_device" + timer_name = "test timer" + invalid_command = "invalid command that does not exist" + agent_id = None # Default agent + + with pytest.raises(NoTimerCommandError): + await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "seconds": {"value": 5}, + "conversation_command": {"value": invalid_command}, + }, + device_id=device_id, + conversation_agent_id=agent_id, + ) + + # Verify no timer was created + timer_manager = hass.data[TIMER_DATA] + assert len(timer_manager.timers) == 0 + + +async def test_start_timer_with_conversation_command_skip_validation( + hass: HomeAssistant, init_components +) -> None: + """Test starting a timer with a conversation command skips validation for non-default agents.""" + device_id = "test_device" + timer_name = "test timer" + invalid_command = "invalid command that does not exist" + agent_id = "conversation.test_llm_agent" + + # This should NOT raise an error because validation is skipped for all non-default agents + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "seconds": {"value": 5}, + "conversation_command": {"value": invalid_command}, + }, + device_id=device_id, + conversation_agent_id=agent_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Verify timer was created successfully despite invalid command + timer_manager = hass.data[TIMER_DATA] + assert len(timer_manager.timers) == 1 + + async def test_pause_unpause_timer_disambiguate( hass: HomeAssistant, init_components ) -> None: