265 lines
9.1 KiB
Python
265 lines
9.1 KiB
Python
import asyncio
|
|
|
|
from hermes_cli import web_server
|
|
import hermes_cli.web_routers.sessions as _rt_sessions
|
|
|
|
|
|
class _FakeSessionDB:
|
|
"""Fake backing the /api/sessions/search endpoint.
|
|
|
|
The endpoint surfaces direct session-id matches first, then FTS message
|
|
matches, deduping both by compression lineage root. This fake has no
|
|
compression chains (get_session returns no parent), so each session is its
|
|
own lineage root.
|
|
"""
|
|
|
|
closed = False
|
|
opened_read_only = None
|
|
requested_fields = None
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
type(self).opened_read_only = kwargs.get("read_only")
|
|
|
|
@staticmethod
|
|
def _source_allowed(row, source=None, sources=None, exclude_sources=None):
|
|
row_source = row.get("source")
|
|
if source and row_source != source:
|
|
return False
|
|
if sources and row_source not in sources:
|
|
return False
|
|
if exclude_sources and row_source in exclude_sources:
|
|
return False
|
|
return True
|
|
|
|
def search_sessions_by_id(
|
|
self,
|
|
query,
|
|
limit=20,
|
|
include_archived=True,
|
|
source=None,
|
|
sources=None,
|
|
exclude_sources=None,
|
|
):
|
|
assert query == "20260603"
|
|
assert include_archived is True
|
|
rows = [
|
|
{
|
|
"id": "20260603_090200_exact",
|
|
"preview": "ID match preview",
|
|
"source": "cli",
|
|
"model": "claude",
|
|
"started_at": 100,
|
|
"last_active": 150,
|
|
}
|
|
]
|
|
return [
|
|
row
|
|
for row in rows
|
|
if self._source_allowed(
|
|
row, source=source, sources=sources, exclude_sources=exclude_sources
|
|
)
|
|
][:limit]
|
|
|
|
def search_messages(
|
|
self,
|
|
query,
|
|
source_filter=None,
|
|
exclude_sources=None,
|
|
limit=20,
|
|
fields=None,
|
|
):
|
|
assert query == "20260603*"
|
|
type(self).requested_fields = fields
|
|
rows = [
|
|
{
|
|
"session_id": "20260603_090200_exact",
|
|
"snippet": "duplicate content hit should not replace ID hit",
|
|
"role": "user",
|
|
"source": "cli",
|
|
"model": "claude",
|
|
"session_started": 100,
|
|
},
|
|
{
|
|
"session_id": "content_session",
|
|
"snippet": "content hit",
|
|
"role": "assistant",
|
|
"source": "desktop",
|
|
"model": "gpt",
|
|
"session_started": 200,
|
|
},
|
|
]
|
|
return [
|
|
row
|
|
for row in rows
|
|
if self._source_allowed(
|
|
row, sources=source_filter, exclude_sources=exclude_sources
|
|
)
|
|
][:limit]
|
|
|
|
def get_session(self, session_id):
|
|
# No compression chains in this fixture — every session is its own root.
|
|
return {"id": session_id, "parent_session_id": None}
|
|
|
|
def get_compression_tip(self, session_id):
|
|
return session_id
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def test_desktop_session_search_merges_id_matches_before_content_matches(monkeypatch):
|
|
_FakeSessionDB.opened_read_only = None
|
|
_FakeSessionDB.requested_fields = None
|
|
monkeypatch.setattr("hermes_state.SessionDB", _FakeSessionDB)
|
|
|
|
response = asyncio.run(_rt_sessions.search_sessions(q="20260603", limit=2))
|
|
|
|
assert _FakeSessionDB.requested_fields is not None
|
|
assert "context" not in _FakeSessionDB.requested_fields
|
|
# ID match surfaces first; the content hit on the SAME session is deduped
|
|
# by lineage root (not double-listed); the unrelated content hit follows.
|
|
assert response == {
|
|
"results": [
|
|
{
|
|
"id": "20260603_090200_exact",
|
|
"profile": "default",
|
|
"is_default_profile": True,
|
|
"session_id": "20260603_090200_exact",
|
|
"lineage_root": "20260603_090200_exact",
|
|
"snippet": "ID match preview",
|
|
"role": None,
|
|
"source": "cli",
|
|
"model": "claude",
|
|
"session_started": 100,
|
|
# Row recency rides on id-match rows (sessions table)...
|
|
"last_active": 150,
|
|
},
|
|
{
|
|
"id": "content_session",
|
|
"profile": "default",
|
|
"is_default_profile": True,
|
|
"session_id": "content_session",
|
|
"lineage_root": "content_session",
|
|
"snippet": "content hit",
|
|
"role": "assistant",
|
|
"source": "desktop",
|
|
"model": "gpt",
|
|
"session_started": 200,
|
|
# ...while FTS hits have none and leave it null.
|
|
"last_active": None,
|
|
},
|
|
]
|
|
}
|
|
assert _FakeSessionDB.opened_read_only is True
|
|
|
|
|
|
def test_desktop_session_search_stamps_the_requested_profile(monkeypatch):
|
|
monkeypatch.setattr(
|
|
_rt_sessions, "_cron_profile_home", lambda profile: (profile, None)
|
|
)
|
|
monkeypatch.setattr(
|
|
_rt_sessions,
|
|
"_open_session_db_for_profile",
|
|
lambda profile, *, read_only: _FakeSessionDB(read_only=read_only),
|
|
)
|
|
|
|
response = asyncio.run(
|
|
_rt_sessions.search_sessions(q="20260603", limit=2, profile="worker")
|
|
)
|
|
|
|
assert {
|
|
(row["profile"], row["is_default_profile"])
|
|
for row in response["results"]
|
|
} == {("worker", False)}
|
|
|
|
|
|
def test_desktop_session_search_attaches_profile_to_rich_results(monkeypatch):
|
|
class _RichFakeSessionDB(_FakeSessionDB):
|
|
def get_session_rich_row(self, session_id):
|
|
return {
|
|
"id": session_id,
|
|
"source": "cli",
|
|
"model": "claude",
|
|
"title": "Custom Title",
|
|
"started_at": 100,
|
|
"ended_at": None,
|
|
"last_active": 100,
|
|
"message_count": 2,
|
|
"tool_call_count": 0,
|
|
"input_tokens": 10,
|
|
"output_tokens": 20,
|
|
"preview": "Test preview",
|
|
"parent_session_id": None,
|
|
"archived": False,
|
|
}
|
|
|
|
monkeypatch.setattr("hermes_state.SessionDB", _RichFakeSessionDB)
|
|
monkeypatch.setattr("hermes_cli.profiles.profile_exists", lambda name: True)
|
|
response = asyncio.run(_rt_sessions.search_sessions(q="20260603", limit=1, profile="personal"))
|
|
assert response["results"][0]["profile"] == "personal"
|
|
assert response["results"][0]["title"] == "Custom Title"
|
|
|
|
|
|
class _DeepLineageSessionDB(_FakeSessionDB):
|
|
"""A compression lineage deeper than the chain walk's defensive bound.
|
|
|
|
Sessions s000..s178 form one in-place-compression lineage (each session's
|
|
parent ended with end_reason='compression'). The forward chain walk is
|
|
bounded; the bug remapped the SEARCH hit via tip(root), which truncates to
|
|
a stale mid id, while the CLI resumes from the matched id and reaches the
|
|
live tip (#125041).
|
|
"""
|
|
|
|
DEPTH = 1101
|
|
TIP = "s1100"
|
|
MID = "s100"
|
|
|
|
def search_sessions_by_id(self, query, limit=20, include_archived=True,
|
|
source=None, sources=None, exclude_sources=None):
|
|
return [] # pragma: no cover - content-hit path only in this test
|
|
|
|
def search_messages(self, query, source_filter=None, exclude_sources=None,
|
|
limit=20, fields=None):
|
|
return [{
|
|
"session_id": self.MID,
|
|
"snippet": "content hit inside a deep lineage",
|
|
"role": "assistant",
|
|
"source": "desktop",
|
|
"model": "gpt",
|
|
"session_started": 200,
|
|
}][:limit]
|
|
|
|
def get_session(self, session_id):
|
|
parent = None
|
|
if session_id != "s000":
|
|
parent = f"s{int(session_id[1:]) - 1:03d}"
|
|
n = int(session_id[1:])
|
|
return {"id": session_id, "parent_session_id": parent,
|
|
"end_reason": "compression",
|
|
"ended_at": n + 1, "started_at": n}
|
|
|
|
def get_compression_tip(self, session_id):
|
|
# Faithful simulation of the REAL bounded forward walk in
|
|
# SessionDB.get_compression_chain: at most _CHAIN_CAP hops from the
|
|
# START id, so a lineage deeper than the cap truncates when the walk
|
|
# starts far from the tip. Resolving from the MATCHED id keeps the
|
|
# remaining distance under the cap; the raised bound lets even a
|
|
# root-started walk terminate at the live tip (#125041).
|
|
from hermes_state_compression import _CHAIN_CAP
|
|
|
|
idx = int(session_id[1:])
|
|
return f"s{min(idx + _CHAIN_CAP, self.DEPTH - 1):03d}"
|
|
|
|
|
|
def test_deep_lineage_search_resolves_tip_from_matched_id(monkeypatch):
|
|
monkeypatch.setattr("hermes_state.SessionDB", _DeepLineageSessionDB)
|
|
|
|
response = asyncio.run(_rt_sessions.search_sessions(q="content", limit=2))
|
|
|
|
[row] = response["results"]
|
|
# The resume id MUST be the live tip — not a stale mid the bounded
|
|
# root-started walk truncated to.
|
|
assert row["session_id"] == _DeepLineageSessionDB.TIP
|
|
# Dedupe/lineage bookkeeping stays keyed by the root.
|
|
assert row["lineage_root"] == "s000"
|