Skip to content

Commit f3d9ac3

Browse files
Patch76claude
andcommitted
fix(tools_integrations): address kp13 review subset (8 of 10 + 4 NITs)
Addresses 8 of kp13's 10 numbered review items + all 4 while-you're-in bullets on PR #1424. Items 1 and 6 (Path 4 happy-path schema harmonization and IDEMPOTENT-SUCCESS CONTRACT docstring rewrite) are held pending the design-clarification response in issue-comment 4527825242. Contract / schema: - Drop the ``result`` passthrough key from Path 4 happy-path return (no other path returns it). [Item 2] Docstring / contract accuracy: - Field descriptions for ``target``, ``helper_type``, ``subentry_id``, ``confirm`` switched from "delete"/"deletion" to "remove"/"removal" to match the public tool verb. [Item 7] - ``uniform for all three paths`` updated to ``four`` in both confirm and empty/whitespace gates; Path 4 added to the empty-target gate's enumeration. [Item 8] - Stale ``ha_delete_helpers_integrations`` reference in docs/superpowers/specs/2026-05-21-1288-auto-backup-design.md updated. [Item 9] Error handling: - Narrowed ``_get_entry_id_for_flow_helper`` broad ``except Exception`` to ``(OSError, TimeoutError)``. KeyError/AttributeError/TypeError now propagate so programmer bugs surface instead of being mis-classified as ``WEBSOCKET_DISCONNECTED``. [Item 10] Tests: - Replaced the tautological OR-disjunct in test_simple_path_non_404_apierror_propagates with two independent assertions so a regression that leaks ``already_deleted`` into a non-``ENTITY_NOT_FOUND`` code still fails. [Item 5a] - Pass ``status_code=404`` to ``HomeAssistantAPIError`` in test_simple_path_disabled_state_check_apierror_resolves_via_registry — the test now actually exercises the 404 narrow it claims to cover. [Item 5b] - New test_direct_path_non_404_apierror_surfaces_structured_error pins the Path 3 narrow (non-404 surfaces as structured error). [Item 4] - New test_remove_helpers_integrations_subentry_string_error_surfaces_ service_call_failed pins the Path 4 dict-only narrow against string-form ``"error": "not found"`` regressions. [Item 3] While you're in there (Boy-Scout): - Module comment near the private ``_delete_*`` helpers explaining the prefix-asymmetry (HA backend verb vs public ``remove`` family). - Tightened "three deletion kinds in four paths" phrasing. - FLOW contract bullet now lists ``bare_id_not_supported`` as a third failure mode (raises ``ENTITY_NOT_FOUND``). - ``state_gone`` block comment compressed to one note about the never-existed-target consequence of the APIError-404 branch. 76 affected unit tests pass locally; ruff clean on touched files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 922a2ab commit f3d9ac3

4 files changed

Lines changed: 96 additions & 33 deletions

File tree

docs/superpowers/specs/2026-05-21-1288-auto-backup-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ which would restart HA. Layered defenses:
231231
`ha_set_area_or_floor`, `ha_remove_area_or_floor`,
232232
`ha_set_todo_item`, `ha_remove_todo_item`,
233233
`ha_set_entity`,
234-
`ha_set_integration_enabled`, `ha_delete_helpers_integrations`.
234+
`ha_set_integration_enabled`, `ha_remove_helpers_integrations`.
235235

236236
**Explicitly NOT wrapped:** `ha_call_service`, `ha_call_event`, `ha_restart`, `ha_reload_core`, `ha_check_config`, `ha_eval_template`, `ha_delete_file`, `ha_remove_entity`, `ha_remove_device`, `ha_update_device`, `ha_install_mcp_tools`, `ha_hacs_*`, blueprint/import ops.
237237

