From 1d31f0cb9b82d2d96ca193030b1b415909c03153 Mon Sep 17 00:00:00 2001 From: keoyyo85 <335569976+keoyyo85@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:21:01 +0800 Subject: [PATCH] refactor(chat): read the action-kind vocabulary from the store that owns it loopx/chat_action_store.py decides which action kinds a proposal may be stored for and loopx/chat_server.py publishes that same set, while loopx/chat_actions.py gated dispatch on a byte-identical copy under another name. A kind added to one side stayed invisible to the other, so a proposal could pass the service gate and then be rejected by the store. The service now consumes the store's object under its existing public name, and an architecture census fails if any module in the package re-materialises that vocabulary, including under a different name. Signed-off-by: keoyyo85 <335569976+keoyyo85@users.noreply.github.com> --- loopx/chat_actions.py | 23 ++--- .../test_chat_action_kind_owner.py | 90 +++++++++++++++++++ 2 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 tests/architecture/test_chat_action_kind_owner.py 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