diff --git a/agent-team/tests/test_gate_kind_coverage.py b/agent-team/tests/test_gate_kind_coverage.py new file mode 100644 index 0000000..dfcd788 --- /dev/null +++ b/agent-team/tests/test_gate_kind_coverage.py @@ -0,0 +1,79 @@ +"""Guard: every ``pending_questions`` ``kind`` has a coordinator delivery path. + +A human-gate interrupt ``kind`` is NOT delivered for free. The responder/notify +path is not kind-agnostic: a ``kind`` added to the ``pending_questions`` CHECK +without a coordinator branch makes the task suspend FOREVER — no Slack post, no +durable ledger row, the human can never answer. That was a real CRITICAL gap +when ``confluence_approval`` was first wired (the node + graph were complete but +the coordinator only branched on ``plan_decision``). See the memory +``reference_agent_team_extension_gotchas``. + +This test pins the invariant from the schema (the single source of truth for +allowed kinds): every non-default kind must have a ``*_KIND`` constant in +``agent_team.graph`` AND an explicit reference to it in ``coordinator.py`` (its +delivery branch). A new kind therefore cannot ship without its delivery wiring. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from agent_team import coordinator as coordinator_mod +from agent_team import graph as graph_mod +from agent_team.db import schema as schema_mod + +# ``clarify`` is delivered by the DEFAULT notify path (the fallthrough in +# ``_post_resume_followups`` / ``start_task``), so it needs no dedicated branch. +# Every OTHER kind must be keyed on its own ``graph.*_KIND`` constant. +_DEFAULT_DELIVERED_KINDS = {"clarify"} + + +def _declared_kinds() -> set[str]: + """The kind values the ``pending_questions`` CHECK allows (source of truth).""" + match = re.search(r"kind IN \(([^)]*)\)", schema_mod.PENDING_QUESTIONS_DDL) + assert match, ( + "could not find the pending_questions kind CHECK in PENDING_QUESTIONS_DDL" + ) + return set(re.findall(r"'([^']+)'", match.group(1))) + + +def _kind_constant_names() -> dict[str, str]: + """Map each kind VALUE to the ``graph.*_KIND`` constant NAME that holds it.""" + out: dict[str, str] = {} + for name in dir(graph_mod): + if name.endswith("_KIND"): + value = getattr(graph_mod, name) + if isinstance(value, str): + out[value] = name + return out + + +def test_declared_kinds_parse_sane() -> None: + # Guards the regex itself: if PENDING_QUESTIONS_DDL is reshaped and the CHECK + # stops parsing, this fails loudly instead of silently passing an empty set. + kinds = _declared_kinds() + assert {"clarify", "plan_decision"} <= kinds + + +def test_every_pending_question_kind_has_coordinator_delivery() -> None: + kinds = _declared_kinds() + constants = _kind_constant_names() + coordinator_src = Path(coordinator_mod.__file__).read_text() + + for kind in sorted(kinds - _DEFAULT_DELIVERED_KINDS): + const = constants.get(kind) + assert const, ( + f"pending_questions kind {kind!r} has no `*_KIND` constant in " + "agent_team.graph — define one and give it a coordinator delivery " + "branch (see reference_agent_team_extension_gotchas)." + ) + assert f"graph_mod.{const}" in coordinator_src, ( + f"pending_questions kind {kind!r} ({const}) has no delivery branch in " + "coordinator.py — a kind added to the CHECK without a coordinator " + "branch suspends the task forever (no Slack post, no ledger row). Add " + "a branch keyed on graph_mod." + (const or "") + " in " + "_post_resume_followups, start_task, AND the redelivery sweep, and " + "make the interrupt payload carry a question_set " + "(see reference_agent_team_extension_gotchas)." + )