mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-16 14:32:34 +00:00
finalize passed conversation_history=history aliasing the snapshot so flush skipped every message and wrote nothing. now flush _session_messages via marker dedup like gateway shutdown. add real db e2e tests.
367 lines
15 KiB
Python
367 lines
15 KiB
Python
"""
|
|
Integration test: verify _finalize_session persists messages on force-quit.
|
|
|
|
Tests the fix for TUI sessions losing conversation history when the
|
|
user interrupts and exits before the agent thread finishes flushing.
|
|
|
|
Scenarios:
|
|
1. Normal interrupt (single Ctrl+C) — messages already in session["history"]
|
|
2. Force-quit mid-tool (double Ctrl+C) — session["history"] has previous turns
|
|
3. Empty session — no-op, no crash
|
|
4. Agent with _persist_session missing — graceful no-op
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock, PropertyMock, patch
|
|
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_agent(history=None, session_id="test_session_001"):
|
|
"""Build a mock AIAgent with enough surface for _finalize_session."""
|
|
agent = MagicMock()
|
|
agent._persist_session = MagicMock()
|
|
agent.commit_memory_session = MagicMock()
|
|
agent.session_id = session_id
|
|
agent.model = "test-model"
|
|
agent.platform = "tui"
|
|
# _session_messages must be explicitly absent (None), otherwise
|
|
# MagicMock auto-creates it and getattr returns a truthy mock.
|
|
agent._session_messages = None
|
|
return agent
|
|
|
|
|
|
def _make_session(agent=None, history=None, session_key="test_key_001"):
|
|
return {
|
|
"agent": agent,
|
|
"history": history or [],
|
|
"history_lock": threading.Lock(),
|
|
"session_key": session_key,
|
|
"_finalized": False,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFinalizeSessionPersist:
|
|
"""Verify _finalize_session flushes messages via _persist_session."""
|
|
|
|
def test_no_session_messages_skips_persist(self):
|
|
"""When _session_messages is empty/None the agent processed nothing
|
|
this session, so there is nothing new to flush. Falling back to
|
|
session["history"] here re-appended already-durable resumed rows as
|
|
duplicates, so finalize must NOT write in that case.
|
|
"""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
history = [
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "hi there"},
|
|
]
|
|
agent = _make_agent() # _session_messages is None
|
|
session = _make_session(agent=agent, history=history)
|
|
|
|
_finalize_session(session, end_reason="test")
|
|
|
|
agent._persist_session.assert_not_called()
|
|
|
|
def test_persist_uses_session_messages(self):
|
|
"""agent._session_messages is flushed via the marker-based dedup path
|
|
(no conversation_history — passing the same list neutered the write)."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
history = [{"role": "user", "content": "old"}]
|
|
session_msgs = [
|
|
{"role": "user", "content": "old"},
|
|
{"role": "assistant", "content": "newer"},
|
|
]
|
|
agent = _make_agent()
|
|
agent._session_messages = session_msgs
|
|
session = _make_session(agent=agent, history=history)
|
|
|
|
_finalize_session(session)
|
|
|
|
agent._persist_session.assert_called_once_with(session_msgs)
|
|
# conversation_history must NOT be passed — it aliases the snapshot and
|
|
# makes _flush_messages_to_session_db skip every message.
|
|
assert "conversation_history" not in agent._persist_session.call_args[1]
|
|
|
|
def test_commit_memory_still_called(self):
|
|
"""Existing memory commit path is preserved."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
history = [{"role": "user", "content": "x"}]
|
|
agent = _make_agent()
|
|
session = _make_session(agent=agent, history=history)
|
|
|
|
_finalize_session(session)
|
|
|
|
agent.commit_memory_session.assert_called_once()
|
|
|
|
def test_no_agent_no_crash(self):
|
|
"""Session with agent=None exits cleanly."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
session = _make_session(agent=None, history=[{"role": "user", "content": "x"}])
|
|
_finalize_session(session) # must not raise
|
|
|
|
def test_empty_history_skips_persist(self):
|
|
"""Empty history → _persist_session not called (guard)."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
agent = _make_agent()
|
|
session = _make_session(agent=agent, history=[])
|
|
|
|
_finalize_session(session)
|
|
|
|
agent._persist_session.assert_not_called()
|
|
|
|
def test_no_persist_method_skips(self):
|
|
"""Agent without _persist_session attribute → graceful skip."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
agent = _make_agent()
|
|
del agent._persist_session # simulate older agent without the method
|
|
session = _make_session(
|
|
agent=agent,
|
|
history=[{"role": "user", "content": "x"}],
|
|
)
|
|
|
|
_finalize_session(session) # must not raise
|
|
|
|
def test_already_finalized_skips(self):
|
|
"""Double-finalize is a no-op."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
agent = _make_agent()
|
|
session = _make_session(agent=agent, history=[{"role": "user", "content": "x"}])
|
|
session["_finalized"] = True
|
|
|
|
_finalize_session(session)
|
|
|
|
agent._persist_session.assert_not_called()
|
|
|
|
def test_persist_exception_does_not_block(self):
|
|
"""If _persist_session raises, finalization continues."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
agent = _make_agent()
|
|
agent._session_messages = [{"role": "user", "content": "x"}]
|
|
agent._persist_session.side_effect = RuntimeError("db is down")
|
|
session = _make_session(
|
|
agent=agent,
|
|
history=[{"role": "user", "content": "x"}],
|
|
)
|
|
|
|
_finalize_session(session) # must not raise
|
|
agent._persist_session.assert_called_once()
|
|
# commit_memory_session should still be called
|
|
agent.commit_memory_session.assert_called_once()
|
|
|
|
@patch("tui_gateway.server._get_db")
|
|
def test_db_end_session_still_called(self, mock_get_db):
|
|
"""Existing db.end_session() path is preserved after the new code."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
mock_db = MagicMock()
|
|
mock_get_db.return_value = mock_db
|
|
|
|
agent = _make_agent(session_id="sess_123")
|
|
session = _make_session(agent=agent, history=[{"role": "user", "content": "x"}])
|
|
|
|
_finalize_session(session, end_reason="test")
|
|
|
|
mock_db.end_session.assert_called_once_with("sess_123", "test")
|
|
|
|
|
|
class TestFinalizeSessionPersistE2E:
|
|
"""End-to-end: _finalize_session must actually land unflushed turns in
|
|
state.db on disconnect/restart.
|
|
|
|
The mock-based tests above assert that _persist_session is *called*, but a
|
|
call whose arguments neuter the underlying flush persists nothing. These
|
|
tests drive the REAL AIAgent flush against a REAL SessionDB, reproducing
|
|
the "conversation contains many events yet is absent from state.db across
|
|
disconnect/restart" symptom.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _real_agent(db, session_id, session_messages):
|
|
from run_agent import AIAgent
|
|
|
|
agent = object.__new__(AIAgent)
|
|
agent._session_db = db
|
|
agent._session_db_created = True
|
|
agent.session_id = session_id
|
|
agent.platform = "tui"
|
|
agent.model = "test-model"
|
|
agent._session_messages = session_messages
|
|
agent._last_flushed_db_idx = 0
|
|
agent._flushed_db_message_ids = set()
|
|
agent._flushed_db_message_session_id = None
|
|
agent._persist_disabled = False
|
|
agent._cached_system_prompt = None
|
|
agent._session_init_model_config = None
|
|
agent._parent_session_id = None
|
|
agent._session_json_enabled = False
|
|
agent.quiet_mode = True
|
|
# commit_memory_session runs heavy machinery we don't exercise here.
|
|
agent.commit_memory_session = lambda *a, **k: None
|
|
return agent
|
|
|
|
def test_unflushed_turn_survives_disconnect(self, tmp_path, monkeypatch):
|
|
"""A completed turn whose transcript flush did NOT durably persist
|
|
(messages live only in agent._session_messages / session['history'],
|
|
never written to the DB) must be flushed to state.db when the WS
|
|
disconnect tears the session down."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
from hermes_state import SessionDB
|
|
import tui_gateway.server as srv
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
session_id = "sess-unflushed"
|
|
db.create_session(session_id=session_id, source="tui")
|
|
monkeypatch.setattr(srv, "_get_db", lambda: db)
|
|
|
|
# The live turn list that became session["history"] AND
|
|
# agent._session_messages (same object), but was never persisted.
|
|
turn = [
|
|
{"role": "user", "content": "scan the repo and summarise"},
|
|
{"role": "assistant", "content": "Here is the summary…"},
|
|
{"role": "user", "content": "now open a PR"},
|
|
{"role": "assistant", "content": "PR opened."},
|
|
]
|
|
agent = self._real_agent(db, session_id, turn)
|
|
session = _make_session(agent=agent, history=turn, session_key=session_id)
|
|
|
|
assert db.get_messages_as_conversation(session_id) == []
|
|
|
|
srv._finalize_session(session, end_reason="ws_disconnect")
|
|
|
|
after = db.get_messages_as_conversation(session_id)
|
|
contents = [m.get("content") for m in after]
|
|
assert len(after) == 4, after
|
|
assert any("scan the repo" in (c or "") for c in contents), contents
|
|
assert any("PR opened" in (c or "") for c in contents), contents
|
|
|
|
def test_resumed_session_not_reflushed_as_duplicates(self, tmp_path, monkeypatch):
|
|
"""A resumed session torn down before any new turn (its transcript is
|
|
already durable in the DB) must NOT re-append duplicate rows."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
from hermes_state import SessionDB
|
|
import tui_gateway.server as srv
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
session_id = "sess-resumed"
|
|
db.create_session(session_id=session_id, source="tui")
|
|
loaded = [
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "hi there"},
|
|
]
|
|
for m in loaded:
|
|
db.append_message(session_id=session_id, role=m["role"], content=m["content"])
|
|
monkeypatch.setattr(srv, "_get_db", lambda: db)
|
|
|
|
# Resumed session: history hydrated from the DB, no turn ran, so the
|
|
# agent processed nothing this session.
|
|
agent = self._real_agent(db, session_id, [])
|
|
session = _make_session(agent=agent, history=loaded, session_key=session_id)
|
|
|
|
srv._finalize_session(session, end_reason="ws_disconnect")
|
|
|
|
after = db.get_messages_as_conversation(session_id)
|
|
assert len(after) == 2, after
|
|
|
|
def test_resumed_then_run_turn_not_duplicated(self, tmp_path, monkeypatch):
|
|
"""A resumed session that RUNS a turn must not have its loaded (durable)
|
|
prefix re-appended by finalize.
|
|
|
|
This exercises the exact path the ``conversation_history`` argument used
|
|
to guard: the in-turn flush stamps the loaded prefix with
|
|
``_DB_PERSISTED_MARKER`` (recognising it as durable), so the marker-only
|
|
finalize flush skips it. Without that stamping — or if finalize wrote a
|
|
markerless copy — the durable prefix would double. The
|
|
``_session_messages``-empty test above skips the flush entirely, so it
|
|
can't catch a duplicate-write regression; this one drives a real flush.
|
|
"""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
|
from hermes_state import SessionDB
|
|
import tui_gateway.server as srv
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
session_id = "sess-resume-run"
|
|
db.create_session(session_id=session_id, source="tui")
|
|
loaded = [
|
|
{"role": "user", "content": "remember: my cat is Mochi"},
|
|
{"role": "assistant", "content": "Noted — Mochi."},
|
|
]
|
|
for m in loaded:
|
|
db.append_message(session_id=session_id, role=m["role"], content=m["content"])
|
|
monkeypatch.setattr(srv, "_get_db", lambda: db)
|
|
|
|
# Live turn list = loaded prefix (same dicts, as run_conversation copies
|
|
# conversation_history) + the new turn.
|
|
new_turn = [
|
|
{"role": "user", "content": "what's the cat's name?"},
|
|
{"role": "assistant", "content": "Mochi."},
|
|
]
|
|
messages = list(loaded) + new_turn
|
|
agent = self._real_agent(db, session_id, messages)
|
|
|
|
# Drive the in-turn flush the way run_conversation does — the loaded
|
|
# prefix rides in as conversation_history, so it is recognised durable
|
|
# (and marker-stamped) while only the new turn is written.
|
|
agent._flush_messages_to_session_db(messages, conversation_history=loaded)
|
|
assert len(db.get_messages_as_conversation(session_id)) == 4
|
|
|
|
# WS disconnect → finalize. Must re-append nothing.
|
|
session = _make_session(agent=agent, history=messages, session_key=session_id)
|
|
srv._finalize_session(session, end_reason="ws_disconnect")
|
|
|
|
after = db.get_messages_as_conversation(session_id)
|
|
assert len(after) == 4, after
|
|
|
|
|
|
class TestOnSessionEndHook:
|
|
"""Verify on_session_end plugin hook fires on finalize."""
|
|
|
|
@patch("hermes_cli.plugins.invoke_hook")
|
|
def test_hook_fired_with_interrupted_true(self, mock_invoke_hook):
|
|
"""on_session_end is called with interrupted=True when finalizing."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
agent = _make_agent(session_id="hook_test_001")
|
|
agent.model = "claude-sonnet-4"
|
|
agent.platform = "tui"
|
|
session = _make_session(agent=agent, history=[{"role": "user", "content": "test"}])
|
|
|
|
_finalize_session(session, end_reason="tui_close")
|
|
|
|
mock_invoke_hook.assert_any_call(
|
|
"on_session_end",
|
|
session_id="hook_test_001",
|
|
completed=False,
|
|
interrupted=True,
|
|
model="claude-sonnet-4",
|
|
platform="tui",
|
|
)
|
|
|
|
@patch("hermes_cli.plugins.invoke_hook")
|
|
def test_hook_exception_does_not_block(self, mock_invoke_hook):
|
|
"""Hook failure doesn't prevent session finalization."""
|
|
from tui_gateway.server import _finalize_session
|
|
|
|
mock_invoke_hook.side_effect = RuntimeError("plugin crash")
|
|
agent = _make_agent()
|
|
session = _make_session(agent=agent, history=[{"role": "user", "content": "x"}])
|
|
|
|
_finalize_session(session) # must not raise
|
|
agent.commit_memory_session.assert_called_once()
|