hermes-agent/tests/gateway/test_13121_shutdown_inflight_transcript_flush.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

189 lines
9.1 KiB
Python

"""Regression tests for #13121 — gateway restart/shutdown must persist an
in-flight (interrupted) turn's transcript to the SQLite session store so the
immediate pre-restart context survives ``load_transcript()`` on resume.
The bug: every normal/graceful turn exit funnels through
``turn_finalizer.finalize_turn`` which calls ``_persist_session`` →
``_flush_messages_to_session_db`` (the only place a turn is written to
state.db). During the tool loop only the *in-memory* ``_session_messages``
reference is refreshed per round — there is no incremental SQLite flush
mid-turn.
When the gateway drain times out it marks the session ``resume_pending``,
interrupts the running agents, waits a short grace window, then tears them
down via ``_finalize_shutdown_agents`` → ``_cleanup_agent_resources``. An
agent blocked in a tool call that does not abort within the grace window
never reaches ``finalize_turn``, so its in-flight tool rounds live only in
``_session_messages`` and are never written to state.db. On resume,
``load_transcript()`` (state.db is now the canonical store — the legacy
JSONL fallback was dropped) returns the pre-turn state, dropping the
immediate pre-restart turn.
The fix flushes ``_session_messages`` to the session DB in
``_finalize_shutdown_agents`` before teardown. The flush is idempotent
(identity-tracked in ``_flush_messages_to_session_db``), so agents that DID
finish gracefully re-flush nothing.
These tests exercise BOTH a lightweight unit path (the flush hook is invoked
with the in-flight messages) AND a true E2E path (a real ``AIAgent`` flush
against a real ``SessionDB`` in a temp ``HERMES_HOME``, read back through the
real ``SessionStore.load_transcript``).
"""
from __future__ import annotations
import asyncio
import sys
import types
from unittest.mock import MagicMock
import pytest
@pytest.fixture(autouse=True)
def _mock_dotenv(monkeypatch):
"""gateway.run imports dotenv at module load; stub so tests run bare."""
fake = types.ModuleType("dotenv")
fake.load_dotenv = lambda *a, **kw: None
monkeypatch.setitem(sys.modules, "dotenv", fake)
def _make_runner():
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
# _finalize_shutdown_agents offloads _cleanup_agent_resources to a worker
# thread via _run_in_executor_with_context (#53175). Stub it to run inline
# so the bounded-cleanup path is exercised deterministically in tests.
async def _inline_executor(func, *args):
return func(*args)
runner._run_in_executor_with_context = _inline_executor
return runner
def _finalize(runner, agents):
"""Drive the now-async _finalize_shutdown_agents from sync test bodies."""
if not hasattr(runner, "_run_in_executor_with_context"):
async def _inline_executor(func, *args):
return func(*args)
runner._run_in_executor_with_context = _inline_executor
asyncio.run(runner._finalize_shutdown_agents(agents))
# ─────────────────────────────────────────────────────────────────────────
# Unit: _finalize_shutdown_agents calls the flush hook with the in-flight
# transcript before teardown.
# ─────────────────────────────────────────────────────────────────────────
class _FakeAgent:
def __init__(self, session_messages=None, has_flush=True):
if session_messages is not None:
self._session_messages = session_messages
if has_flush:
self._flush_messages_to_session_db = MagicMock()
self._drop_trailing_empty_response_scaffolding = MagicMock()
self.shutdown_memory_provider = MagicMock()
self.close = MagicMock()
self.session_id = "sess-1"
class TestFinalizeShutdownFlushesInflightTranscript:
def test_inflight_messages_flushed_before_teardown(self):
"""The mid-turn transcript (tail = pending tool result) is flushed
to the session DB during shutdown finalization."""
runner = _make_runner()
inflight = [
{"role": "user", "content": "scan the repo and summarise"},
{"role": "assistant", "content": "", "tool_calls": [
{"id": "c1", "function": {"name": "terminal", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "c1", "content": "huge output..."},
]
agent = _FakeAgent(session_messages=inflight)
_finalize(runner, {"agent:main:discord:dm:42": agent})
agent._flush_messages_to_session_db.assert_called_once_with(inflight)
# Cleanup still happens after the flush.
agent.close.assert_called_once()
# ─────────────────────────────────────────────────────────────────────────
# E2E: real AIAgent flush → real SessionDB → real load_transcript.
# ─────────────────────────────────────────────────────────────────────────
class TestShutdownTranscriptSurvivesResumeE2E:
def test_interrupted_turn_persisted_and_readable_on_resume(self, tmp_path, monkeypatch):
"""Drive the real flush path against a real SessionDB and confirm the
in-flight turn is readable back through SessionStore.load_transcript —
the exact path the resume logic reads on the next message."""
# Isolated state.db.
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
from hermes_state import SessionDB
from run_agent import AIAgent
db = SessionDB(db_path=tmp_path / "state.db")
session_id = "sess-e2e-13121"
db.create_session(session_id=session_id, source="discord")
# Simulate a session whose FIRST turn completed and was persisted...
db.append_message(session_id=session_id, role="user",
content="hello, remember my cat is Mochi")
db.append_message(session_id=session_id, role="assistant",
content="Noted — Mochi the cat.")
# ...and a SECOND turn that was interrupted mid tool-loop. These rows
# were NEVER flushed to the DB (only live in _session_messages).
prior_history = [
{"role": "user", "content": "hello, remember my cat is Mochi"},
{"role": "assistant", "content": "Noted — Mochi the cat."},
]
inflight_tail = [
{"role": "user", "content": "now scan the whole repo for TODOs"},
{"role": "assistant", "content": "", "tool_calls": [
{"id": "tc1", "function": {"name": "terminal",
"arguments": "{\"command\": \"grep -r TODO\"}"}}
]},
{"role": "tool", "tool_call_id": "tc1", "name": "terminal",
"content": "src/a.py: TODO fix this\nsrc/b.py: TODO and that"},
]
# _session_messages is the live list: history copy + in-flight tail.
session_messages = list(prior_history) + list(inflight_tail)
# Build a real AIAgent shaped only with what the flush path reads.
agent = object.__new__(AIAgent)
agent._session_db = db
agent._session_db_created = True
agent.session_id = session_id
agent.platform = "discord"
agent._session_messages = session_messages
# Model a real agent: turn 1 already flushed, so its message identities
# are recorded in the dedup set. Only the in-flight turn-2 tail is new.
agent._last_flushed_db_idx = len(prior_history)
agent._flushed_db_message_ids = {id(m) for m in prior_history}
agent._flushed_db_message_session_id = session_id
# Sanity: only the 2 first-turn rows are in the DB before shutdown.
before = db.get_messages_as_conversation(session_id)
assert len(before) == 2, before
# Drive the gateway shutdown finalization with this real agent.
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
_finalize(runner, {"agent:main:discord:dm:7": agent})
# The in-flight turn must now be durable and readable via the SAME
# path the resume logic uses (SessionStore.load_transcript → DB).
after = db.get_messages_as_conversation(session_id)
roles = [m.get("role") for m in after]
contents = [m.get("content") for m in after]
assert len(after) == 5, after
# The interrupted user message survived.
assert any("scan the whole repo for TODOs" in (c or "") for c in contents), contents
# The pending tool result (the immediate pre-restart context) survived.
assert any("TODO fix this" in (c or "") for c in contents), contents
# Tail is a tool result — exactly what the _has_fresh_tool_tail resume
# branch in _handle_message_with_agent expects to handle.
assert roles[-1] == "tool", roles