mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-25 11:02:03 +00:00
Session-scoped /model and /reasoning overrides were silently lost on Telegram DM/forum topics and after compression session splits (#30479). Root cause: _handle_message_with_agent rewrites source.thread_id via _recover_telegram_topic_thread_id (lobby/stripped reply -> the user's bound topic) before deriving the session key. The /model and /reasoning handlers derived their override key from the raw inbound event.source, skipping that recovery, so the override was stored under one key and the next message turn read a different key. Fix: add _normalize_source_for_session_key (applies the same recovery a message turn does) and use it in both handlers before deriving the key. session_id rotation on compression was never the cause — overrides are keyed by the durable session_key; the split path preserves it. Author: teknium1 <127238744+teknium1@users.noreply.github.com>
110 lines
4.2 KiB
Python
110 lines
4.2 KiB
Python
"""Regression tests for #30479 — session-scoped /model and /reasoning overrides
|
|
silently lost on Telegram forum/DM topics and after compression session splits.
|
|
|
|
Root cause: ``_handle_message_with_agent`` rewrites ``source.thread_id`` via
|
|
``_recover_telegram_topic_thread_id`` (lobby/stripped reply -> the user's
|
|
last-active bound topic) *before* deriving the session key for a message turn.
|
|
The ``/model`` and ``/reasoning`` command handlers derived their override key
|
|
from the raw inbound ``event.source``, skipping that recovery — so the override
|
|
was stored under one key and the next message turn read a different key, and the
|
|
override was dropped.
|
|
|
|
Fix: both command handlers normalize the source via
|
|
``_normalize_source_for_session_key`` before deriving the override key, so
|
|
storage and read keys are identical.
|
|
"""
|
|
|
|
import threading
|
|
from unittest.mock import MagicMock
|
|
|
|
import gateway.run as gateway_run
|
|
from gateway.config import Platform
|
|
from gateway.session import SessionSource, build_session_key
|
|
|
|
|
|
def _make_runner(recovered_thread_id=None):
|
|
runner = object.__new__(gateway_run.GatewayRunner)
|
|
runner.config = None
|
|
runner.session_store = None
|
|
runner._session_db = None
|
|
runner._session_model_overrides = {}
|
|
runner._session_reasoning_overrides = {}
|
|
runner._agent_cache = {}
|
|
runner._agent_cache_lock = threading.Lock()
|
|
# Stub topic recovery: returns the bound topic id for a lobby message,
|
|
# None otherwise (the real method's contract).
|
|
runner._recover_telegram_topic_thread_id = MagicMock(return_value=recovered_thread_id)
|
|
return runner
|
|
|
|
|
|
def _topic_dm_source(thread_id):
|
|
"""A Telegram DM in topic mode. thread_id="" / "1" == General/lobby."""
|
|
return SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="555",
|
|
chat_name="Forum DM",
|
|
chat_type="dm",
|
|
user_id="user-1",
|
|
thread_id=thread_id,
|
|
)
|
|
|
|
|
|
def test_normalize_rewrites_lobby_thread_to_bound_topic():
|
|
"""A lobby (stripped) reply gets pinned to the user's bound topic id."""
|
|
runner = _make_runner(recovered_thread_id="42")
|
|
src = _topic_dm_source(thread_id="") # lobby/General — no message_thread_id
|
|
|
|
normalized = runner._normalize_source_for_session_key(src)
|
|
|
|
assert normalized.thread_id == "42"
|
|
# Original source is left untouched (we return a copy).
|
|
assert src.thread_id == ""
|
|
|
|
|
|
def test_normalize_passthrough_when_no_recovery():
|
|
"""No recovery -> source returned unchanged (identity)."""
|
|
runner = _make_runner(recovered_thread_id=None)
|
|
src = _topic_dm_source(thread_id="42")
|
|
|
|
normalized = runner._normalize_source_for_session_key(src)
|
|
|
|
assert normalized is src
|
|
|
|
|
|
def test_normalize_swallows_recovery_exceptions():
|
|
"""Recovery raising must not break the command — return the raw source."""
|
|
runner = _make_runner()
|
|
runner._recover_telegram_topic_thread_id = MagicMock(side_effect=RuntimeError("boom"))
|
|
src = _topic_dm_source(thread_id="")
|
|
|
|
normalized = runner._normalize_source_for_session_key(src)
|
|
|
|
assert normalized is src
|
|
|
|
|
|
def test_override_key_matches_message_turn_key_after_recovery():
|
|
"""The bug, end to end at the key level.
|
|
|
|
/model arrives as a lobby reply (thread_id=""). The next message turn
|
|
runs recovery and lands on the bound topic ("42"). After the fix, the
|
|
key the command stores under must equal the key the message turn reads.
|
|
"""
|
|
runner = _make_runner(recovered_thread_id="42")
|
|
|
|
# --- /model command path (raw inbound is a lobby reply) ---
|
|
command_source = _topic_dm_source(thread_id="")
|
|
normalized_command_source = runner._normalize_source_for_session_key(command_source)
|
|
# _session_key_for_source falls back to build_session_key when there is no
|
|
# session_store; emulate that resolution here directly.
|
|
command_key = build_session_key(normalized_command_source)
|
|
|
|
# --- next message turn path (recovery already applied to source) ---
|
|
message_turn_source = _topic_dm_source(thread_id="42")
|
|
message_turn_key = build_session_key(message_turn_source)
|
|
|
|
assert command_key == message_turn_key
|
|
|
|
# And the orphaning the bug caused: storing under the RAW (pre-recovery)
|
|
# key would NOT be found by the message turn.
|
|
raw_key = build_session_key(command_source)
|
|
assert raw_key != message_turn_key
|