hermes-agent/tests/manual/cron_inchannel_e2e.py
Ben 2c84fb42b0 fix(cron/slack): CREATE the flat session for in_channel (mirror only appends)
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.
2026-07-01 03:16:13 -07:00

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()