Files
hermes-agent/tests/gateway/test_fifo_overflow_rescue.py
T
Teknium 98eb6ebdef fix(gateway): rescued FIFO orphan runs exactly once, chain stays in order (#99882)
Follow-up to the salvaged #99912 rescue. The original helper left the
rescued orphan IN the adapter slot while the caller also swapped it in as
the current turn, so the post-turn _dequeue_pending_event ran the same
follow-up a second time (live repro: TURNS=['Sent','C','C','D']). The
helper now pops the oldest orphan and returns it to run as this turn,
stages the NEXT orphan in the slot so the drain continues the chain in
arrival order, and the call site parks the incoming message behind the
chain via _enqueue_fifo (slot when free, overflow otherwise) instead of
always appending to overflow. The rescued event's own source drives the
turn so reply anchors point at the message actually being answered.

Tests: contract updated for the new return type; added the 2-orphan chain
case and the single-orphan-then-new-message slot case (both fail against
the original helper shape).
2026-09-02 00:34:17 -07:00

160 lines
6.2 KiB
Python

"""Regression tests for #99882: FIFO overflow orphan rescue.
When a follow-up is demoted to /queue during compression-in-flight,
it lands in SessionState.conversation.queued_events (overflow) with
the current turn's event occupying adapter._pending_messages[session_key]
(slot). After the slot's turn completes, _promote_queued_event moves
the overflow head into the slot. When that drain never runs — the
busy window ended through an exit that skipped the promotion site
(/stop, turn exception, generation bump) — the overflow is silently
orphaned: never dispatched, never persisted, never logged.
The rescue in GatewayRunner._rescue_orphaned_overflow pops the oldest
orphan for the caller to run as the current turn and stages the next
orphan in the slot, so FIFO order (#28503) holds and nothing runs twice.
"""
from unittest.mock import MagicMock
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
Platform,
PlatformConfig,
)
from gateway.run import GatewayRunner
class _StubAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="test"), Platform.TELEGRAM)
async def connect(self, *, is_reconnect: bool = False) -> bool:
return True
async def disconnect(self) -> None:
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
from gateway.platforms.base import SendResult
return SendResult(success=True, message_id="msg-1")
async def get_chat_info(self, chat_id):
return {"id": chat_id, "type": "dm"}
def _text_event(text: str, msg_id: str) -> MessageEvent:
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=MagicMock(chat_id="123", platform=Platform.TELEGRAM, profile=None),
message_id=msg_id,
)
def _runner() -> GatewayRunner:
runner = GatewayRunner.__new__(GatewayRunner)
runner._queued_events = {}
return runner
class TestRescueOrphanedOverflow:
def test_single_orphan_is_returned_and_removed_from_both_stores(self):
runner = _runner()
adapter = _StubAdapter()
session_key = "telegram:user:1"
runner._session_state(session_key).conversation.queued_events.append(
_text_event("orphan-1", "o1")
)
assert session_key not in adapter._pending_messages
rescued = runner._rescue_orphaned_overflow(session_key, adapter)
assert rescued is not None and rescued.text == "orphan-1"
# The rescued event runs as the current turn, so it must NOT also
# sit in the slot — the post-turn drain would run it a second time.
assert session_key not in adapter._pending_messages
assert runner._session_state(session_key).conversation.queued_events == []
def test_two_orphans_return_oldest_and_stage_next_in_slot(self):
runner = _runner()
adapter = _StubAdapter()
session_key = "telegram:user:1b"
runner._session_state(session_key).conversation.queued_events.extend(
[_text_event("orphan-1", "o1"), _text_event("orphan-2", "o2")]
)
rescued = runner._rescue_orphaned_overflow(session_key, adapter)
assert rescued is not None and rescued.text == "orphan-1"
# Slot now holds the NEXT orphan so the drain continues the chain.
assert adapter._pending_messages[session_key].text == "orphan-2"
assert runner._session_state(session_key).conversation.queued_events == []
def test_noop_when_slot_occupied(self):
runner = _runner()
adapter = _StubAdapter()
session_key = "telegram:user:2"
runner._session_state(session_key).conversation.queued_events.append(
_text_event("orphan", "o1")
)
adapter._pending_messages[session_key] = _text_event("busy-slot", "slot")
rescued = runner._rescue_orphaned_overflow(session_key, adapter)
assert rescued is None
assert adapter._pending_messages[session_key].text == "busy-slot"
assert len(runner._session_state(session_key).conversation.queued_events) == 1
def test_noop_when_no_overflow(self):
runner = _runner()
adapter = _StubAdapter()
session_key = "telegram:user:3"
rescued = runner._rescue_orphaned_overflow(session_key, adapter)
assert rescued is None
assert session_key not in adapter._pending_messages
def test_fifo_order_preserved_across_rescue_and_new_message(self):
"""Oldest orphan runs first, new arrival last — FIFO (#28503).
Mirrors the idle-arrival call site: rescue → _enqueue_fifo(new).
"""
runner = _runner()
adapter = _StubAdapter()
session_key = "telegram:user:4"
runner._session_state(session_key).conversation.queued_events.extend(
[_text_event("orphan-1", "o1"), _text_event("orphan-2", "o2")]
)
rescued = runner._rescue_orphaned_overflow(session_key, adapter)
assert rescued is not None and rescued.text == "orphan-1"
runner._enqueue_fifo(session_key, _text_event("new-msg", "new1"), adapter)
# Drain order after this turn: slot (orphan-2), then overflow (new-msg)
assert adapter._pending_messages[session_key].text == "orphan-2"
overflow_texts = [
e.text for e in runner._session_state(session_key).conversation.queued_events
]
assert overflow_texts == ["new-msg"]
def test_single_orphan_then_new_message_lands_in_slot(self):
"""With one orphan the slot is free after rescue, so the incoming
message must go to the slot (not overflow) or the drain never sees it."""
runner = _runner()
adapter = _StubAdapter()
session_key = "telegram:user:5"
runner._session_state(session_key).conversation.queued_events.append(
_text_event("orphan-1", "o1")
)
rescued = runner._rescue_orphaned_overflow(session_key, adapter)
assert rescued is not None and rescued.text == "orphan-1"
runner._enqueue_fifo(session_key, _text_event("new-msg", "new1"), adapter)
assert adapter._pending_messages[session_key].text == "new-msg"
assert runner._session_state(session_key).conversation.queued_events == []