Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson
2026-05-21 13:53:53 -05:00
105 changed files with 5022 additions and 1714 deletions
+116 -17
View File
@@ -313,19 +313,30 @@ def _scan_for_plugin_adapter_antipattern(source: str) -> list[str]:
return offenses
def pytest_configure(config):
"""Reject plugin-adapter tests that use the sys.path anti-pattern.
def _fingerprint_gateway_tests() -> str:
"""Return a short fingerprint that changes when any gateway test file changes.
Runs once per pytest session on the controller, BEFORE any xdist
worker is spawned. If any file under ``tests/gateway/`` matches the
anti-pattern, we fail the whole session with a clear message —
before a polluted ``sys.path`` can cascade across workers.
Uses (mtime, size) pairs instead of content hashing — fast to compute
(stat-only, no reads) and sufficient for cache invalidation across
per-file subprocess runs.
"""
# Only run on the xdist controller (or in non-xdist runs). Skip on
# worker subprocesses so we don't scan the filesystem N times.
if hasattr(config, "workerinput"):
return
import hashlib
h = hashlib.sha256()
for path in sorted(_GATEWAY_DIR.rglob("test_*.py")):
try:
st = path.stat()
h.update(f"{path.name}:{st.st_mtime_ns}:{st.st_size}".encode())
except OSError:
h.update(f"{path.name}:missing".encode())
return h.hexdigest()[:16]
def _run_adapter_antipattern_scan() -> list[str]:
"""Scan gateway test files for the plugin-adapter anti-pattern.
Returns a list of violation strings (empty if clean).
"""
violations: list[str] = []
for path in _GATEWAY_DIR.rglob("test_*.py"):
if path.name in {"_plugin_adapter_loader.py", "conftest.py"}:
@@ -334,20 +345,108 @@ def pytest_configure(config):
source = path.read_text(encoding="utf-8")
except OSError:
continue
# Fast string pre-filter: skip files that can't possibly violate.
# A violating file MUST contain both (a) an adapter/plugins/platforms
# reference AND (b) either sys.path manipulation or a bare adapter import.
if "adapter" not in source and "plugins/platforms" not in source:
continue
if not (
"sys.path" in source
or "import adapter" in source
or "from adapter import" in source
):
continue
offenses = _scan_for_plugin_adapter_antipattern(source)
if offenses:
violations.append(
f" {path.relative_to(_GATEWAY_DIR.parent.parent)}:\n "
+ "\n ".join(offenses)
)
return violations
if violations:
raise pytest.UsageError(
"Plugin-adapter-import anti-pattern detected in gateway tests:\n"
+ "\n".join(violations)
+ "\n\n"
+ _GUARD_HINT
)
def pytest_configure(config):
"""Reject plugin-adapter tests that use the sys.path anti-pattern.
Runs once per pytest session on the controller, BEFORE any xdist
worker is spawned. If any file under ``tests/gateway/`` matches the
anti-pattern, we fail the whole session with a clear message —
before a polluted ``sys.path`` can cascade across workers.
**Performance**: in the per-file subprocess isolation model (no xdist),
every subprocess is a "controller" — so the naive scan would run 257
times, each costing ~1s of AST walking. We avoid this with two
strategies:
1. **Tight string pre-filter**: a file can only violate if it contains
*both* an adapter/plugins/platforms reference *and* a sys.path
manipulation or bare ``import adapter``. This drops ~95% of files
from needing AST parsing.
2. **File-locked cache**: the scan result is cached in
``.pytest-cache/gw-adapter-guard-<fingerprint>`` keyed on a
fingerprint of the gateway test file mtimes/sizes. Concurrent
subprocesses acquire a lock; only the first performs the scan;
the rest wait and read the cached result.
"""
# Only run on the xdist controller (or in non-xdist runs). Skip on
# worker subprocesses so we don't scan the filesystem N times.
if hasattr(config, "workerinput"):
return
fp = _fingerprint_gateway_tests()
cache_dir = Path.cwd() / ".pytest-cache"
cache_file = cache_dir / f"gw-adapter-guard-{fp}"
lock_file = cache_dir / f".gw-adapter-guard-{fp}.lock"
cache_dir.mkdir(parents=True, exist_ok=True)
# Evict stale cache entries from previous fingerprints (best-effort).
try:
for old in cache_dir.glob("gw-adapter-guard-*"):
if old.name != f"gw-adapter-guard-{fp}":
old.unlink(missing_ok=True)
for old in cache_dir.glob(".gw-adapter-guard-*.lock"):
if old.name != f".gw-adapter-guard-{fp}.lock":
old.unlink(missing_ok=True)
except OSError:
pass # Non-critical; old files are harmless.
# Use filelock to ensure only one process scans at a time.
# Concurrent subprocesses all hit pytest_configure simultaneously;
# without a lock they'd all find no cache and all run the scan.
try:
from filelock import FileLock
lock = FileLock(str(lock_file), timeout=120)
except ImportError:
# Fallback: no locking (still correct, just slower under contention).
import contextlib
class _NoLock:
def __enter__(self):
return self
def __exit__(self, *a):
pass
lock = _NoLock()
with lock:
if cache_file.exists():
cached = cache_file.read_text(encoding="utf-8")
if cached == "clean":
return
raise pytest.UsageError(cached)
# Slow path: this process is the first to acquire the lock.
violations = _run_adapter_antipattern_scan()
if violations:
msg = (
"Plugin-adapter-import anti-pattern detected in gateway tests:\n"
+ "\n".join(violations)
+ "\n\n"
+ _GUARD_HINT
)
cache_file.write_text(msg, encoding="utf-8")
raise pytest.UsageError(msg)
else:
cache_file.write_text("clean", encoding="utf-8")
View File
@@ -0,0 +1,88 @@
"""Yuanbao recall: branch A1 (exact id) and A2 (content-match) against DB-only transcripts.
state.db persists the platform-side ``message_id`` via the
``platform_message_id`` column (added in the salvage of PR #29211) and
``load_transcript`` surfaces it back on each message dict as ``message_id``
— so the recall guard's exact-id match path stays canonical even with the
JSONL file gone. When a row has no platform id (e.g. agent-processed
@bot messages whose adapter didn't carry a msg_id, or pre-column legacy
rows), recall falls through to content-match.
"""
from gateway.session import SessionStore
from gateway.config import GatewayConfig
def _pin_db(monkeypatch, tmp_path):
"""Force SessionDB() to write into tmp_path instead of the real ~/.hermes."""
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
def test_recall_branch_a1_exact_id_match_round_trips_through_db(tmp_path, monkeypatch):
"""A user message persisted with ``message_id`` must round-trip through
state.db so recall can find and redact it by exact id (branch A1)."""
_pin_db(monkeypatch, tmp_path)
config = GatewayConfig()
store = SessionStore(sessions_dir=tmp_path, config=config)
sid = "test-yuanbao-recall-a1"
store._db.create_session(session_id=sid, source="yuanbao:group:G")
store.append_to_transcript(sid, {
"role": "user",
"content": "sensitive content",
"timestamp": 1.0,
"message_id": "platform-msg-abc",
})
store.append_to_transcript(sid, {
"role": "assistant",
"content": "ack",
"timestamp": 2.0,
})
history = store.load_transcript(sid)
# The user row must carry its platform id back so the recall guard can
# match by exact id; the assistant row had no platform id so it should
# not gain one spuriously.
user_msg = next(m for m in history if m["role"] == "user")
assistant_msg = next(m for m in history if m["role"] == "assistant")
assert user_msg.get("message_id") == "platform-msg-abc"
assert "message_id" not in assistant_msg
# Branch A1: locate the row by exact platform id — no content heuristics.
target = next(
(m for m in history if m.get("message_id") == "platform-msg-abc"),
None,
)
assert target is not None
assert target["content"] == "sensitive content"
def test_recall_branch_a2_content_match_when_no_platform_id(tmp_path, monkeypatch):
"""Rows that lack a platform_message_id (e.g. agent-processed @bot
messages) still match by content as a fallback."""
_pin_db(monkeypatch, tmp_path)
config = GatewayConfig()
store = SessionStore(sessions_dir=tmp_path, config=config)
sid = "test-yuanbao-recall-a2"
store._db.create_session(session_id=sid, source="yuanbao:group:G")
# No message_id on the dict — simulates an agent-processed message
# that did not carry the platform msg_id through.
store.append_to_transcript(sid, {
"role": "user",
"content": "sensitive content",
"timestamp": 1.0,
})
history = store.load_transcript(sid)
assert all("message_id" not in m for m in history)
# Branch A2: content match recovers the target.
target = next(
(m for m in history
if m.get("role") == "user" and m.get("content") == "sensitive content"),
None,
)
assert target is not None
+17 -10
View File
@@ -22,19 +22,26 @@ from gateway.config import PlatformConfig
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
telegram_mod = MagicMock()
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
telegram_mod.constants.ChatType.GROUP = "group"
telegram_mod.constants.ChatType.SUPERGROUP = "supergroup"
telegram_mod.constants.ChatType.CHANNEL = "channel"
telegram_mod.constants.ChatType.PRIVATE = "private"
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, telegram_mod)
# Register telegram.constants as a separate module mock so that
# ``from telegram.constants import ChatType`` resolves to our mock
# with string-valued members (not auto-generated MagicMocks).
constants_mod = MagicMock()
constants_mod.ParseMode.MARKDOWN_V2 = "MarkdownV2"
constants_mod.ChatType.GROUP = "group"
constants_mod.ChatType.SUPERGROUP = "supergroup"
constants_mod.ChatType.CHANNEL = "channel"
constants_mod.ChatType.PRIVATE = "private"
sys.modules["telegram"] = telegram_mod
sys.modules["telegram.ext"] = telegram_mod.ext
sys.modules["telegram.constants"] = constants_mod
sys.modules["telegram.request"] = telegram_mod.request
# Force reimport so the adapter picks up the mock ChatType.
sys.modules.pop("gateway.platforms.telegram", None)
_ensure_telegram_mock()
+11 -6
View File
@@ -22,6 +22,11 @@ import pytest
from gateway.config import Platform, PlatformConfig, load_gateway_config
# Platform uses _missing_() for dynamic members, so "google_chat" is
# resolvable via Platform("google_chat") even without a static
# GOOGLE_CHAT attribute on the enum class.
_GC = Platform("google_chat")
# ---------------------------------------------------------------------------
# Mock the google-* packages if they are not installed
@@ -229,7 +234,7 @@ def _make_chat_envelope(text="hello", sender_email="u@example.com", sender_type=
class TestPlatformRegistration:
def test_enum_value(self):
assert Platform.GOOGLE_CHAT.value == "google_chat"
assert _GC.value == "google_chat"
def test_requirements_check_returns_true_when_available(self):
# The shim flag is True in this test module.
@@ -266,14 +271,14 @@ class TestEnvConfigLoading:
monkeypatch.setenv("GOOGLE_CHAT_PROJECT_ID", "p")
# No subscription.
cfg = load_gateway_config()
assert Platform.GOOGLE_CHAT not in cfg.platforms
assert _GC not in cfg.platforms
def test_missing_project_does_not_enable(self, monkeypatch):
self._clean_env(monkeypatch)
monkeypatch.setenv("GOOGLE_CHAT_SUBSCRIPTION_NAME",
"projects/p/subscriptions/s")
cfg = load_gateway_config()
assert Platform.GOOGLE_CHAT not in cfg.platforms
assert _GC not in cfg.platforms
@@ -2583,7 +2588,7 @@ class TestAuthorizationEmailMatch:
runner.pairing_store.is_approved = MagicMock(return_value=False)
source = SessionSource(
platform=Platform.GOOGLE_CHAT,
platform=_GC,
chat_id="spaces/S",
chat_type="dm",
user_id="alice@example.com", # post-swap: email is canonical
@@ -2604,7 +2609,7 @@ class TestAuthorizationEmailMatch:
runner.pairing_store.is_approved = MagicMock(return_value=False)
source = SessionSource(
platform=Platform.GOOGLE_CHAT,
platform=_GC,
chat_id="spaces/S",
chat_type="dm",
user_id="bob@example.com",
@@ -2630,7 +2635,7 @@ class TestAuthorizationEmailMatch:
runner.pairing_store.is_approved = MagicMock(return_value=False)
source = SessionSource(
platform=Platform.GOOGLE_CHAT,
platform=_GC,
chat_id="spaces/S",
chat_type="dm",
user_id="users/77777", # no email available — resource name wins
@@ -0,0 +1,32 @@
"""Verify load_transcript returns SQLite messages without any JSONL file."""
from pathlib import Path
import pytest
from gateway.session import SessionStore
from gateway.config import GatewayConfig
def test_load_transcript_returns_db_messages_when_no_jsonl(tmp_path, monkeypatch):
"""Reading a transcript must work from SQLite alone — no JSONL fallback needed.
Pin DEFAULT_DB_PATH to tmp_path so this test cannot write to the real
~/.hermes/state.db. (DEFAULT_DB_PATH is a module-level constant computed
at hermes_state import time, before pytest's HERMES_HOME monkeypatch
fires — the autouse fixture's HERMES_HOME override doesn't help here.)
"""
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
config = GatewayConfig()
store = SessionStore(sessions_dir=tmp_path, config=config)
sid = "test-session-db-only"
store._db.create_session(session_id=sid, source="test")
store.append_to_transcript(sid, {"role": "user", "content": "hello", "timestamp": 1.0})
store.append_to_transcript(sid, {"role": "assistant", "content": "world", "timestamp": 2.0})
history = store.load_transcript(sid)
assert len(history) == 2
assert history[0]["content"] == "hello"
assert history[1]["content"] == "world"
+12 -39
View File
@@ -8,7 +8,6 @@ import gateway.mirror as mirror_mod
from gateway.mirror import (
mirror_to_session,
_find_session_id,
_append_to_jsonl,
)
@@ -152,33 +151,6 @@ class TestFindSessionId:
assert result == "sess_1"
class TestAppendToJsonl:
def test_appends_message(self, tmp_path):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
with patch.object(mirror_mod, "_SESSIONS_DIR", sessions_dir):
_append_to_jsonl("sess_1", {"role": "assistant", "content": "Hello"})
transcript = sessions_dir / "sess_1.jsonl"
lines = transcript.read_text().strip().splitlines()
assert len(lines) == 1
msg = json.loads(lines[0])
assert msg["role"] == "assistant"
assert msg["content"] == "Hello"
def test_appends_multiple_messages(self, tmp_path):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
with patch.object(mirror_mod, "_SESSIONS_DIR", sessions_dir):
_append_to_jsonl("sess_1", {"role": "assistant", "content": "msg1"})
_append_to_jsonl("sess_1", {"role": "assistant", "content": "msg2"})
transcript = sessions_dir / "sess_1.jsonl"
lines = transcript.read_text().strip().splitlines()
assert len(lines) == 2
class TestMirrorToSession:
def test_successful_mirror(self, tmp_path):
@@ -192,15 +164,16 @@ class TestMirrorToSession:
with patch.object(mirror_mod, "_SESSIONS_DIR", sessions_dir), \
patch.object(mirror_mod, "_SESSIONS_INDEX", index_file), \
patch("gateway.mirror._append_to_sqlite"):
patch("gateway.mirror._append_to_sqlite") as mock_sqlite:
result = mirror_to_session("telegram", "12345", "Hello!", source_label="cli")
assert result is True
# Check JSONL was written
transcript = sessions_dir / "sess_abc.jsonl"
assert transcript.exists()
msg = json.loads(transcript.read_text().strip())
# Check SQLite writer was called with the mirror message
mock_sqlite.assert_called_once()
call_args = mock_sqlite.call_args
assert call_args[0][0] == "sess_abc"
msg = call_args[0][1]
assert msg["content"] == "Hello!"
assert msg["role"] == "assistant"
assert msg["mirror"] is True
@@ -222,12 +195,12 @@ class TestMirrorToSession:
with patch.object(mirror_mod, "_SESSIONS_DIR", sessions_dir), \
patch.object(mirror_mod, "_SESSIONS_INDEX", index_file), \
patch("gateway.mirror._append_to_sqlite"):
patch("gateway.mirror._append_to_sqlite") as mock_sqlite:
result = mirror_to_session("telegram", "-1001", "Hello topic!", source_label="cron", thread_id="10")
assert result is True
assert (sessions_dir / "sess_topic_a.jsonl").exists()
assert not (sessions_dir / "sess_topic_b.jsonl").exists()
mock_sqlite.assert_called_once()
assert mock_sqlite.call_args[0][0] == "sess_topic_a"
def test_successful_mirror_uses_user_id_for_group_session(self, tmp_path):
sessions_dir, index_file = _setup_sessions(tmp_path, {
@@ -245,7 +218,7 @@ class TestMirrorToSession:
with patch.object(mirror_mod, "_SESSIONS_DIR", sessions_dir), \
patch.object(mirror_mod, "_SESSIONS_INDEX", index_file), \
patch("gateway.mirror._append_to_sqlite"):
patch("gateway.mirror._append_to_sqlite") as mock_sqlite:
result = mirror_to_session(
"telegram",
"-1001",
@@ -255,8 +228,8 @@ class TestMirrorToSession:
)
assert result is True
assert (sessions_dir / "sess_alice.jsonl").exists()
assert not (sessions_dir / "sess_bob.jsonl").exists()
mock_sqlite.assert_called_once()
assert mock_sqlite.call_args[0][0] == "sess_alice"
def test_no_matching_session(self, tmp_path):
sessions_dir, index_file = _setup_sessions(tmp_path, {})
+9 -6
View File
@@ -1,6 +1,6 @@
"""Regression tests for /retry replacement semantics."""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -11,14 +11,17 @@ from gateway.session import SessionStore
@pytest.mark.asyncio
async def test_gateway_retry_replaces_last_user_turn_in_transcript(tmp_path):
async def test_gateway_retry_replaces_last_user_turn_in_transcript(tmp_path, monkeypatch):
# Pin DEFAULT_DB_PATH so SessionDB() doesn't write to the real ~/.hermes/state.db.
# (Module-level constant snapshot, see test_load_transcript_db_only.)
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
store = SessionStore(sessions_dir=tmp_path, config=config)
store._db = None
store._loaded = True
store = SessionStore(sessions_dir=tmp_path, config=config)
session_id = "retry_session"
store._db.create_session(session_id=session_id, source="test")
for msg in [
{"role": "session_meta", "tools": []},
{"role": "user", "content": "first question"},
+25 -169
View File
@@ -1,6 +1,4 @@
"""Tests for gateway session management."""
import builtins
import json
import pytest
from pathlib import Path
@@ -503,19 +501,19 @@ class TestSenderPrefixWithBackfill:
class TestSessionStoreRewriteTranscript:
"""Regression: /retry and /undo must persist truncated history to disk."""
"""Regression: /retry and /undo must persist truncated history to DB."""
@pytest.fixture()
def store(self, tmp_path):
def store(self, tmp_path, monkeypatch):
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
s = SessionStore(sessions_dir=tmp_path, config=config)
s._db = None # no SQLite for these tests
s._loaded = True
s = SessionStore(sessions_dir=tmp_path, config=config)
return s
def test_rewrite_replaces_jsonl(self, store, tmp_path):
def test_rewrite_replaces_transcript(self, store, tmp_path):
session_id = "test_session_1"
store._db.create_session(session_id=session_id, source="test")
# Write initial transcript
for msg in [
{"role": "user", "content": "hello"},
@@ -538,6 +536,7 @@ class TestSessionStoreRewriteTranscript:
def test_rewrite_with_empty_list(self, store):
session_id = "test_session_2"
store._db.create_session(session_id=session_id, source="test")
store.append_to_transcript(session_id, {"role": "user", "content": "hi"})
store.rewrite_transcript(session_id, [])
@@ -546,171 +545,28 @@ class TestSessionStoreRewriteTranscript:
assert reloaded == []
class TestLoadTranscriptCorruptLines:
"""Regression: corrupt JSONL lines (e.g. from mid-write crash) must be
skipped instead of crashing the entire transcript load. GH-1193."""
class TestLoadTranscriptDBOnly:
"""After spec 002, load_transcript reads only from state.db."""
@pytest.fixture()
def store(self, tmp_path):
def test_db_only_returns_empty_for_nonexistent(self, tmp_path, monkeypatch):
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
s = SessionStore(sessions_dir=tmp_path, config=config)
s._db = None
s._loaded = True
return s
def test_corrupt_line_skipped(self, store, tmp_path):
session_id = "corrupt_test"
transcript_path = store.get_transcript_path(session_id)
transcript_path.parent.mkdir(parents=True, exist_ok=True)
with open(transcript_path, "w") as f:
f.write('{"role": "user", "content": "hello"}\n')
f.write('{"role": "assistant", "content": "hi th') # truncated
f.write("\n")
f.write('{"role": "user", "content": "goodbye"}\n')
messages = store.load_transcript(session_id)
assert len(messages) == 2
assert messages[0]["content"] == "hello"
assert messages[1]["content"] == "goodbye"
def test_all_lines_corrupt_returns_empty(self, store, tmp_path):
session_id = "all_corrupt"
transcript_path = store.get_transcript_path(session_id)
transcript_path.parent.mkdir(parents=True, exist_ok=True)
with open(transcript_path, "w") as f:
f.write("not json at all\n")
f.write("{truncated\n")
messages = store.load_transcript(session_id)
assert messages == []
def test_valid_transcript_unaffected(self, store, tmp_path):
session_id = "valid_test"
store.append_to_transcript(session_id, {"role": "user", "content": "a"})
store.append_to_transcript(session_id, {"role": "assistant", "content": "b"})
messages = store.load_transcript(session_id)
assert len(messages) == 2
assert messages[0]["content"] == "a"
assert messages[1]["content"] == "b"
class TestLoadTranscriptPreferLongerSource:
"""Regression: load_transcript must return whichever source (SQLite or JSONL)
has more messages to prevent silent truncation. GH-3212."""
@pytest.fixture()
def store_with_db(self, tmp_path):
"""SessionStore with both SQLite and JSONL active."""
from hermes_state import SessionDB
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
s = SessionStore(sessions_dir=tmp_path, config=config)
s._db = SessionDB(db_path=tmp_path / "state.db")
s._loaded = True
return s
def test_jsonl_longer_than_sqlite_returns_jsonl(self, store_with_db):
"""Legacy session: JSONL has full history, SQLite has only recent turn."""
sid = "legacy_session"
store_with_db._db.create_session(session_id=sid, source="gateway", model="m")
# JSONL has 10 messages (legacy history — written before SQLite existed)
for i in range(10):
role = "user" if i % 2 == 0 else "assistant"
store_with_db.append_to_transcript(
sid, {"role": role, "content": f"msg-{i}"}, skip_db=True,
)
# SQLite has only 2 messages (recent turn after migration)
store_with_db._db.append_message(session_id=sid, role="user", content="new-q")
store_with_db._db.append_message(session_id=sid, role="assistant", content="new-a")
result = store_with_db.load_transcript(sid)
assert len(result) == 10
assert result[0]["content"] == "msg-0"
def test_sqlite_longer_than_jsonl_returns_sqlite(self, store_with_db):
"""Fully migrated session: SQLite has more (JSONL stopped growing)."""
sid = "migrated_session"
store_with_db._db.create_session(session_id=sid, source="gateway", model="m")
# JSONL has 2 old messages
store_with_db.append_to_transcript(
sid, {"role": "user", "content": "old-q"}, skip_db=True,
)
store_with_db.append_to_transcript(
sid, {"role": "assistant", "content": "old-a"}, skip_db=True,
)
# SQLite has 4 messages (superset after migration)
for i in range(4):
role = "user" if i % 2 == 0 else "assistant"
store_with_db._db.append_message(session_id=sid, role=role, content=f"db-{i}")
result = store_with_db.load_transcript(sid)
assert len(result) == 4
assert result[0]["content"] == "db-0"
def test_sqlite_empty_falls_back_to_jsonl(self, store_with_db):
"""No SQLite rows — falls back to JSONL (original behavior preserved)."""
sid = "no_db_rows"
store_with_db.append_to_transcript(
sid, {"role": "user", "content": "hello"}, skip_db=True,
)
store_with_db.append_to_transcript(
sid, {"role": "assistant", "content": "hi"}, skip_db=True,
)
result = store_with_db.load_transcript(sid)
assert len(result) == 2
assert result[0]["content"] == "hello"
def test_both_empty_returns_empty(self, store_with_db):
"""Neither source has data — returns empty list."""
result = store_with_db.load_transcript("nonexistent")
store = SessionStore(sessions_dir=tmp_path, config=config)
result = store.load_transcript("nonexistent")
assert result == []
def test_equal_length_prefers_sqlite(self, store_with_db):
"""When both have same count, SQLite wins (has richer fields like reasoning)."""
sid = "equal_session"
store_with_db._db.create_session(session_id=sid, source="gateway", model="m")
# Write 2 messages to JSONL only
store_with_db.append_to_transcript(
sid, {"role": "user", "content": "jsonl-q"}, skip_db=True,
)
store_with_db.append_to_transcript(
sid, {"role": "assistant", "content": "jsonl-a"}, skip_db=True,
)
# Write 2 different messages to SQLite only
store_with_db._db.append_message(session_id=sid, role="user", content="db-q")
store_with_db._db.append_message(session_id=sid, role="assistant", content="db-a")
def test_db_only_returns_messages(self, tmp_path, monkeypatch):
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
config = GatewayConfig()
store = SessionStore(sessions_dir=tmp_path, config=config)
sid = "db_only_session"
store._db.create_session(session_id=sid, source="gateway", model="m")
store._db.append_message(session_id=sid, role="user", content="db-q")
store._db.append_message(session_id=sid, role="assistant", content="db-a")
result = store_with_db.load_transcript(sid)
assert len(result) == 2
# Should be the SQLite version (equal count → prefers SQLite)
assert result[0]["content"] == "db-q"
def test_unreadable_jsonl_returns_sqlite(self, store_with_db, monkeypatch):
"""Unreadable legacy JSONL must not hide valid SQLite history."""
sid = "unreadable_jsonl"
store_with_db._db.create_session(session_id=sid, source="gateway", model="m")
store_with_db._db.append_message(session_id=sid, role="user", content="db-q")
store_with_db._db.append_message(session_id=sid, role="assistant", content="db-a")
transcript_path = store_with_db.get_transcript_path(sid)
transcript_path.parent.mkdir(parents=True, exist_ok=True)
transcript_path.write_text('{"role": "user", "content": "jsonl-q"}\n', encoding="utf-8")
real_open = builtins.open
def raise_for_transcript(path, *args, **kwargs):
mode = args[0] if args else kwargs.get("mode", "r")
if Path(path) == transcript_path and "r" in mode:
raise OSError("simulated unreadable transcript")
return real_open(path, *args, **kwargs)
monkeypatch.setattr(builtins, "open", raise_for_transcript)
result = store_with_db.load_transcript(sid)
result = store.load_transcript(sid)
assert len(result) == 2
assert result[0]["content"] == "db-q"
assert result[1]["content"] == "db-a"
@@ -22,13 +22,18 @@ from gateway.session import SessionSource, SessionStore, build_session_key
@pytest.fixture()
def store(tmp_path):
"""SessionStore with no SQLite, for fast unit tests."""
def store(tmp_path, monkeypatch):
"""SessionStore with SQLite — load_transcript reads from DB only.
Pin DEFAULT_DB_PATH to tmp_path so SessionDB() can't write to the real
~/.hermes/state.db. (DEFAULT_DB_PATH is a module-level constant computed
at hermes_state import time, before pytest's HERMES_HOME monkeypatch
fires — the autouse fixture's HERMES_HOME override doesn't help here.)
"""
import hermes_state
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db")
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
s = SessionStore(sessions_dir=tmp_path, config=config)
s._db = None
s._loaded = True
s = SessionStore(sessions_dir=tmp_path, config=config)
return s
+370 -5
View File
@@ -1,8 +1,11 @@
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from gateway.config import Platform, PlatformConfig, load_gateway_config
from gateway.platforms.base import MessageType
from gateway.session import SessionSource
def _make_adapter(
@@ -15,7 +18,9 @@ def _make_adapter(
allow_from=None,
group_allow_from=None,
allowed_chats=None,
group_allowed_chats=None,
guest_mode=None,
observe_unmentioned_group_messages=None,
bot_username="hermes_bot",
):
from gateway.platforms.telegram import TelegramAdapter
@@ -49,8 +54,14 @@ def _make_adapter(
# environment; production adapters without this explicit key still fall
# back to the env var.
extra["allowed_chats"] = []
if group_allowed_chats is not None:
extra["group_allowed_chats"] = group_allowed_chats
else:
extra["group_allowed_chats"] = []
if guest_mode is not None:
extra["guest_mode"] = guest_mode
if observe_unmentioned_group_messages is not None:
extra["observe_unmentioned_group_messages"] = observe_unmentioned_group_messages
adapter = object.__new__(TelegramAdapter)
adapter.platform = Platform.TELEGRAM
@@ -60,7 +71,12 @@ def _make_adapter(
adapter._pending_text_batches = {}
adapter._pending_text_batch_tasks = {}
adapter._text_batch_delay_seconds = 0.01
adapter._text_batch_split_delay_seconds = 0.01
adapter._mention_patterns = adapter._compile_mention_patterns()
adapter._forum_lock = asyncio.Lock()
adapter._forum_command_registered = set()
adapter._active_sessions = {}
adapter._pending_messages = {}
# Trigger-gating tests don't exercise the allowlist gate (added by
# #23795 + #24468). Force-authorize all senders so the trigger logic
# under test runs. Without this, every fake message hits the new
@@ -74,6 +90,7 @@ def _group_message(
*,
chat_id=-100,
from_user_id=111,
from_user_name="Alice Example",
thread_id=None,
reply_to_bot=False,
entities=None,
@@ -82,29 +99,34 @@ def _group_message(
):
reply_to_message = None
if reply_to_bot:
reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999))
reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999), message_id=10, text="previous bot reply", caption=None)
return SimpleNamespace(
message_id=42,
text=text,
caption=caption,
entities=entities or [],
caption_entities=caption_entities or [],
message_thread_id=thread_id,
chat=SimpleNamespace(id=chat_id, type="group"),
from_user=SimpleNamespace(id=from_user_id),
is_topic_message=thread_id is not None,
chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=thread_id is not None),
from_user=SimpleNamespace(id=from_user_id, full_name=from_user_name, first_name=from_user_name.split()[0]),
reply_to_message=reply_to_message,
date=None,
)
def _dm_message(text="hello", *, from_user_id=111):
return SimpleNamespace(
message_id=43,
text=text,
caption=None,
entities=[],
caption_entities=[],
message_thread_id=None,
chat=SimpleNamespace(id=from_user_id, type="private"),
from_user=SimpleNamespace(id=from_user_id),
chat=SimpleNamespace(id=from_user_id, type="private", full_name="Alice Example", title=None, is_forum=False),
from_user=SimpleNamespace(id=from_user_id, full_name="Alice Example", first_name="Alice"),
reply_to_message=None,
date=None,
)
@@ -134,6 +156,157 @@ def test_group_messages_can_be_opened_via_config():
assert adapter._should_process_message(_group_message("hello everyone")) is True
def test_unmentioned_group_messages_can_be_observed_without_dispatching():
async def _run():
adapter = _make_adapter(
require_mention=True,
allowed_chats=["-100"],
group_allowed_chats=["-100"],
observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
update = SimpleNamespace(
update_id=1001,
message=_group_message("side chatter"),
effective_message=None,
)
await adapter._handle_text_message(update, SimpleNamespace())
adapter._message_handler.assert_not_awaited()
assert len(store.messages) == 1
session_id, message, skip_db = store.messages[0]
assert session_id == "telegram-group-session"
assert skip_db is False
assert message["role"] == "user"
assert message["content"] == "[Alice Example|111]\nside chatter"
assert message["observed"] is True
assert message["message_id"] == "42"
assert store.sources[0].chat_id == "-100"
assert store.sources[0].chat_type == "group"
assert store.sources[0].user_id is None
assert store.sources[0].user_name is None
asyncio.run(_run())
def test_observed_group_context_uses_shared_source_and_prompt_for_later_mentions():
async def _run():
adapter = _make_adapter(
require_mention=True,
allowed_chats=["-100"],
group_allowed_chats=["-100"],
observe_unmentioned_group_messages=True,
)
adapter._session_store = _FakeSessionStore()
text = "@hermes_bot what did Alice say?"
msg = _group_message(
text,
from_user_id=222,
from_user_name="Bob Example",
entities=[_mention_entity(text)],
)
event = adapter._build_message_event(msg, MessageType.TEXT, update_id=1003)
event.text = adapter._clean_bot_trigger_text(event.text)
event.channel_prompt = "Existing topic prompt"
event = adapter._apply_telegram_group_observe_attribution(event)
assert event.source.chat_id == "-100"
assert event.source.chat_type == "group"
assert event.source.user_id is None
assert event.source.user_name is None
assert event.text == "[Bob Example|222]\nwhat did Alice say?"
assert "Existing topic prompt" in event.channel_prompt
assert "observed Telegram group context" in event.channel_prompt
assert "current new message" in event.channel_prompt
asyncio.run(_run())
def test_unmentioned_group_observe_requires_chat_allowlist_for_shared_context():
async def _run():
adapter = _make_adapter(
require_mention=True,
allowed_chats=["-100"],
observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
update = SimpleNamespace(
update_id=1004,
message=_group_message("side chatter"),
effective_message=None,
)
await adapter._handle_text_message(update, SimpleNamespace())
adapter._message_handler.assert_not_awaited()
assert store.messages == []
asyncio.run(_run())
def test_shared_group_observe_source_is_authorized_by_group_allowed_chats(monkeypatch):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="-100",
chat_type="group",
user_id=None,
user_name=None,
)
monkeypatch.setenv("TELEGRAM_GROUP_ALLOWED_CHATS", "-100")
monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False)
assert runner._is_user_authorized(source) is True
def test_unmentioned_group_observe_respects_chat_allowlist():
async def _run():
adapter = _make_adapter(
require_mention=True,
allowed_chats=["-200"],
group_allowed_chats=["-200"],
observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
update = SimpleNamespace(
update_id=1002,
message=_group_message("side chatter", chat_id=-201),
effective_message=None,
)
await adapter._handle_text_message(update, SimpleNamespace())
adapter._message_handler.assert_not_awaited()
assert store.messages == []
asyncio.run(_run())
class _FakeSessionEntry:
session_id = "telegram-group-session"
class _FakeSessionStore:
def __init__(self):
self.sources = []
self.messages = []
def get_or_create_session(self, source):
self.sources.append(source)
return _FakeSessionEntry()
def append_to_transcript(self, session_id, message, skip_db=False):
self.messages.append((session_id, message, skip_db))
def test_group_messages_can_require_direct_trigger_via_config():
adapter = _make_adapter(require_mention=True)
@@ -349,12 +522,15 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
" require_mention: true\n"
" guest_mode: true\n"
" exclusive_bot_mentions: true\n"
" observe_unmentioned_group_messages: true\n"
" mention_patterns:\n"
" - \"^\\\\s*chompy\\\\b\"\n"
" free_response_chats:\n"
" - \"-123\"\n"
" allowed_chats:\n"
" - \"-100\"\n"
" group_allowed_chats:\n"
" - \"-100\"\n"
" allowed_topics:\n"
" - 8\n",
encoding="utf-8",
@@ -365,8 +541,10 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
monkeypatch.delenv("TELEGRAM_MENTION_PATTERNS", raising=False)
monkeypatch.delenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS", raising=False)
monkeypatch.delenv("TELEGRAM_GUEST_MODE", raising=False)
monkeypatch.delenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES", raising=False)
monkeypatch.delenv("TELEGRAM_FREE_RESPONSE_CHATS", raising=False)
monkeypatch.delenv("TELEGRAM_ALLOWED_CHATS", raising=False)
monkeypatch.delenv("TELEGRAM_GROUP_ALLOWED_CHATS", raising=False)
monkeypatch.delenv("TELEGRAM_ALLOWED_TOPICS", raising=False)
config = load_gateway_config()
@@ -374,17 +552,21 @@ def test_config_bridges_telegram_group_settings(monkeypatch, tmp_path):
assert config is not None
assert __import__("os").environ["TELEGRAM_REQUIRE_MENTION"] == "true"
assert __import__("os").environ["TELEGRAM_GUEST_MODE"] == "true"
assert __import__("os").environ["TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] == "true"
assert __import__("os").environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] == "true"
assert json.loads(__import__("os").environ["TELEGRAM_MENTION_PATTERNS"]) == [r"^\s*chompy\b"]
assert __import__("os").environ["TELEGRAM_FREE_RESPONSE_CHATS"] == "-123"
assert __import__("os").environ["TELEGRAM_ALLOWED_CHATS"] == "-100"
assert __import__("os").environ["TELEGRAM_GROUP_ALLOWED_CHATS"] == "-100"
assert __import__("os").environ["TELEGRAM_ALLOWED_TOPICS"] == "8"
tg_cfg = config.platforms.get(Platform.TELEGRAM)
assert tg_cfg is not None
assert tg_cfg.extra.get("guest_mode") is True
assert tg_cfg.extra.get("allowed_chats") == ["-100"]
assert tg_cfg.extra.get("group_allowed_chats") == ["-100"]
assert tg_cfg.extra.get("allowed_topics") == [8]
assert tg_cfg.extra.get("exclusive_bot_mentions") is True
assert tg_cfg.extra.get("observe_unmentioned_group_messages") is True
def test_config_bridges_telegram_user_allowlists(monkeypatch, tmp_path):
@@ -518,3 +700,186 @@ def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path):
assert config is not None
assert __import__("os").environ["TELEGRAM_IGNORED_THREADS"] == "31,42"
# ---------------------------------------------------------------------------
# Helpers for location / media observe+attribution tests
# ---------------------------------------------------------------------------
def _group_location_message(
*,
chat_id=-100,
from_user_id=111,
from_user_name="Alice Example",
lat=37.7749,
lon=-122.4194,
):
return SimpleNamespace(
message_id=50,
text=None,
caption=None,
entities=[],
caption_entities=[],
message_thread_id=None,
is_topic_message=False,
chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=False),
from_user=SimpleNamespace(
id=from_user_id, full_name=from_user_name,
first_name=from_user_name.split()[0],
),
reply_to_message=None,
date=None,
location=SimpleNamespace(latitude=lat, longitude=lon),
venue=None,
sticker=None,
photo=None,
video=None,
audio=None,
voice=None,
document=None,
)
def _group_voice_message(
*,
chat_id=-100,
from_user_id=111,
from_user_name="Alice Example",
caption=None,
):
return SimpleNamespace(
message_id=51,
text=None,
caption=caption,
entities=[],
caption_entities=[],
message_thread_id=None,
is_topic_message=False,
chat=SimpleNamespace(id=chat_id, type="group", title="Test Group", is_forum=False),
from_user=SimpleNamespace(
id=from_user_id, full_name=from_user_name,
first_name=from_user_name.split()[0],
),
reply_to_message=None,
date=None,
location=None,
venue=None,
sticker=None,
photo=None,
video=None,
audio=None,
voice=SimpleNamespace(
get_file=AsyncMock(side_effect=Exception("simulated download failure"))
),
document=None,
)
# ---------------------------------------------------------------------------
# Observe + attribution parity: location messages
# ---------------------------------------------------------------------------
def test_unmentioned_location_message_observed_in_group():
async def _run():
adapter = _make_adapter(
require_mention=True,
allowed_chats=["-100"],
group_allowed_chats=["-100"],
observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
update = SimpleNamespace(
update_id=2001,
message=_group_location_message(),
effective_message=None,
)
await adapter._handle_location_message(update, SimpleNamespace())
adapter._message_handler.assert_not_awaited()
assert len(store.messages) == 1
_, message, _ = store.messages[0]
assert message["observed"] is True
assert store.sources[0].user_id is None
asyncio.run(_run())
def test_triggered_location_message_uses_shared_session_in_observe_mode():
async def _run():
adapter = _make_adapter(
require_mention=False,
group_allowed_chats=["-100"],
observe_unmentioned_group_messages=True,
)
adapter.handle_message = AsyncMock()
update = SimpleNamespace(
update_id=2002,
message=_group_location_message(),
effective_message=None,
)
await adapter._handle_location_message(update, SimpleNamespace())
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.call_args[0][0]
assert event.source.user_id is None
assert "[Alice Example|111]" in event.text
asyncio.run(_run())
# ---------------------------------------------------------------------------
# Observe + attribution parity: media messages (voice as representative)
# ---------------------------------------------------------------------------
def test_unmentioned_voice_message_observed_in_group():
async def _run():
adapter = _make_adapter(
require_mention=True,
allowed_chats=["-100"],
group_allowed_chats=["-100"],
observe_unmentioned_group_messages=True,
)
store = _FakeSessionStore()
adapter._session_store = store
update = SimpleNamespace(
update_id=3001,
message=_group_voice_message(),
effective_message=None,
)
await adapter._handle_media_message(update, SimpleNamespace())
adapter._message_handler.assert_not_awaited()
assert len(store.messages) == 1
_, message, _ = store.messages[0]
assert message["observed"] is True
assert store.sources[0].user_id is None
asyncio.run(_run())
def test_triggered_voice_message_uses_shared_session_in_observe_mode():
async def _run():
adapter = _make_adapter(
require_mention=False,
group_allowed_chats=["-100"],
observe_unmentioned_group_messages=True,
)
adapter.handle_message = AsyncMock()
update = SimpleNamespace(
update_id=3002,
message=_group_voice_message(caption="check this audio"),
effective_message=None,
)
await adapter._handle_media_message(update, SimpleNamespace())
adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.call_args[0][0]
assert event.source.user_id is None
assert "[Alice Example|111]" in event.text
asyncio.run(_run())