Give intent match failures a message instead of a repr (#182343)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michael Hansen
2026-09-29 19:29:42 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 9dccac7fa5
commit fe11885cae
5 changed files with 515 additions and 124 deletions
@@ -8,7 +8,7 @@ from enum import Enum, auto
import logging
from pathlib import Path
import time
from typing import IO, Any, cast, override
from typing import IO, Any, NamedTuple, assert_never, cast, override
from gazetteer_matcher import FrameCandidate, GazetteerMatcher
from hassil.expression import Expression, Group, ListReference
@@ -642,7 +642,7 @@ class DefaultAgent(ConversationEntity):
except intent.MatchFailedError as match_error:
# Intent was valid, but no entities matched the constraints.
error_response_type, error_response_args = _get_match_error_response(
self.hass, match_error
match_error
)
intent_response = _make_error_result(
language,
@@ -1444,8 +1444,12 @@ class DefaultAgent(ConversationEntity):
else:
response_key = error_key
# A new error reaches here before every language has translated it, so
# prefer the language's own generic error over the English default
response_str = (
lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT
lang_intents.error_responses.get(response_key)
or lang_intents.error_responses.get(ErrorKey.NO_INTENT.value)
or _DEFAULT_ERROR_TEXT
)
response_template = template.Template(response_str, self.hass)
@@ -1738,132 +1742,165 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str
return ErrorKey.NO_INTENT, {}
def _get_match_error_response(
hass: HomeAssistant,
match_error: intent.MatchFailedError,
) -> tuple[ErrorKey, dict[str, Any]]:
"""Return key and template arguments for error when target matching fails."""
# Map to errors in home-assistant-intents
_NO_TARGET_ERRORS: dict[tuple[str, str, bool], ErrorKey] = {
("entity", "", False): ErrorKey.NO_ENTITY,
("entity", "", True): ErrorKey.NO_ENTITY_EXPOSED,
("entity", "area", False): ErrorKey.NO_ENTITY_IN_AREA,
("entity", "area", True): ErrorKey.NO_ENTITY_IN_AREA_EXPOSED,
("entity", "floor", False): ErrorKey.NO_ENTITY_IN_FLOOR,
("entity", "floor", True): ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED,
("device_class", "", False): ErrorKey.NO_DEVICE_CLASS,
("device_class", "", True): ErrorKey.NO_DEVICE_CLASS_EXPOSED,
("device_class", "area", False): ErrorKey.NO_DEVICE_CLASS_IN_AREA,
("device_class", "area", True): ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED,
("device_class", "floor", False): ErrorKey.NO_DEVICE_CLASS_IN_FLOOR,
("device_class", "floor", True): ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED,
("domain", "", False): ErrorKey.NO_DOMAIN,
("domain", "", True): ErrorKey.NO_DOMAIN_EXPOSED,
("domain", "area", False): ErrorKey.NO_DOMAIN_IN_AREA,
("domain", "area", True): ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED,
("domain", "floor", False): ErrorKey.NO_DOMAIN_IN_FLOOR,
("domain", "floor", True): ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED,
}
class _NoTargetOrder(NamedTuple):
"""Which constraints an error may name, most specific first."""
subjects: tuple[str, ...]
"""What was asked for."""
scopes: tuple[str, ...]
"""Where it was asked for."""
# The failing constraint is what the message should be about
_ANY_TARGET = ("entity", "device_class", "domain")
_ANY_SCOPE = ("area", "floor")
_NO_TARGET_ORDER: dict[intent.MatchFailedReason, _NoTargetOrder] = {
intent.MatchFailedReason.NAME: _NoTargetOrder(("entity",), _ANY_SCOPE),
intent.MatchFailedReason.AREA: _NoTargetOrder(_ANY_TARGET, ("area",)),
intent.MatchFailedReason.FLOOR: _NoTargetOrder(_ANY_TARGET, ("floor",)),
intent.MatchFailedReason.DOMAIN: _NoTargetOrder(
("device_class", "domain"), _ANY_SCOPE
),
intent.MatchFailedReason.DEVICE_CLASS: _NoTargetOrder(
("device_class", "domain"), _ANY_SCOPE
),
intent.MatchFailedReason.ASSISTANT: _NoTargetOrder(_ANY_TARGET, _ANY_SCOPE),
}
def _get_no_target_response(
constraints: intent.MatchTargetsConstraints,
order: _NoTargetOrder,
*,
exposed_only: bool,
) -> tuple[ErrorKey, dict[str, Any]]:
"""Return the error naming the failed constraint, scoped to an area or floor."""
subjects: dict[str, dict[str, Any]] = {}
if constraints.name:
subjects["entity"] = {"entity": constraints.name}
if constraints.device_classes:
subjects["device_class"] = {
"device_class": next(iter(constraints.device_classes))
}
if constraints.domains:
subjects["domain"] = {"domain": next(iter(constraints.domains))}
for kind in order.subjects:
if (args := subjects.get(kind)) is not None:
break
else:
# The constraint that failed was not set, so there is nothing to name
return ErrorKey.NO_INTENT, {}
scopes = {"area": constraints.area_name, "floor": constraints.floor_name}
scope = ""
for candidate in order.scopes:
if scopes[candidate]:
scope = candidate
args[candidate] = scopes[candidate]
break
return _NO_TARGET_ERRORS[kind, scope, exposed_only], args
# Present in intents, but not the ErrorKey enum yet.
_DUPLICATE_TARGETS = "duplicate_targets"
def _get_duplicate_response(
name: str | None, constraints: intent.MatchTargetsConstraints
) -> tuple[ErrorKey | str, dict[str, Any]]:
"""Return the error for a match ambiguous between more entities than can be used."""
if not name:
# Nothing was named, so all the answer can say is that several matched
return _DUPLICATE_TARGETS, {}
if constraints.area_name:
return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, {
"entity": name,
"area": constraints.area_name,
}
if constraints.floor_name:
return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, {
"entity": name,
"floor": constraints.floor_name,
}
return ErrorKey.DUPLICATE_ENTITIES, {"entity": name}
def _get_match_error_response(
match_error: intent.MatchFailedError,
) -> tuple[ErrorKey | str, dict[str, Any]]:
"""Return key and template arguments for error when target matching fails."""
constraints, result = match_error.constraints, match_error.result
reason = result.no_match_reason
if reason is None:
return ErrorKey.NO_INTENT, {}
if (
reason
in (intent.MatchFailedReason.DEVICE_CLASS, intent.MatchFailedReason.DOMAIN)
) and constraints.device_classes:
device_class = next(iter(constraints.device_classes)) # first device class
if constraints.area_name:
# device_class in area
return ErrorKey.NO_DEVICE_CLASS_IN_AREA, {
"device_class": device_class,
"area": constraints.area_name,
match reason:
case (
intent.MatchFailedReason.NAME
| intent.MatchFailedReason.AREA
| intent.MatchFailedReason.FLOOR
| intent.MatchFailedReason.DOMAIN
| intent.MatchFailedReason.DEVICE_CLASS
| intent.MatchFailedReason.ASSISTANT
):
return _get_no_target_response(
constraints,
_NO_TARGET_ORDER[reason],
exposed_only=reason is intent.MatchFailedReason.ASSISTANT,
)
case intent.MatchFailedReason.INVALID_AREA:
return ErrorKey.NO_AREA, {"area": result.no_match_name}
case intent.MatchFailedReason.INVALID_FLOOR:
return ErrorKey.NO_FLOOR, {"floor": result.no_match_name}
case intent.MatchFailedReason.FEATURE:
return ErrorKey.FEATURE_NOT_SUPPORTED, {}
case intent.MatchFailedReason.STATE:
if not constraints.states:
return ErrorKey.NO_INTENT, {}
return ErrorKey.ENTITY_WRONG_STATE, {
"state": next(iter(constraints.states))
}
# device_class only
return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class}
case intent.MatchFailedReason.DUPLICATE_NAME:
return _get_duplicate_response(result.no_match_name, constraints)
if (reason is intent.MatchFailedReason.DOMAIN) and constraints.domains:
domain = next(iter(constraints.domains)) # first domain
if constraints.area_name:
# domain in area
return ErrorKey.NO_DOMAIN_IN_AREA, {
"domain": domain,
"area": constraints.area_name,
}
case intent.MatchFailedReason.MULTIPLE_TARGETS:
return _get_duplicate_response(constraints.name, constraints)
if constraints.floor_name:
# domain in floor
return ErrorKey.NO_DOMAIN_IN_FLOOR, {
"domain": domain,
"floor": constraints.floor_name,
}
# domain only
return ErrorKey.NO_DOMAIN, {"domain": domain}
if reason is intent.MatchFailedReason.DUPLICATE_NAME:
if constraints.floor_name:
# duplicate on floor
return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, {
"entity": result.no_match_name,
"floor": constraints.floor_name,
}
if constraints.area_name:
# duplicate on area
return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, {
"entity": result.no_match_name,
"area": constraints.area_name,
}
return ErrorKey.DUPLICATE_ENTITIES, {"entity": result.no_match_name}
if reason is intent.MatchFailedReason.INVALID_AREA:
# Invalid area name
return ErrorKey.NO_AREA, {"area": result.no_match_name}
if reason is intent.MatchFailedReason.INVALID_FLOOR:
# Invalid floor name
return ErrorKey.NO_FLOOR, {"floor": result.no_match_name}
if reason is intent.MatchFailedReason.FEATURE:
# Feature not supported by entity
return ErrorKey.FEATURE_NOT_SUPPORTED, {}
if reason is intent.MatchFailedReason.STATE:
# Entity is not in correct state
assert constraints.states
state = next(iter(constraints.states))
return ErrorKey.ENTITY_WRONG_STATE, {"state": state}
if reason is intent.MatchFailedReason.ASSISTANT:
# Not exposed
if constraints.name:
if constraints.area_name:
return ErrorKey.NO_ENTITY_IN_AREA_EXPOSED, {
"entity": constraints.name,
"area": constraints.area_name,
}
if constraints.floor_name:
return ErrorKey.NO_ENTITY_IN_FLOOR_EXPOSED, {
"entity": constraints.name,
"floor": constraints.floor_name,
}
return ErrorKey.NO_ENTITY_EXPOSED, {"entity": constraints.name}
if constraints.device_classes:
device_class = next(iter(constraints.device_classes))
if constraints.area_name:
return ErrorKey.NO_DEVICE_CLASS_IN_AREA_EXPOSED, {
"device_class": device_class,
"area": constraints.area_name,
}
if constraints.floor_name:
return ErrorKey.NO_DEVICE_CLASS_IN_FLOOR_EXPOSED, {
"device_class": device_class,
"floor": constraints.floor_name,
}
return ErrorKey.NO_DEVICE_CLASS_EXPOSED, {"device_class": device_class}
if constraints.domains:
domain = next(iter(constraints.domains))
if constraints.area_name:
return ErrorKey.NO_DOMAIN_IN_AREA_EXPOSED, {
"domain": domain,
"area": constraints.area_name,
}
if constraints.floor_name:
return ErrorKey.NO_DOMAIN_IN_FLOOR_EXPOSED, {
"domain": domain,
"floor": constraints.floor_name,
}
return ErrorKey.NO_DOMAIN_EXPOSED, {"domain": domain}
# Default error
return ErrorKey.NO_INTENT, {}
assert_never(reason)
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
+65 -1
View File
@@ -192,7 +192,12 @@ class MatchFailedError(IntentError):
@override
def __str__(self) -> str:
"""Return string representation."""
"""Return why matching failed."""
return _match_failure_message(self.result, self.constraints)
@override
def __repr__(self) -> str:
"""Return the full result and constraints, for logs and debugging."""
return (
f"<MatchFailedError result={self.result},"
f" constraints={self.constraints},"
@@ -351,6 +356,65 @@ class MatchTargetsConstraints:
)
_MATCH_FAILURE_REASONS: dict[MatchFailedReason, str] = {
MatchFailedReason.NAME: "No entities matched the name",
MatchFailedReason.AREA: "No entities were in the area",
MatchFailedReason.FLOOR: "No entities were on the floor",
MatchFailedReason.DOMAIN: "No entities matched the domain",
MatchFailedReason.DEVICE_CLASS: "No entities matched the device class",
MatchFailedReason.FEATURE: "No entities supported the required features",
MatchFailedReason.STATE: "No entities were in the required state",
MatchFailedReason.ASSISTANT: "No matching entities are exposed to the assistant",
MatchFailedReason.INVALID_AREA: "The area does not exist",
MatchFailedReason.INVALID_FLOOR: "The floor does not exist",
MatchFailedReason.DUPLICATE_NAME: ("Multiple entities share the name"),
MatchFailedReason.MULTIPLE_TARGETS: (
"Multiple entities matched, but a single target is required"
),
}
def _describe_constraints(constraints: MatchTargetsConstraints) -> str:
"""List the constraints that were set, in an order that does not vary."""
described: list[str] = []
for label, value in (
("name", constraints.name),
("area", constraints.area_name),
("floor", constraints.floor_name),
):
if value:
described.append(f"{label} {value!r}")
# Sorted because a set's iteration order is not stable between runs
for label, values in (
("domains", constraints.domains),
("device classes", constraints.device_classes),
("states", constraints.states),
):
if values:
described.append(f"{label} {', '.join(sorted(values))}")
if constraints.features:
described.append(f"features {constraints.features}")
return ", ".join(described)
def _match_failure_message(
result: MatchTargetsResult, constraints: MatchTargetsConstraints
) -> str:
"""Describe a failed match as a sentence naming the constraints in play."""
message = "No entities matched"
if (reason := result.no_match_reason) is not None:
message = _MATCH_FAILURE_REASONS.get(reason, message)
if described := _describe_constraints(constraints):
return f"{message} (given {described})"
return message
@dataclass
class MatchTargetsPreferences:
"""Preferences to disambiguate duplicate name matches."""
@@ -6,12 +6,15 @@ import tempfile
from typing import Any
from unittest.mock import AsyncMock, patch
from hassil.intents import Intents
from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult
from home_assistant_intents import ErrorKey
import pytest
from syrupy.assertion import SnapshotAssertion
import yaml
from homeassistant.components import conversation, cover, media_player, weather
from homeassistant.components.climate import ClimateEntityFeature
from homeassistant.components.conversation import (
DOMAIN,
async_get_agent,
@@ -24,7 +27,11 @@ from homeassistant.components.conversation.chat_log import (
async_get_chat_log,
)
from homeassistant.components.conversation.const import ATTR_LANGUAGE, ATTR_TEXT
from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE
from homeassistant.components.conversation.default_agent import (
METADATA_CUSTOM_SENTENCE,
LanguageIntents,
_get_match_error_response,
)
from homeassistant.components.conversation.models import ConversationInput
from homeassistant.components.cover import SERVICE_OPEN_COVER
from homeassistant.components.homeassistant.exposed_entities import (
@@ -39,6 +46,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_FRIENDLY_NAME,
ATTR_SUPPORTED_FEATURES,
STATE_CLOSED,
STATE_OFF,
STATE_ON,
@@ -993,6 +1001,237 @@ async def test_error_no_device_on_floor_exposed(
)
@pytest.mark.usefixtures("init_components")
async def test_error_device_in_other_area(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
area_registry: ar.AreaRegistry,
) -> None:
"""Test error message when a known device exists, but not in the asked-for area."""
area_kitchen = area_registry.async_update(
area_registry.async_get_or_create("kitchen_id").id, name="kitchen"
)
area_bedroom = area_registry.async_update(
area_registry.async_get_or_create("bedroom_id").id, name="bedroom"
)
# The kitchen has a light, so the area itself is a valid target
for object_id, name, area in (
("1234", "ceiling", area_kitchen),
("5678", "test light", area_bedroom),
):
entry = entity_registry.async_get_or_create("light", "demo", object_id)
entry = entity_registry.async_update_entity(
entry.entity_id, name=name, area_id=area.id
)
hass.states.async_set(entry.entity_id, "off", {ATTR_FRIENDLY_NAME: name})
expose_entity(hass, entry.entity_id, True)
await hass.async_block_till_done()
result = await conversation.async_converse(
hass, "turn on the test light in the kitchen", None, Context(), None
)
assert result.response.response_type is intent.IntentResponseType.ERROR
assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS
assert (
result.response.speech["plain"]["speech"]
== "Sorry, I am not aware of any device called test light in the kitchen area"
)
@pytest.mark.parametrize("reason", list(intent.MatchFailedReason))
def test_match_error_response_never_falls_back(
reason: intent.MatchFailedReason,
) -> None:
"""Test every failure keeps its own message when the constraint was set.
A reason with no branch used to reach the generic "couldn't understand"
error, which says nothing about what was actually wrong.
"""
match_error = intent.MatchFailedError(
result=intent.MatchTargetsResult(False, reason, no_match_name="test light"),
constraints=intent.MatchTargetsConstraints(
name="test light",
area_name="kitchen",
domains={"light"},
device_classes={"garage"},
states={"on"},
),
)
error_key, _error_args = _get_match_error_response(match_error)
assert error_key is not ErrorKey.NO_INTENT
@pytest.mark.parametrize(
("reason", "expected_key", "expected_args"),
[
pytest.param(
intent.MatchFailedReason.NAME,
ErrorKey.NO_ENTITY,
{"entity": "test light"},
id="name",
),
pytest.param(
intent.MatchFailedReason.DOMAIN,
ErrorKey.NO_DOMAIN,
{"domain": "light"},
id="domain",
),
pytest.param(
intent.MatchFailedReason.ASSISTANT,
ErrorKey.NO_ENTITY_EXPOSED,
{"entity": "test light"},
id="assistant",
),
],
)
def test_match_error_response_names_the_failed_constraint(
reason: intent.MatchFailedReason,
expected_key: ErrorKey,
expected_args: dict[str, str],
) -> None:
"""Test the reason picks the subject, not whichever constraint was set first.
A domain failure alongside a name means no entity of that domain exists, so
saying nothing is called that name would be wrong.
"""
match_error = intent.MatchFailedError(
result=intent.MatchTargetsResult(False, reason),
constraints=intent.MatchTargetsConstraints(
name="test light", domains={"light"}
),
)
assert _get_match_error_response(match_error) == (expected_key, expected_args)
@pytest.mark.parametrize(
("reason", "expected_key", "expected_args"),
[
pytest.param(
intent.MatchFailedReason.FLOOR,
ErrorKey.NO_ENTITY_IN_FLOOR,
{"entity": "test light", "floor": "ground"},
id="floor",
),
pytest.param(
intent.MatchFailedReason.AREA,
ErrorKey.NO_ENTITY_IN_AREA,
{"entity": "test light", "area": "kitchen"},
id="area",
),
],
)
def test_match_error_response_names_the_failed_scope(
reason: intent.MatchFailedReason,
expected_key: ErrorKey,
expected_args: dict[str, str],
) -> None:
"""Test the reason picks the scope when both an area and a floor were asked for.
Floors are filtered before areas, so a floor failure says nothing about
whether the area would have matched.
"""
match_error = intent.MatchFailedError(
result=intent.MatchTargetsResult(False, reason),
constraints=intent.MatchTargetsConstraints(
name="test light", area_name="kitchen", floor_name="ground"
),
)
assert _get_match_error_response(match_error) == (expected_key, expected_args)
@pytest.mark.usefixtures("init_components")
async def test_error_multiple_targets_without_name_is_spoken(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
area_registry: ar.AreaRegistry,
) -> None:
"""Test an ambiguous match with nothing named speaks a real response.
duplicate_targets is not an ErrorKey, so this asserts the rendered speech to
catch the key failing to resolve and falling back to the generic error.
"""
area_kitchen = area_registry.async_update(
area_registry.async_get_or_create("kitchen_id").id, name="kitchen"
)
for object_id, name in (("1234", "Thermostat One"), ("5678", "Thermostat Two")):
entry = entity_registry.async_get_or_create("climate", "demo", object_id)
entry = entity_registry.async_update_entity(
entry.entity_id, name=name, area_id=area_kitchen.id
)
hass.states.async_set(
entry.entity_id,
"heat",
{
ATTR_FRIENDLY_NAME: name,
"current_temperature": 20,
ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE,
},
)
expose_entity(hass, entry.entity_id, True)
await hass.async_block_till_done()
result = await conversation.async_converse(
hass, "what is the temperature in the kitchen", None, Context(), None
)
assert result.response.response_type is intent.IntentResponseType.ERROR
assert (
result.response.speech["plain"]["speech"]
== "Sorry, more than one device matched your request"
)
def test_match_error_response_multiple_targets_without_name() -> None:
"""Test an ambiguous match with nothing named still reports the ambiguity.
Several single_target callers constrain only a domain or device class, so
there is no name to say which one was meant.
"""
match_error = intent.MatchFailedError(
result=intent.MatchTargetsResult(
False, intent.MatchFailedReason.MULTIPLE_TARGETS
),
constraints=intent.MatchTargetsConstraints(
area_name="kitchen", domains={"climate"}
),
)
assert _get_match_error_response(match_error) == ("duplicate_targets", {})
@pytest.mark.usefixtures("init_components")
async def test_error_text_prefers_the_language_over_english(
hass: HomeAssistant,
) -> None:
"""Test a language that translates only some errors still answers in itself.
Most languages translate a subset of the errors, and a missing one used to
fall through to English no matter what language was being spoken.
"""
generic = "Ho sento, no entenc això"
lang_intents = LanguageIntents(
intents=Intents.from_dict({"language": "ca", "intents": {}}),
intents_dict={},
intent_responses={},
error_responses={ErrorKey.NO_INTENT.value: generic},
language_variant="ca",
)
agent = conversation.async_get_agent(hass)
assert (
agent._get_error_text(
ErrorKey.NO_ENTITY_EXPOSED, lang_intents, entity="test light"
)
== generic
)
@pytest.mark.usefixtures("init_components")
async def test_error_no_device_in_area_exposed(
hass: HomeAssistant,
+4 -1
View File
@@ -177,7 +177,10 @@ async def test_http_handle_intent_match_failure(
assert resp.status == 200
data = await resp.json()
assert "DUPLICATE_NAME" in data["speech"]["plain"]["speech"]
# The API speaks the failure, so it must be a sentence and not a repr
assert data["speech"]["plain"]["speech"] == (
"Multiple entities share the name (given name 'Garage Door')"
)
async def test_http_assistant(
+48
View File
@@ -1118,3 +1118,51 @@ async def test_intent_response_dict() -> None:
# The original dict should not be affected by the mutations
assert response_dict1 == response_dict2
@pytest.mark.parametrize("reason", list(intent.MatchFailedReason))
def test_match_failed_error_describes_every_reason(
reason: intent.MatchFailedReason,
) -> None:
"""Test every reason has wording, since str() is spoken by the REST API."""
error = intent.MatchFailedError(
result=intent.MatchTargetsResult(False, reason),
constraints=intent.MatchTargetsConstraints(name="Lamp"),
)
assert str(error) == f"{intent._MATCH_FAILURE_REASONS[reason]} (given name 'Lamp')"
def test_match_failed_error_str_names_the_constraints() -> None:
"""Test the message names what was asked for, and only what was set."""
error = intent.MatchFailedError(
result=intent.MatchTargetsResult(False, intent.MatchFailedReason.AREA),
constraints=intent.MatchTargetsConstraints(
name="Lamp", area_name="Kitchen", assistant="conversation"
),
)
assert (
str(error) == "No entities were in the area (given name 'Lamp', area 'Kitchen')"
)
def test_match_failed_error_str_without_constraints() -> None:
"""Test the message stands alone when nothing was constrained."""
error = intent.MatchFailedError(
result=intent.MatchTargetsResult(False, intent.MatchFailedReason.DOMAIN),
constraints=intent.MatchTargetsConstraints(),
)
assert str(error) == "No entities matched the domain"
def test_match_failed_error_repr_keeps_the_detail() -> None:
"""Test the full state is still available for logs and debugging."""
error = intent.MatchFailedError(
result=intent.MatchTargetsResult(False, intent.MatchFailedReason.NAME),
constraints=intent.MatchTargetsConstraints(name="Lamp"),
)
assert repr(error).startswith("<MatchFailedError result=MatchTargetsResult(")
assert "constraints=MatchTargetsConstraints(" in repr(error)