Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions tests/tui_gateway/test_message_react_rpc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""message.react RPC: session-key fallback for rehydrated / rotated sessions.

Regression coverage for #80670 — reacting in a resumed conversation failed
with 4040 because the registry entry's ``session_key`` was missing (desktop
rows predating the column) or stale (compression rotation), while the durable
rows live under the routed session id / lineage tip.
"""

import contextlib
import threading
from unittest.mock import MagicMock, patch

import pytest

_HEART = "\u2764\ufe0f"
_THUMBS = "\U0001f44d"
_LAUGH = "\U0001f602"


@pytest.fixture()
def server():
# Mirror tests/tui_gateway/test_protocol.py: mock the heavy modules for
# the *initial* import only, then snapshot/restore the RPC registry.
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
"hermes_state": MagicMock(),
}):
import importlib
mod = importlib.import_module("tui_gateway.server")

methods = dict(mod._methods)
yield mod
mod._methods.clear()
mod._methods.update(methods)
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._live_transports.clear()


class _ReactionDB:
"""Minimal SessionDB stand-in that records every write it receives."""

def __init__(self, results=None, tips=None, latest=None):
self.results = results or {} # session key -> reactions list (or None)
self.tips = tips or {} # session key -> lineage tip
self.latest = latest or {} # role -> row_id
self.calls = []

def set_message_reaction(self, session_id, row_id, emoji, author="user"):
self.calls.append((session_id, row_id, emoji, author))
return self.results.get(session_id)

def latest_message_row_id(self, session_id, *, role="user", offset=0, require_text=True):
return self.latest.get(role)

def resolve_resume_session_id(self, key):
return self.tips.get(key, key)


def _register(server, sid, session_key):
server._sessions[sid] = {"session_key": session_key, "history_lock": threading.Lock()}


def _call(server, **params):
return server._methods["message.react"]("rid-1", params)


def test_react_falls_back_to_routed_sid_when_session_key_missing(server, monkeypatch):
"""Resumed legacy desktop session: session_key=None, rows under the sid."""
db = _ReactionDB(results={"sess-abc": [{"emoji": _HEART, "author": "user"}]})
monkeypatch.setattr(server, "_session_db", lambda session: contextlib.nullcontext(db))
_register(server, "sess-abc", None)

resp = _call(server, session_id="sess-abc", row_id=7, emoji=_HEART)

assert db.calls == [("sess-abc", 7, _HEART, "user")]
assert resp["result"]["row_id"] == 7


def test_react_retries_lineage_tip_after_rotated_key_miss(server, monkeypatch):
"""Compaction rotated the live key: write misses under the parent, lands
under the continuation tip. Row ids are globally unique, so the retry
targets the exact row the client addressed."""
db = _ReactionDB(
results={"child-2": [{"emoji": _THUMBS, "author": "user"}]},
tips={"parent-1": "child-2"},
)
monkeypatch.setattr(server, "_session_db", lambda session: contextlib.nullcontext(db))
_register(server, "parent-1", "parent-1")

resp = _call(server, session_id="parent-1", row_id=7, emoji=_THUMBS)

assert db.calls == [("parent-1", 7, _THUMBS, "user"), ("child-2", 7, _THUMBS, "user")]
assert resp["result"]["row_id"] == 7


def test_react_newest_role_uses_fallback_key(server, monkeypatch):
"""The role-based (no row_id) path resolves through the fallback key too."""
db = _ReactionDB(results={"sess-abc": [{"emoji": _LAUGH, "author": "user"}]}, latest={"user": 42})
monkeypatch.setattr(server, "_session_db", lambda session: contextlib.nullcontext(db))
_register(server, "sess-abc", None)

resp = _call(server, session_id="sess-abc", newest_role="user", emoji=_LAUGH)

assert db.calls == [("sess-abc", 42, _LAUGH, "user")]
assert resp["result"]["row_id"] == 42


def test_react_still_4040_when_row_exists_nowhere(server, monkeypatch, caplog):
"""A genuinely stale row id still fails loudly (with a diagnostic log)."""
db = _ReactionDB(results={}, tips={"parent-1": "child-2"})
monkeypatch.setattr(server, "_session_db", lambda session: contextlib.nullcontext(db))
_register(server, "parent-1", "parent-1")

resp = _call(server, session_id="parent-1", row_id=999, emoji=_HEART)

assert resp["error"]["code"] == 4040
assert db.calls == [("parent-1", 999, _HEART, "user"), ("child-2", 999, _HEART, "user")]
assert "message.react: no row" in caplog.text
39 changes: 35 additions & 4 deletions tui_gateway/methods_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1053,19 +1053,50 @@ def _(rid, params: dict) -> dict:
if db is None:
return _db_unavailable_error(rid, code=5007)
try:
# Rehydrated sessions — desktop rows predating the session_key
# column, or a registry entry bound to a rotated compression
# parent — can surface with a missing or stale key while the
# durable rows live under the routed session id / lineage tip.
# Fall back exactly like the turn payload does (`or sid`).
write_key = str(
session.get("session_key") or params.get("session_id") or ""
)
if row_id is None:
row_id = db.latest_message_row_id(
session["session_key"], role=newest_role
)
row_id = db.latest_message_row_id(write_key, role=newest_role)
if row_id is None:
return _err(rid, 4040, "no message to react to yet")
reactions = db.set_message_reaction(
session["session_key"], int(row_id), emoji, author=author
write_key, int(row_id), emoji, author=author
)
if reactions is None:
# Key-rotation retry. Row ids are globally unique, so a retry
# can only land on the exact row the client addressed — never
# on a different message. Best-effort: no cost on the happy
# path beyond the miss we already paid for.
alt_key = ""
try:
tip = db.resolve_resume_session_id(write_key)
if tip and str(tip) != write_key:
alt_key = str(tip)
except Exception:
pass
if not alt_key:
alt_key = str(params.get("session_id") or "")
if alt_key and alt_key != write_key:
reactions = db.set_message_reaction(
alt_key, int(row_id), emoji, author=author
)
except Exception as e:
return _err(rid, 5007, str(e))

if reactions is None:
logger.warning(
"message.react: no row for session=%r row_id=%r key=%r — stale "
"client row id or session not rehydrated",
params.get("session_id"),
row_id,
write_key,
)
return _err(rid, 4040, "message not found in this session")

return _ok(rid, {"row_id": int(row_id), "reactions": reactions})
Expand Down