hermes-agent/tests/gateway/test_async_delegation_session_binding.py

417 lines
14 KiB
Python

"""Gateway-side session binding for async delegations (#57498, #55578).
Three invariants on the messaging-gateway surface, mirroring the TUI rules:
1. Completions are pinned to the spawning session (contributor commit).
2. A dead/ended spawning session is never resurrected: the injection is
dropped, fail-closed (never rerouted to the peer's current session).
3. /new interrupts the old conversation's in-flight async delegations.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import tools.async_delegation as ad
@pytest.fixture(autouse=True)
def _reset_async_delegation():
ad._reset_for_tests()
yield
ad._reset_for_tests()
def _seed_record(delegation_id, session_key="", parent_session_id="", status="running"):
fn = MagicMock()
with ad._records_lock:
ad._records[delegation_id] = {
"delegation_id": delegation_id,
"status": status,
"session_key": session_key,
"parent_session_id": parent_session_id,
"interrupt_fn": fn,
}
return fn
class TestInterruptForSessionByParentId:
def test_parent_session_id_selector(self):
mine = _seed_record("d1", session_key="agent:main:telegram:dm:1", parent_session_id="sess_old")
other = _seed_record("d2", session_key="agent:main:telegram:dm:2", parent_session_id="sess_other")
n = ad.interrupt_for_session(parent_session_id="sess_old")
assert n == 1
mine.assert_called_once()
other.assert_not_called()
def test_reset_interrupts_by_key_and_parent(self):
"""A /new reset passes both selectors — either match claims the record."""
by_key = _seed_record("d1", session_key="agent:main:telegram:dm:1", parent_session_id="")
by_parent = _seed_record("d2", session_key="", parent_session_id="sess_old")
unrelated = _seed_record("d3", session_key="other", parent_session_id="other")
n = ad.interrupt_for_session(
session_key="agent:main:telegram:dm:1",
parent_session_id="sess_old",
reason="session_reset",
)
assert n == 2
by_key.assert_called_once()
by_parent.assert_called_once()
unrelated.assert_not_called()
class TestGatewayPinningFailsClosed:
"""The gateway must follow only verified compression continuations."""
@staticmethod
def _entry(session_id):
from datetime import datetime
from gateway.config import Platform
from gateway.session import SessionEntry
return SessionEntry(
session_key="agent:main:telegram:group:-100:4",
session_id=session_id,
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="group",
)
def _make_runner(
self,
rows,
*,
compression_tip=None,
compression_error=None,
switched_entry=None,
):
from gateway.run import GatewayRunner
from gateway.session import AsyncSessionStore
runner = object.__new__(GatewayRunner)
db = MagicMock()
db.get_session = AsyncMock(side_effect=lambda session_id: rows.get(session_id))
db.get_compression_tip = AsyncMock(
return_value=compression_tip,
side_effect=compression_error,
)
runner._session_db = db
runner.session_store = MagicMock()
runner.session_store.switch_session = MagicMock(return_value=switched_entry)
runner.session_store.advance_compression_session = MagicMock(
return_value=switched_entry
)
runner._async_session_store = AsyncSessionStore(runner.session_store)
return runner
@staticmethod
def _assert_no_route_change(runner):
getattr(runner.session_store, "switch_session").assert_not_called()
getattr(
runner.session_store, "advance_compression_session"
).assert_not_called()
@pytest.mark.asyncio
async def test_live_spawning_session_stays_pinned(self):
current = self._entry("sess_live")
runner = self._make_runner(
{"sess_live": {"id": "sess_live", "ended_at": None}}
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_live"
)
assert resolved is current
self._assert_no_route_change(runner)
@pytest.mark.asyncio
async def test_live_spawning_session_rebinds_from_different_route(self):
current = self._entry("sess_current")
pinned = self._entry("sess_live")
runner = self._make_runner(
{"sess_live": {"id": "sess_live", "ended_at": None}},
switched_entry=pinned,
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_live"
)
assert resolved is pinned
getattr(runner.session_store, "switch_session").assert_called_once_with(
current.session_key, "sess_live"
)
@pytest.mark.asyncio
async def test_non_compression_ended_parent_drops(self):
current = self._entry("sess_old")
runner = self._make_runner(
{
"sess_old": {
"id": "sess_old",
"ended_at": "2026-07-08T00:00:00",
"end_reason": "session_reset",
}
}
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_old"
)
assert resolved is None
self._assert_no_route_change(runner)
@pytest.mark.asyncio
async def test_compression_parent_advances_stale_route_to_live_tip(self):
current = self._entry("sess_parent")
tip = self._entry("sess_tip")
runner = self._make_runner(
{
"sess_parent": {
"id": "sess_parent",
"ended_at": "2026-07-08T00:00:00",
"end_reason": "compression",
},
"sess_tip": {
"id": "sess_tip",
"ended_at": None,
"parent_session_id": "sess_parent",
},
},
compression_tip="sess_tip",
switched_entry=tip,
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_parent"
)
assert resolved is tip
getattr(
runner.session_store, "advance_compression_session"
).assert_called_once_with(current.session_key, "sess_parent", "sess_tip")
@pytest.mark.asyncio
async def test_compression_cas_losing_to_new_drops(self):
current = self._entry("sess_parent")
runner = self._make_runner(
{
"sess_parent": {
"id": "sess_parent",
"ended_at": "2026-07-08T00:00:00",
"end_reason": "compression",
},
"sess_tip": {
"id": "sess_tip",
"ended_at": None,
"parent_session_id": "sess_parent",
},
},
compression_tip="sess_tip",
switched_entry=None,
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_parent"
)
assert resolved is None
getattr(
runner.session_store, "advance_compression_session"
).assert_called_once_with(current.session_key, "sess_parent", "sess_tip")
@pytest.mark.asyncio
async def test_intermediate_compression_route_advances_to_same_live_tip(self):
current = self._entry("sess_middle")
tip = self._entry("sess_tip")
runner = self._make_runner(
{
"sess_parent": {
"id": "sess_parent",
"ended_at": "2026-07-08T00:00:00",
"end_reason": "compression",
},
"sess_middle": {
"id": "sess_middle",
"ended_at": "2026-07-08T00:01:00",
"end_reason": "compression",
"parent_session_id": "sess_parent",
},
"sess_tip": {
"id": "sess_tip",
"ended_at": None,
"parent_session_id": "sess_middle",
},
},
compression_tip="sess_tip",
switched_entry=tip,
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_parent"
)
assert resolved is tip
getattr(
runner.session_store, "advance_compression_session"
).assert_called_once_with(current.session_key, "sess_middle", "sess_tip")
@pytest.mark.asyncio
async def test_compression_parent_follows_real_sessiondb_lineage(self, tmp_path):
from gateway.run import GatewayRunner
from gateway.session import AsyncSessionStore
from hermes_state import AsyncSessionDB, SessionDB
session_db = SessionDB(db_path=tmp_path / "state.db")
session_db.create_session("sess_parent", source="telegram")
session_db.end_session("sess_parent", end_reason="compression")
session_db.create_session(
"sess_tip",
source="telegram",
parent_session_id="sess_parent",
)
current = self._entry("sess_parent")
tip = self._entry("sess_tip")
runner = object.__new__(GatewayRunner)
runner._session_db = AsyncSessionDB(session_db)
runner.session_store = MagicMock()
runner.session_store.switch_session = MagicMock(return_value=tip)
runner.session_store.advance_compression_session = MagicMock(return_value=tip)
runner._async_session_store = AsyncSessionStore(runner.session_store)
resolved = await runner._resolve_async_delegation_session(
current, "sess_parent"
)
assert resolved is tip
getattr(
runner.session_store, "advance_compression_session"
).assert_called_once_with(current.session_key, "sess_parent", "sess_tip")
@pytest.mark.asyncio
async def test_ended_compression_tip_drops(self):
current = self._entry("sess_parent")
runner = self._make_runner(
{
"sess_parent": {
"id": "sess_parent",
"ended_at": "2026-07-08T00:00:00",
"end_reason": "compression",
},
"sess_tip": {
"id": "sess_tip",
"ended_at": "2026-07-08T00:01:00",
"end_reason": "session_reset",
"parent_session_id": "sess_parent",
},
},
compression_tip="sess_tip",
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_parent"
)
assert resolved is None
self._assert_no_route_change(runner)
@pytest.mark.asyncio
async def test_compression_lookup_failure_drops(self):
current = self._entry("sess_parent")
runner = self._make_runner(
{
"sess_parent": {
"id": "sess_parent",
"ended_at": "2026-07-08T00:00:00",
"end_reason": "compression",
}
},
compression_error=RuntimeError("db unavailable"),
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_parent"
)
assert resolved is None
self._assert_no_route_change(runner)
@pytest.mark.asyncio
async def test_compression_parent_accepts_already_current_tip(self):
current = self._entry("sess_tip")
runner = self._make_runner(
{
"sess_parent": {
"id": "sess_parent",
"ended_at": "2026-07-08T00:00:00",
"end_reason": "compression",
},
"sess_tip": {
"id": "sess_tip",
"ended_at": None,
"parent_session_id": "sess_parent",
},
},
compression_tip="sess_tip",
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_parent"
)
assert resolved is current
self._assert_no_route_change(runner)
@pytest.mark.asyncio
async def test_compression_parent_does_not_override_new_route(self):
current = self._entry("sess_after_new")
runner = self._make_runner(
{
"sess_parent": {
"id": "sess_parent",
"ended_at": "2026-07-08T00:00:00",
"end_reason": "compression",
},
"sess_tip": {
"id": "sess_tip",
"ended_at": None,
"parent_session_id": "sess_parent",
},
},
compression_tip="sess_tip",
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_parent"
)
assert resolved is None
self._assert_no_route_change(runner)
@pytest.mark.asyncio
async def test_unknown_spawning_session_drops(self):
current = self._entry("sess_current")
runner = self._make_runner({})
resolved = await runner._resolve_async_delegation_session(
current, "sess_gone"
)
assert resolved is None
self._assert_no_route_change(runner)
class TestResetHandlerInterruptsDelegations:
def test_reset_command_calls_interrupt_for_session(self):
"""The /new handler must sever the old conversation's delegations."""
import inspect
from gateway import slash_commands
src = inspect.getsource(slash_commands.GatewaySlashCommandsMixin._handle_reset_command)
assert "interrupt_for_session" in src
assert "session_reset" in src