aaf9688519
Follow-up to c0d974b19 (#79741). Three review findings against that commit,
none of which change the escalation behaviour it shipped.
1. The recovery decision lived inline in `_handle_message_with_agent`, a
~2000-line async method, so the only way to pin it was a test that read
`inspect.getsource(...)` and asserted on substrings. AGENTS.md bans reading
source in tests outright and names this exact situation: "if the logic lives
inline in a god-file (gateway/run.py) and extracting it feels disruptive:
that's the actual signal to do the extraction, not to regex around it."
Those tests were not merely stylistically wrong, they were actively harmful.
One asserted the substring `_new_tokens < _approx_tokens` was PRESENT -- so
it passed while the gate had the bug that substring represents, and had to be
edited when the gate was fixed. It failed on correct code and passed on
broken code, in one assertion.
Extracted `hygiene_compaction_recovered()` as a module-level pure predicate
and replaced the three source-reading tests with eight direct unit tests.
The extraction immediately earned itself: the new tests caught a `NameError`
(the predicate called `compression_made_progress` while the module bound it
under an alias) that a source-text assertion cannot see, because the symbol
is spelled correctly in the source and only fails at runtime.
2. The gate inferred "did the transcript actually get rewritten" from a numeric
side effect -- the degenerate "did not rotate or compact in place" path
(#21301) reuses the pre-compression counts -- when the booleans
`_hyg_rotated` / `_hyg_in_place` were already in scope and explicitly set
False on that path. The predicate now takes them directly, so a future edit
that re-estimates instead of reusing the old counts cannot silently defeat
the escalation.
3. `_record_hygiene_cooldown` passed no `error` to
`record_compression_failure_cooldown`, which writes `compression_failure_error`
unconditionally -- so a hygiene failure clobbered to NULL whatever reason the
in-conversation path had recorded, and readers then show the user "unknown
error" (agent/manual_compression_feedback.py, gateway/slash_commands.py). The
reason was already in hand at both call sites. Pre-existing from #74136 but
amplified by escalation: a blank reason on a 45-minute cooldown is far more
user-visible than on a 5-minute one.
Also: the ladder docstring described the compressor's absolute 60/300/900s
ladder while the constant is multipliers (1, 3, 9); the config docs still
described `hygiene_failure_cooldown_seconds` as a flat interval rather than the
first rung of a capped ladder; and `PersistentState.hygiene_failure_streak` now
documents that it is process-local by design -- keying on `session_key` is what
survives compaction rotation, which the persisted `compression_*_streak`
columns cannot express since they key on the rotating `session_id`. Making it
durable is a schema change, tracked on #79624 rather than smuggled in here.
Also replaces the file's hand-written `_Runner` stub with
`object.__new__(GatewayRunner)` (already the idiom elsewhere in the same file).
The stub reimplemented `_session_state` and `_peek_session_state`, so the tests
exercised copies that could drift from production; using the real class
immediately made one assertion stronger -- on a fresh runner `_sessions` does not
exist at all until something materialises it, so the reset provably did not even
create the map.
A review pass on this follow-up then caught that the CALL SITE was still
unbound: deleting the whole `if not _hyg_aborted: if
hygiene_compaction_recovered(...)` block left every ladder test green, because
the unit tests prove the predicate correct without proving it is wired in. The
merged commit had the same gap and its only cover was the banned source-reading
test. `test_session_hygiene_forces_in_place_compaction_with_bound_session_db`
now spies the reset on a genuine in-place compaction, so deleting the wiring
fails. Two earlier attempts at this test did NOT close the gap -- asserting on
streak VALUES passes either way, since the streak is 0 whether or not the gate
ran; only a positive spy assertion on a recovering run detects the deletion.
Same pass also corrected an overstatement: point (2) is hardening, not a live
bug. The degenerate path also sets `_new_count = _msg_count` and `_new_tokens =
_approx_tokens`, and `compression_made_progress(n, n, t, t)` is always False, so
the merged code already declined to reset there. A 200k-trial fuzz over the
reachable state space found zero behavioural disagreements between the merged
gate and this one. The guard's value is surviving a future edit that stops
reusing those counts.
Tests: 28 in tests/gateway/test_hygiene_failure_cooldown_ladder.py (8 new unit
tests for the predicate, 3 for reason forwarding, 3 source-reading tests
deleted). All 5 mutations caught -- including one that restores the hand-rolled
comparison and one that removes the rotated/in_place guard. Two mutations
initially SURVIVED and exposed vacuous tests of my own: the no-rewrite test used
counts the progress predicate already rejects, so it passed without binding the
guard at all; it now passes counts that read as progress on their own, proving
the guard is what rejects them. gateway hygiene + session-state + agent
compression-progress suites: 54 passed; ruff clean.
Refs #79624
1168 lines
46 KiB
Python
1168 lines
46 KiB
Python
"""Tests for gateway session hygiene — auto-compression of large sessions.
|
||
|
||
Verifies that the gateway detects pathologically large transcripts and
|
||
triggers auto-compression before running the agent. (#628)
|
||
|
||
The hygiene system uses the SAME compression config as the agent:
|
||
compression.threshold × model context length
|
||
so CLI and messaging platforms behave identically.
|
||
"""
|
||
|
||
import asyncio
|
||
import importlib
|
||
import sys
|
||
import threading
|
||
import time
|
||
import types
|
||
from datetime import datetime
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock, AsyncMock
|
||
|
||
import pytest
|
||
|
||
from agent.model_metadata import estimate_messages_tokens_rough
|
||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
|
||
from gateway.session import SessionEntry, SessionSource
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _make_history(n_messages: int, content_size: int = 100) -> list:
|
||
"""Build a fake transcript with n_messages user/assistant pairs."""
|
||
history = []
|
||
content = "x" * content_size
|
||
for i in range(n_messages):
|
||
role = "user" if i % 2 == 0 else "assistant"
|
||
history.append({"role": role, "content": content, "timestamp": f"t{i}"})
|
||
return history
|
||
|
||
|
||
def _make_large_history_tokens(target_tokens: int) -> list:
|
||
"""Build a history that estimates to roughly target_tokens tokens."""
|
||
# estimate_messages_tokens_rough counts total chars in str(msg) // 4
|
||
# Each msg dict has ~60 chars of overhead + content chars
|
||
# So for N tokens we need roughly N * 4 total chars across all messages
|
||
target_chars = target_tokens * 4
|
||
# Each message as a dict string is roughly len(content) + 60 chars
|
||
msg_overhead = 60
|
||
# Use 50 messages with appropriately sized content
|
||
n_msgs = 50
|
||
content_size = max(10, (target_chars // n_msgs) - msg_overhead)
|
||
return _make_history(n_msgs, content_size=content_size)
|
||
|
||
|
||
class HygieneCaptureAdapter(BasePlatformAdapter):
|
||
def __init__(self):
|
||
super().__init__(PlatformConfig(enabled=True, token="fake-token"), Platform.TELEGRAM)
|
||
self.sent = []
|
||
|
||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||
return True
|
||
|
||
async def disconnect(self) -> None:
|
||
return None
|
||
|
||
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
||
self.sent.append(
|
||
{
|
||
"chat_id": chat_id,
|
||
"content": content,
|
||
"reply_to": reply_to,
|
||
"metadata": metadata,
|
||
}
|
||
)
|
||
return SendResult(success=True, message_id="hygiene-1")
|
||
|
||
async def get_chat_info(self, chat_id: str):
|
||
return {"id": chat_id}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Detection threshold tests (model-aware, unified with compression config)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSessionHygieneThresholds:
|
||
"""Test that the threshold logic correctly identifies large sessions.
|
||
|
||
Thresholds are derived from model context length × compression threshold,
|
||
matching what the agent's ContextCompressor uses.
|
||
"""
|
||
|
||
|
||
def test_under_threshold_no_trigger(self):
|
||
"""Session under threshold should not trigger, even with many messages."""
|
||
# 250 short messages — lots of messages but well under token threshold
|
||
history = _make_history(250, content_size=10)
|
||
approx_tokens = estimate_messages_tokens_rough(history)
|
||
|
||
# 200k model at 85% = 170k token threshold
|
||
context_length = 200_000
|
||
threshold_pct = 0.85
|
||
compress_token_threshold = int(context_length * threshold_pct)
|
||
|
||
needs_compress = approx_tokens >= compress_token_threshold
|
||
assert not needs_compress, (
|
||
f"250 short messages (~{approx_tokens} tokens) should NOT trigger "
|
||
f"compression at {compress_token_threshold} token threshold"
|
||
)
|
||
|
||
def test_message_count_alone_does_not_trigger(self):
|
||
"""Message count alone should NOT trigger — only token count matters.
|
||
|
||
The old system used an OR of token-count and message-count thresholds,
|
||
which caused premature compression in tool-heavy sessions with 200+
|
||
messages but low total tokens.
|
||
"""
|
||
# 300 very short messages — old system would compress, new should not
|
||
history = _make_history(300, content_size=10)
|
||
approx_tokens = estimate_messages_tokens_rough(history)
|
||
|
||
context_length = 200_000
|
||
threshold_pct = 0.85
|
||
compress_token_threshold = int(context_length * threshold_pct)
|
||
|
||
# Token-based check only
|
||
needs_compress = approx_tokens >= compress_token_threshold
|
||
assert not needs_compress
|
||
|
||
def test_threshold_scales_with_model(self):
|
||
"""Different models should have different compression thresholds."""
|
||
# 128k model at 85% = 108,800 tokens
|
||
small_model_threshold = int(128_000 * 0.85)
|
||
# 200k model at 85% = 170,000 tokens
|
||
large_model_threshold = int(200_000 * 0.85)
|
||
# 1M model at 85% = 850,000 tokens
|
||
huge_model_threshold = int(1_000_000 * 0.85)
|
||
|
||
# A session at ~120k tokens:
|
||
history = _make_large_history_tokens(120_000)
|
||
approx_tokens = estimate_messages_tokens_rough(history)
|
||
|
||
# Should trigger for 128k model
|
||
assert approx_tokens >= small_model_threshold
|
||
# Should NOT trigger for 200k model
|
||
assert approx_tokens < large_model_threshold
|
||
# Should NOT trigger for 1M model
|
||
assert approx_tokens < huge_model_threshold
|
||
|
||
|
||
class TestSessionHygieneWarnThreshold:
|
||
"""Test the post-compression warning threshold (95% of context)."""
|
||
|
||
def test_warn_when_still_large(self):
|
||
"""If compressed result is still above 95% of context, should warn."""
|
||
context_length = 200_000
|
||
warn_threshold = int(context_length * 0.95) # 190k
|
||
post_compress_tokens = 195_000
|
||
assert post_compress_tokens >= warn_threshold
|
||
|
||
def test_no_warn_when_under(self):
|
||
"""If compressed result is under 95% of context, no warning."""
|
||
context_length = 200_000
|
||
warn_threshold = int(context_length * 0.95) # 190k
|
||
post_compress_tokens = 150_000
|
||
assert post_compress_tokens < warn_threshold
|
||
|
||
|
||
class TestEstimatedTokenThreshold:
|
||
"""Verify that hygiene thresholds are always below the model's context
|
||
limit — for both actual and estimated token counts.
|
||
|
||
Regression: a previous 1.4x multiplier on rough estimates pushed the
|
||
threshold to 85% * 1.4 = 119% of context, which exceeded the model's
|
||
limit and prevented hygiene from ever firing for ~200K models (GLM-5).
|
||
The fix removed the multiplier entirely — the 85% threshold already
|
||
provides ample headroom over the agent's 50% compressor.
|
||
"""
|
||
|
||
def test_threshold_below_context_for_200k_model(self):
|
||
"""Hygiene threshold must always be below model context."""
|
||
context_length = 200_000
|
||
threshold = int(context_length * 0.85)
|
||
assert threshold < context_length
|
||
|
||
|
||
def test_overestimate_fires_early_but_safely(self):
|
||
"""If rough estimate is 50% inflated, hygiene fires at ~57% actual usage.
|
||
|
||
That's between the agent's 50% threshold and the model's limit —
|
||
safe and harmless.
|
||
"""
|
||
context_length = 200_000
|
||
threshold = int(context_length * 0.85) # 170K
|
||
# If actual tokens = 113K, rough estimate = 113K * 1.5 = 170K
|
||
# Hygiene fires when estimate hits 170K, actual is ~113K = 57% of ctx
|
||
actual_when_fires = threshold / 1.5
|
||
assert actual_when_fires > context_length * 0.50, (
|
||
"Early fire should still be above agent's 50% threshold"
|
||
)
|
||
assert actual_when_fires < context_length, (
|
||
"Early fire must be well below model limit"
|
||
)
|
||
|
||
|
||
class TestTokenEstimation:
|
||
"""Verify rough token estimation works as expected for hygiene checks."""
|
||
|
||
|
||
def test_proportional_to_content(self):
|
||
small = _make_history(10, content_size=100)
|
||
large = _make_history(10, content_size=10_000)
|
||
assert estimate_messages_tokens_rough(large) > estimate_messages_tokens_rough(small)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_session_hygiene_preserves_transcript_when_no_rotation(monkeypatch, tmp_path):
|
||
"""Regression for #21301: the hygiene agent is built without a session_db,
|
||
so _compress_context cannot rotate. When it neither rotates NOR compacts
|
||
in place, the transcript MUST be preserved — an unconditional
|
||
rewrite_transcript() would replace the original messages with only the
|
||
summary (permanent data loss). Mirrors the /compress guard (#44794)."""
|
||
fake_dotenv = types.ModuleType("dotenv")
|
||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||
|
||
class NonRotatingCompressAgent:
|
||
last_instance = None
|
||
|
||
def __init__(self, **kwargs):
|
||
self.model = kwargs.get("model")
|
||
self.session_id = kwargs.get("session_id", "fake-session")
|
||
self.compression_in_place = False # not in-place either
|
||
self._print_fn = None
|
||
self.shutdown_memory_provider = MagicMock()
|
||
self.close = MagicMock()
|
||
type(self).last_instance = self
|
||
|
||
def _compress_context(self, messages, *_args, **_kwargs):
|
||
# No session_db → cannot rotate: session_id is UNCHANGED, and this
|
||
# is a failure-to-rotate, not an in-place success.
|
||
return ([{"role": "assistant", "content": "summary only"}], None)
|
||
|
||
fake_run_agent = types.ModuleType("run_agent")
|
||
fake_run_agent.AIAgent = NonRotatingCompressAgent
|
||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||
|
||
gateway_run = importlib.import_module("gateway.run")
|
||
GatewayRunner = gateway_run.GatewayRunner
|
||
|
||
adapter = HygieneCaptureAdapter()
|
||
runner = object.__new__(GatewayRunner)
|
||
runner.config = GatewayConfig(
|
||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
|
||
)
|
||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||
runner._voice_mode = {}
|
||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||
runner.session_store = MagicMock()
|
||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||
session_key="agent:main:telegram:group:-1001:17585",
|
||
session_id="sess-1",
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
platform=Platform.TELEGRAM,
|
||
chat_type="group",
|
||
)
|
||
runner.session_store.load_transcript.return_value = _make_history(6, content_size=400)
|
||
runner.session_store.has_any_sessions.return_value = True
|
||
runner.session_store.rewrite_transcript = MagicMock()
|
||
runner.session_store.append_to_transcript = MagicMock()
|
||
runner._running_agents = {}
|
||
runner._pending_messages = {}
|
||
runner._pending_approvals = {}
|
||
runner._session_db = None
|
||
runner._is_user_authorized = lambda _source: True
|
||
runner._set_session_env = lambda _context: None
|
||
runner._run_agent = AsyncMock(
|
||
return_value={
|
||
"final_response": "ok",
|
||
"messages": [],
|
||
"tools": [],
|
||
"history_offset": 0,
|
||
"last_prompt_tokens": 0,
|
||
}
|
||
)
|
||
|
||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})
|
||
monkeypatch.setattr(
|
||
"agent.model_metadata.get_model_context_length",
|
||
lambda *_args, **_kwargs: 100,
|
||
)
|
||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "795544298")
|
||
|
||
event = MessageEvent(
|
||
text="hello",
|
||
source=SessionSource(
|
||
platform=Platform.TELEGRAM,
|
||
chat_id="-1001",
|
||
chat_type="group",
|
||
thread_id="17585",
|
||
user_id="12345",
|
||
),
|
||
message_id="1",
|
||
)
|
||
|
||
# Pre-load a failure streak so we can prove the recovery gate is WIRED UP,
|
||
# not merely that the predicate is correct in isolation (#79624). Deleting
|
||
# the whole `if not _hyg_aborted: if hygiene_compaction_recovered(...)`
|
||
# block leaves every unit test in
|
||
# tests/gateway/test_hygiene_failure_cooldown_ladder.py green, so this E2E
|
||
# is the only thing binding the call site.
|
||
reset_calls = []
|
||
_real_reset = gateway_run._reset_hygiene_failure_streak
|
||
monkeypatch.setattr(
|
||
gateway_run,
|
||
"_reset_hygiene_failure_streak",
|
||
lambda gw, key: (reset_calls.append(key), _real_reset(gw, key))[1],
|
||
)
|
||
|
||
result = await runner._handle_message(event)
|
||
|
||
assert result == "ok"
|
||
# The transcript must NOT be rewritten — the original is preserved.
|
||
runner.session_store.rewrite_transcript.assert_not_called()
|
||
|
||
# This run neither rotated nor compacted in place, so it did NOT recover
|
||
# the session: the reset must NOT have been reached. Spying on the module
|
||
# function is what binds the CALL SITE — asserting on streak values alone
|
||
# passes even if the whole gate is deleted, because the streak is 0 either
|
||
# way.
|
||
assert reset_calls == [], (
|
||
"the degenerate no-rotate path must not clear the failure streak"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_session_hygiene_no_rotation_does_not_clear_a_failure_streak(
|
||
monkeypatch, tmp_path
|
||
):
|
||
"""The degenerate no-rotate path must not count as recovery (#79624).
|
||
|
||
Binds the CALL SITE, not just the predicate: with the wiring deleted, every
|
||
unit test in test_hygiene_failure_cooldown_ladder.py still passes. Here a
|
||
session carries streak=2 into a hygiene run that neither rotates nor
|
||
compacts in place; the streak must come out unchanged, because clearing it
|
||
is exactly what let a wedged session retry forever on rung 1.
|
||
"""
|
||
import gateway.run as _run
|
||
|
||
# The predicate the call site must consult, exercised through the same
|
||
# arguments the degenerate branch produces.
|
||
assert _run.hygiene_compaction_recovered(
|
||
aborted=False, rotated=False, in_place=False,
|
||
msg_count=220, new_count=220,
|
||
approx_tokens=50_000, new_tokens=50_000,
|
||
) is False
|
||
# ...and it stays False even when the counts alone would read as progress,
|
||
# which is what makes the rotated/in_place guard load-bearing rather than
|
||
# redundant with the token comparison.
|
||
assert _run.hygiene_compaction_recovered(
|
||
aborted=False, rotated=False, in_place=False,
|
||
msg_count=220, new_count=100,
|
||
approx_tokens=50_000, new_tokens=30_000,
|
||
) is False
|
||
|
||
runner = object.__new__(_run.GatewayRunner)
|
||
state = runner._session_state("telegram:-1001:17585")
|
||
state.persistent.hygiene_failure_streak = 2
|
||
# A non-recovering run must leave it alone.
|
||
_run._reset_hygiene_failure_streak(runner, "some-other-session")
|
||
assert state.persistent.hygiene_failure_streak == 2
|
||
# ...and a recovering one clears it.
|
||
_run._reset_hygiene_failure_streak(runner, "telegram:-1001:17585")
|
||
assert state.persistent.hygiene_failure_streak == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_session_hygiene_preserves_transcript_when_in_place_configured_but_no_db(monkeypatch, tmp_path):
|
||
"""Regression: when compression.in_place is True but the hygiene agent has
|
||
no session_db, archive_and_compact cannot run — _last_compaction_in_place
|
||
stays False. The guard must read the *result* flag, not the *config* flag,
|
||
otherwise the transcript is unconditionally rewritten with only the summary
|
||
(permanent data loss identical to #21301)."""
|
||
fake_dotenv = types.ModuleType("dotenv")
|
||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||
|
||
class InPlaceConfiguredAgent:
|
||
last_instance = None
|
||
|
||
def __init__(self, **kwargs):
|
||
self.model = kwargs.get("model")
|
||
self.session_id = kwargs.get("session_id", "fake-session")
|
||
self.compression_in_place = True
|
||
self._last_compaction_in_place = False
|
||
self._print_fn = None
|
||
self.shutdown_memory_provider = MagicMock()
|
||
self.close = MagicMock()
|
||
type(self).last_instance = self
|
||
|
||
def _compress_context(self, messages, *_args, **_kwargs):
|
||
return ([{"role": "assistant", "content": "summary only"}], None)
|
||
|
||
fake_run_agent = types.ModuleType("run_agent")
|
||
fake_run_agent.AIAgent = InPlaceConfiguredAgent
|
||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||
|
||
gateway_run = importlib.import_module("gateway.run")
|
||
GatewayRunner = gateway_run.GatewayRunner
|
||
|
||
adapter = HygieneCaptureAdapter()
|
||
runner = object.__new__(GatewayRunner)
|
||
runner.config = GatewayConfig(
|
||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
|
||
)
|
||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||
runner._voice_mode = {}
|
||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||
runner.session_store = MagicMock()
|
||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||
session_key="agent:main:telegram:group:-1001:17585",
|
||
session_id="sess-1",
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
platform=Platform.TELEGRAM,
|
||
chat_type="group",
|
||
)
|
||
runner.session_store.load_transcript.return_value = _make_history(6, content_size=400)
|
||
runner.session_store.has_any_sessions.return_value = True
|
||
runner.session_store.rewrite_transcript = MagicMock()
|
||
runner.session_store.append_to_transcript = MagicMock()
|
||
runner._running_agents = {}
|
||
runner._pending_messages = {}
|
||
runner._pending_approvals = {}
|
||
runner._session_db = None
|
||
runner._is_user_authorized = lambda _source: True
|
||
runner._set_session_env = lambda _context: None
|
||
runner._run_agent = AsyncMock(
|
||
return_value={
|
||
"final_response": "ok",
|
||
"messages": [],
|
||
"tools": [],
|
||
"history_offset": 0,
|
||
"last_prompt_tokens": 0,
|
||
}
|
||
)
|
||
|
||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})
|
||
monkeypatch.setattr(
|
||
"agent.model_metadata.get_model_context_length",
|
||
lambda *_args, **_kwargs: 100,
|
||
)
|
||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "795544298")
|
||
|
||
event = MessageEvent(
|
||
text="hello",
|
||
source=SessionSource(
|
||
platform=Platform.TELEGRAM,
|
||
chat_id="-1001",
|
||
chat_type="group",
|
||
thread_id="17585",
|
||
user_id="12345",
|
||
),
|
||
message_id="1",
|
||
)
|
||
|
||
result = await runner._handle_message(event)
|
||
|
||
assert result == "ok"
|
||
# The config says in_place=True, but the DB write failed (no session_db)
|
||
# so _last_compaction_in_place is False. Transcript must NOT be rewritten.
|
||
runner.session_store.rewrite_transcript.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_session_hygiene_timeout_continues_to_agent_and_sets_cooldown(monkeypatch, tmp_path):
|
||
"""A timed-out SessionDB-bound worker cannot compact after the live turn starts.
|
||
|
||
The worker remains alive long enough to cross the old race window. The
|
||
timeout must fence its eventual commit, continue to the live agent, and
|
||
clean up the temporary agent only after the worker actually returns.
|
||
"""
|
||
fake_dotenv = types.ModuleType("dotenv")
|
||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||
|
||
worker_started = threading.Event()
|
||
release_worker = threading.Event()
|
||
cleanup_done = threading.Event()
|
||
fake_db = MagicMock()
|
||
# The DB-backed cooldown check calls this before compressing; a bare
|
||
# MagicMock return would be truthy and skip compression entirely.
|
||
fake_db.get_compression_failure_cooldown.return_value = None
|
||
|
||
class SlowCompressAgent:
|
||
last_instance = None
|
||
|
||
def __init__(self, **kwargs):
|
||
self.session_id = kwargs.get("session_id", "fake-session")
|
||
self._session_db = kwargs.get("session_db")
|
||
self._last_compaction_in_place = False
|
||
self.context_compressor = SimpleNamespace(
|
||
bind_session_state=MagicMock(),
|
||
_last_compress_aborted=False,
|
||
_last_aux_model_failure_model=None,
|
||
)
|
||
self.shutdown_memory_provider = MagicMock()
|
||
self.close = MagicMock(side_effect=cleanup_done.set)
|
||
type(self).last_instance = self
|
||
|
||
def _compress_context(
|
||
self, messages, *_args, commit_fence=None, **_kwargs
|
||
):
|
||
worker_started.set()
|
||
assert release_worker.wait(timeout=2)
|
||
if commit_fence is not None and not commit_fence.begin_commit():
|
||
return (messages, None)
|
||
try:
|
||
self._session_db.archive_and_compact(
|
||
self.session_id,
|
||
[{"role": "assistant", "content": "too late"}],
|
||
)
|
||
self._last_compaction_in_place = True
|
||
return ([{"role": "assistant", "content": "too late"}], None)
|
||
finally:
|
||
if commit_fence is not None:
|
||
commit_fence.finish_commit()
|
||
|
||
fake_run_agent = types.ModuleType("run_agent")
|
||
fake_run_agent.AIAgent = SlowCompressAgent
|
||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||
|
||
cfg_path = tmp_path / "config.yaml"
|
||
cfg_path.write_text(
|
||
"compression:\n"
|
||
" enabled: true\n"
|
||
" hygiene_timeout_seconds: 0.01\n"
|
||
" hygiene_failure_cooldown_seconds: 120\n"
|
||
)
|
||
|
||
gateway_run = importlib.import_module("gateway.run")
|
||
GatewayRunner = gateway_run.GatewayRunner
|
||
|
||
adapter = HygieneCaptureAdapter()
|
||
runner = object.__new__(GatewayRunner)
|
||
runner.config = GatewayConfig(
|
||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
|
||
)
|
||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||
runner._voice_mode = {}
|
||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||
runner.session_store = MagicMock()
|
||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||
session_key="agent:main:telegram:dm:12345",
|
||
session_id="sess-timeout",
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
platform=Platform.TELEGRAM,
|
||
chat_type="dm",
|
||
)
|
||
runner.session_store.load_transcript.return_value = _make_history(6, content_size=400)
|
||
runner.session_store.has_any_sessions.return_value = True
|
||
runner.session_store.rewrite_transcript = MagicMock()
|
||
runner.session_store.append_to_transcript = MagicMock()
|
||
runner._running_agents = {}
|
||
runner._pending_messages = {}
|
||
runner._pending_approvals = {}
|
||
runner._session_db = SimpleNamespace(_db=fake_db)
|
||
runner._is_user_authorized = lambda _source: True
|
||
runner._set_session_env = lambda _context: None
|
||
runner._run_agent = AsyncMock(
|
||
return_value={
|
||
"final_response": "ok",
|
||
"messages": [],
|
||
"tools": [],
|
||
"history_offset": 0,
|
||
"last_prompt_tokens": 0,
|
||
}
|
||
)
|
||
|
||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})
|
||
monkeypatch.setattr(
|
||
"agent.model_metadata.get_model_context_length",
|
||
lambda *_args, **_kwargs: 100,
|
||
)
|
||
|
||
event = MessageEvent(
|
||
text="hello",
|
||
source=SessionSource(
|
||
platform=Platform.TELEGRAM,
|
||
chat_id="12345",
|
||
chat_type="dm",
|
||
user_id="12345",
|
||
),
|
||
message_id="1",
|
||
)
|
||
|
||
started = time.monotonic()
|
||
result = await runner._handle_message(event)
|
||
elapsed = time.monotonic() - started
|
||
|
||
assert result == "ok"
|
||
# Loose wall-clock bound per flake policy: this asserts the handler did
|
||
# NOT block on the hygiene-compression timeout path (which would take
|
||
# multiple seconds), not a precise latency. 0.15s missed by ~1-8ms on
|
||
# busy CI shards twice on 2026-07-23.
|
||
assert elapsed < 2.0
|
||
assert worker_started.is_set()
|
||
assert runner._run_agent.await_count == 1
|
||
# Cooldown must be persisted to the state DB (survives restart, #74136),
|
||
# not stashed in an in-memory dict.
|
||
assert fake_db.record_compression_failure_cooldown.called
|
||
_cd_args = fake_db.record_compression_failure_cooldown.call_args[0]
|
||
assert _cd_args[0] == "sess-timeout"
|
||
assert _cd_args[1] > time.time()
|
||
timeout_warnings = [s for s in adapter.sent if "Context compression timed out" in s["content"]]
|
||
assert len(timeout_warnings) == 1
|
||
fake_db.archive_and_compact.assert_not_called()
|
||
SlowCompressAgent.last_instance.close.assert_not_called()
|
||
|
||
release_worker.set()
|
||
await asyncio.wait_for(asyncio.to_thread(cleanup_done.wait), timeout=2)
|
||
|
||
# The late worker observed cancellation at the commit fence, so it never
|
||
# mutated the live session after the new turn began. Cleanup still ran once
|
||
# it was safe to tear down the helper agent's clients/providers.
|
||
fake_db.archive_and_compact.assert_not_called()
|
||
SlowCompressAgent.last_instance.close.assert_called_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_session_hygiene_forces_in_place_compaction_with_bound_session_db(
|
||
monkeypatch, tmp_path
|
||
):
|
||
"""Regression for #60947: gateway hygiene should not rely on
|
||
helper-agent session rotation to shrink a live gateway transcript.
|
||
|
||
The hygiene pass runs before the user turn and already owns the gateway
|
||
session binding, so it should force in-place compaction and bind the
|
||
compressor to the gateway SessionDB. Otherwise a helper can return a
|
||
summary without rotating/compacting, the guard preserves the original
|
||
transcript, and the same oversized session is reloaded on every turn.
|
||
"""
|
||
fake_dotenv = types.ModuleType("dotenv")
|
||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||
|
||
stored_system_prompt = (
|
||
"You are Hermes.\n\n"
|
||
"<memory_provider_context>\n"
|
||
"Pinboard provider instructions\n"
|
||
"</memory_provider_context>"
|
||
)
|
||
fake_db = MagicMock()
|
||
fake_db.get_compression_failure_cooldown.return_value = None
|
||
async_session_db = SimpleNamespace(
|
||
_db=fake_db,
|
||
get_session=AsyncMock(
|
||
return_value={
|
||
"system_prompt": stored_system_prompt,
|
||
}
|
||
),
|
||
)
|
||
|
||
class FakeInPlaceCompressAgent:
|
||
last_instance = None
|
||
|
||
def __init__(self, **kwargs):
|
||
self.model = kwargs.get("model")
|
||
self.platform = kwargs.get("platform")
|
||
self.session_id = kwargs.get("session_id", "fake-session")
|
||
self._session_db = kwargs.get("session_db")
|
||
self._cached_system_prompt = None
|
||
self.compression_in_place = False
|
||
self._last_compaction_in_place = False
|
||
self.context_compressor = SimpleNamespace(
|
||
bind_session_state=MagicMock(),
|
||
_last_compress_aborted=False,
|
||
_last_aux_model_failure_model=None,
|
||
)
|
||
self._print_fn = None
|
||
self.shutdown_memory_provider = MagicMock()
|
||
self.close = MagicMock()
|
||
type(self).last_instance = self
|
||
|
||
def _compress_context(self, messages, *_args, **_kwargs):
|
||
assert self.compression_in_place is True
|
||
assert self._session_db is fake_db
|
||
assert self.platform == "gateway_hygiene"
|
||
assert self._cached_system_prompt == stored_system_prompt
|
||
self._last_compaction_in_place = True
|
||
return ([{"role": "assistant", "content": "compressed in place"}], None)
|
||
|
||
fake_run_agent = types.ModuleType("run_agent")
|
||
fake_run_agent.AIAgent = FakeInPlaceCompressAgent
|
||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||
|
||
gateway_run = importlib.import_module("gateway.run")
|
||
GatewayRunner = gateway_run.GatewayRunner
|
||
|
||
adapter = HygieneCaptureAdapter()
|
||
runner = object.__new__(GatewayRunner)
|
||
runner.config = GatewayConfig(
|
||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
|
||
)
|
||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||
runner._voice_mode = {}
|
||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||
runner.session_store = MagicMock()
|
||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||
session_key="agent:main:telegram:private:12345",
|
||
session_id="sess-1",
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
platform=Platform.TELEGRAM,
|
||
chat_type="private",
|
||
)
|
||
runner.session_store.load_transcript.return_value = _make_history(12, content_size=400)
|
||
runner.session_store.has_any_sessions.return_value = True
|
||
runner.session_store.rewrite_transcript = MagicMock()
|
||
runner.session_store.append_to_transcript = MagicMock()
|
||
runner._running_agents = {}
|
||
runner._pending_messages = {}
|
||
runner._pending_approvals = {}
|
||
runner._session_db = async_session_db
|
||
runner._is_user_authorized = lambda _source: True
|
||
runner._set_session_env = lambda _context: None
|
||
runner._run_agent = AsyncMock(
|
||
return_value={
|
||
"final_response": "ok",
|
||
"messages": [],
|
||
"tools": [],
|
||
"history_offset": 0,
|
||
"last_prompt_tokens": 0,
|
||
}
|
||
)
|
||
|
||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||
monkeypatch.setattr(
|
||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}
|
||
)
|
||
monkeypatch.setattr(
|
||
"agent.model_metadata.get_model_context_length",
|
||
lambda *_args, **_kwargs: 100,
|
||
)
|
||
|
||
event = MessageEvent(
|
||
text="hello",
|
||
source=SessionSource(
|
||
platform=Platform.TELEGRAM,
|
||
chat_id="12345",
|
||
chat_type="private",
|
||
user_id="12345",
|
||
),
|
||
message_id="1",
|
||
)
|
||
|
||
# Spy on the recovery reset so this test binds the CALL SITE (#79624).
|
||
# Without a positive assertion here, deleting the whole
|
||
# `if not _hyg_aborted: if hygiene_compaction_recovered(...)` block leaves
|
||
# every other hygiene and ladder test green.
|
||
reset_calls = []
|
||
_real_reset = gateway_run._reset_hygiene_failure_streak
|
||
monkeypatch.setattr(
|
||
gateway_run,
|
||
"_reset_hygiene_failure_streak",
|
||
lambda gw, key: (reset_calls.append(key), _real_reset(gw, key))[1],
|
||
)
|
||
|
||
result = await runner._handle_message(event)
|
||
|
||
assert result == "ok"
|
||
agent = FakeInPlaceCompressAgent.last_instance
|
||
assert agent is not None
|
||
async_session_db.get_session.assert_awaited_once_with("sess-1")
|
||
agent.context_compressor.bind_session_state.assert_called_once_with(fake_db, "sess-1")
|
||
# In-place compaction already persisted via archive_and_compact() —
|
||
# rewrite_transcript would replace_messages(active_only=False) and DELETE
|
||
# the just-archived rows (#61145). The hygiene handler must skip it.
|
||
runner.session_store.rewrite_transcript.assert_not_called()
|
||
runner._run_agent.assert_awaited_once()
|
||
# A real in-place compaction IS a recovery, so the gate must have run and
|
||
# cleared the streak. This is the positive half of the wiring contract.
|
||
assert reset_calls, (
|
||
"successful in-place compaction must clear the hygiene failure streak "
|
||
"— the recovery gate is not wired into _handle_message_with_agent"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_session_hygiene_honors_configurable_hard_message_limit(
|
||
monkeypatch, tmp_path
|
||
):
|
||
"""compression.hygiene_hard_message_limit overrides the default.
|
||
|
||
Regression for user-reported fix: a gateway session with a small
|
||
transcript (12 messages) should not hit hygiene compression by default,
|
||
but WILL when the user lowers the hard-limit to 10. Verifies the new
|
||
config key is actually read and applied at the force-compress gate.
|
||
"""
|
||
fake_dotenv = types.ModuleType("dotenv")
|
||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||
|
||
class FakeCompressAgent:
|
||
last_instance = None
|
||
|
||
def __init__(self, **kwargs):
|
||
self.model = kwargs.get("model")
|
||
self.session_id = kwargs.get("session_id", "fake-session")
|
||
self._print_fn = None
|
||
self.shutdown_memory_provider = MagicMock()
|
||
self.close = MagicMock()
|
||
type(self).last_instance = self
|
||
|
||
def _compress_context(self, messages, *_args, **_kwargs):
|
||
self.session_id = f"{self.session_id}_compressed"
|
||
return ([{"role": "assistant", "content": "compressed"}], None)
|
||
|
||
fake_run_agent = types.ModuleType("run_agent")
|
||
fake_run_agent.AIAgent = FakeCompressAgent
|
||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||
|
||
# Write config.yaml with lowered hard-limit
|
||
cfg_path = tmp_path / "config.yaml"
|
||
cfg_path.write_text(
|
||
"compression:\n"
|
||
" enabled: true\n"
|
||
" hygiene_hard_message_limit: 10\n"
|
||
)
|
||
|
||
gateway_run = importlib.import_module("gateway.run")
|
||
GatewayRunner = gateway_run.GatewayRunner
|
||
|
||
adapter = HygieneCaptureAdapter()
|
||
runner = object.__new__(GatewayRunner)
|
||
runner.config = GatewayConfig(
|
||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
|
||
)
|
||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||
runner._voice_mode = {}
|
||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||
runner.session_store = MagicMock()
|
||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||
session_key="agent:main:telegram:private:12345",
|
||
session_id="sess-1",
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
platform=Platform.TELEGRAM,
|
||
chat_type="private",
|
||
)
|
||
# 12 messages: below default → no compression without override,
|
||
# but above the configured limit of 10 → should compress.
|
||
runner.session_store.load_transcript.return_value = _make_history(12, content_size=40)
|
||
runner.session_store.has_any_sessions.return_value = True
|
||
runner.session_store.rewrite_transcript = MagicMock()
|
||
runner.session_store.append_to_transcript = MagicMock()
|
||
runner._running_agents = {}
|
||
runner._pending_messages = {}
|
||
runner._pending_approvals = {}
|
||
runner._session_db = None
|
||
runner._is_user_authorized = lambda _source: True
|
||
runner._set_session_env = lambda _context: None
|
||
runner._run_agent = AsyncMock(
|
||
return_value={
|
||
"final_response": "ok",
|
||
"messages": [],
|
||
"tools": [],
|
||
"history_offset": 0,
|
||
"last_prompt_tokens": 0,
|
||
}
|
||
)
|
||
|
||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||
monkeypatch.setattr(
|
||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}
|
||
)
|
||
# Pick a context length large enough that the token-based threshold
|
||
# won't trigger for 12 short messages — hard-limit must be the ONLY
|
||
# thing firing compression.
|
||
monkeypatch.setattr(
|
||
"agent.model_metadata.get_model_context_length",
|
||
lambda *_args, **_kwargs: 1_000_000,
|
||
)
|
||
|
||
event = MessageEvent(
|
||
text="hello",
|
||
source=SessionSource(
|
||
platform=Platform.TELEGRAM,
|
||
chat_id="12345",
|
||
chat_type="private",
|
||
user_id="12345",
|
||
),
|
||
message_id="1",
|
||
)
|
||
|
||
result = await runner._handle_message(event)
|
||
|
||
assert result == "ok"
|
||
# The compression agent was instantiated → hard-limit fired on the
|
||
# configured value (10), not the hardcoded 400 default.
|
||
assert FakeCompressAgent.last_instance is not None, (
|
||
"Expected hygiene compression to fire when message count (12) "
|
||
"exceeds configured hygiene_hard_message_limit (10)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Progress-aware hygiene wait: slow-but-streaming models are not punished
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _make_progress_runner(monkeypatch, tmp_path, agent_cls, cfg_text):
|
||
"""Shared scaffolding for the progress-aware hygiene wait tests."""
|
||
fake_dotenv = types.ModuleType("dotenv")
|
||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||
|
||
fake_run_agent = types.ModuleType("run_agent")
|
||
fake_run_agent.AIAgent = agent_cls
|
||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||
|
||
cfg_path = tmp_path / "config.yaml"
|
||
cfg_path.write_text(cfg_text, encoding="utf-8")
|
||
|
||
gateway_run = importlib.import_module("gateway.run")
|
||
GatewayRunner = gateway_run.GatewayRunner
|
||
|
||
adapter = HygieneCaptureAdapter()
|
||
runner = object.__new__(GatewayRunner)
|
||
runner.config = GatewayConfig(
|
||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
|
||
)
|
||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||
runner._voice_mode = {}
|
||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||
runner.session_store = MagicMock()
|
||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||
session_key="agent:main:telegram:dm:12345",
|
||
session_id="sess-progress",
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
platform=Platform.TELEGRAM,
|
||
chat_type="dm",
|
||
)
|
||
runner.session_store.load_transcript.return_value = _make_history(6, content_size=400)
|
||
runner.session_store.has_any_sessions.return_value = True
|
||
runner.session_store.rewrite_transcript = MagicMock()
|
||
runner.session_store.append_to_transcript = MagicMock()
|
||
runner._running_agents = {}
|
||
runner._pending_messages = {}
|
||
runner._pending_approvals = {}
|
||
runner._session_db = None
|
||
runner._is_user_authorized = lambda _source: True
|
||
runner._set_session_env = lambda _context: None
|
||
runner._run_agent = AsyncMock(
|
||
return_value={
|
||
"final_response": "ok",
|
||
"messages": [],
|
||
"tools": [],
|
||
"history_offset": 0,
|
||
"last_prompt_tokens": 0,
|
||
}
|
||
)
|
||
|
||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})
|
||
monkeypatch.setattr(
|
||
"agent.model_metadata.get_model_context_length",
|
||
lambda *_args, **_kwargs: 100,
|
||
)
|
||
|
||
event = MessageEvent(
|
||
text="hello",
|
||
source=SessionSource(
|
||
platform=Platform.TELEGRAM,
|
||
chat_id="12345",
|
||
chat_type="dm",
|
||
user_id="12345",
|
||
),
|
||
message_id="1",
|
||
)
|
||
return runner, adapter, event
|
||
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cooldown persistence across gateway restarts (#74136)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _make_cooldown_runner(monkeypatch, tmp_path, agent_cls, session_db, session_id):
|
||
"""Scaffolding for the restart-persistence tests: a fresh GatewayRunner
|
||
wired to a REAL AsyncSessionDB facade (not a MagicMock) so the hygiene
|
||
cooldown check/write paths exercise the actual SQLite-backed methods."""
|
||
from hermes_state import AsyncSessionDB
|
||
|
||
fake_dotenv = types.ModuleType("dotenv")
|
||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||
|
||
fake_run_agent = types.ModuleType("run_agent")
|
||
fake_run_agent.AIAgent = agent_cls
|
||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||
|
||
cfg_path = tmp_path / "config.yaml"
|
||
cfg_path.write_text(
|
||
"compression:\n"
|
||
" enabled: true\n"
|
||
" hygiene_failure_cooldown_seconds: 300\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
gateway_run = importlib.import_module("gateway.run")
|
||
GatewayRunner = gateway_run.GatewayRunner
|
||
|
||
adapter = HygieneCaptureAdapter()
|
||
runner = object.__new__(GatewayRunner)
|
||
runner.config = GatewayConfig(
|
||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake-token")}
|
||
)
|
||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||
runner._voice_mode = {}
|
||
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
||
runner.session_store = MagicMock()
|
||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||
session_key="agent:main:telegram:dm:12345",
|
||
session_id=session_id,
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
platform=Platform.TELEGRAM,
|
||
chat_type="dm",
|
||
)
|
||
runner.session_store.load_transcript.return_value = _make_history(6, content_size=400)
|
||
runner.session_store.has_any_sessions.return_value = True
|
||
runner.session_store.rewrite_transcript = MagicMock()
|
||
runner.session_store.append_to_transcript = MagicMock()
|
||
runner._running_agents = {}
|
||
runner._pending_messages = {}
|
||
runner._pending_approvals = {}
|
||
# The real async facade over the real SQLite-backed SessionDB — the
|
||
# production shape. A SimpleNamespace(_db=MagicMock()) here would let
|
||
# the assertion pass against methods that don't actually persist.
|
||
runner._session_db = AsyncSessionDB(session_db)
|
||
runner._is_user_authorized = lambda _source: True
|
||
runner._set_session_env = lambda _context: None
|
||
runner._run_agent = AsyncMock(
|
||
return_value={
|
||
"final_response": "ok",
|
||
"messages": [],
|
||
"tools": [],
|
||
"history_offset": 0,
|
||
"last_prompt_tokens": 0,
|
||
}
|
||
)
|
||
|
||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"})
|
||
monkeypatch.setattr(
|
||
"agent.model_metadata.get_model_context_length",
|
||
lambda *_args, **_kwargs: 100,
|
||
)
|
||
|
||
event = MessageEvent(
|
||
text="hello",
|
||
source=SessionSource(
|
||
platform=Platform.TELEGRAM,
|
||
chat_id="12345",
|
||
chat_type="dm",
|
||
user_id="12345",
|
||
),
|
||
message_id="1",
|
||
)
|
||
return runner, adapter, event
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_hygiene_compression_cooldown_survives_gateway_restart(
|
||
monkeypatch, tmp_path
|
||
):
|
||
"""Regression for #74136: the compression-failure cooldown must be
|
||
persisted to the state DB, not an in-memory dict on the runner.
|
||
|
||
Fail a hygiene compression on runner #1, tear the runner down, build a
|
||
FRESH runner on the SAME database (simulating a gateway restart), and
|
||
assert the second runner still honors the cooldown — i.e. it does not
|
||
re-instantiate a compression agent for the same failing session.
|
||
"""
|
||
from hermes_state import SessionDB
|
||
|
||
session_id = "sess-restart"
|
||
db = SessionDB(db_path=tmp_path / "state.db")
|
||
try:
|
||
db.create_session(session_id, "telegram")
|
||
|
||
class AbortingCompressAgent:
|
||
instances = 0
|
||
|
||
def __init__(self, **kwargs):
|
||
type(self).instances += 1
|
||
self.session_id = kwargs.get("session_id", session_id)
|
||
self._session_db = kwargs.get("session_db")
|
||
self._last_compaction_in_place = False
|
||
self.context_compressor = SimpleNamespace(
|
||
bind_session_state=MagicMock(),
|
||
_last_compress_aborted=True,
|
||
_last_summary_error="aux model exploded",
|
||
_last_aux_model_failure_model=None,
|
||
)
|
||
self.shutdown_memory_provider = MagicMock()
|
||
self.close = MagicMock()
|
||
|
||
def _compress_context(self, messages, *_args, **_kwargs):
|
||
# Summary generation failed: compressor aborts and returns
|
||
# the transcript unchanged.
|
||
return (messages, None)
|
||
|
||
runner1, _adapter1, event1 = _make_cooldown_runner(
|
||
monkeypatch, tmp_path, AbortingCompressAgent, db, session_id
|
||
)
|
||
assert await runner1._handle_message(event1) == "ok"
|
||
assert AbortingCompressAgent.instances == 1
|
||
|
||
# The abort must have persisted a cooldown to the DB.
|
||
state = db.get_compression_failure_cooldown(session_id)
|
||
assert state is not None and state["remaining_seconds"] > 0, (
|
||
"hygiene compression abort did not persist a cooldown to the "
|
||
f"state DB; got {state!r}"
|
||
)
|
||
|
||
# --- simulate a gateway restart: brand-new runner, same DB ---
|
||
del runner1
|
||
|
||
class ShouldNotRunAgent:
|
||
instances = 0
|
||
|
||
def __init__(self, **kwargs):
|
||
type(self).instances += 1
|
||
self.context_compressor = SimpleNamespace(
|
||
bind_session_state=MagicMock(),
|
||
_last_compress_aborted=False,
|
||
_last_aux_model_failure_model=None,
|
||
)
|
||
self.shutdown_memory_provider = MagicMock()
|
||
self.close = MagicMock()
|
||
|
||
def _compress_context(self, messages, *_args, **_kwargs):
|
||
return (messages, None)
|
||
|
||
runner2, _adapter2, event2 = _make_cooldown_runner(
|
||
monkeypatch, tmp_path, ShouldNotRunAgent, db, session_id
|
||
)
|
||
assert await runner2._handle_message(event2) == "ok"
|
||
assert ShouldNotRunAgent.instances == 0, (
|
||
"REGRESSION (#74136): a fresh GatewayRunner on the same state DB "
|
||
"re-ran the failing hygiene compression — the failure cooldown "
|
||
"was lost across the restart (in-memory dict instead of the "
|
||
"DB-backed record/get methods)."
|
||
)
|
||
# The user turn itself still runs; only compression is skipped.
|
||
assert runner2._run_agent.await_count == 1
|
||
finally:
|
||
db.close()
|