207 lines
7.5 KiB
Python
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
|
|
)
|