Files
hermes-webui/tests/test_webui_session_db_adapter.py
nesquena-hermes c7a389e0a7
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.305 — Release JU (stage-p2b — dormant unified-SessionDB adapter) (#3759)
* refactor(sessions): add dormant JSON-backed SessionDB adapter (#3720, #3383)

First, lowest-risk slice of the unified-session-db migration: a SessionDB-shaped
adapter over the existing WebUI JSON store, behind a dormant experimental flag
(experimental.unified_session_db, default false). No runtime call site is rewired
— is_unified_session_db_enabled() has no live callers, so persistence behavior is
unchanged until a later migration PR opts in. Includes the adapter, the dormant
config flag + _apply_config_defaults wiring, an architecture doc, and adapter tests.

Also adds docs/architecture/ to the .gitignore docs allowlist (the docs/* rule
excludes subdirectories; the new architecture doc and its presence test would
otherwise be silently dropped on a clean checkout).

Co-authored-by: rodboev <[email protected]>

* docs(changelog): stamp v0.51.305 — Release JU (stage-p2b #3720)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 18:16:07 -07:00

163 lines
5.6 KiB
Python

import json
from pathlib import Path
import pytest
import api.config as config
import api.models as models
import api.webui_session_db as session_db
from api.webui_session_db import WebUIJsonSessionDB
@pytest.fixture
def session_dir(tmp_path, monkeypatch):
path = tmp_path / "sessions"
path.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", path)
return path
def _write_json_session(session_dir, sid="session_1", **overrides):
payload = {
"session_id": sid,
"title": "Adapter Session",
"workspace": str(session_dir.parent),
"model": "gpt-test",
"model_provider": "openai",
"created_at": 100.0,
"updated_at": 200.0,
"pinned": False,
"archived": False,
"profile": "default",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "world"},
],
"tool_calls": [{"id": "tool-1", "name": "demo"}],
}
payload.update(overrides)
payload["message_count"] = len(payload["messages"])
path = session_dir / f"{sid}.json"
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return payload, path
def test_list_and_read_existing_json_sessions(session_dir):
payload, _path = _write_json_session(session_dir)
db = WebUIJsonSessionDB()
rows = db.list_sessions()
loaded = db.read_session(payload["session_id"])
assert [row["session_id"] for row in rows] == [payload["session_id"]]
assert rows[0]["title"] == payload["title"]
assert rows[0]["message_count"] == 2
assert loaded == payload
def test_metadata_update_survives_reload_and_preserves_messages(session_dir):
payload, path = _write_json_session(session_dir)
db = WebUIJsonSessionDB()
db.update_metadata(payload["session_id"], {"title": "Renamed", "pinned": True})
reloaded = json.loads(path.read_text(encoding="utf-8"))
assert reloaded["title"] == "Renamed"
assert reloaded["pinned"] is True
assert reloaded["messages"] == payload["messages"]
assert reloaded["tool_calls"] == payload["tool_calls"]
assert reloaded["message_count"] == len(payload["messages"])
def test_metadata_update_rejects_unsafe_fields(session_dir):
payload, path = _write_json_session(session_dir)
before = path.read_text(encoding="utf-8")
db = WebUIJsonSessionDB()
with pytest.raises(ValueError):
db.update_metadata(payload["session_id"], {"messages": []})
with pytest.raises(ValueError):
db.update_metadata(payload["session_id"], {"unknown_field": "unsafe"})
assert path.read_text(encoding="utf-8") == before
def test_metadata_update_refuses_metadata_only_stub(session_dir):
sid = "stub_session"
path = session_dir / f"{sid}.json"
path.write_text(
json.dumps({"session_id": sid, "title": "Stub"}, indent=2),
encoding="utf-8",
)
with pytest.raises(ValueError, match="metadata-only"):
WebUIJsonSessionDB().update_metadata(sid, {"title": "Nope"})
def test_archive_unarchive_round_trip(session_dir):
payload, path = _write_json_session(session_dir)
db = WebUIJsonSessionDB()
archived = db.archive(payload["session_id"])
unarchived = db.archive(payload["session_id"], archived=False)
reloaded = json.loads(path.read_text(encoding="utf-8"))
assert archived["archived"] is True
assert unarchived["archived"] is False
assert reloaded["archived"] is False
assert reloaded["messages"] == payload["messages"]
def test_read_only_operations_do_not_mutate_files(session_dir):
payload, path = _write_json_session(session_dir)
before_text = path.read_text(encoding="utf-8")
before_stat = path.stat()
db = WebUIJsonSessionDB()
assert db.list_sessions()
assert db.read_session(payload["session_id"]) == payload
after_stat = path.stat()
assert path.read_text(encoding="utf-8") == before_text
assert after_stat.st_mtime_ns == before_stat.st_mtime_ns
assert after_stat.st_size == before_stat.st_size
def test_sort_timestamp_falls_back_past_missing_values():
assert WebUIJsonSessionDB._sort_timestamp({"last_message_at": None, "updated_at": 25.0}) == 25.0
assert WebUIJsonSessionDB._sort_timestamp({"last_message_at": "", "created_at": "15.5"}) == 15.5
def test_module_level_write_session_wrapper(session_dir):
payload, _path = _write_json_session(session_dir, sid="wrapper_session")
written = session_db.write_session(payload)
assert written == payload
assert session_db.read_session(payload["session_id"]) == payload
def test_unified_session_db_flag_default_remains_false(monkeypatch, tmp_path):
cfg_path = tmp_path / "missing-config.yaml"
monkeypatch.setattr(config, "_get_config_path", lambda: cfg_path)
config.reload_config()
assert config.get_config()["experimental"]["unified_session_db"] is False
assert config.is_unified_session_db_enabled() is False
assert config.is_unified_session_db_enabled({"experimental": {"unified_session_db": True}}) is True
def test_adapter_docs_pin_runtime_wiring_preconditions():
doc = (Path(__file__).resolve().parents[1] / "docs" / "architecture" / "unified-session-db.md").read_text(
encoding="utf-8"
)
assert "Runtime Wiring Preconditions" in doc
assert "per-session mutation locks" in doc
assert "in-memory `Session` cache and `_index.json`" in doc
assert "pending first turns" in doc
assert "test/migration helpers, not runtime persistence replacements" in doc
assert "Runtime wiring must add Session lock/cache/index parity" in (
session_db.WebUIJsonSessionDB.update_metadata.__doc__ or ""
)