Files
nousresearch--hermes-agent/tests/gateway/test_async_delegation_session_binding.py
T
wehub-resource-sync b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:56:03 +08:00

131 lines
5.1 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.
"""
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