"""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. """ import asyncio 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 injection path must never resurrect an ended session.""" def _make_runner(self, pinned_row): from gateway.run import GatewayRunner runner = object.__new__(GatewayRunner) db = MagicMock() db.get_session = AsyncMock(return_value=pinned_row) runner._session_db = db entry = MagicMock() entry.session_key = "agent:main:telegram:dm:1" entry.session_id = "sess_current" runner.session_store = MagicMock() runner.session_store.get_or_create_session.return_value = entry runner.session_store.switch_session.return_value = entry return runner, entry def _run_pinning_prefix(self, runner, pinned_session_id): """Execute the pinning guard logic exactly as _handle_message does.""" async def _go(): event = MagicMock() event.metadata = {"gateway_session_id": pinned_session_id} session_entry = runner.session_store.get_or_create_session(MagicMock()) pinned = str((getattr(event, "metadata", None) or {}).get("gateway_session_id") or "").strip() if pinned and pinned != session_entry.session_id: pinned_row = None try: if runner._session_db is not None: pinned_row = await runner._session_db.get_session(pinned) except Exception: pinned_row = None if pinned_row is None or pinned_row.get("ended_at"): return "dropped" switched = runner.session_store.switch_session(session_entry.session_key, pinned) if switched is not None: return "pinned" return "default" return asyncio.run(_go()) def test_live_spawning_session_pins(self): runner, _ = self._make_runner({"id": "sess_old", "ended_at": None}) assert self._run_pinning_prefix(runner, "sess_old") == "pinned" def test_ended_spawning_session_drops(self): runner, _ = self._make_runner({"id": "sess_old", "ended_at": "2026-07-08T00:00:00"}) assert self._run_pinning_prefix(runner, "sess_old") == "dropped" runner.session_store.switch_session.assert_not_called() def test_unknown_spawning_session_drops(self): runner, _ = self._make_runner(None) assert self._run_pinning_prefix(runner, "sess_gone") == "dropped" runner.session_store.switch_session.assert_not_called() 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