Merge pull request #2149 into stage-351
perf(sessions): cache CLI session scans (starship-s)
Conflict resolution on api/routes.py:
(1) Master grew a new helper '_messages_include_tool_metadata()' that
pr-2149 doesn't have. Kept it (unrelated function — detects whether
returned messages contain tool metadata, used elsewhere).
(2) pr-2149 renames the CLI-metadata gate from '_needs_cli_session_metadata'
to '_session_requires_cli_metadata_lookup' AND broadens it to cover
legacy-imported sidecars with 'read_only=False' but persisted 'is_cli_session'
or session_source markers. The new gate is strictly more inclusive than
the master version — covers (a) is_cli_session, (b) read_only=True,
(c) session_source in {messaging, external_agent}, AND (d) source_tag,
raw_source, source, source_label, platform markers. All sessions that
previously took the slow path still do, plus a few more legacy shapes
that needed CLI metadata for correct display.
(3) Removed the obsolete '_needs_cli_session_metadata()' definition from
master (only consumer migrated to the new name).
29/29 tests pass across test_session_cli_scan_fast_path (new), claude_code
session import, session_index, and session_lineage_full_transcript.
This commit is contained in:
commit
2accf6335c
5 changed files with 471 additions and 117 deletions
298
api/models.py
298
api/models.py
|
|
@ -1,5 +1,6 @@
|
|||
"""Hermes Web UI -- Session model and in-memory session store."""
|
||||
import collections
|
||||
import copy
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
|
|
@ -22,6 +23,9 @@ from api.agent_sessions import read_importable_agent_session_rows, read_session_
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
CLI_VISIBLE_SESSION_LIMIT = 20
|
||||
_CLI_SESSIONS_CACHE_TTL_SECONDS = 5.0
|
||||
_CLI_SESSIONS_CACHE_LOCK = threading.Lock()
|
||||
_CLI_SESSIONS_CACHE = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stale temp-file cleanup
|
||||
|
|
@ -383,6 +387,7 @@ class Session:
|
|||
self.raw_source = kwargs.get('raw_source')
|
||||
self.session_source = kwargs.get('session_source')
|
||||
self.source_label = kwargs.get('source_label')
|
||||
self.read_only = bool(kwargs.get('read_only', False))
|
||||
self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override
|
||||
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
|
||||
self._metadata_message_count = None
|
||||
|
|
@ -426,7 +431,7 @@ class Session:
|
|||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
|
||||
'parent_session_id',
|
||||
'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label', 'read_only',
|
||||
'enabled_toolsets', 'composer_draft',
|
||||
]
|
||||
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
|
||||
|
|
@ -610,6 +615,7 @@ class Session:
|
|||
'raw_source': self.raw_source,
|
||||
'session_source': self.session_source,
|
||||
'source_label': self.source_label,
|
||||
'read_only': self.read_only,
|
||||
'enabled_toolsets': self.enabled_toolsets,
|
||||
'composer_draft': self.composer_draft if isinstance(self.composer_draft, dict) else {},
|
||||
'is_streaming': _is_streaming_session(
|
||||
|
|
@ -1016,9 +1022,11 @@ def all_sessions(diag=None):
|
|||
_diag_stage(diag, "all_sessions.read_index")
|
||||
index = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
_diag_stage(diag, "all_sessions.prune_index")
|
||||
with LOCK:
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
index = [
|
||||
s for s in index
|
||||
if _index_entry_exists(s.get('session_id'))
|
||||
if _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids)
|
||||
]
|
||||
backfilled = []
|
||||
for i, s in enumerate(index):
|
||||
|
|
@ -1536,20 +1544,51 @@ def get_claude_code_session_messages(sid, projects_dir: Path | str | None = None
|
|||
return []
|
||||
|
||||
|
||||
def get_cli_sessions() -> list:
|
||||
"""Read CLI sessions from the agent's SQLite store and return them as
|
||||
dicts in a format the WebUI sidebar can render alongside local sessions.
|
||||
def clear_cli_sessions_cache() -> None:
|
||||
with _CLI_SESSIONS_CACHE_LOCK:
|
||||
_CLI_SESSIONS_CACHE.clear()
|
||||
|
||||
Returns empty list if the SQLite DB is missing or any error occurs -- the
|
||||
bridge is purely additive and never crashes the WebUI.
|
||||
"""
|
||||
import os
|
||||
cli_sessions = []
|
||||
|
||||
def _copy_cli_sessions(sessions: list) -> list:
|
||||
return copy.deepcopy(sessions)
|
||||
|
||||
|
||||
def _cli_sessions_cache_ttl_seconds() -> float:
|
||||
try:
|
||||
cli_sessions.extend(get_claude_code_sessions())
|
||||
except Exception:
|
||||
logger.debug("Claude Code session scan failed", exc_info=True)
|
||||
return max(0.0, float(_CLI_SESSIONS_CACHE_TTL_SECONDS))
|
||||
except (TypeError, ValueError):
|
||||
return 5.0
|
||||
|
||||
|
||||
def _path_cache_key(path) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return str(Path(path).expanduser().resolve(strict=False))
|
||||
except Exception:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _path_stat_cache_key(path):
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
st = Path(path).stat()
|
||||
return (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _sqlite_file_stat_cache_key(db_path: Path):
|
||||
"""Return a cheap invalidation key for a SQLite DB and WAL sidecars."""
|
||||
return (
|
||||
_path_stat_cache_key(db_path),
|
||||
_path_stat_cache_key(Path(f"{db_path}-wal")),
|
||||
_path_stat_cache_key(Path(f"{db_path}-shm")),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_cli_sessions_context():
|
||||
# Use the active WebUI profile's HERMES_HOME to find state.db.
|
||||
# The active profile is determined by what the user has selected in the UI
|
||||
# (stored in the server's runtime config). This means:
|
||||
|
|
@ -1564,17 +1603,35 @@ def get_cli_sessions() -> list:
|
|||
except Exception:
|
||||
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
|
||||
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return cli_sessions
|
||||
|
||||
# Try to resolve the active CLI profile so imported sessions integrate
|
||||
# with the WebUI profile filter (available since Sprint 22).
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
_cli_profile = get_active_profile_name()
|
||||
except ImportError:
|
||||
_cli_profile = None # older agent -- fall back to no profile
|
||||
cli_profile = get_active_profile_name()
|
||||
except Exception:
|
||||
cli_profile = None
|
||||
|
||||
db_path = hermes_home / 'state.db'
|
||||
projects_dir = _default_claude_code_projects_dir()
|
||||
cache_key = (
|
||||
str(hermes_home),
|
||||
str(cli_profile or ''),
|
||||
str(db_path),
|
||||
_sqlite_file_stat_cache_key(db_path),
|
||||
_path_cache_key(projects_dir),
|
||||
_path_stat_cache_key(projects_dir),
|
||||
_path_stat_cache_key(SESSION_INDEX_FILE),
|
||||
)
|
||||
return hermes_home, db_path, cli_profile, cache_key
|
||||
|
||||
|
||||
def _load_cli_sessions_uncached(hermes_home: Path, db_path: Path, _cli_profile) -> list:
|
||||
cli_sessions = []
|
||||
try:
|
||||
cli_sessions.extend(get_claude_code_sessions())
|
||||
except Exception:
|
||||
logger.debug("Claude Code session scan failed", exc_info=True)
|
||||
|
||||
if not db_path.exists():
|
||||
return cli_sessions
|
||||
|
||||
# Memoize the cron project ID for this scan so we don't pay a lock-acquire +
|
||||
# disk-read of projects.json per cron session in the loop below.
|
||||
|
|
@ -1585,96 +1642,129 @@ def get_cli_sessions() -> list:
|
|||
_cron_pid_cache[0] = ensure_cron_project()
|
||||
return _cron_pid_cache[0]
|
||||
|
||||
try:
|
||||
for row in read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=CLI_VISIBLE_SESSION_LIMIT,
|
||||
log=logger,
|
||||
exclude_sources=None,
|
||||
):
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
for row in read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=CLI_VISIBLE_SESSION_LIMIT,
|
||||
log=logger,
|
||||
exclude_sources=None,
|
||||
):
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
|
||||
_source = row['source'] or 'cli'
|
||||
_title = row['title']
|
||||
if not _title and _source == 'cron' and sid.startswith('cron_'):
|
||||
# Extract job_id from session ID (cron_{job_id}_{timestamp})
|
||||
# and look up the human-friendly job name from jobs.json
|
||||
parts = sid.split('_')
|
||||
if len(parts) >= 3:
|
||||
_job_id = parts[1]
|
||||
try:
|
||||
_jobs_path = hermes_home / 'cron' / 'jobs.json'
|
||||
if _jobs_path.exists():
|
||||
import json as _json
|
||||
_jobs_data = _json.loads(_jobs_path.read_text())
|
||||
for _j in _jobs_data.get('jobs', []):
|
||||
if _j.get('id') == _job_id:
|
||||
_title = _j.get('name') or _title
|
||||
break
|
||||
except Exception:
|
||||
pass # degrade gracefully
|
||||
# If a WebUI JSON file exists for this session (e.g. previously
|
||||
# imported or renamed in the sidebar), prefer its title over the
|
||||
# state.db title. This fixes rename-not-persisting for CLI sessions
|
||||
# after compression chain extension (#1486).
|
||||
_source = row['source'] or 'cli'
|
||||
_title = row['title']
|
||||
if not _title and _source == 'cron' and sid.startswith('cron_'):
|
||||
# Extract job_id from session ID (cron_{job_id}_{timestamp})
|
||||
# and look up the human-friendly job name from jobs.json
|
||||
parts = sid.split('_')
|
||||
if len(parts) >= 3:
|
||||
_job_id = parts[1]
|
||||
try:
|
||||
_jobs_path = hermes_home / 'cron' / 'jobs.json'
|
||||
if _jobs_path.exists():
|
||||
import json as _json
|
||||
_jobs_data = _json.loads(_jobs_path.read_text())
|
||||
for _j in _jobs_data.get('jobs', []):
|
||||
if _j.get('id') == _job_id:
|
||||
_title = _j.get('name') or _title
|
||||
break
|
||||
except Exception:
|
||||
pass # degrade gracefully
|
||||
# If a WebUI JSON file exists for this session (e.g. previously
|
||||
# imported or renamed in the sidebar), prefer its title over the
|
||||
# state.db title. This fixes rename-not-persisting for CLI sessions
|
||||
# after compression chain extension (#1486).
|
||||
try:
|
||||
_webui_meta = Session.load_metadata_only(sid)
|
||||
if _webui_meta and getattr(_webui_meta, 'title', None):
|
||||
_title = _webui_meta.title
|
||||
except Exception:
|
||||
pass
|
||||
_display_title = _title or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': raw_ts,
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': _cron_pid() if is_cron_session(sid, _source) else None,
|
||||
'profile': profile,
|
||||
'source_tag': _source,
|
||||
'raw_source': row.get('raw_source'),
|
||||
'user_id': row.get('user_id'),
|
||||
'chat_id': row.get('chat_id') or row.get('origin_chat_id'),
|
||||
'chat_type': row.get('chat_type'),
|
||||
'thread_id': row.get('thread_id'),
|
||||
'session_key': row.get('session_key'),
|
||||
'platform': row.get('platform'),
|
||||
'session_source': row.get('session_source'),
|
||||
'source_label': row.get('source_label'),
|
||||
'parent_session_id': row.get('parent_session_id'),
|
||||
'parent_title': row.get('parent_title'),
|
||||
'parent_source': row.get('parent_source'),
|
||||
'relationship_type': row.get('relationship_type'),
|
||||
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
|
||||
'end_reason': row.get('end_reason'),
|
||||
'actual_message_count': row.get('actual_message_count'),
|
||||
'user_message_count': row.get('actual_user_message_count'),
|
||||
'_lineage_root_id': row.get('_lineage_root_id'),
|
||||
'_lineage_tip_id': row.get('_lineage_tip_id'),
|
||||
'_compression_segment_count': row.get('_compression_segment_count'),
|
||||
'is_cli_session': True,
|
||||
})
|
||||
|
||||
return cli_sessions
|
||||
|
||||
|
||||
def get_cli_sessions() -> list:
|
||||
"""Read CLI sessions from the agent's SQLite store and return them as
|
||||
dicts in a format the WebUI sidebar can render alongside local sessions.
|
||||
|
||||
Returns empty list if the SQLite DB is missing or any error occurs -- the
|
||||
bridge is purely additive and never crashes the WebUI.
|
||||
"""
|
||||
hermes_home, db_path, cli_profile, cache_key = _resolve_cli_sessions_context()
|
||||
ttl = _cli_sessions_cache_ttl_seconds()
|
||||
now = time.monotonic()
|
||||
|
||||
if ttl > 0:
|
||||
with _CLI_SESSIONS_CACHE_LOCK:
|
||||
cached = _CLI_SESSIONS_CACHE.get(cache_key)
|
||||
if cached:
|
||||
expires_at, cached_sessions = cached
|
||||
if expires_at > now:
|
||||
return _copy_cli_sessions(cached_sessions)
|
||||
_CLI_SESSIONS_CACHE.pop(cache_key, None)
|
||||
try:
|
||||
_webui_meta = Session.load_metadata_only(sid)
|
||||
if _webui_meta and getattr(_webui_meta, 'title', None):
|
||||
_title = _webui_meta.title
|
||||
except Exception:
|
||||
pass
|
||||
_display_title = _title or f'{_source.title()} Session'
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': raw_ts,
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': _cron_pid() if is_cron_session(sid, _source) else None,
|
||||
'profile': profile,
|
||||
'source_tag': _source,
|
||||
'raw_source': row.get('raw_source'),
|
||||
'user_id': row.get('user_id'),
|
||||
'chat_id': row.get('chat_id') or row.get('origin_chat_id'),
|
||||
'chat_type': row.get('chat_type'),
|
||||
'thread_id': row.get('thread_id'),
|
||||
'session_key': row.get('session_key'),
|
||||
'platform': row.get('platform'),
|
||||
'session_source': row.get('session_source'),
|
||||
'source_label': row.get('source_label'),
|
||||
'parent_session_id': row.get('parent_session_id'),
|
||||
'parent_title': row.get('parent_title'),
|
||||
'parent_source': row.get('parent_source'),
|
||||
'relationship_type': row.get('relationship_type'),
|
||||
'_parent_lineage_root_id': row.get('_parent_lineage_root_id'),
|
||||
'end_reason': row.get('end_reason'),
|
||||
'actual_message_count': row.get('actual_message_count'),
|
||||
'user_message_count': row.get('actual_user_message_count'),
|
||||
'_lineage_root_id': row.get('_lineage_root_id'),
|
||||
'_lineage_tip_id': row.get('_lineage_tip_id'),
|
||||
'_compression_segment_count': row.get('_compression_segment_count'),
|
||||
'is_cli_session': True,
|
||||
})
|
||||
sessions = _load_cli_sessions_uncached(hermes_home, db_path, cli_profile)
|
||||
except Exception as _cli_err:
|
||||
logger.warning(
|
||||
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
|
||||
db_path, _cli_err,
|
||||
)
|
||||
return []
|
||||
_CLI_SESSIONS_CACHE[cache_key] = (
|
||||
time.monotonic() + ttl,
|
||||
_copy_cli_sessions(sessions),
|
||||
)
|
||||
return _copy_cli_sessions(sessions)
|
||||
|
||||
try:
|
||||
return _load_cli_sessions_uncached(hermes_home, db_path, cli_profile)
|
||||
except Exception as _cli_err:
|
||||
# DB schema changed, locked, or corrupted -- log warning so admins can diagnose.
|
||||
# Still degrade gracefully (don't crash the WebUI).
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
logger.warning(
|
||||
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
|
||||
db_path, _cli_err,
|
||||
)
|
||||
return []
|
||||
|
||||
return cli_sessions
|
||||
|
||||
|
||||
def _json_loads_if_string(value):
|
||||
if not isinstance(value, str):
|
||||
|
|
|
|||
|
|
@ -1525,18 +1525,6 @@ def _lookup_cli_session_metadata(session_id: str) -> dict:
|
|||
return {}
|
||||
|
||||
|
||||
def _needs_cli_session_metadata(session) -> bool:
|
||||
"""Return true when /api/session should pay for Agent/CLI metadata lookup."""
|
||||
if not session:
|
||||
return False
|
||||
is_cli = (
|
||||
bool(session.get("is_cli_session"))
|
||||
if isinstance(session, dict)
|
||||
else bool(getattr(session, "is_cli_session", False))
|
||||
)
|
||||
return is_cli or _is_messaging_session_record(session)
|
||||
|
||||
|
||||
def _messaging_session_identity(session: dict, raw_source: str) -> str:
|
||||
metadata = _lookup_gateway_session_identity(session.get("session_id"))
|
||||
session_key = _safe_first(
|
||||
|
|
@ -1684,6 +1672,43 @@ def _messages_include_tool_metadata(messages) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _session_requires_cli_metadata_lookup(session) -> bool:
|
||||
"""Return True when a sidecar/session row still needs CLI metadata.
|
||||
|
||||
Legacy imported sidecars may predate the ``read_only`` field and therefore
|
||||
load with ``read_only=False``. They still persist ``is_cli_session`` and/or
|
||||
source metadata from import time, so those markers intentionally keep them
|
||||
on the CLI lookup path while ordinary WebUI-native sessions take the fast
|
||||
path.
|
||||
|
||||
Supersedes the simpler is-cli-or-messaging gate from PR #1822 — the new
|
||||
gate is strictly more inclusive (also covers ``read_only=True`` sidecars,
|
||||
``session_source`` markers, and source_tag/raw_source/platform metadata)
|
||||
so all sessions that previously took the slow path still do, plus a few
|
||||
more legacy shapes.
|
||||
"""
|
||||
if not session:
|
||||
return False
|
||||
|
||||
def _field(name):
|
||||
return session.get(name) if isinstance(session, dict) else getattr(session, name, None)
|
||||
|
||||
if _is_messaging_session_record(session):
|
||||
return True
|
||||
if bool(_field("is_cli_session")) or bool(_field("read_only")):
|
||||
return True
|
||||
session_source = _normalize_messaging_source(_safe_first(_field("session_source")))
|
||||
if session_source in {"messaging", "external_agent", "external-agent"}:
|
||||
return True
|
||||
return bool(_safe_first(
|
||||
_field("source_tag"),
|
||||
_field("raw_source"),
|
||||
_field("source"),
|
||||
_field("source_label"),
|
||||
_field("platform"),
|
||||
))
|
||||
|
||||
|
||||
def _is_messaging_session_id(sid: str) -> bool:
|
||||
"""Detect messaging-backed sessions from WebUI metadata or Agent rows."""
|
||||
try:
|
||||
|
|
@ -3252,7 +3277,7 @@ def handle_get(handler, parsed) -> bool:
|
|||
_t1 = _time.monotonic()
|
||||
s = get_session(sid, metadata_only=(not load_messages))
|
||||
_clear_stale_stream_state(s)
|
||||
cli_meta = _lookup_cli_session_metadata(sid) if _needs_cli_session_metadata(s) else {}
|
||||
cli_meta = _lookup_cli_session_metadata(sid) if _session_requires_cli_metadata_lookup(s) else {}
|
||||
is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta)
|
||||
cli_messages = []
|
||||
if is_messaging_session:
|
||||
|
|
|
|||
|
|
@ -86,6 +86,89 @@ def test_claude_code_scan_skips_symlinks_and_oversized_files(tmp_path):
|
|||
assert models.get_claude_code_sessions(projects_dir=root_link) == []
|
||||
|
||||
|
||||
def test_get_cli_sessions_reuses_short_ttl_cache(monkeypatch, tmp_path):
|
||||
import api.models as models
|
||||
import api.profiles as profiles
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: str(hermes_home))
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
||||
monkeypatch.setattr(models, "_CLI_SESSIONS_CACHE_TTL_SECONDS", 60.0, raising=False)
|
||||
models.clear_cli_sessions_cache()
|
||||
|
||||
calls = 0
|
||||
|
||||
def fake_claude_code_sessions():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return [
|
||||
{
|
||||
"session_id": "claude_code_cached",
|
||||
"title": "Cached Claude Code",
|
||||
"updated_at": calls,
|
||||
"message_count": 1,
|
||||
"source_tag": "claude_code",
|
||||
"is_cli_session": True,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(models, "get_claude_code_sessions", fake_claude_code_sessions)
|
||||
|
||||
first = models.get_cli_sessions()
|
||||
first[0]["title"] = "mutated by caller"
|
||||
second = models.get_cli_sessions()
|
||||
|
||||
assert calls == 1
|
||||
assert second[0]["title"] == "Cached Claude Code"
|
||||
assert second[0]["updated_at"] == 1
|
||||
|
||||
|
||||
def test_get_cli_sessions_cache_invalidates_when_sqlite_wal_changes(monkeypatch, tmp_path):
|
||||
import api.models as models
|
||||
import api.profiles as profiles
|
||||
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
db_path = hermes_home / "state.db"
|
||||
db_path.write_text("initial", encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: str(hermes_home))
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
||||
monkeypatch.setattr(models, "_CLI_SESSIONS_CACHE_TTL_SECONDS", 60.0, raising=False)
|
||||
monkeypatch.setattr(models, "get_claude_code_sessions", lambda: [])
|
||||
models.clear_cli_sessions_cache()
|
||||
|
||||
calls = 0
|
||||
|
||||
def fake_rows(_db_path, **_kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return [
|
||||
{
|
||||
"id": "cli_cached_state_db",
|
||||
"title": "State DB Session",
|
||||
"model": "test-model",
|
||||
"source": "cli",
|
||||
"raw_source": "cli",
|
||||
"message_count": calls,
|
||||
"actual_message_count": calls,
|
||||
"actual_user_message_count": 1,
|
||||
"last_activity": float(calls),
|
||||
"started_at": 1.0,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_rows)
|
||||
|
||||
first = models.get_cli_sessions()
|
||||
Path(f"{db_path}-wal").write_text("new wal contents", encoding="utf-8")
|
||||
second = models.get_cli_sessions()
|
||||
|
||||
assert calls == 2
|
||||
assert first[0]["message_count"] == 1
|
||||
assert second[0]["message_count"] == 2
|
||||
|
||||
|
||||
def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
|
|
|
|||
109
tests/test_session_cli_scan_fast_path.py
Normal file
109
tests/test_session_cli_scan_fast_path.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def test_webui_session_metadata_load_skips_cli_metadata_scan(monkeypatch):
|
||||
"""Opening a normal WebUI session should not scan imported CLI sessions."""
|
||||
import api.routes as routes
|
||||
from api.models import Session
|
||||
|
||||
session = Session(
|
||||
session_id="webui_normal",
|
||||
title="Normal WebUI chat",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: session)
|
||||
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda _session: None)
|
||||
monkeypatch.setattr(routes, "redact_session_data", lambda payload: payload)
|
||||
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"_lookup_cli_session_metadata",
|
||||
lambda _sid: (_ for _ in ()).throw(AssertionError("normal WebUI loads should not scan CLI sessions")),
|
||||
)
|
||||
|
||||
response = routes.handle_get(
|
||||
object(),
|
||||
urlparse("/api/session?session_id=webui_normal&messages=0&resolve_model=0"),
|
||||
)
|
||||
|
||||
assert response["session"]["session_id"] == "webui_normal"
|
||||
assert response["session"]["messages"] == []
|
||||
|
||||
|
||||
def test_read_only_session_metadata_load_preserves_cli_metadata_lookup(monkeypatch):
|
||||
"""Read-only imported sidecars still need CLI metadata for source identity."""
|
||||
import api.routes as routes
|
||||
from api.models import Session
|
||||
|
||||
session = Session(
|
||||
session_id="readonly_sidecar",
|
||||
title="Imported chat",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
read_only=True,
|
||||
)
|
||||
looked_up = []
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: session)
|
||||
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda _session: None)
|
||||
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid: [])
|
||||
monkeypatch.setattr(routes, "redact_session_data", lambda payload: payload)
|
||||
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
||||
|
||||
def fake_lookup(sid):
|
||||
looked_up.append(sid)
|
||||
return {
|
||||
"session_id": sid,
|
||||
"read_only": True,
|
||||
"source_label": "External Agent",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", fake_lookup)
|
||||
|
||||
response = routes.handle_get(
|
||||
object(),
|
||||
urlparse("/api/session?session_id=readonly_sidecar&messages=0&resolve_model=0"),
|
||||
)
|
||||
|
||||
assert looked_up == ["readonly_sidecar"]
|
||||
assert response["session"]["read_only"] is True
|
||||
|
||||
|
||||
def test_messaging_session_metadata_load_preserves_cli_metadata_lookup(monkeypatch):
|
||||
"""Messaging/imported sidecars still need CLI metadata for source identity."""
|
||||
import api.routes as routes
|
||||
from api.models import Session
|
||||
|
||||
session = Session(
|
||||
session_id="messaging_sidecar",
|
||||
title="Telegram chat",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
session_source="messaging",
|
||||
raw_source="telegram",
|
||||
)
|
||||
looked_up = []
|
||||
|
||||
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: session)
|
||||
monkeypatch.setattr(routes, "_clear_stale_stream_state", lambda _session: None)
|
||||
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid: [])
|
||||
monkeypatch.setattr(routes, "redact_session_data", lambda payload: payload)
|
||||
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
||||
|
||||
def fake_lookup(sid):
|
||||
looked_up.append(sid)
|
||||
return {
|
||||
"session_id": sid,
|
||||
"session_source": "messaging",
|
||||
"raw_source": "telegram",
|
||||
"source_label": "Telegram",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", fake_lookup)
|
||||
|
||||
response = routes.handle_get(
|
||||
object(),
|
||||
urlparse("/api/session?session_id=messaging_sidecar&messages=0&resolve_model=0"),
|
||||
)
|
||||
|
||||
assert looked_up == ["messaging_sidecar"]
|
||||
assert response["session"]["source_label"] == "Telegram"
|
||||
|
|
@ -123,6 +123,53 @@ def test_all_sessions_backfills_last_message_at_for_legacy_index_rows():
|
|||
assert persisted[0].get("last_message_at") == 100.0
|
||||
|
||||
|
||||
def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch):
|
||||
"""Index pruning should not reacquire the session lock for every row."""
|
||||
index_file = models.SESSION_INDEX_FILE
|
||||
entries = [
|
||||
{
|
||||
"session_id": "sess_a",
|
||||
"title": "Alpha",
|
||||
"updated_at": 200.0,
|
||||
"last_message_at": 200.0,
|
||||
"workspace": "/tmp",
|
||||
"model": "test",
|
||||
"message_count": 1,
|
||||
"created_at": 100.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
},
|
||||
{
|
||||
"session_id": "sess_b",
|
||||
"title": "Bravo",
|
||||
"updated_at": 150.0,
|
||||
"last_message_at": 150.0,
|
||||
"workspace": "/tmp",
|
||||
"model": "test",
|
||||
"message_count": 1,
|
||||
"created_at": 90.0,
|
||||
"pinned": False,
|
||||
"archived": False,
|
||||
},
|
||||
]
|
||||
_write_index_file(index_file, entries)
|
||||
|
||||
seen = []
|
||||
|
||||
def _assert_snapshot_used(session_id, in_memory_ids=None):
|
||||
assert in_memory_ids is not None, "all_sessions should snapshot SESSIONS once before pruning"
|
||||
seen.append(session_id)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(models, "_index_entry_exists", _assert_snapshot_used)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert [row["session_id"] for row in rows] == ["sess_a", "sess_b"]
|
||||
assert seen == ["sess_a", "sess_b"]
|
||||
|
||||
|
||||
# ── 6. test_incremental_patch_correctness ─────────────────────────────────
|
||||
|
||||
def test_incremental_patch_correctness():
|
||||
|
|
|
|||
Loading…
Reference in a new issue