"""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() def test_empty_session_messages_not_flushed(self): """An agent that ran no turns (empty list) triggers no flush — there is nothing in flight to persist.""" runner = _make_runner() agent = _FakeAgent(session_messages=[]) _finalize(runner, {"k": agent}) agent._flush_messages_to_session_db.assert_not_called() agent.close.assert_called_once() def test_missing_flush_method_is_tolerated(self): """A stub agent without the flush method (object.__new__ test stubs) must not break shutdown — teardown still runs.""" runner = _make_runner() agent = _FakeAgent(session_messages=[{"role": "user", "content": "x"}], has_flush=False) _finalize(runner, {"k": agent}) agent.close.assert_called_once() def test_flush_exception_is_swallowed(self): """A raising flush must not prevent teardown — a transcript-flush failure is best-effort, losing tool resources is worse.""" runner = _make_runner() agent = _FakeAgent(session_messages=[{"role": "user", "content": "x"}]) agent._flush_messages_to_session_db.side_effect = RuntimeError("db locked") _finalize(runner, {"k": agent}) 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 def test_graceful_agent_reflush_is_idempotent(self, tmp_path, monkeypatch): """An agent that already flushed via finalize_turn must not produce duplicate rows when _finalize_shutdown_agents re-flushes.""" 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-idem" db.create_session(session_id=session_id, source="discord") msgs = [ {"role": "user", "content": "what is 2+2"}, {"role": "assistant", "content": "4"}, ] agent = object.__new__(AIAgent) agent._session_db = db agent._session_db_created = True agent.session_id = session_id agent.platform = "discord" agent._session_messages = msgs agent._last_flushed_db_idx = 0 agent._flushed_db_message_ids = set() agent._flushed_db_message_session_id = None # First flush (simulating finalize_turn). agent._flush_messages_to_session_db(msgs) assert len(db.get_messages_as_conversation(session_id)) == 2 # Shutdown re-flush of the SAME list identity must add nothing. from gateway.run import GatewayRunner runner = object.__new__(GatewayRunner) _finalize(runner, {"k": agent}) after = db.get_messages_as_conversation(session_id) assert len(after) == 2, after