diff --git a/tests/tui_gateway/test_loop_command.py b/tests/tui_gateway/test_loop_command.py index 22af6d7f97..ea4730687a 100644 --- a/tests/tui_gateway/test_loop_command.py +++ b/tests/tui_gateway/test_loop_command.py @@ -171,6 +171,27 @@ def test_tui_tick_defers_when_running(server, session): assert LoopManager(session_key).state.ticks_fired == 0 +def test_tui_tick_leaves_gateway_routed_loop_for_gateway(server, session): + """A /loop set from a messaging chat (route pinned by the gateway) must not be consumed by a TUI/Desktop + viewer of the same session: the gateway's wakeup scanner owns delivery back to that chat (#111841).""" + sid, session_key, s = session + from hermes_cli.loops import LoopManager, save_loop + + mgr = LoopManager(session_key) + mgr.set("poll", interval_seconds=60, route={"platform": "telegram", "chat_id": "42"}) + mgr.state.next_due_at = time.time() - 1 + save_loop(session_key, mgr.state) + + with patch.object(server, "_run_prompt_submit") as submit, \ + patch.object(server, "_emit"): + server._maybe_fire_tui_loop_tick(sid, s) + + submit.assert_not_called() + assert s["running"] is False + state = LoopManager(session_key).state + assert state.ticks_fired == 0 and not state.awaiting_response and state.next_due_at <= time.time() + + def test_tui_tick_defers_to_active_goal(server, session): sid, session_key, s = session from hermes_cli.goals import GoalManager diff --git a/tui_gateway/session_notifications.py b/tui_gateway/session_notifications.py index 00e58f7ed7..50c7c4024d 100644 --- a/tui_gateway/session_notifications.py +++ b/tui_gateway/session_notifications.py @@ -248,6 +248,14 @@ def _maybe_fire_tui_heartbeat_tick(sid: str, session: dict) -> None: mgr.abandon_fire() +def _loop_route_is_gateway_chat(state) -> bool: + """A /loop set from a messaging chat carries the gateway's ``route`` (platform + chat_id); its wakeup scanner + (``gateway/run_goals.py::_loop_wakeup_fire_one``) fires those and skips route-less CLI/TUI loops. Mirror it here + so a Desktop viewer of the same session never consumes the tick and strands the reply off the chat.""" + route = getattr(state, "route", None) or {} + return bool(route.get("platform") and route.get("chat_id")) + + def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None: """Fire a due /loop wakeup for an idle TUI/Desktop/dashboard session (per-session poller, coarse cadence). Claims the session (running=True) before dispatching so a racing user prompt wins; the post-turn hook completes the tick.""" @@ -258,7 +266,9 @@ def _maybe_fire_tui_loop_tick(sid: str, session: dict) -> None: if not (sid_key := session.get("session_key") or ""): return mgr = LoopManager(session_id=sid_key) - if not mgr.is_due() or goal_blocks_loop_tick(sid_key) or not _notif_claim_turn(session): + if not mgr.is_due() or goal_blocks_loop_tick(sid_key) or _loop_route_is_gateway_chat(mgr.state): + return # not due, or the gateway's wakeup scanner owns the routed chat — stays due there + if not _notif_claim_turn(session): return # busy — stays due, next poll retries if not (wakeup := mgr.fire_tick()): _notif_release_turn(session)