Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""Regression tests for #54947 — cross-process guard must not invalidate the
|
||||
agent cache when the active ``session_id`` differs from the snapshot's
|
||||
``session_id``, even when both share the same ``session_key``.
|
||||
|
||||
Bug
|
||||
---
|
||||
The cache key is the gateway ``session_key`` (e.g. ``agent:main:telegram:dm:USER_ID``)
|
||||
which groups all DM sessions for that user. Different ``session_id``s (separate
|
||||
conversation threads) can share a ``session_key``. When the user switches
|
||||
between session_ids, the cached agent is shared, and the cross-process
|
||||
coherence guard (``_cached_mc`` vs ``_current_msg_count``) treats different
|
||||
sessions' ``message_count`` values as the same counter — invalidating the
|
||||
agent on EVERY session switch and busting the per-conversation prompt cache.
|
||||
|
||||
These tests pin the production guard's reuse decision across:
|
||||
L1 — session-id switch must REUSE (not invalidate) the cached agent.
|
||||
L2 — cache tuple records the snapshot's session_id.
|
||||
L3 — re-baseline skips the cache entry when session_id differs.
|
||||
L4 — same-session_id turns still re-baseline correctly (no regression
|
||||
of #45966 / #46237).
|
||||
L5 — legacy 2-tuples and pending sentinels are still untouched.
|
||||
|
||||
All tests drive the REAL production helper (``_refresh_agent_cache_message_count``)
|
||||
against a REAL ``SessionDB`` and exercise the cache-hit guard's logic with the
|
||||
REAL cache lock, mirroring the structure used by
|
||||
``TestAgentCacheMessageCountRebaseline``.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import AsyncSessionDB
|
||||
|
||||
|
||||
def _make_runner():
|
||||
"""Create a minimal GatewayRunner with just the cache infrastructure."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
return runner
|
||||
|
||||
|
||||
def _guard_would_reuse(runner, session_key, session_id):
|
||||
"""Mirror the production cache-hit guard's reuse decision exactly
|
||||
AFTER the fix: reuse when the session_id matches the snapshot's
|
||||
session_id, OR when the entry is a legacy 2-tuple / pending sentinel.
|
||||
|
||||
Reuse iff any of:
|
||||
- cached session_id matches current session_id AND live count matches
|
||||
snapshot count (same-process turn OR no foreign write)
|
||||
- cached session_id differs from current session_id (different
|
||||
conversation, snapshot is from a different DB row → meaningless
|
||||
to compare, REUSE without invalidation)
|
||||
- entry is a 2-tuple (legacy opt-out of guard)
|
||||
- either side is None (unknown state → REUSE, fail-safe)
|
||||
|
||||
Invalidate iff:
|
||||
- cached session_id == current session_id AND
|
||||
cached_mc is not None AND live_mc is not None AND
|
||||
live_mc != cached_mc (genuine cross-process write on the SAME
|
||||
session — guard fires, agent rebuilds).
|
||||
"""
|
||||
try:
|
||||
# Mirror the production guard, which reads the sync underlying DB
|
||||
# (``self._session_db._db.get_session``) off the async facade.
|
||||
row = runner._session_db._db.get_session(session_id)
|
||||
live = row.get("message_count", 0) if row else None
|
||||
except Exception:
|
||||
live = None
|
||||
with runner._agent_cache_lock:
|
||||
cached = runner._agent_cache.get(session_key)
|
||||
|
||||
if cached is None:
|
||||
return True # no entry → cache miss → fresh build (not invalidation)
|
||||
# Legacy 2-tuple opts out of the guard.
|
||||
if len(cached) < 3:
|
||||
return True
|
||||
# Pending sentinel — treat as a no-op reuse.
|
||||
cached_sid = cached[3] if len(cached) > 3 else None
|
||||
cached_mc = cached[2]
|
||||
|
||||
# Snapshot belongs to a DIFFERENT session_id → comparison is
|
||||
# meaningless; REUSE without invalidation.
|
||||
if cached_sid is not None and session_id is not None and cached_sid != session_id:
|
||||
return True
|
||||
|
||||
# Same session_id: standard cross-process guard.
|
||||
invalidate = (
|
||||
cached_mc is not None
|
||||
and live is not None
|
||||
and live != cached_mc
|
||||
)
|
||||
return not invalidate
|
||||
|
||||
|
||||
class TestSessionIdCacheCoherence:
|
||||
"""#54947 — guard must not invalidate the agent cache on session_id switch
|
||||
under the same session_key."""
|
||||
|
||||
def test_session_id_switch_reuses_cached_agent(self, tmp_path):
|
||||
"""The reported bug: cache built from session A, switch to session B
|
||||
under the same session_key. The guard must REUSE the cached agent
|
||||
(the message_count comparison is meaningless across different
|
||||
session_ids), not rebuild and bust the prompt cache.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=tmp_path / "sessions.db")
|
||||
db.create_session("sA", source="telegram")
|
||||
db.create_session("sB", source="telegram")
|
||||
# Make counts differ to make the bug observable.
|
||||
db.append_message("sA", role="user", content="hello from A")
|
||||
db.append_message("sA", role="assistant", content="hi A")
|
||||
db.append_message("sA", role="user", content="another from A")
|
||||
# sA count = 3, sB count = 0
|
||||
runner = _make_runner()
|
||||
runner._session_db = AsyncSessionDB(db)
|
||||
agent = object()
|
||||
|
||||
# Build cache from session A (mc=3, sid=sA).
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["telegram:USER1"] = (agent, "sig", 3, "sA")
|
||||
|
||||
# User switches to session B (mc=0, sid=sB) — same session_key.
|
||||
# Guard must NOT invalidate.
|
||||
assert _guard_would_reuse(runner, "telegram:USER1", "sB") is True, (
|
||||
"BUG: cache was invalidated on session_id switch — "
|
||||
"the #54947 root cause is back."
|
||||
)
|
||||
# The original agent must still be in the cache.
|
||||
with runner._agent_cache_lock:
|
||||
assert runner._agent_cache["telegram:USER1"][0] is agent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_session_id_turns_still_reuse(self, tmp_path):
|
||||
"""#46237 / #45966 invariant: consecutive same-session turns must
|
||||
REUSE the cached agent (prompt cache preserved)."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=tmp_path / "sessions.db")
|
||||
db.create_session("s1", source="telegram")
|
||||
runner = _make_runner()
|
||||
runner._session_db = AsyncSessionDB(db)
|
||||
agent = object()
|
||||
|
||||
_row = db.get_session("s1")
|
||||
build_count = _row.get("message_count", 0) if _row else 0
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["telegram:s1"] = (agent, "sig", build_count, "s1")
|
||||
|
||||
reuses = 0
|
||||
for _ in range(1, 6):
|
||||
db.append_message("s1", role="user", content="u")
|
||||
db.append_message("s1", role="assistant", content="a")
|
||||
# Post-turn re-baseline (the #46237 fix).
|
||||
await runner._refresh_agent_cache_message_count("telegram:s1", "s1")
|
||||
if _guard_would_reuse(runner, "telegram:s1", "s1"):
|
||||
reuses += 1
|
||||
|
||||
assert reuses == 5
|
||||
with runner._agent_cache_lock:
|
||||
assert runner._agent_cache["telegram:s1"][0] is agent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_process_write_still_invalidates(self, tmp_path):
|
||||
"""The original #45966 invariant must hold: a DIFFERENT process
|
||||
appending to the same session in the shared DB invalidates the
|
||||
cache (genuine cross-process write)."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=tmp_path / "sessions.db")
|
||||
db.create_session("s1", source="telegram")
|
||||
runner = _make_runner()
|
||||
runner._session_db = AsyncSessionDB(db)
|
||||
agent = object()
|
||||
|
||||
with runner._agent_cache_lock:
|
||||
_row = db.get_session("s1")
|
||||
runner._agent_cache["telegram:s1"] = (
|
||||
agent, "sig", (_row.get("message_count", 0) if _row else 0), "s1",
|
||||
)
|
||||
|
||||
# Our own turn + re-baseline → reuse next turn.
|
||||
db.append_message("s1", role="user", content="u")
|
||||
db.append_message("s1", role="assistant", content="a")
|
||||
await runner._refresh_agent_cache_message_count("telegram:s1", "s1")
|
||||
assert _guard_would_reuse(runner, "telegram:s1", "s1") is True
|
||||
|
||||
# ANOTHER process (e.g. the desktop dashboard backend) appends a
|
||||
# turn to the SAME session in the shared DB.
|
||||
db.append_message("s1", role="user", content="external from dashboard")
|
||||
|
||||
# Guard must invalidate.
|
||||
assert _guard_would_reuse(runner, "telegram:s1", "s1") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_3tuple_session_id_unknown_still_guarded(self, tmp_path):
|
||||
"""An entry in the OLD 3-tuple shape (agent, sig, mc) with no
|
||||
session_id — entries already in the cache from BEFORE the fix —
|
||||
must STILL be guarded by the cross-process check. The fix only
|
||||
ADDS a session_id-aware skip path; it does not weaken the
|
||||
existing #45966 guard for entries that pre-date it. When
|
||||
live count != snapshot count, the guard fires and the agent
|
||||
rebuilds (same behavior as before the fix for legacy entries).
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=tmp_path / "sessions.db")
|
||||
db.create_session("s1", source="telegram")
|
||||
db.append_message("s1", role="user", content="x")
|
||||
runner = _make_runner()
|
||||
runner._session_db = AsyncSessionDB(db)
|
||||
|
||||
# Existing entry in old 3-tuple shape, no session_id recorded.
|
||||
# Snapshot is mc=0; live is mc=1 — guard must fire (invalidate).
|
||||
with runner._agent_cache_lock:
|
||||
runner._agent_cache["telegram:s1"] = (object(), "sig", 0)
|
||||
|
||||
# No session_id on cached entry → standard cross-process check
|
||||
# still runs. live (1) != snapshot (0) → invalidates.
|
||||
assert _guard_would_reuse(runner, "telegram:s1", "s1") is False
|
||||
|
||||
# After the re-baseline (same session_id) snapshot matches live.
|
||||
await runner._refresh_agent_cache_message_count("telegram:s1", "s1")
|
||||
with runner._agent_cache_lock:
|
||||
assert runner._agent_cache["telegram:s1"][2] == 1
|
||||
assert _guard_would_reuse(runner, "telegram:s1", "s1") is True
|
||||
Reference in New Issue
Block a user