diff --git a/loopx/chat_server.py b/loopx/chat_server.py index de9c8c7d3..c0f3ebc76 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1055,6 +1055,13 @@ def _action_preview(self) -> None: status=201, ) + def _action_not_found(self) -> None: + self._send_error( + "typed Chat action proposal was not found", + status=404, + error_code="action_not_found", + ) + def _action_snapshot(self, proposal_id: str) -> None: try: proposal = self.server.action_service.load(proposal_id) @@ -1062,11 +1069,7 @@ def _action_snapshot(self, proposal_id: str) -> None: self._send_error(str(exc), status=400, error_code="invalid_proposal_id") return if proposal is None: - self._send_error( - "typed Chat action proposal was not found", - status=404, - error_code="action_not_found", - ) + self._action_not_found() return self._send_json( { @@ -1107,11 +1110,7 @@ def _action_cancel(self, proposal_id: str) -> None: raise ValueError("action cancel request must be empty") proposal = self.server.action_service.cancel(proposal_id) except KeyError: - self._send_error( - "typed Chat action proposal was not found", - status=404, - error_code="action_not_found", - ) + self._action_not_found() return except ActionConflictError as exc: self._send_error(str(exc), status=409, error_code="action_conflict") @@ -1144,11 +1143,7 @@ def _action_transition(self, proposal_id: str, transition: str) -> None: else: raise ValueError("unsupported action transition") except KeyError: - self._send_error( - "typed Chat action proposal was not found", - status=404, - error_code="action_not_found", - ) + self._action_not_found() return except ActionConflictError as exc: self._send_error(str(exc), status=409, error_code="action_conflict") @@ -1190,11 +1185,7 @@ def _action_apply(self, proposal_id: str) -> None: ) return except KeyError: - self._send_error( - "typed Chat action proposal was not found", - status=404, - error_code="action_not_found", - ) + self._action_not_found() return except ActionConflictError as exc: self._send_error(str(exc), status=409, error_code="action_conflict") diff --git a/loopx/extensions/lark/goal_topic_connections.py b/loopx/extensions/lark/goal_topic_connections.py index 55d7bd393..48dc33e00 100644 --- a/loopx/extensions/lark/goal_topic_connections.py +++ b/loopx/extensions/lark/goal_topic_connections.py @@ -92,6 +92,7 @@ IngressMode, ReplyMode, _routing_value, + _connection_routing_modes, decide_lark_topic_route_event, ) from .presentation.kanban import ( @@ -1035,29 +1036,7 @@ def list_lark_connections( ) connector_status: dict[str, Any] | None = None try: - capture_scope = _routing_value( - CaptureScope, - routing.get("capture_scope") - or ( - "configured_chat_all" - if routing.get("incoming_mode") == "all" - else "addressed_only" - ), - default=CaptureScope.ADDRESSED_ONLY.value, - field="capture_scope", - ) - ingress_mode = _routing_value( - IngressMode, - routing.get("ingress_mode"), - default=IngressMode.DIRECT_SESSION.value, - field="ingress_mode", - ) - reply_mode = _routing_value( - ReplyMode, - routing.get("reply_mode"), - default=ReplyMode.TOPIC_REPLY.value, - field="reply_mode", - ) + capture_scope, ingress_mode, reply_mode = _connection_routing_modes(routing) raw_connector = binding.get("connector") if raw_connector is not None: if not isinstance(raw_connector, Mapping): @@ -1216,29 +1195,7 @@ def decide_lark_topic_event( else {} ) try: - capture_scope = _routing_value( - CaptureScope, - routing.get("capture_scope") - or ( - "configured_chat_all" - if routing.get("incoming_mode") == "all" - else "addressed_only" - ), - default=CaptureScope.ADDRESSED_ONLY.value, - field="capture_scope", - ) - ingress_mode = _routing_value( - IngressMode, - routing.get("ingress_mode"), - default=IngressMode.DIRECT_SESSION.value, - field="ingress_mode", - ) - reply_mode = _routing_value( - ReplyMode, - routing.get("reply_mode"), - default=ReplyMode.TOPIC_REPLY.value, - field="reply_mode", - ) + capture_scope, ingress_mode, reply_mode = _connection_routing_modes(routing) connector = binding.get("connector") if connector is not None: if not isinstance(connector, Mapping): diff --git a/loopx/extensions/lark/goal_topic_routing.py b/loopx/extensions/lark/goal_topic_routing.py index 81b8e0ccc..09caab8c5 100644 --- a/loopx/extensions/lark/goal_topic_routing.py +++ b/loopx/extensions/lark/goal_topic_routing.py @@ -47,6 +47,37 @@ def _routing_value( raise ValueError(f"{field} must be one of: {allowed}") from exc +def _connection_routing_modes( + routing: Mapping[str, Any], +) -> tuple[str, str, str]: + """Normalize persisted modes for both connection readback and event routing.""" + + capture_scope = _routing_value( + CaptureScope, + routing.get("capture_scope") + or ( + "configured_chat_all" + if routing.get("incoming_mode") == "all" + else "addressed_only" + ), + default=CaptureScope.ADDRESSED_ONLY.value, + field="capture_scope", + ) + ingress_mode = _routing_value( + IngressMode, + routing.get("ingress_mode"), + default=IngressMode.DIRECT_SESSION.value, + field="ingress_mode", + ) + reply_mode = _routing_value( + ReplyMode, + routing.get("reply_mode"), + default=ReplyMode.TOPIC_REPLY.value, + field="reply_mode", + ) + return capture_scope, ingress_mode, reply_mode + + def _normalize_mention_name(name: str) -> str: cleaned = str(name or "").strip() if cleaned.startswith("@"): diff --git a/tests/extensions/test_lark_goal_topic_connections.py b/tests/extensions/test_lark_goal_topic_connections.py index 70d24bb28..2add32f6f 100644 --- a/tests/extensions/test_lark_goal_topic_connections.py +++ b/tests/extensions/test_lark_goal_topic_connections.py @@ -2122,6 +2122,75 @@ def _prep_goal_channel_target(root: Path) -> Path: return target_path +@pytest.mark.parametrize( + ("routing", "expected"), + [ + ({}, ("addressed_only", "direct_session", "topic_reply")), + ( + {"incoming_mode": "all"}, + ("configured_chat_all", "direct_session", "topic_reply"), + ), + ( + { + "incoming_mode": "all", + "capture_scope": " ADDRESSED_ONLY ", + "ingress_mode": " SESSION_QUEUE ", + "reply_mode": " TOPIC_REPLY ", + }, + ("addressed_only", "session_queue", "topic_reply"), + ), + ({"capture_scope": "invalid"}, None), + ({"ingress_mode": "async-inbox"}, None), + ({"reply_mode": "invalid"}, None), + ], +) +def test_connection_readback_and_event_route_share_persisted_mode_rules( + tmp_path: Path, + routing: dict[str, str], + expected: tuple[str, str, str] | None, +) -> None: + target_path = _prep_goal_channel_target(tmp_path) + binding_path = tmp_path / "binding.json" + payload = _legacy_v0_binding_payload("om_topic_alpha", "agent-alpha") + payload["bindings"]["goal-alpha"]["routing"] = routing + write_goal_channel_binding(binding_path, payload) + before = binding_path.read_bytes() + rows = list_lark_connections( + registry=_registry(tmp_path), + target_path=target_path, + binding_paths={"goal-alpha": binding_path}, + runner=_runner({}), + ) + decision = decide_lark_topic_event( + target_payload=read_goal_channel_targets(target_path), + binding_payloads={"goal-alpha": read_goal_channel_binding(binding_path)}, + event={ + "chat_id": CHAT_ID, + "root_id": "om_topic_alpha", + "message_id": "om_incoming", + "content": "@mew bot hello", + }, + ) + assert len(rows) == 1 + if expected is None: + assert rows[0]["reply_ready"] is False + assert rows[0]["health_error_code"] == "invalid_routing_state" + assert decision == { + "matched": False, + "reason": "invalid_routing_state", + "route": None, + } + else: + assert rows[0]["reply_ready"] is True + assert decision["matched"] is True + for key, value in zip( + ("capture_scope", "ingress_mode", "reply_mode"), expected + ): + assert rows[0][key] == value + assert decision["route"][key] == value + assert binding_path.read_bytes() == before + + def test_reconnect_after_upgrade_reuses_legacy_topic_root_without_resend( tmp_path: Path, ) -> None: diff --git a/tests/extensions/test_lark_goal_topic_runtime.py b/tests/extensions/test_lark_goal_topic_runtime.py index a5e664726..2367bf870 100644 --- a/tests/extensions/test_lark_goal_topic_runtime.py +++ b/tests/extensions/test_lark_goal_topic_runtime.py @@ -5,6 +5,7 @@ import subprocess import threading from collections.abc import Mapping +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -283,6 +284,21 @@ def test_mention_uses_existing_inbox_reply_and_ack_path(tmp_path: Path) -> None: assert projection["processed_count"] == 1 +@pytest.fixture +def manager_context_clock(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the dated fixture within retention without disabling compaction.""" + from loopx.extensions.lark import manager_context + + class FixtureDatetime(datetime): + @classmethod + def now(cls, tz=None): + instant = datetime(2026, 9, 13, 6, 1, tzinfo=UTC) + return instant.astimezone(tz) if tz is not None else instant.replace(tzinfo=None) + + monkeypatch.setattr(manager_context, "datetime", FixtureDatetime) + + +@pytest.mark.usefixtures("manager_context_clock") def test_manager_captures_unaddressed_context_without_granting_turn_authority( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -467,6 +483,7 @@ def decision(**options: Any) -> dict[str, Any]: assert answer_calls == [] +@pytest.mark.usefixtures("manager_context_clock") def test_manager_authorized_turn_quietly_recovers_history_as_context( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 92b9f02fa..752161992 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -213,6 +213,39 @@ def test_chat_action_context_cannot_persist_or_emit_overflowed_float( server.server_close() +@pytest.mark.parametrize( + "action", ["snapshot", "apply", "cancel", "regenerate", "reject", "defer"] +) +def test_missing_action_returns_the_same_http_error( + tmp_path: Path, action: str +) -> None: + server, thread = _start_server() + server.action_store = ChatActionStore(tmp_path / "actions") + server.action_service = ChatActionService( + store=server.action_store, registry_path=tmp_path / "registry.json" + ) + try: + response = _request( + server.server_address[1], + method="GET" if action == "snapshot" else "POST", + origin=None, + path="/api/actions/missing" + + ("" if action == "snapshot" else f"/{action}"), + body=None if action == "snapshot" else b"{}", + ) + assert response.status == 404 + assert json.loads(response.read()) == { + "ok": False, + "error": "typed Chat action proposal was not found", + "error_code": "action_not_found", + } + assert server.action_store.list() == [] + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + def test_chat_status_forwards_valid_goal_activation_scope(monkeypatch) -> None: calls: list[dict[str, object]] = []