From 2b606d20e28d40b64ef54e380f6d1b9fb7f4e7fd Mon Sep 17 00:00:00 2001 From: yoniebans Date: Tue, 12 May 2026 13:41:43 +0200 Subject: [PATCH] feat(hermes_state): add get_messages_around for anchored windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SessionDB.get_messages_around(session_id, around_message_id, window) which returns up to 'window' messages before the anchor, the anchor itself, and up to 'window' after — all from the same session, ordered by id ascending. Used by the upcoming session_search mode='guided' (anchored drill-down) to surface a focused conversation window without summarisation cost or the 100k-char truncation gamble of mode='summary'. Boundaries are honoured (fewer messages at session start/end), the anchor is verified to exist in the named session before fetching (cheap guard against cross-session id confusion), and content/tool_calls decoding mirrors get_messages() so callers can swap between the two without surprises. Tested: 9 new cases in tests/hermes_state/test_get_messages_around.py (middle-of-session, first-message, last-message, anchor-not-in-session, no cross-session leakage, window > session, window=0, window negative, content decoding parity with get_messages). 62/62 passing including the existing 53 session_search tests. --- hermes_state.py | 68 +++++++++ .../hermes_state/test_get_messages_around.py | 137 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 tests/hermes_state/test_get_messages_around.py diff --git a/hermes_state.py b/hermes_state.py index 7fdf875c30..cac3aeeb57 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1618,6 +1618,74 @@ class SessionDB: result.append(msg) return result + def get_messages_around( + self, + session_id: str, + around_message_id: int, + window: int = 5, + ) -> List[Dict[str, Any]]: + """Load a window of messages anchored on a specific message id. + + Returns up to ``window`` messages before the anchor, the anchor itself, + and up to ``window`` messages after — all from the same session, + ordered by id ascending. Boundaries are honoured: if the anchor is + near the start or end of the session, fewer messages are returned on + the truncated side. + + If ``around_message_id`` is not a message id within ``session_id``, + returns an empty list. Callers decide whether to surface that as an + error. + + Used by ``session_search`` mode='guided' to provide anchored + drill-down into a specific session at a specific message — without + the cost of summarisation or the risk of 100k-char truncation. + """ + if window < 0: + window = 0 + with self._lock: + # Confirm the anchor exists in this session — cheap guard against + # cross-session contamination if a caller mixes up session/message + # ids. + anchor_exists = self._conn.execute( + "SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1", + (around_message_id, session_id), + ).fetchone() + if not anchor_exists: + return [] + + # Two queries: anchor + before (DESC, take window+1), and after + # (ASC, take window). Final order is id ASC. + before_rows = self._conn.execute( + "SELECT * FROM messages " + "WHERE session_id = ? AND id <= ? " + "ORDER BY id DESC LIMIT ?", + (session_id, around_message_id, window + 1), + ).fetchall() + after_rows = self._conn.execute( + "SELECT * FROM messages " + "WHERE session_id = ? AND id > ? " + "ORDER BY id ASC LIMIT ?", + (session_id, around_message_id, window), + ).fetchall() + + # before_rows is DESC; reverse so it's ASC, then concatenate after_rows. + rows = list(reversed(before_rows)) + list(after_rows) + result = [] + for row in rows: + msg = dict(row) + if "content" in msg: + msg["content"] = self._decode_content(msg["content"]) + if msg.get("tool_calls"): + try: + msg["tool_calls"] = json.loads(msg["tool_calls"]) + except (json.JSONDecodeError, TypeError): + logger.warning( + "Failed to deserialize tool_calls in get_messages_around, falling back to []" + ) + msg["tool_calls"] = [] + result.append(msg) + return result + def resolve_resume_session_id(self, session_id: str) -> str: """Redirect a resume target to the descendant session that holds the messages. diff --git a/tests/hermes_state/test_get_messages_around.py b/tests/hermes_state/test_get_messages_around.py new file mode 100644 index 0000000000..01e92bede0 --- /dev/null +++ b/tests/hermes_state/test_get_messages_around.py @@ -0,0 +1,137 @@ +"""Unit tests for SessionDB.get_messages_around() — anchored message windows. + +The method is used by ``session_search`` mode='guided' for anchored drill-down. +It must: + - Return an ordered window: up to ``window`` messages before the anchor, + the anchor itself, then up to ``window`` after, all id-ascending. + - Honour session boundaries (fewer messages returned at start / end). + - Honour session isolation (same id range, different session = nothing). + - Return an empty list when the anchor is not in the named session. +""" +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + return SessionDB(tmp_path / "state.db") + + +def _seed_session(db: SessionDB, session_id: str, n_messages: int): + """Append n_messages alternating user/assistant messages to a session. + + Returns the list of message ids created (in append order). + """ + db.create_session(session_id, source="cli") + ids = [] + for i in range(n_messages): + role = "user" if i % 2 == 0 else "assistant" + msg_id = db.append_message(session_id, role=role, content=f"msg {i}") + ids.append(msg_id) + return ids + + +def test_returns_window_around_anchor_in_middle(db): + ids = _seed_session(db, "s1", 11) + anchor = ids[5] # middle of 11 + + result = db.get_messages_around("s1", anchor, window=3) + + # Expect 3 before + anchor + 3 after = 7 messages + assert len(result) == 7 + # All from the right session + assert all(m["session_id"] == "s1" for m in result) + # Order is id ASC and contiguous + result_ids = [m["id"] for m in result] + assert result_ids == ids[2:9] + + +def test_anchor_at_first_message_returns_only_after_slice(db): + ids = _seed_session(db, "s1", 8) + anchor = ids[0] # first + + result = db.get_messages_around("s1", anchor, window=3) + + # Anchor + 3 after = 4 messages, no "before" + assert len(result) == 4 + assert [m["id"] for m in result] == ids[0:4] + + +def test_anchor_at_last_message_returns_only_before_slice(db): + ids = _seed_session(db, "s1", 8) + anchor = ids[-1] # last + + result = db.get_messages_around("s1", anchor, window=3) + + # 3 before + anchor = 4 messages, no "after" + assert len(result) == 4 + assert [m["id"] for m in result] == ids[-4:] + + +def test_anchor_not_in_session_returns_empty_list(db): + ids = _seed_session(db, "s1", 5) + _seed_session(db, "s2", 5) + + # Use s1 as session but pass an id that exists, just in s2 + result = db.get_messages_around("s2", ids[2], window=3) + + assert result == [] + + +def test_does_not_leak_across_sessions(db): + # Two sessions with adjacent message id ranges + s1_ids = _seed_session(db, "s1", 5) + s2_ids = _seed_session(db, "s2", 5) + + # Anchor on s1's last message — even though s2 ids are "after", they must + # not appear in the window + result = db.get_messages_around("s1", s1_ids[-1], window=3) + + assert all(m["session_id"] == "s1" for m in result) + # All result ids belong to s1, not s2 + assert set(m["id"] for m in result).issubset(set(s1_ids)) + assert set(m["id"] for m in result).isdisjoint(set(s2_ids)) + + +def test_window_larger_than_session_returns_full_session(db): + ids = _seed_session(db, "s1", 4) + anchor = ids[1] + + result = db.get_messages_around("s1", anchor, window=100) + + # Whole session returned, ordered ASC + assert [m["id"] for m in result] == ids + + +def test_window_zero_returns_only_anchor(db): + ids = _seed_session(db, "s1", 5) + anchor = ids[2] + + result = db.get_messages_around("s1", anchor, window=0) + + assert len(result) == 1 + assert result[0]["id"] == anchor + + +def test_negative_window_treated_as_zero(db): + ids = _seed_session(db, "s1", 5) + anchor = ids[2] + + result = db.get_messages_around("s1", anchor, window=-3) + + assert len(result) == 1 + assert result[0]["id"] == anchor + + +def test_decodes_content_like_get_messages(db): + """Content roundtrip should match get_messages's behaviour (no surprises + for callers who switch between the two methods).""" + ids = _seed_session(db, "s1", 3) + anchor = ids[1] + + around = db.get_messages_around("s1", anchor, window=1) + full = db.get_messages("s1") + + # Same rows, same content shape + assert [m["content"] for m in around] == [m["content"] for m in full]