Skip to content

Commit c3acc51

Browse files
swissmoclaude
andcommitted
fix(bulk): surface resolution warnings at top level, fix E2E MCPAssertions usage
Two CodeRabbit findings on the latest push: - resolution.summary()'s warnings list was left nested under response["resolution"]["warnings"] on both the dry-run and dispatch return paths, contradicting AGENTS.md's "warnings is always a top-level list[str], never nested" contract -- a consumer reading the top-level key never saw the hidden-entity degradation. Extracted _attach_resolution_to_response() (also needed to keep _run_bulk_selector under the C901 complexity threshold) which pops the nested warnings and extends (not overwrites) any warnings bulk_device_control's own response already carries. - The new E2E dispatch test read the excluded entity's post-dispatch state via the raw mcp_client + parse_mcp_result outside the MCPAssertions context, instead of mcp.call_tool_success() inside it -- inconsistent with this PR's own earlier fix (c62d9e4) and the repo's coding guideline for success-path tool calls in E2E tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 7255db4 commit c3acc51

3 files changed

Lines changed: 112 additions & 13 deletions

File tree

src/ha_mcp/tools/tools_service.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .bulk_selector import (
2929
BulkControlSelector,
3030
BulkSelectorInfrastructureError,
31+
BulkSelectorResolution,
3132
BulkSelectorValidationError,
3233
resolve_bulk_selector,
3334
)
@@ -211,6 +212,26 @@ def _selector_only_parameter_offender(
211212
return None
212213

213214

215+
def _attach_resolution_to_response(
216+
response: dict[str, Any], resolution: BulkSelectorResolution
217+
) -> None:
218+
"""Attach ``resolution.summary()`` to a response, surfacing its warnings
219+
at the top level.
220+
221+
Per AGENTS.md "Return Values", ``warnings`` is always a top-level
222+
``list[str]``, never nested inside another field.
223+
``resolution.summary()`` nests its own warnings (e.g. "N entities were
224+
hidden") under ``resolution`` for internal cohesion, so this pops them
225+
back out. ``response`` may already carry dispatch-time warnings (e.g.
226+
from ``bulk_device_control``) -- those are extended, not overwritten.
227+
"""
228+
summary = resolution.summary()
229+
resolution_warnings = summary.pop("warnings", [])
230+
response["resolution"] = summary
231+
if resolution_warnings:
232+
response.setdefault("warnings", []).extend(resolution_warnings)
233+
234+
214235
class _AmbiguousDispatch:
215236
"""Sentinel type for a post-send-ambiguous component write (see below)."""
216237

@@ -1767,12 +1788,13 @@ async def _run_bulk_selector(
17671788
raise # unreachable: exception_to_structured_error always raises
17681789

17691790
if dry_run:
1770-
return {
1791+
response: dict[str, Any] = {
17711792
"success": True,
17721793
"dry_run": True,
17731794
"dispatched": False,
1774-
"resolution": resolution.summary(),
17751795
}
1796+
_attach_resolution_to_response(response, resolution)
1797+
return response
17761798
try:
17771799
result = await self._device_tools.bulk_device_control(
17781800
operations=resolution.operations,
@@ -1799,7 +1821,7 @@ async def _run_bulk_selector(
17991821
)
18001822
raise # unreachable: exception_to_structured_error always raises
18011823
response = cast(dict[str, Any], result)
1802-
response["resolution"] = resolution.summary()
1824+
_attach_resolution_to_response(response, resolution)
18031825
return response
18041826

18051827
@tool(

tests/src/e2e/workflows/core/test_bulk.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -179,19 +179,19 @@ async def test_selector_dispatch_turns_off_included_and_spares_excluded(
179179
},
180180
)
181181

182+
assert await wait_for_entity_state(mcp_client, included_id, "off"), (
183+
f"Included helper {included_id} was not turned off by the dispatch"
184+
)
185+
excluded_data = await mcp.call_tool_success(
186+
"ha_get_state", {"entity_id": excluded_id}
187+
)
188+
assert excluded_data.get("data", {}).get("state") == "on", (
189+
f"Excluded helper's real state must never move: got {excluded_data}"
190+
)
191+
182192
assert data.get("dry_run") is None
183193
assert data["resolution"]["resolved_entity_ids"] == [included_id]
184194
assert data["resolution"]["excluded_entity_ids"] == [excluded_id]
185-
assert await wait_for_entity_state(mcp_client, included_id, "off"), (
186-
f"Included helper {included_id} was not turned off by the dispatch"
187-
)
188-
excluded_state = await mcp_client.call_tool(
189-
"ha_get_state", {"entity_id": excluded_id}
190-
)
191-
excluded_data = parse_mcp_result(excluded_state)
192-
assert excluded_data.get("data", {}).get("state") == "on", (
193-
f"Excluded helper's real state must never move: got {excluded_data}"
194-
)
195195
finally:
196196
for entity_id in entity_ids:
197197
await safe_call_tool(

tests/src/unit/test_ha_bulk_control_selector.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,83 @@ async def test_selector_dry_run_never_dispatches(
5656
device_tools.bulk_device_control.assert_not_awaited()
5757

5858

59+
@pytest.mark.asyncio
60+
async def test_dry_run_surfaces_resolution_warnings_at_top_level(
61+
monkeypatch: pytest.MonkeyPatch,
62+
) -> None:
63+
"""Per AGENTS.md, `warnings` must be top-level, never nested under
64+
`resolution` -- a consumer that only reads the top-level key must still
65+
see a hidden-entity degradation."""
66+
resolution_with_warning = BulkSelectorResolution(
67+
resolved_entity_ids=("light.sofa",),
68+
excluded_entity_ids=(),
69+
selected_area_ids=("salon",),
70+
expanded_group_ids=(),
71+
hidden_entity_count=1,
72+
warnings=("1 matching entity was hidden by the entity visibility filter.",),
73+
_operation_common={"action": "off", "validate_first": True},
74+
)
75+
monkeypatch.setattr(
76+
"ha_mcp.tools.tools_service.resolve_bulk_selector",
77+
AsyncMock(return_value=resolution_with_warning),
78+
)
79+
tools = ServiceTools(MagicMock(), MagicMock())
80+
81+
result = await tools.ha_bulk_control(
82+
selector={"domain": "light", "area_ids": ["salon"]},
83+
action="off",
84+
dry_run=True,
85+
)
86+
87+
assert result["warnings"] == [
88+
"1 matching entity was hidden by the entity visibility filter."
89+
]
90+
assert "warnings" not in result["resolution"]
91+
92+
93+
@pytest.mark.asyncio
94+
async def test_dispatch_merges_resolution_warnings_with_dispatch_warnings(
95+
monkeypatch: pytest.MonkeyPatch,
96+
) -> None:
97+
"""Dispatch-time warnings from bulk_device_control (per-operation
98+
degradations) must be extended with, not overwritten by, the
99+
resolution's own warnings -- both belong in the same top-level list."""
100+
resolution_with_warning = BulkSelectorResolution(
101+
resolved_entity_ids=("light.sofa",),
102+
excluded_entity_ids=(),
103+
selected_area_ids=("salon",),
104+
expanded_group_ids=(),
105+
hidden_entity_count=1,
106+
warnings=("1 matching entity was hidden by the entity visibility filter.",),
107+
_operation_common={"action": "off", "validate_first": True},
108+
)
109+
monkeypatch.setattr(
110+
"ha_mcp.tools.tools_service.resolve_bulk_selector",
111+
AsyncMock(return_value=resolution_with_warning),
112+
)
113+
device_tools = MagicMock()
114+
device_tools.bulk_device_control = AsyncMock(
115+
return_value={
116+
"success": True,
117+
"successful": 1,
118+
"failed": 0,
119+
"warnings": ["light.sofa took longer than expected to confirm"],
120+
}
121+
)
122+
tools = ServiceTools(MagicMock(), device_tools)
123+
124+
result = await tools.ha_bulk_control(
125+
selector={"domain": "light", "area_ids": ["salon"]},
126+
action="off",
127+
)
128+
129+
assert result["warnings"] == [
130+
"light.sofa took longer than expected to confirm",
131+
"1 matching entity was hidden by the entity visibility filter.",
132+
]
133+
assert "warnings" not in result["resolution"]
134+
135+
59136
@pytest.mark.asyncio
60137
async def test_selector_dispatches_only_frozen_leaf_operations(
61138
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)