Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""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
|
||||
)
|
||||
Reference in New Issue
Block a user