Compare commits

...

10 Commits

Author SHA1 Message Date
Michael Hansen
44a471cb58 Clean up 2026-03-19 09:15:12 -05:00
Michael Hansen
35f5e92e00 Fixes from rebase 2026-03-19 09:11:28 -05:00
Michael Hansen
c43744affa Fix duplicate test from merge 2026-03-19 09:09:25 -05:00
Michael Hansen
538578d5be Fix ollama test 2026-03-19 09:08:23 -05:00
Michael Hansen
3a17714516 Update more tests 2026-03-19 09:08:23 -05:00
Michael Hansen
0da7da83ec Fix tests 2026-03-19 09:08:22 -05:00
Michael Hansen
e85318cef6 Add tests 2026-03-19 09:07:57 -05:00
Michael Hansen
e8e58ed8c2 Add match failure info to handle API 2026-03-19 09:07:54 -05:00
Michael Hansen
94188a54f0 Implement suggestions 2026-03-19 09:07:29 -05:00
Michael Hansen
63e0baff3e Add missing parameters from handle API 2026-03-19 09:07:10 -05:00
13 changed files with 491 additions and 8 deletions

View File

@@ -656,7 +656,13 @@ class IntentHandleView(http.HomeAssistantView):
device_id=data.get("device_id"),
satellite_id=data.get("satellite_id"),
)
except (intent.IntentHandleError, intent.MatchFailedError) as err:
except intent.MatchFailedError as err:
# Match failure.
# Be more specific so the client can create a proper error message.
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_match_failed_error(err)
except intent.IntentHandleError as err:
# General error
intent_result = intent.IntentResponse(language=language)
intent_result.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE, str(err)

View File

@@ -1377,6 +1377,7 @@ class IntentResponse:
self.unmatched_states: list[State] = []
self.speech_slots: dict[str, Any] = {}
self.response_type = IntentResponseType.ACTION_DONE
self.match_failed_error: MatchFailedError | None = None
@callback
def async_set_speech(
@@ -1420,6 +1421,23 @@ class IntentResponse:
# Speak error message
self.async_set_speech(message)
@callback
def async_set_match_failed_error(
self, match_failed_error: MatchFailedError
) -> None:
"""Set response error."""
self.response_type = IntentResponseType.ERROR
self.error_code = IntentResponseErrorCode.NO_VALID_TARGETS
self.match_failed_error = match_failed_error
@callback
def async_set_targets(
self,
intent_targets: list[IntentResponseTarget],
) -> None:
"""Set response targets."""
self.intent_targets = intent_targets
@callback
def async_set_results(
self,
@@ -1463,6 +1481,28 @@ class IntentResponse:
if self.response_type == IntentResponseType.ERROR:
assert self.error_code is not None, "error code is required"
response_data["code"] = self.error_code.value
if self.match_failed_error:
match_error_dict: dict[str, Any] = {}
if self.match_failed_error.result.no_match_reason:
match_error_dict["no_match_reason"] = (
self.match_failed_error.result.no_match_reason.name
)
if self.match_failed_error.result.no_match_name:
match_error_dict["no_match_name"] = (
self.match_failed_error.result.no_match_name
)
match_error_dict["constraints"] = dataclasses.asdict(
self.match_failed_error.constraints
)
if self.match_failed_error.preferences:
match_error_dict["preferences"] = dataclasses.asdict(
self.match_failed_error.preferences
)
response_data["match_error"] = match_error_dict
else:
# action done or query answer
response_data["success"] = [
@@ -1473,6 +1513,18 @@ class IntentResponse:
dataclasses.asdict(target) for target in self.failed_results
]
# Add query matched/unmatched
response_data["query"] = {
"matched": [
{"entity_id": s.entity_id, "state": s.state}
for s in self.matched_states
],
"unmatched": [
{"entity_id": s.entity_id, "state": s.state}
for s in self.unmatched_states
],
}
response_dict["data"] = response_data
return response_dict

View File

@@ -1246,6 +1246,7 @@
]),
intent=None,
language='en',
match_failed_error=None,
matched_states=list([
]),
reprompt=dict({

View File

@@ -110,6 +110,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -343,6 +349,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -573,6 +585,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -650,6 +668,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -702,6 +726,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -754,6 +784,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),

View File

@@ -659,6 +659,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),

View File

