hermes-agent/tests/gateway/test_compression_interrupt_demotion_56391.py
nankingjing 5eaccf5802 fix(gateway): queue interrupts during in-flight context compression
With the default busy_input_mode=interrupt, a burst of rapid gateway
messages arriving while context compression is in flight could interrupt
the current turn and start a fresh turn against the pre-rotation parent
session. Because compression is interrupt-immune (#23975), the still-
running compression later rotates the id out from under that new turn,
and if the new turn also grew past the compression threshold it started
its own uncancellable compression on the same stale parent — forking
multiple orphaned one-shot sibling continuations (#56391).

While a state.db compression lock is held for the session, demote
'interrupt' busy-input mode to 'queue' semantics (mirroring the subagent
protection in #30170), so the follow-up message waits for the in-flight
compression + its id rotation to land instead of racing a new turn
against the stale parent. Ack copy explains the compression demotion.

Fixes #56391.
2026-07-01 06:38:24 -07:00

195 lines
7 KiB
Python

"""Regression tests for #56391.
When context compression is in flight (state.db compression lock held),
gateway ``busy_input_mode='interrupt'`` must demote to queue semantics so a
rapid message burst cannot start a follow-up turn against the pre-rotation
parent and fork orphaned compression siblings.
"""
from __future__ import annotations
import sys
import threading
import time
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
_tg = types.ModuleType("telegram")
_tg.constants = types.ModuleType("telegram.constants")
_ct = MagicMock()
_ct.SUPERGROUP = "supergroup"
_ct.GROUP = "group"
_ct.PRIVATE = "private"
_tg.constants.ChatType = _ct
sys.modules.setdefault("telegram", _tg)
sys.modules.setdefault("telegram.constants", _tg.constants)
sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext"))
from gateway.platforms.base import ( # noqa: E402
MessageEvent,
MessageType,
SessionSource,
build_session_key,
)
from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL # noqa: E402
def _make_event(text: str = "hello", chat_id: str = "123") -> MessageEvent:
source = SessionSource(
platform=MagicMock(value="telegram"),
chat_id=chat_id,
chat_type="private",
user_id="user1",
)
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id="msg1",
)
def _make_runner(*, session_id: str = "parent-session") -> GatewayRunner:
runner = object.__new__(GatewayRunner)
runner._running_agents = {}
runner._running_agents_ts = {}
runner._pending_messages = {}
runner._busy_ack_ts = {}
runner._draining = False
runner.adapters = {}
runner.config = MagicMock()
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.pairing_store = MagicMock()
runner.pairing_store.is_approved.return_value = True
runner._is_user_authorized = lambda _source: True
runner._busy_input_mode = "interrupt"
session_key = build_session_key(_make_event().source)
entry = SimpleNamespace(session_key=session_key, session_id=session_id)
session_store = SimpleNamespace(
_lock=threading.Lock(),
_entries={session_key: entry},
switch_session=MagicMock(),
)
session_store._ensure_loaded_locked = lambda: None
runner.session_store = session_store
runner._session_db = MagicMock()
runner._session_db._db = MagicMock()
runner._session_db._db.get_compression_lock_holder.return_value = None
return runner
def _make_adapter() -> MagicMock:
adapter = MagicMock()
adapter._pending_messages = {}
adapter._send_with_retry = AsyncMock()
adapter.config = MagicMock()
adapter.config.extra = {}
adapter.platform = MagicMock(value="telegram")
return adapter
def _make_parent_no_subagents() -> MagicMock:
parent = MagicMock()
parent._active_children = []
parent._active_children_lock = threading.Lock()
parent.get_activity_summary.return_value = {
"api_call_count": 3,
"max_iterations": 60,
"current_tool": "terminal",
}
return parent
class TestSessionHasCompressionInFlight:
def test_returns_false_without_session_store(self) -> None:
runner = _make_runner()
runner.session_store = None
assert runner._session_has_compression_in_flight("sk") is False
def test_returns_true_when_lock_held(self) -> None:
runner = _make_runner()
sk = build_session_key(_make_event().source)
runner._session_db._db.get_compression_lock_holder.return_value = "holder-1"
assert runner._session_has_compression_in_flight(sk) is True
def test_returns_false_when_lock_free(self) -> None:
runner = _make_runner()
sk = build_session_key(_make_event().source)
runner._session_db._db.get_compression_lock_holder.return_value = None
assert runner._session_has_compression_in_flight(sk) is False
class TestBusyHandlerDemotesInterruptForCompression:
@pytest.mark.asyncio
async def test_does_not_interrupt_when_compression_in_flight(self) -> None:
runner = _make_runner()
adapter = _make_adapter()
event = _make_event(text="follow up during compression")
sk = build_session_key(event.source)
parent = _make_parent_no_subagents()
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
runner._session_db._db.get_compression_lock_holder.return_value = "compressing"
handled = await runner._handle_active_session_busy_message(event, sk)
assert handled is True
parent.interrupt.assert_not_called()
assert adapter._pending_messages.get(sk) is event
@pytest.mark.asyncio
async def test_ack_explains_compression_demotion(self) -> None:
runner = _make_runner()
adapter = _make_adapter()
event = _make_event(text="hi mid-compress")
sk = build_session_key(event.source)
parent = _make_parent_no_subagents()
runner._running_agents[sk] = parent
runner._running_agents_ts[sk] = time.time() - 120
runner.adapters[event.source.platform] = adapter
runner._session_db._db.get_compression_lock_holder.return_value = "compressing"
with patch("gateway.run.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
adapter._send_with_retry.assert_called_once()
content = adapter._send_with_retry.call_args.kwargs.get("content", "")
assert "Compressing context" in content
assert "queued" in content.lower()
assert "/stop" in content
assert "Interrupting" not in content
@pytest.mark.asyncio
async def test_interrupt_still_fires_without_compression_lock(self) -> None:
runner = _make_runner()
adapter = _make_adapter()
event = _make_event(text="please stop")
sk = build_session_key(event.source)
parent = _make_parent_no_subagents()
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
runner._session_db._db.get_compression_lock_holder.return_value = None
with patch("gateway.run.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
parent.interrupt.assert_called_once_with("please stop")
@pytest.mark.asyncio
async def test_pending_sentinel_does_not_trigger_false_positive(self) -> None:
runner = _make_runner()
adapter = _make_adapter()
event = _make_event(text="hello")
sk = build_session_key(event.source)
runner._running_agents[sk] = _AGENT_PENDING_SENTINEL
runner.adapters[event.source.platform] = adapter
runner._session_db._db.get_compression_lock_holder.return_value = "compressing"
with patch("gateway.run.merge_pending_message_event"):
handled = await runner._handle_active_session_busy_message(event, sk)
assert handled is True