src/ha_mcp/tools/tools_integrations.py

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,13 @@ async def _get_entry_id_for_flow_helper(
141141
except (HomeAssistantConnectionError, HomeAssistantAuthError):
142142
# Typed errors must reach the outer handler — do not swallow.
143143
raise
144-
except Exception as e:
144+
except (OSError, TimeoutError) as e:
145+
# Network / transport errors from the WS layer (ConnectionError,
146+
# BrokenPipeError, TimeoutError, …). Programmer-bug-shape
147+
# exceptions (KeyError, AttributeError, TypeError) intentionally
148+
# propagate — the response is shape-checked at the dict guard
149+
# below, and a raise here would otherwise mask the bug as a
150+
# transient WEBSOCKET_DISCONNECTED.
145151
logger.debug(f"entity_registry/get failed for {entity_id}: {e}")
146152
if warnings is not None:
147153
warnings.append(f"entity_registry/get failed for {entity_id}: {e}")
@@ -1131,7 +1137,7 @@ async def ha_remove_helpers_integrations(
11311137
str,
11321138
Field(
11331139
description=(
1134-
"What to delete. One of: "
1140+
"What to remove. One of: "
11351141
"(a) bare helper_id for SIMPLE helpers (requires helper_type), "
11361142
"e.g. 'my_button'; "
11371143
"(b) full entity_id (requires helper_type), "
@@ -1149,7 +1155,7 @@ async def ha_remove_helpers_integrations(
11491155
description=(
11501156
"Helper type. Required when target is a helper_id (bare) "
11511157
"or entity_id. Set to None when target is a config entry_id "
1152-
"to delete any integration. Use 'config_subentry' to delete "
1158+
"to remove any integration. Use 'config_subentry' to remove "
11531159
"a config subentry under target."
11541160
),
11551161
default=None,
@@ -1159,7 +1165,7 @@ async def ha_remove_helpers_integrations(
11591165
str | None,
11601166
Field(
11611167
description=(
1162-
"Config subentry ID to delete when helper_type='config_subentry'."
1168+
"Config subentry ID to remove when helper_type='config_subentry'."
11631169
),
11641170
default=None,
11651171
),
@@ -1168,7 +1174,7 @@ async def ha_remove_helpers_integrations(
11681174
bool | str,
11691175
Field(
11701176
description=(
1171-
"Must be True to confirm deletion. Accepts bool or "
1177+
"Must be True to confirm removal. Accepts bool or "
11721178
"string ('true'/'false'/'1'/'0'/'yes'/'no'/'on'/'off', "
11731179
"case-insensitive) for transport ergonomics."
11741180
),
@@ -1191,9 +1197,9 @@ async def ha_remove_helpers_integrations(
11911197
) -> dict[str, Any]:
11921198
"""Remove a Home Assistant helper or integration config entry.
11931199
1194-
Combines simple-helper websocket deletion, config-entry deletion, and
1195-
config-subentry deletion under one entry point with four routing paths
1196-
driven by helper_type.
1200+
Unifies three backend removal mechanisms — simple-helper websocket
1201+
delete, config-entry delete, and config-subentry delete — behind one
1202+
entry point with four routing paths driven by helper_type.
11971203
11981204
WHEN NOT TO USE:
11991205
- Removing only an entity (without deleting its underlying helper or
@@ -1227,7 +1233,9 @@ async def ha_remove_helpers_integrations(
12271233
- FLOW: entity_id not in registry (target was never there or
12281234
already removed). YAML-configured helpers (no config entry
12291235
backing) still raise ``RESOURCE_NOT_FOUND`` — that's a
1230-
call-shape conflict, not a missing target.
1236+
call-shape conflict, not a missing target. A bare helper_id
1237+
(no ``.``) on a FLOW target raises ``ENTITY_NOT_FOUND`` —
1238+
FLOW resolution needs a full entity_id.
12311239
- Direct config entry (helper_type=None): backend returns HTTP 404.
12321240
- Config subentry: backend returns a "not_found" error.
12331241
@@ -1261,7 +1269,7 @@ async def ha_remove_helpers_integrations(
12611269
Use ha_search_entities() / ha_get_integration() to verify before
12621270
removal. Cannot be undone.
12631271
"""
1264-
# === Confirm gate (uniform for all three paths) ===
1272+
# === Confirm gate (uniform for all four paths) ===
12651273
confirm_bool = coerce_bool_param(confirm, "confirm", default=False)
12661274
if not confirm_bool:
12671275
raise_tool_error(
@@ -1279,14 +1287,15 @@ async def ha_remove_helpers_integrations(
12791287
)
12801288
)
12811289

1282-
# === Empty/whitespace target gate (uniform for all three paths) ===
1290+
# === Empty/whitespace target gate (uniform for all four paths) ===
12831291
# Empty/whitespace ``target`` would reach the destructive backend call
12841292
# on every path: Path 1 (simple-helper websocket delete), Path 2
12851293
# (flow-helper entity-resolution → entry_id delete), Path 3
1286-
# (_delete_direct_entry → client.delete_config_entry("")). Each path
1287-
# surfaces a different misleading error from HA. Reject up-front so
1288-
# the caller learns the identifier was unusable before any backend
1289-
# call.
1294+
# (_delete_direct_entry → client.delete_config_entry("")), Path 4
1295+
# (_delete_config_subentry → ws delete on empty parent entry_id).
1296+
# Each path surfaces a different misleading error from HA. Reject
1297+
# up-front so the caller learns the identifier was unusable before
1298+
# any backend call.
12901299
validate_identifier_not_empty(
12911300
target,
12921301
"target",
@@ -1339,6 +1348,12 @@ async def ha_remove_helpers_integrations(
13391348
)
13401349
)
13411350

1351+
# Private helpers keep the ``_delete_*`` prefix because they wrap HA's
1352+
# own backend verb — the WebSocket API is ``<type>/delete`` and the
1353+
# REST API is HTTP DELETE. The public tool surface uses ``remove`` to
1354+
# join the ``ha_remove_*`` behavioural family; the prefix asymmetry is
1355+
# intentional and prevents future renames pulled by either side.
1356+
13421357
# === Path 3: Direct config entry delete (any integration) ===
13431358
async def _delete_direct_entry(self, entry_id: str) -> dict[str, Any]:
13441359
"""Delete a config entry directly via the REST delete API."""
@@ -1680,7 +1695,6 @@ async def _delete_config_subentry(
16801695
"subentry_id": subentry_id,
16811696
"method": "config_subentry_delete",
16821697
"message": f"Successfully deleted config subentry: {subentry_id}",
1683-
"result": result.get("result"),
16841698
}
16851699

16861700
# === Path 1: SIMPLE helper delete via websocket ===
@@ -1806,16 +1820,13 @@ async def _delete_simple_helper(
18061820
)
18071821
return response
18081822

1809-
# Fallback strategy 2: already-deleted check. Confirm via the
1810-
# registry too — a disabled entity is missing from the state
1811-
# machine but still registry-resident, so state-absence alone
1812-
# is not enough to declare success.
1813-
#
1814-
# state_gone is True when get_entity_state returns falsy OR
1815-
# raises HomeAssistantAPIError (404 = entity absent from the
1816-
# state machine). Without the APIError branch a never-existed
1817-
# target would fall through to ENTITY_NOT_FOUND instead of
1818-
# the idempotent already_deleted return.
1823+
# Fallback strategy 2: already-deleted check. Confirm via
1824+
# the registry too — a disabled entity is state-absent but
1825+
# still registry-resident, so state-absence alone is not
1826+
# enough to declare success. The APIError-404 branch covers
1827+
# the never-existed-target case (HA returns 404 on
1828+
# get_entity_state for unknown entity_ids); without it the
1829+
# caller would see ENTITY_NOT_FOUND for a typo'd target.
18191830
state_gone = False
18201831
try:
18211832
final_state_check = await client.get_entity_state(entity_id)

tests/src/unit/test_config_subentries_folded.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,3 +555,31 @@ async def test_remove_helpers_integrations_subentry_other_error_surfaces_service
555555
error_data = json.loads(str(exc_info.value))
556556
assert error_data["error"]["code"] == "SERVICE_CALL_FAILED"
557557
assert "Insufficient permissions" in error_data["error"]["message"]
558+
559+
560+
async def test_remove_helpers_integrations_subentry_string_error_surfaces_service_call_failed(
561+
mock_client,
562+
):
563+
"""Path 4 narrow stays narrow: the idempotent ``not_found`` branch
564+
only fires when ``error`` is a dict carrying ``code="not_found"``.
565+
A string-form ``"error": "not found"`` (no structured code) must
566+
NOT classify as idempotent — it surfaces as SERVICE_CALL_FAILED.
567+
Without this pin, a regression that loosened to substring-matching
568+
against ``error_msg`` would pass CI silently.
569+
"""
570+
mock_client.delete_config_subentry.return_value = {
571+
"success": False,
572+
"error": "Subentry not found",
573+
}
574+
575+
with pytest.raises(ToolError) as exc_info:
576+
await IntegrationTools(mock_client).ha_remove_helpers_integrations(
577+
target="entry-1",
578+
helper_type="config_subentry",
579+
subentry_id="subentry-1",
580+
confirm=True,
581+
)
582+
583+
error_data = json.loads(str(exc_info.value))
584+
assert error_data["error"]["code"] == "SERVICE_CALL_FAILED"
585+
assert "already_deleted" not in json.dumps(error_data)

tests/src/unit/test_tools_integrations.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,28 @@ async def test_direct_path_entry_not_found(self, tools, mock_client):
208208
assert result["entry_id"] == "ghost_entry"
209209
assert result["method"] == "config_entry_delete"
210210

211+
async def test_direct_path_non_404_apierror_surfaces_structured_error(
212+
self, tools, mock_client
213+
):
214+
"""Pin the Path 3 narrow: only status_code == 404 routes to
215+
idempotent already_deleted. Non-404 API errors (500 server, 401
216+
auth, …) must propagate via exception_to_structured_error and
217+
surface as a structured error, not as idempotent success. Mirrors
218+
the Path 1 narrow in test_simple_path_non_404_apierror_propagates.
219+
"""
220+
mock_client.delete_config_entry.side_effect = HomeAssistantAPIError(
221+
"Internal server error", status_code=500
222+
)
223+
with pytest.raises(ToolError) as exc_info:
224+
await tools.ha_remove_helpers_integrations(
225+
target="some_entry",
226+
confirm=True,
227+
)
228+
err = json.loads(str(exc_info.value))
229+
assert err["success"] is False
230+
assert "already_deleted" not in json.dumps(err)
231+
assert err["error"]["code"] != "ENTITY_NOT_FOUND"
232+
211233
# === Path 1: SIMPLE ===
212234

213235
async def test_simple_path_standard_via_unique_id(self, tools, mock_client):
@@ -386,11 +408,13 @@ async def test_simple_path_non_404_apierror_propagates(self, tools, mock_client)
386408
err = json.loads(str(exc_info.value))
387409
assert err["success"] is False
388410
# Outer chain converts the propagated APIError via
389-
# exception_to_structured_error — must NOT be classified as
390-
# already_deleted.
391-
assert err["error"]["code"] != "ENTITY_NOT_FOUND" or (
392-
"already_deleted" not in err.get("error", {}).get("message", "")
393-
)
411+
# exception_to_structured_error — must surface as a structured
412+
# error, not as the idempotent ENTITY_NOT_FOUND/already_deleted
413+
# branch. Two independent assertions so a regression that leaks
414+
# ``already_deleted`` into a non-``ENTITY_NOT_FOUND`` code still
415+
# fails the test (the previous OR-disjunct passed in that case).
416+
assert err["error"]["code"] != "ENTITY_NOT_FOUND"
417+
assert "already_deleted" not in json.dumps(err)
394418

395419
async def test_simple_path_disabled_no_unique_id_surfaces_error(
396420
self, tools, mock_client
@@ -438,7 +462,7 @@ async def test_simple_path_disabled_state_check_apierror_resolves_via_registry(
438462
still has to run.
439463
"""
440464
mock_client.get_entity_state.side_effect = HomeAssistantAPIError(
441-
"404 simulated"
465+
"404 simulated", status_code=404
442466
)
443467
mock_client.send_websocket_message.side_effect = [
444468
{"success": True, "result": {"unique_id": "uid-disabled-apierror"}},

0 commit comments

Comments
 (0)