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
31 changes: 11 additions & 20 deletions loopx/chat_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,18 +1055,21 @@ 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)
except ValueError as exc:
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(
{
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
49 changes: 3 additions & 46 deletions loopx/extensions/lark/goal_topic_connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
IngressMode,
ReplyMode,
_routing_value,
_connection_routing_modes,
decide_lark_topic_route_event,
)
from .presentation.kanban import (
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
31 changes: 31 additions & 0 deletions loopx/extensions/lark/goal_topic_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("@"):
Expand Down
69 changes: 69 additions & 0 deletions tests/extensions/test_lark_goal_topic_connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions tests/extensions/test_lark_goal_topic_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions tests/test_chat_server_cors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []

Expand Down
Loading