@@ -53,6 +53,7 @@ async def test_broadcast_intent(
"card": {},
"data": {
"failed": [],
"query": {"matched": [], "unmatched": []},
"success": [
{
"id": "assist_satellite.test_entity",
@@ -90,6 +91,7 @@ async def test_broadcast_intent(
"card": {},
"data": {
"failed": [],
"query": {"matched": [], "unmatched": []},
"success": [
{
"id": "assist_satellite.test_entity_2",
@@ -127,6 +129,7 @@ async def test_broadcast_intent_excluded_domains(
"card": {},
"data": {
"failed": [],
"query": {"matched": [], "unmatched": []},
"success": [], # no satellites
},
"language": "en",

View File

@@ -9,6 +9,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -33,6 +39,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -57,6 +69,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -81,6 +99,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -110,6 +138,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -181,6 +219,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'on',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -210,6 +258,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -239,6 +297,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.late',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.late',
@@ -268,6 +336,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.late',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.late',
@@ -318,6 +396,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'on',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -389,6 +477,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'on',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -439,6 +537,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'on',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -468,6 +576,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'on',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',

View File

@@ -276,6 +276,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -305,6 +315,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',

View File

@@ -9,6 +9,12 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
]),
'unmatched': list([
]),
}),
'success': list([
]),
}),
@@ -54,6 +60,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -83,6 +99,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -112,6 +138,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -141,6 +177,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -170,6 +216,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -199,6 +255,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -228,6 +294,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',
@@ -257,6 +333,16 @@
'data': dict({
'failed': list([
]),
'query': dict({
'matched': list([
dict({
'entity_id': 'light.kitchen',
'state': 'off',
}),
]),
'unmatched': list([
]),
}),
'success': list([
dict({
'id': 'light.kitchen',

View File

@@ -85,7 +85,11 @@ async def test_http_handle_intent(
},
"language": hass.config.language,
"response_type": intent.IntentResponseType.ACTION_DONE.value,
"data": {"success": [], "failed": []},
"data": {
"success": [],
"failed": [],
"query": {"matched": [], "unmatched": []},
},
}
@@ -105,10 +109,9 @@ async def test_http_language_device_satellite_id(
async def async_handle(self, intent_obj: intent.Intent):
"""Handle the intent."""
assert intent_obj.context.user_id == hass_admin_user.id
# Verify language, device id, and satellite id were passed through.
assert intent_obj.language == language
assert intent_obj.device_id == device_id
assert intent_obj.satellite_id == satellite_id
assert intent_obj.language == language
response = intent_obj.create_response()
response.async_set_speech("Test response")
@@ -134,6 +137,7 @@ async def test_http_language_device_satellite_id(
assert resp.status == 200
data = await resp.json()
# Verify language, device id, and satellite id were passed through.
# Also check speech slots.
assert data == {
"card": {},
@@ -149,7 +153,11 @@ async def test_http_language_device_satellite_id(
},
"language": language,
"response_type": "action_done",
"data": {"success": [], "failed": []},
"data": {
"success": [],
"failed": [],
"query": {"matched": [], "unmatched": []},
},
}
@@ -160,6 +168,7 @@ async def test_http_handle_intent_match_failure(
assert await async_setup_component(hass, "intent", {})
# Duplicate names
hass.states.async_set(
"cover.garage_door_1", "closed", {ATTR_FRIENDLY_NAME: "Garage Door"}
)
@@ -176,7 +185,12 @@ async def test_http_handle_intent_match_failure(
assert resp.status == 200
data = await resp.json()
assert "DUPLICATE_NAME" in data["speech"]["plain"]["speech"]
assert data["response_type"] == intent.IntentResponseType.ERROR.value
assert data["data"]["code"] == intent.IntentResponseErrorCode.NO_VALID_TARGETS.value
assert (
data["data"]["match_error"]["no_match_reason"]
== intent.MatchFailedReason.DUPLICATE_NAME.name
)
async def test_http_assistant(
@@ -221,7 +235,7 @@ async def test_http_assistant(
assert resp.status == 200
data = await resp.json()
assert data["response_type"] == intent.IntentResponseType.ERROR.value
assert data["data"]["code"] == intent.IntentResponseErrorCode.FAILED_TO_HANDLE.value
assert data["data"]["code"] == intent.IntentResponseErrorCode.NO_VALID_TARGETS.value
# No assistant (exposure is irrelevant)
resp = await client.post(
@@ -802,3 +816,131 @@ async def test_stop_moving_intent_unsupported_domain(hass: HomeAssistant) -> Non
await intent.async_handle(
hass, "test", intent.INTENT_STOP_MOVING, {"name": {"value": "test light"}}
)
async def test_intent_response_match_failed_error_attributes(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:
"""Test that IntentResponse stores match_failed_error correctly via HTTP API."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
class TestIntentHandler(intent.IntentHandler):
"""Test Intent Handler."""
intent_type = "TestMatchError"
async def async_handle(self, intent_obj):
"""Handle the intent."""
raise intent.MatchFailedError(
result=intent.MatchTargetsResult(
False,
intent.MatchFailedReason.DUPLICATE_NAME,
no_match_name="Duplicate Name",
),
constraints=intent.MatchTargetsConstraints(
name="Duplicate Name",
single_target=True,
),
preferences=intent.MatchTargetsPreferences(
area_id="preferred-area-id",
floor_id="preferred-floor-id",
),
)
intent.async_register(hass, TestIntentHandler())
client = await hass_client()
resp = await client.post(
"/api/intent/handle",
json={"name": "TestMatchError", "data": {}},
)
assert resp.status == 200
data = await resp.json()
assert data["response_type"] == intent.IntentResponseType.ERROR.value
assert data["data"]["code"] == intent.IntentResponseErrorCode.NO_VALID_TARGETS.value
assert "match_error" in data["data"]
assert data["data"]["match_error"]["no_match_reason"] == "DUPLICATE_NAME"
assert data["data"]["match_error"]["no_match_name"] == "Duplicate Name"
assert data["data"]["match_error"]["constraints"]["name"] == "Duplicate Name"
assert data["data"]["match_error"]["constraints"]["single_target"] is True
assert data["data"]["match_error"]["preferences"]["area_id"] == "preferred-area-id"
assert (
data["data"]["match_error"]["preferences"]["floor_id"] == "preferred-floor-id"
)
async def test_intent_response_query_field_attributes(hass: HomeAssistant) -> None:
"""Test that IntentResponse stores matched/unmatched states correctly."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
class TestIntentHandler(intent.IntentHandler):
"""Test Intent Handler."""
intent_type = "TestQuery"
async def async_handle(self, intent_obj):
"""Handle the intent."""
response = intent_obj.create_response()
matched_state = hass.states.get("light.test_light")
unmatched_state = hass.states.get("light.test_light_2")
response.async_set_states(
matched_states=[matched_state] if matched_state else [],
unmatched_states=[unmatched_state] if unmatched_state else [],
)
response.async_set_speech("Test query response")
return response
intent.async_register(hass, TestIntentHandler())
hass.states.async_set("light.test_light", "on")
hass.states.async_set("light.test_light_2", "off")
response = await intent.async_handle(hass, "test", "TestQuery", {})
assert response.response_type.value == intent.IntentResponseType.ACTION_DONE.value
assert len(response.matched_states) == 1
assert len(response.unmatched_states) == 1
assert response.matched_states[0].entity_id == "light.test_light"
assert response.unmatched_states[0].entity_id == "light.test_light_2"
response_dict = response.as_dict()
assert response_dict["data"]["query"]["matched"] == [
{"entity_id": "light.test_light", "state": "on"}
]
assert response_dict["data"]["query"]["unmatched"] == [
{"entity_id": "light.test_light_2", "state": "off"}
]
async def test_intent_response_query_empty_states(hass: HomeAssistant) -> None:
"""Test that IntentResponse handles empty matched/unmatched states."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
class TestIntentHandler(intent.IntentHandler):
"""Test Intent Handler."""
intent_type = "TestEmptyQuery"
async def async_handle(self, intent_obj):
"""Handle the intent."""
response = intent_obj.create_response()
response.async_set_states(matched_states=[], unmatched_states=[])
response.async_set_speech("Empty query response")
return response
intent.async_register(hass, TestIntentHandler())
response = await intent.async_handle(hass, "test", "TestEmptyQuery", {})
assert response.response_type.value == intent.IntentResponseType.ACTION_DONE.value
assert len(response.matched_states) == 0
assert len(response.unmatched_states) == 0
response_dict = response.as_dict()
assert response_dict["data"]["query"]["matched"] == []
assert response_dict["data"]["query"]["unmatched"] == []

View File

@@ -1142,8 +1142,10 @@ async def test_webhook_handle_conversation_process(
},
"language": hass.config.language,
"data": {
"success": [],
"failed": [],
"query": {"matched": [], "unmatched": []},
"success": [],
"targets": [],
},
},
"conversation_id": None,

View File

@@ -11,6 +11,7 @@
]),
intent=None,
language='en',
match_failed_error=None,
matched_states=list([
]),
reprompt=dict({

View File

@@ -249,6 +249,11 @@ async def test_assist_api(
"data": {
"failed": [],
"success": [],
"targets": [],
"query": {
"matched": [{"entity_id": "light.matched", "state": "on"}],
"unmatched": [{"entity_id": "light.unmatched", "state": "on"}],
},
},
"reprompt": {
"plain": {
@@ -307,6 +312,11 @@ async def test_assist_api(
"data": {
"failed": [],
"success": [],
"targets": [],
"query": {
"matched": [{"entity_id": "light.matched", "state": "on"}],
"unmatched": [{"entity_id": "light.unmatched", "state": "on"}],
},
},
"response_type": "action_done",
"reprompt": {