diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 0328fac6e3..cbf269ee76 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -13,7 +13,11 @@ from .control_plane.coordination.new_goal_storage_transport import initialize_goal_storage_target from .chat import apply_todo_review_preview, build_todo_review_preview from .chat_action_normalization import ChatActionNormalizationMixin -from .chat_action_store import ActionConflictError, ChatActionStore +from .chat_action_store import ( + ACTION_KINDS as SUPPORTED_ACTION_KINDS, + ActionConflictError, + ChatActionStore, +) from .chat_goal_lifecycle_actions import ChatGoalLifecycleActionMixin from .chat_monitor_actions import ChatMonitorActionMixin from .chat_store import ChatSessionStore @@ -40,22 +44,7 @@ CHAT_ACTION_RESPONSE_SCHEMA_VERSION = "loopx_chat_action_response_v1" -SUPPORTED_ACTION_KINDS = { - "goal.acceptance.check", - "todo.create", - "todo.update", - "run.correct", - "goal.create", - "goal.update", - "goal.lifecycle", - "agent.bind", - "heartbeat.bind", - "monitor.create", - "monitor.update", - "gate.resolve", - "operation.execute", - "team.plan", -} + # Runtime Endpoint ids and durable Goal agent ids are chosen independently, so # a family token collapses both onto the host that produced them: Endpoint # `codex` has to resolve to a registered `codex-main-control`. Every host that diff --git a/tests/architecture/test_chat_action_kind_owner.py b/tests/architecture/test_chat_action_kind_owner.py new file mode 100644 index 0000000000..8f94565eb9 --- /dev/null +++ b/tests/architecture/test_chat_action_kind_owner.py @@ -0,0 +1,90 @@ +"""The Chat action-kind vocabulary has one owner, so a new kind cannot land twice. + +``loopx/chat_action_store.py`` decides which action kinds a proposal may be stored +for, and ``loopx/chat_server.py`` publishes that same set to hosts. +``loopx/chat_actions.py`` kept a byte-identical copy under a different name and +gated dispatch on it, so a kind added to one side stayed invisible to the other +and a proposal could be accepted by the service while the store rejected it. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from loopx import chat_action_store, chat_actions + +REPO_ROOT = Path(__file__).resolve().parents[2] +OWNER_PATH = "loopx/chat_action_store.py" +OWNER_NAME = "ACTION_KINDS" +KIND_VALUE = frozenset( + { + "agent.bind", + "gate.resolve", + "goal.acceptance.check", + "goal.create", + "goal.lifecycle", + "goal.update", + "heartbeat.bind", + "monitor.create", + "monitor.update", + "operation.execute", + "run.correct", + "team.plan", + "todo.create", + "todo.update", + } +) + + +def set_literal(node: ast.AST) -> frozenset[str] | None: + """Return the string members of a set literal, including a frozenset call.""" + if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "frozenset": + if not node.args: + return None + node = node.args[0] + if not isinstance(node, ast.Set): + return None + members: set[str] = set() + for element in node.elts: + if not isinstance(element, ast.Constant) or not isinstance(element.value, str): + return None + members.add(element.value) + return frozenset(members) + + +def modules_holding_the_vocabulary() -> list[str]: + offenders: list[str] = [] + for path in sorted(REPO_ROOT.glob("loopx/**/*.py")): + relative = path.relative_to(REPO_ROOT).as_posix() + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): # pragma: no cover - guarded tree + continue + for statement in tree.body: + if not isinstance(statement, ast.Assign): + continue + vocabulary = set_literal(statement.value) + if vocabulary == KIND_VALUE: + offenders.append(relative) + break + return offenders + + +def test_the_store_owns_the_declared_action_kind_vocabulary() -> None: + assert frozenset(chat_action_store.ACTION_KINDS) == KIND_VALUE + + +def test_the_action_service_applies_the_owner_object() -> None: + assert chat_actions.SUPPORTED_ACTION_KINDS is chat_action_store.ACTION_KINDS + + +def test_no_module_outside_the_owner_holds_its_own_copy() -> None: + assert modules_holding_the_vocabulary() == [OWNER_PATH] + + +@pytest.mark.parametrize("action_kind", sorted(KIND_VALUE)) +def test_every_declared_kind_is_dispatchable(action_kind: str) -> None: + assert action_kind in chat_actions.SUPPORTED_ACTION_KINDS