mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
Live testing exposed a real bug: an in_channel continuable cron delivered
flat to the channel (✅) but the reply did NOT continue the job — the bot
had no brief in context and confabulated the answer.
Root cause: mirror_to_session only APPENDS to a session that already
exists (_find_session_id → no-op when none matches); it never CREATEs one.
A flat (slack, chat_id, None) row is only created when a human posts a
top-level message the bot processes — a cron chat_postMessage delivery
never goes through the inbound handler, so the row is absent and the brief
is silently dropped. The prior impl relied on the bare mirror (F5/OQ-1
concluded "deletion only" — wrong).
Fix: _seed_cron_channel_session mirrors _seed_cron_thread_session —
get_or_create_session FIRST (chat_type = "dm" if is_dm else "group",
thread_id=None), keyed to the ORIGIN USER'S id, then mirror. The channel
session key embeds user_id (…:group:<chat>:<user>), so a system:cron id
would key the seed away from the reply; the origin user's id makes seed
key == inbound reply key. DM key ignores user_id but needs chat_type=dm
to match the prefix. Wired into the in_channel branch after delivery;
suppresses the generic mirror to avoid double-write.
DM validated (per request): the seeded key equals the inbound DM reply key
for a 1:1 DM; continuation works there too.
Tests:
- Rewrote the in_channel tests to use a real _session_store and the origin
user_id; assert get_or_create_session is called with the flat, correctly-
keyed source. Prove-fail: (a) reverting the create step and (b) seeding
with system:cron each turn a targeted test RED; restore → GREEN.
- +2 direct _seed_cron_channel_session unit tests asserting the KEY-MATCH
invariant (seed key == inbound reply key) via build_session_key, for both
channel and DM.
- Rewrote tests/manual/cron_inchannel_e2e.py to drive a REAL SessionStore +
real mirror_to_session + real _find_session_id + real build_session_key
(no session-layer mocks — the old mocked E2E is exactly why the bug
shipped). Asserts the brief lands in the transcript and the reply resolves
to the same session, for BOTH channel and 1:1 DM.
Full relevant sweep: 283 passed.
159 lines
6.2 KiB
Python
159 lines
6.2 KiB
Python
"""
|
|
Offline E2E for continuable in-channel cron (specs/cron-inchannel-continuable).
|
|
|
|
Exercises the REAL create→persist→find→append path end-to-end against a REAL
|
|
SessionStore + REAL mirror_to_session + REAL _find_session_id + REAL
|
|
build_session_key — NO mocking of the session layer. This is the harness that
|
|
would have caught the shipped bug (the first version mocked mirror_to_session and
|
|
so never exercised the fact that the mirror only APPENDS to a pre-existing
|
|
session; the flat channel row was never created and the brief was silently lost).
|
|
|
|
Two scenarios, each asserting the brief actually lands in the SAME session the
|
|
inbound reply resolves to:
|
|
|
|
CHANNEL: cron in_channel delivery → _seed_cron_channel_session CREATES the flat
|
|
(slack, C, None) session (chat_type=group, keyed to the origin user) and
|
|
mirrors the brief in. Then a plain channel reply (reply_in_thread:false →
|
|
thread_id=None) keys to the SAME session → the brief is in its transcript.
|
|
|
|
1:1 DM: same, chat_type=dm. The DM session key ignores user_id, so the reply
|
|
resolves regardless; assert the brief lands and the key matches.
|
|
|
|
Run from INSIDE the worktree (so the worktree code loads, not the editable
|
|
main-checkout install):
|
|
|
|
cd <worktree>
|
|
PYTHONPATH="$PWD" ../../.venv/bin/python tests/manual/cron_inchannel_e2e.py
|
|
|
|
Uses a throwaway HERMES_HOME so it never touches ~/.hermes. No real names.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
def _fresh_home():
|
|
"""Point HERMES_HOME at a throwaway dir BEFORE importing gateway modules
|
|
(mirror.py binds _SESSIONS_INDEX from get_hermes_home() at import time)."""
|
|
d = tempfile.mkdtemp(prefix="cron_inchannel_e2e_")
|
|
os.environ["HERMES_HOME"] = d
|
|
return Path(d)
|
|
|
|
|
|
HOME = _fresh_home()
|
|
|
|
# Import AFTER HERMES_HOME is set.
|
|
import cron.scheduler as sched # noqa: E402
|
|
import gateway.mirror as mirror # noqa: E402
|
|
from gateway.config import GatewayConfig, Platform # noqa: E402
|
|
from gateway.session import SessionStore, SessionSource, build_session_key # noqa: E402
|
|
|
|
# Force mirror.py's module-level index path to our temp home (it may have bound
|
|
# a different get_hermes_home() at import if something imported it earlier).
|
|
mirror._SESSIONS_DIR = HOME / "sessions"
|
|
mirror._SESSIONS_INDEX = HOME / "sessions" / "sessions.json"
|
|
|
|
BRIEF = "brief: PRs need review\n- Harden: session lifecycle teardown"
|
|
|
|
|
|
def _real_store():
|
|
cfg = GatewayConfig()
|
|
store = SessionStore(HOME / "sessions", cfg)
|
|
return store
|
|
|
|
|
|
def _run_scenario(name, chat_id, is_dm, reply_chat_type):
|
|
print(f"\n=== {name} (chat_id={chat_id}, is_dm={is_dm}) ===")
|
|
store = _real_store()
|
|
|
|
# A real Slack-like adapter exposing only what the seeder needs: the live
|
|
# session store. (We call the seeder directly — the delivery leg's flat-post
|
|
# is covered by the unit tests; here we prove the SESSION plumbing works.)
|
|
class _Adapter:
|
|
_session_store = store
|
|
|
|
ok = sched._seed_cron_channel_session(
|
|
{"id": "brief-job", "name": "PR review brief"},
|
|
_Adapter(), "slack", chat_id, BRIEF,
|
|
is_dm=is_dm, user_id="U_HUMAN", chat_name="test",
|
|
)
|
|
assert ok, f"{name}: seeder returned False — session not created/mirrored"
|
|
|
|
# LEG 1: what session key did the seed create?
|
|
seeded_source = SessionSource(
|
|
platform=Platform.SLACK, chat_id=chat_id,
|
|
chat_type="dm" if is_dm else "group",
|
|
user_id="U_HUMAN", thread_id=None,
|
|
)
|
|
seed_key = build_session_key(seeded_source)
|
|
|
|
# LEG 2: what does a plain inbound reply (reply_in_thread:false → thread None)
|
|
# from the same user resolve to?
|
|
inbound = SessionSource(
|
|
platform=Platform.SLACK, chat_id=chat_id, chat_type=reply_chat_type,
|
|
user_id="U_HUMAN", thread_id=None,
|
|
)
|
|
reply_key = build_session_key(inbound)
|
|
print(f" seed key : {seed_key}")
|
|
print(f" reply key: {reply_key}")
|
|
assert seed_key == reply_key, f"{name}: KEY MISMATCH — reply won't continue the seed"
|
|
|
|
# GROUND TRUTH: the brief must actually be in that session's transcript, and
|
|
# discoverable via the same _find_session_id the inbound reply path uses.
|
|
sid = mirror._find_session_id("slack", chat_id, thread_id=None, user_id="U_HUMAN")
|
|
assert sid, f"{name}: _find_session_id found NO session — the reply would dead-end"
|
|
# Read the session transcript back and confirm the brief text is present.
|
|
idx = mirror._SESSIONS_INDEX
|
|
import json
|
|
data = json.loads(idx.read_text())
|
|
entry = next((e for e in data.values() if isinstance(e, dict) and e.get("session_id") == sid), None)
|
|
assert entry, f"{name}: session {sid} not in index"
|
|
# transcript lives in the JSONL / SQLite; verify via the store's own read.
|
|
found = _brief_in_transcript(store, sid)
|
|
assert found, f"{name}: brief NOT found in session {sid} transcript"
|
|
print(f" ✓ session {sid} created, brief present, reply resolves here")
|
|
return True
|
|
|
|
|
|
def _brief_in_transcript(store, sid):
|
|
"""Best-effort read of the session transcript to confirm the brief landed."""
|
|
# Try the SQLite DB first (the mirror writes both JSONL + SQLite).
|
|
try:
|
|
from hermes_state import SessionDB
|
|
db = SessionDB()
|
|
msgs = db.get_messages(sid)
|
|
for m in msgs:
|
|
if "PRs need review" in str(m.get("content", "")):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
# Fallback: scan the JSONL transcript file.
|
|
for p in (HOME / "sessions").glob("*.json*"):
|
|
try:
|
|
if "PRs need review" in p.read_text():
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
def main():
|
|
print(f"scheduler module: {sched.__file__}")
|
|
print(f"HERMES_HOME (throwaway): {HOME}")
|
|
if "cron-inchannel" not in sched.__file__:
|
|
print("WARNING: not the worktree scheduler — set PYTHONPATH=$PWD", file=sys.stderr)
|
|
|
|
_run_scenario("CHANNEL", "C_TEST", is_dm=False, reply_chat_type="group")
|
|
_run_scenario("1:1 DM", "D_TEST", is_dm=True, reply_chat_type="dm")
|
|
|
|
print(
|
|
"\nPASS: in_channel cron seeds the flat session for BOTH a channel and a "
|
|
"1:1 DM; the brief lands in the transcript and a plain reply resolves to "
|
|
"the same session (continuation works)."
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|