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>
This commit is contained in:
Elias Wernicke
2026-02-08 19:56:07 +01:00
committed by GitHub
parent ba6f1343cc
commit b9bfbc9e98
2 changed files with 166 additions and 1 deletions

View File

@@ -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."""

View File

@@ -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: