Files
hermes-webui/tests/test_sessions_search_depth_validation.py
T
4d9965b37a Release: content search no longer evicts the working-set cache (#6084, @ai-ag2026) (#6343)
* fix(webui): keep the content search from evicting the user's working set

/api/sessions/search?content=1 walks EVERY session and pulls each one through
get_session(), which inserts it into the SESSIONS LRU and marks it
recently-used. On any install with more sessions than sessions_cache_max
(default 300), a single search therefore flushes the whole cache and refills it
with sessions the user is not looking at — the classic buffer-pool
scan-pollution problem. The sessions actually open in the UI are exactly the
ones evicted, and the search is keystroke-debounced, so it repeats while typing.

A scan reads each session exactly once, so nothing it touches has earned
"recently used". get_session_for_scan() reuses a resident session without
promoting it, and reads a cold one straight from disk without caching it. It
returns None rather than raising, since a scan skips what it cannot open.

This is a correctness fix for cache behaviour, not a latency fix. The
multi-second searches that led here were contention, not scan cost: a trivial
/api/profiles took 9.2s in the same window, and a full read+parse of ~1700 real
sessions measures ~4s total.

test_sessions_search_depth_validation patched api.routes.get_session. With the
search reading through the scan accessor it now patches get_session_for_scan —
left unfixed, two of its cases fail and the third passes vacuously against an
empty result set.

Validation:
  pytest tests/test_issue4765_sessions_lru_eviction.py
         tests/test_sessions_search_depth_validation.py   ->  13 passed
  Both added eviction tests fail on the pre-fix accessor (verified by revert):
  the working set drops from 4/4 to 0/4 resident after one scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): stamp #6084 content-search working-set preservation

---------

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-20 01:21:18 +00:00

78 lines
3.4 KiB
Python

"""Regression: a malformed/negative ``depth`` on the session content-search
endpoint must not crash or silently exclude the newest messages.
``GET /api/sessions/search?...&depth=<x>`` parsed ``depth`` with a bare
``int()``. A non-numeric value (e.g. ``?depth=deep``) raised ValueError, which
propagated to the top-level request handler and surfaced as a generic HTTP 500.
``depth`` caps how many leading messages are scanned per session
(``sess.messages[:depth]``). A negative value sliced as ``messages[:-n]``,
silently dropping the *most recent* messages from the search instead of capping
the scan — so a match in a session's latest turn would be missed. depth is now
clamped to ``>= 0`` (0 keeps its existing "search the whole transcript"
meaning), mirroring the guard sibling handlers already use.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from urllib.parse import urlparse
def _run_search(query):
"""Invoke _handle_sessions_search against one synthetic session whose match
lives in its LAST message, capturing the JSON payload/status."""
import api.routes as routes
sessions_meta = [{"session_id": "s1", "title": "Untitled", "profile": "default"}]
session = SimpleNamespace(
session_id="s1",
messages=[
{"role": "user", "content": "first message"},
{"role": "assistant", "content": "second message"},
{"role": "user", "content": "NEEDLE in the latest message"},
],
)
captured = {}
def fake_j(handler, payload, status=200, extra_headers=None):
captured["status"] = status
captured["payload"] = payload
# The content search reads through get_session_for_scan (the LRU-transparent
# scan accessor), not get_session — patching the wrong one here would make
# the depth assertions pass vacuously on an empty result set.
with patch("api.routes.all_sessions", return_value=list(sessions_meta)), patch(
"api.routes.get_session_for_scan", return_value=session
), patch("api.profiles.get_active_profile_name", return_value="default"), patch(
"api.routes.j", side_effect=fake_j
):
routes._handle_sessions_search(SimpleNamespace(), urlparse(query))
return captured
def test_search_non_numeric_depth_does_not_500():
# Before the fix this raised ValueError -> 500.
captured = _run_search("/api/sessions/search?q=needle&content=1&depth=deep")
assert captured["status"] == 200
# depth falls back to 5 (>= 3 messages here), so the needle is found.
assert captured["payload"]["count"] == 1
def test_search_negative_depth_still_scans_newest_message():
# depth=-2 with 3 messages: an unclamped messages[:-2] scan would look only
# at the first message and MISS the needle in the latest one. Clamped to a
# default >= 0, the newest message is searched and the match is found.
captured = _run_search("/api/sessions/search?q=needle&content=1&depth=-2")
assert captured["status"] == 200
assert captured["payload"]["count"] == 1
def test_search_valid_depth_still_caps_scan():
# depth=1 scans only the first message, which does NOT contain the needle,
# so no match — proving the cap still works for well-formed input.
captured = _run_search("/api/sessions/search?q=needle&content=1&depth=1")
assert captured["status"] == 200
assert captured["payload"]["count"] == 0