Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 6 additions & 17 deletions loopx/chat_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
90 changes: 90 additions & 0 deletions tests/architecture/test_chat_action_kind_owner.py
Original file line number Diff line number Diff line change
@@ -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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] 把带类型的集合赋值纳入 census:这里仅接受 ast.Assign,独立反例 LOCAL_COPY: set[str] = {...同样14项...} 会漏过,而普通赋值会被正确拦下;包含全部17项原断言的隔离检查仍通过。建议同时处理 ast.AnnAssign,或明确限定扫描器的覆盖声明。这是未来回退检测的非阻塞建议,当前生产别名导入及运行行为已验证。

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
Loading