Skip to content

Commit 36b3862

Browse files
kingpanther13claude
andcommitted
fix(flows): scope the no-echo rule to values, keep the per-encounter no-op
Three points from review, all measured first: - The comment justifying position-over-step-id claimed nothing echoed a nested caller key. Field names do: step_values.<step>.<field> goes into ignored_config_keys, which _unconsumed_reconfigure_keys unions into the reconfigure abort error — the same logged path. That is kept, because naming the field is the whole diagnostic for a typo and a name is not the secret a value is, so the comment is scoped to values instead. - An empty object INSIDE a longer list is a capability, not a mistake: it means 'leave this encounter as it would be without the directive', which nothing else expresses — omitting the step affects every encounter, and {'field': None} is an explicit clear. Documented in the validator docstring and pinned by a test; entries that apply nothing anywhere are still rejected. - context={} was a no-op (create_error_response gates on truthiness) and the guard reduced to . Both simplified. Verified live on a real workday options flow before fixing: the [{}, ...] shape is accepted, the empty entry is a genuine no-op, and the tail is reported. Found by Patch76 review on #2256. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012d29UJTiH4Uy2Pm37SBPtr
1 parent 2e42bfb commit 36b3862

2 files changed

Lines changed: 60 additions & 13 deletions

File tree

src/ha_mcp/tools/config_entry_flow_form.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,9 @@ def validate_step_values(config: dict[str, Any]) -> None:
852852
853853
Accepted: a dict of ``step_id -> entry``, where an entry is a dict of field
854854
values, or a LIST of such dicts consumed one per encounter of that step.
855+
An empty dict inside such a list is a per-encounter no-op — that encounter
856+
behaves as it would with no directive at all — but an entry that applies
857+
nothing anywhere is rejected.
855858
"""
856859
# Key PRESENCE is the test, not truthiness: an explicit ``None`` is a
857860
# caller who meant to pass a directive and got the shape wrong, and the
@@ -883,12 +886,17 @@ def validate_step_values(config: dict[str, Any]) -> None:
883886
)
884887
)
885888

886-
# The step id is a caller-controlled key, and nothing echoed a NESTED one
887-
# before this directive existed — the walker's own supplied_keys reports
888-
# only top-level names. These errors reach the usage log unmasked (see the
889-
# note above), so report the entry's POSITION instead: it is what the
890-
# caller needs to find the entry in their own directive, and it cannot
891-
# carry a value they typed (CodeRabbit review, issue #2254).
889+
# Report the entry's POSITION, not the caller's step id: these errors reach
890+
# the usage log unmasked (see the note above), and a step id is a
891+
# caller-controlled key. The position still says which entry is wrong and
892+
# cannot carry a value they typed (CodeRabbit review, issue #2254).
893+
#
894+
# Scoped to VALUES deliberately. Caller-supplied field NAMES do reach the
895+
# same logged path — ``step_values.<step_id>.<field>`` goes into
896+
# ``ignored_config_keys``, which ``_unconsumed_reconfigure_keys`` unions
897+
# into the reconfigure abort error — and that is kept, because naming the
898+
# field is the whole diagnostic for a typo and a name is not the secret a
899+
# value is (Patch76 review, issue #2254).
892900
for index, entry in enumerate(directive.values(), start=1):
893901
where = f"{_PER_STEP_VALUES_KEY} entry #{index}"
894902
entries = entry if isinstance(entry, list) else [entry]
@@ -908,12 +916,18 @@ def validate_step_values(config: dict[str, Any]) -> None:
908916
},
909917
)
910918
)
911-
# An entry that can apply nothing is a caller mistake in the same way a
912-
# malformed one is, and it was reported inconsistently: a bare {} left
913-
# a leftover the warning named, while [] and [{}] were popped at their
914-
# first encounter and vanished, so the walk reported a clean success
915-
# for a directive that did nothing (Patch76 review, issue #2254).
916-
if not entries or not any(item for item in entries):
919+
# An entry that can apply NOTHING AT ALL is a caller mistake in the
920+
# same way a malformed one is, and it was reported inconsistently: a
921+
# bare {} left a leftover the warning named, while [] and [{}] were
922+
# popped at their first encounter and vanished, so the walk reported a
923+
# clean success for a directive that did nothing (Patch76 review).
924+
#
925+
# An empty object INSIDE a longer list is deliberately not that: it
926+
# means "leave this encounter as it would be without the directive",
927+
# which nothing else expresses — omitting the step affects every
928+
# encounter, and {"field": None} is an explicit clear rather than a
929+
# fallback. So the test spans the whole entry rather than each item.
930+
if not any(entries):
917931
raise_tool_error(
918932
create_error_response(
919933
ErrorCode.VALIDATION_INVALID_PARAMETER,
@@ -931,7 +945,6 @@ def validate_step_values(config: dict[str, Any]) -> None:
931945
ErrorCode.VALIDATION_INVALID_PARAMETER,
932946
f"'{_PER_STEP_VALUES_KEY}' is empty, so it would apply nothing",
933947
suggestions=[f"Name a step: {_PER_STEP_VALUES_KEY}={example}."],
934-
context={},
935948
)
936949
)
937950

tests/src/unit/test_flow_options_preserve.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,40 @@ def test_a_list_entry_can_clear_on_one_visit_only(self) -> None:
11341134
# Dry: the step's own stored value takes over again.
11351135
assert seen[2]["province"] == "BW"
11361136

1137+
def test_an_empty_object_in_a_list_is_a_per_encounter_no_op(self) -> None:
1138+
"""The one empty shape that is a capability, not a mistake.
1139+
1140+
It means "leave this encounter as it would be without the directive",
1141+
which nothing else expresses: omitting the step affects EVERY
1142+
encounter, and {"field": None} is an explicit clear rather than a
1143+
fallback. The rejection deliberately spans the whole entry rather than
1144+
each item, so this survives while [] and [{}] do not (Patch76 review).
1145+
"""
1146+
reuse_state = _ReuseState()
1147+
remaining: dict[str, Any] = {
1148+
"province": "BW",
1149+
"step_values": {"init": [{}, {"province": "NY"}]},
1150+
}
1151+
seen = [
1152+
_handle_form_step(
1153+
"flow-2254",
1154+
self._step("init"),
1155+
remaining,
1156+
None,
1157+
set(),
1158+
reuse_state,
1159+
keep_current_values=True,
1160+
)["province"]
1161+
for _ in range(2)
1162+
]
1163+
1164+
# Encounter one falls back to what it would have been; two is addressed.
1165+
assert seen == ["BW", "NY"]
1166+
# ...while an entry that applies nothing anywhere is still rejected.
1167+
for dead in ([{}], [], {}, [{}, {}]):
1168+
with pytest.raises(ToolError):
1169+
validate_step_values({"step_values": {"init": dead}})
1170+
11371171
def test_a_leftover_list_tail_is_reported(self) -> None:
11381172
"""A tail left over means the step ran fewer times than expected."""
11391173
remaining: dict[str, Any] = {

0 commit comments

Comments
 (0)