Files
aiturk-hermes-ide/tests/gateway/test_restart_drain_recovery_dedup.py
T

207 lines
7.5 KiB
Python

"""Restart drain-window recovery must be able to dedup an interrupted turn.
The Discord missed-message backfill (``_run_missed_message_backfill``) exists
to recover messages the bot never saw while it was down. A gateway RESTART
produces a harder case: the message WAS received and a turn WAS started, then
the drain window force-interrupted it. The transcript is the only durable
record of that, and the transcript row for the user turn is written WITHOUT
the platform-side message id — so nothing downstream can ask "did this
Discord message already reach the transcript?" and the recovery pass has no
authority to dedup against.
``SessionDB`` already carries a ``platform_message_id`` column, a partial
unique index over ``(session_id, platform_message_id)``, and a
``has_platform_message_id`` lookup — the storage and the query exist. What is
missing is the WRITE on the normal agent-persisted turn path: the id is only
attached on the gateway-side transient-failure fallback
(``_handle_message_with_agent``), never on the path the agent itself flushes.
"""
from __future__ import annotations
import types
import pytest
from hermes_state import SessionDB
def _make_db(tmp_path) -> SessionDB:
return SessionDB(db_path=tmp_path / "state.db")
class _MinimalAgent:
"""The narrow slice of AIAgent that ``_apply_persist_user_message_override``
and ``_flush_messages_to_session_db`` read."""
def __init__(self, db: SessionDB, session_id: str):
self._session_db = db
self._session_db_created = True
self.session_id = session_id
self._last_flushed_db_idx = 0
self._flushed_db_message_ids = set()
self._flushed_db_message_session_id = session_id
self._persist_user_message_idx = None
self._persist_user_message_override = None
self._persist_user_message_timestamp = None
self._persist_disabled = False
def _ensure_db_session(self): # pragma: no cover - already created
return None
def test_build_turn_context_stamps_the_platform_message_id_on_the_user_turn():
"""The turn prologue must carry the platform id onto the user turn dict.
This is the row the early crash-resilience persist writes, so it is the
only place a drain-interrupted turn can pick the id up.
"""
from agent.turn_context import build_turn_context
agent = types.SimpleNamespace()
ctx = _build_turn_context_for_test(
build_turn_context, agent, persist_user_platform_id="discord-991"
)
user_msgs = [m for m in ctx.messages if m.get("role") == "user"]
assert user_msgs, "no user turn in the built context"
assert user_msgs[-1].get("platform_message_id") == "discord-991", (
"the user turn reached persistence without its platform message id — a "
"drain-interrupted turn is then unrecoverable/undedupable by "
"has_platform_message_id"
)
def test_persisted_interrupted_turn_is_findable_by_platform_message_id(tmp_path):
"""E2E: flush a turn the way the agent does, then ask the dedup authority.
This is the exact question the restart drain-window recovery pass asks
before re-dispatching a message. On main the answer is False even though
the turn IS in the transcript, so recovery would re-run a turn that
already ran (duplicate work, duplicate spend, duplicate reply).
"""
from run_agent import AIAgent
db = _make_db(tmp_path)
session_id = db.create_session("sess-drain-window", "gateway")
agent = _MinimalAgent(db, session_id)
agent._persist_user_message_idx = 0
agent._persist_user_message_platform_id = "discord-4242"
messages = [{"role": "user", "content": "please do the thing"}]
AIAgent._apply_persist_user_message_override(agent, messages)
AIAgent._flush_messages_to_session_db_unlocked(
agent, messages, conversation_history=None
)
assert db.has_platform_message_id(session_id, "discord-4242"), (
"the interrupted turn is in the transcript but carries no "
"platform_message_id, so restart drain-window recovery cannot tell it "
"already ran and will re-dispatch it"
)
def test_platform_message_id_survives_a_persist_content_override(tmp_path):
"""The id must not be lost on the override path.
Group-chat / observed-context turns route through
``_persist_user_message_override``; the id has to survive that rewrite or
the dedup authority is blind for exactly the busy channels that need it.
"""
from run_agent import AIAgent
db = _make_db(tmp_path)
session_id = db.create_session("sess-override", "gateway")
agent = _MinimalAgent(db, session_id)
agent._persist_user_message_idx = 0
agent._persist_user_message_override = "clean transcript text"
agent._persist_user_message_platform_id = "discord-7777"
messages = [{"role": "user", "content": "api-facing text with context"}]
AIAgent._apply_persist_user_message_override(agent, messages)
AIAgent._flush_messages_to_session_db_unlocked(
agent, messages, conversation_history=None
)
assert db.has_platform_message_id(session_id, "discord-7777")
def _build_turn_context_for_test(build_turn_context, agent, **overrides):
"""Construct a minimal build_turn_context call.
Mirrors ``tests/agent/test_turn_context.py::_build`` but is kept local so
this file stays self-contained.
"""
from tests.agent.test_turn_context import _FakeAgent, _stub_runtime_main
fake = _FakeAgent()
kwargs = dict(
agent=fake,
user_message="hello",
system_message=None,
conversation_history=None,
task_id=None,
stream_callback=None,
persist_user_message=None,
restore_or_build_system_prompt=lambda *a, **k: None,
install_safe_stdio=lambda: None,
sanitize_surrogates=lambda s: s,
summarize_user_message_for_log=lambda s: s,
set_session_context=lambda _sid: None,
set_current_write_origin=lambda _o: None,
ra=lambda: types.SimpleNamespace(_set_interrupt=lambda *a, **k: None),
)
kwargs.update(overrides)
return build_turn_context(**kwargs)
def test_gateway_run_agent_threads_the_event_message_id_into_the_turn():
"""AST proof that the gateway call site passes the id down.
The unit tests above prove the persistence layer STORES the id once it is
given one. This pins the wiring: without the gateway forwarding
``event_message_id`` as ``persist_user_platform_id``, the whole path is
dead code and every real inbound turn still persists without its id.
"""
import ast
import inspect
import gateway.run as gateway_run
source = inspect.getsource(gateway_run)
tree = ast.parse(source)
forwards = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Subscript)
and isinstance(node.slice, ast.Constant)
and node.slice.value == "persist_user_platform_id"
]
assert forwards, (
"gateway/run.py never forwards persist_user_platform_id — the inbound "
"platform message id never reaches the persisted user turn, so a "
"drain-interrupted turn stays undedupable"
)
def test_run_conversation_accepts_persist_user_platform_id():
"""The public forwarder must expose the kwarg the gateway passes."""
import inspect
from agent.conversation_loop import run_conversation
from run_agent import AIAgent
assert (
"persist_user_platform_id"
in inspect.signature(run_conversation).parameters
)
assert (
"persist_user_platform_id"
in inspect.signature(AIAgent.run_conversation).parameters
)