Files
hermes-webui/tests/test_session_lineage_metadata_api.py
T

696 lines
24 KiB
Python

"""Regression tests for /api/sessions lineage metadata used by sidebar collapse."""
import sqlite3
import time
import pytest
import api.models as models
import api.routes as routes
from api.models import SESSIONS, STREAMS, Session, all_sessions
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
state_db = tmp_path / "state.db"
index_file.write_text("[]", encoding="utf-8")
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
monkeypatch.setattr(models, "_active_state_db_path", lambda: state_db)
monkeypatch.setattr(models, "_start_session_index_rebuild_thread", lambda: None)
def uncached_persisted_session_ids():
return frozenset(
p.stem
for p in models.SESSION_DIR.glob("*.json")
if not p.name.startswith("_")
)
monkeypatch.setattr(models, "_persisted_session_ids_snapshot", uncached_persisted_session_ids)
SESSIONS.clear()
STREAMS.clear()
yield state_db
SESSIONS.clear()
STREAMS.clear()
def _ensure_state_db(path):
conn = sqlite3.connect(str(path))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
);
"""
)
return conn
def _ensure_messages_table(conn):
conn.execute(
"""
CREATE TABLE messages (
session_id TEXT,
role TEXT,
content TEXT,
timestamp REAL
)
"""
)
def _insert_state_message(conn, sid, *, role, content, timestamp):
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
(sid, role, content, timestamp),
)
conn.commit()
def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None):
conn.execute(
"""
INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)
""",
(sid, source, session_source, title or sid, started_at or time.time(), parent, ended_at, end_reason),
)
conn.commit()
def _save_webui_session(sid, *, title, updated_at):
session = Session(
session_id=sid,
title=title,
messages=[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}],
updated_at=updated_at,
)
session.save(touch_updated_at=False)
return session
def test_all_sessions_exposes_state_db_lineage_metadata_for_webui_json_sessions(_isolate):
"""PR #1358 can only collapse rows when /api/sessions exposes lineage keys."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_root", title="Hermes WebUI", updated_at=t0)
_save_webui_session("lineage_api_tip", title="Hermes WebUI #2", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_tip",
parent="lineage_api_root",
started_at=t0 + 6,
)
rows = {row["session_id"]: row for row in all_sessions()}
assert rows["lineage_api_tip"].get("parent_session_id") == "lineage_api_root"
assert rows["lineage_api_tip"].get("_lineage_root_id") == "lineage_api_root"
assert rows["lineage_api_tip"].get("_compression_segment_count") == 2
assert "_lineage_root_id" not in rows["lineage_api_root"]
finally:
conn.close()
def test_all_sessions_keeps_explicit_forks_out_of_state_db_lineage_metadata(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_root", title="Visible root", updated_at=t0)
_save_webui_session("lineage_api_fork", title="Explicit fork", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_fork",
parent="lineage_api_root",
started_at=t0 + 6,
session_source="fork",
)
rows = {row["session_id"]: row for row in all_sessions()}
fork = rows["lineage_api_fork"]
assert fork.get("parent_session_id") == "lineage_api_root"
assert fork.get("relationship_type") == "child_session"
assert fork.get("parent_title") == "lineage_api_root"
assert fork.get("_parent_lineage_root_id") == "lineage_api_root"
assert "_lineage_root_id" not in fork
assert "_compression_segment_count" not in fork
finally:
conn.close()
def test_non_compression_state_db_parent_does_not_create_sidebar_lineage(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_plain_parent", title="Parent", updated_at=t0)
_save_webui_session("lineage_api_plain_child", title="Child", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_plain_parent",
started_at=t0,
ended_at=t0 + 5,
end_reason="user_stop",
)
_insert_state_row(
conn,
"lineage_api_plain_child",
parent="lineage_api_plain_parent",
started_at=t0 + 6,
)
rows = {row["session_id"]: row for row in all_sessions()}
# Non-continuation parents should remain visible child-session links,
# not compression lineage. The frontend must nest them under the parent
# without collapsing sibling child sessions into one lineage row.
child = rows["lineage_api_plain_child"]
assert child.get("parent_session_id") == "lineage_api_plain_parent"
assert child.get("relationship_type") == "child_session"
assert child.get("parent_title") == "lineage_api_plain_parent"
assert child.get("_parent_lineage_root_id") == "lineage_api_plain_parent"
assert "_lineage_root_id" not in child
finally:
conn.close()
def test_child_of_hidden_compression_segment_exposes_parent_lineage_root(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_root", title="Visible root", updated_at=t0)
_save_webui_session("lineage_api_tip", title="Visible tip", updated_at=t0 + 10)
_save_webui_session("lineage_api_subtask", title="Subtask", updated_at=t0 + 20)
_insert_state_row(
conn,
"lineage_api_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_tip",
parent="lineage_api_root",
started_at=t0 + 6,
ended_at=t0 + 15,
end_reason="user_stop",
)
_insert_state_row(
conn,
"lineage_api_subtask",
parent="lineage_api_tip",
started_at=t0 + 12,
)
rows = {row["session_id"]: row for row in all_sessions()}
child = rows["lineage_api_subtask"]
assert child.get("relationship_type") == "child_session"
assert child.get("parent_session_id") == "lineage_api_tip"
assert child.get("_parent_lineage_root_id") == "lineage_api_root"
assert child.get("_parent_lineage_tip_id") == "lineage_api_tip"
serialized = routes._sidebar_session_response_item(child, redact_enabled=False)
assert serialized.get("_parent_lineage_tip_id") == "lineage_api_tip"
assert "_lineage_root_id" not in child
finally:
conn.close()
def test_cli_close_parent_preserves_cross_surface_continuation_lineage(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_cli_parent", title="Hermes WebUI #8", updated_at=t0)
_save_webui_session("lineage_api_webui_child", title="Hermes WebUI #8", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_cli_parent",
started_at=t0,
ended_at=t0 + 5,
end_reason="cli_close",
)
_insert_state_row(
conn,
"lineage_api_webui_child",
parent="lineage_api_cli_parent",
started_at=t0 + 6,
)
rows = {row["session_id"]: row for row in all_sessions()}
assert rows["lineage_api_webui_child"].get("parent_session_id") == "lineage_api_cli_parent"
assert rows["lineage_api_webui_child"].get("_lineage_root_id") == "lineage_api_cli_parent"
finally:
conn.close()
def test_cross_surface_child_session_metadata_marks_orphan_top_level_candidate(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_telegram_parent", title="Telegram parent", updated_at=t0)
_save_webui_session("lineage_api_webui_tip", title="WebUI tip", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_telegram_parent",
source="telegram",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_webui_tip",
source="webui",
parent="lineage_api_telegram_parent",
started_at=t0 + 6,
)
rows = {row["session_id"]: row for row in all_sessions()}
tip = rows["lineage_api_webui_tip"]
assert tip.get("relationship_type") == "child_session"
assert tip.get("parent_source") == "telegram"
assert tip.get("_cross_surface_child_session") is True
finally:
conn.close()
def test_state_db_webui_source_overrides_stale_cli_json_metadata(_isolate):
"""State-db WebUI mirrors should clear stale CLI source fields in sidebar rows."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
session = Session(
session_id="lineage_api_stale_cli_source",
title="WebUI Chatnachrichten verschwinden nach Neustart #9",
messages=[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}],
updated_at=t0,
is_cli_session=True,
source_tag="cli",
raw_source="cli",
session_source="cli",
source_label="CLI",
)
session.save(touch_updated_at=False)
_insert_state_row(
conn,
"lineage_api_stale_cli_source",
source="webui",
started_at=t0,
)
row = {row["session_id"]: row for row in all_sessions()}["lineage_api_stale_cli_source"]
assert row["source_tag"] == "webui"
assert row["raw_source"] == "webui"
assert row["session_source"] == "webui"
assert row["source_label"] == "WebUI"
assert row["is_cli_session"] is False
finally:
conn.close()
def test_sessions_route_keeps_state_db_webui_row_with_stale_cli_json_when_cli_hidden(_isolate, monkeypatch):
"""The hot route must apply state.db source correction before CLI filtering."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
session = Session(
session_id="lineage_api_route_stale_cli_source",
title="WebUI Chatnachrichten verschwinden nach Neustart #9",
messages=[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}],
updated_at=t0,
is_cli_session=True,
source_tag="cli",
raw_source="cli",
session_source="cli",
source_label="CLI",
)
session.save(touch_updated_at=False)
_insert_state_row(
conn,
"lineage_api_route_stale_cli_source",
source="webui",
started_at=t0,
)
monkeypatch.setattr(routes, "all_sessions", models.all_sessions)
monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", models._enrich_sidebar_lineage_metadata)
monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _sessions: False)
payload = routes._build_session_list_cache_payload(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
)
rows = {row["session_id"]: row for row in payload["sessions"]}
row = rows["lineage_api_route_stale_cli_source"]
assert row["source_tag"] == "webui"
assert row["raw_source"] == "webui"
assert row["session_source"] == "webui"
assert row["source_label"] == "WebUI"
assert row["is_cli_session"] is False
finally:
conn.close()
def test_generic_webui_title_gets_read_only_state_db_display_title(_isolate):
"""Sidebar rows can display the fresher state.db title without mutating JSON."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_stale_title", title="Hermes WebUI #8", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_stale_title",
title="Hermes WebUI #177",
started_at=t0,
)
row = {row["session_id"]: row for row in all_sessions()}["lineage_api_stale_title"]
assert row["title"] == "Hermes WebUI #8"
assert row["display_title"] == "Hermes WebUI #177"
assert row["_state_db_title"] == "Hermes WebUI #177"
finally:
conn.close()
def test_generic_subagent_title_gets_goal_display_title(_isolate):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_goal", title="Subagent Session", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_goal",
title="Subagent Session",
source="subagent",
started_at=t0,
)
_insert_state_message(
conn,
"lineage_api_subagent_goal",
role="user",
content="Find the root cause of the failing sidebar test",
timestamp=t0 + 1,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_goal"]
assert row["title"] == "Subagent Session"
assert row["display_title"] == "Find the root cause of the failing sidebar test"
finally:
conn.close()
def test_custom_subagent_title_stays_authoritative(_isolate):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_custom", title="Investigate auth", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_custom",
title="Investigate auth",
source="subagent",
started_at=t0,
)
_insert_state_message(
conn,
"lineage_api_subagent_custom",
role="user",
content="A different goal",
timestamp=t0 + 1,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_custom"]
assert row["title"] == "Investigate auth"
assert "display_title" not in row
finally:
conn.close()
def test_generic_subagent_title_falls_back_without_first_user_message(_isolate):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_empty", title="Subagent Session", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_empty",
title="Subagent Session",
source="subagent",
started_at=t0,
)
_insert_state_message(
conn,
"lineage_api_subagent_empty",
role="assistant",
content="Only assistant output",
timestamp=t0 + 1,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_empty"]
assert row["title"] == "Subagent Session"
assert "display_title" not in row
finally:
conn.close()
def test_generic_subagent_title_skips_null_first_user_message(_isolate):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_null_first", title="Subagent Session", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_null_first",
title="Subagent Session",
source="subagent",
started_at=t0,
)
_insert_state_message(
conn,
"lineage_api_subagent_null_first",
role="user",
content=None,
timestamp=t0 + 1,
)
_insert_state_message(
conn,
"lineage_api_subagent_null_first",
role="user",
content="Recover the next usable delegated title",
timestamp=t0 + 2,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_null_first"]
assert row["title"] == "Subagent Session"
assert row["display_title"] == "Recover the next usable delegated title"
finally:
conn.close()
def test_generic_subagent_title_respects_sidebar_override_cap(_isolate, monkeypatch):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
older = time.time() - 200
newer = time.time() - 100
try:
monkeypatch.setenv("HERMES_WEBUI_STATE_DB_OVERRIDE_TOP_N", "1")
_save_webui_session("lineage_api_subagent_old", title="Subagent Session", updated_at=older)
_save_webui_session("lineage_api_subagent_new", title="Subagent Session", updated_at=newer)
_insert_state_row(
conn,
"lineage_api_subagent_old",
title="Subagent Session",
source="subagent",
started_at=older,
)
_insert_state_row(
conn,
"lineage_api_subagent_new",
title="Subagent Session",
source="subagent",
started_at=newer,
)
_insert_state_message(
conn,
"lineage_api_subagent_old",
role="user",
content="Older delegated title",
timestamp=older + 1,
)
_insert_state_message(
conn,
"lineage_api_subagent_new",
role="user",
content="Newest delegated title",
timestamp=newer + 1,
)
rows = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}
assert rows["lineage_api_subagent_new"]["display_title"] == "Newest delegated title"
assert "display_title" not in rows["lineage_api_subagent_old"]
finally:
conn.close()
def test_generic_subagent_title_falls_back_without_messages_table(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_no_messages", title="Subagent Session", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_no_messages",
title="Subagent Session",
source="subagent",
started_at=t0,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_no_messages"]
assert row["title"] == "Subagent Session"
assert "display_title" not in row
finally:
conn.close()
def test_state_db_display_title_does_not_override_custom_json_title(_isolate):
"""Manual/custom JSON titles stay authoritative even when state.db differs."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_custom_title", title="Customer escalation notes", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_custom_title",
title="Hermes WebUI #177",
started_at=t0,
)
row = {row["session_id"]: row for row in all_sessions()}["lineage_api_custom_title"]
assert row["title"] == "Customer escalation notes"
assert "display_title" not in row
assert "_state_db_title" not in row
finally:
conn.close()
def test_sessions_route_preserves_visible_child_lineage_when_archived_parent_filtered(_isolate, monkeypatch):
"""Default /api/sessions omits archived rows but keeps their lineage metadata.
The route builds the hot sidebar payload with archived rows filtered out by
default. A visible continuation child still needs lineage metadata from its
archived parent so the client can collapse/display the logical conversation
correctly.
"""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
archived_parent = _save_webui_session(
"lineage_api_archived_parent",
title="Hermes WebUI",
updated_at=t0,
)
archived_parent.archived = True
archived_parent.save(touch_updated_at=False)
_save_webui_session(
"lineage_api_visible_tip",
title="Hermes WebUI #2",
updated_at=t0 + 10,
)
_insert_state_row(
conn,
"lineage_api_archived_parent",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_visible_tip",
parent="lineage_api_archived_parent",
started_at=t0 + 6,
)
monkeypatch.setattr(routes, "all_sessions", models.all_sessions)
monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", models._enrich_sidebar_lineage_metadata)
monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _sessions: False)
default_payload = routes._build_session_list_cache_payload(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
)
assert [row["session_id"] for row in default_payload["sessions"]] == ["lineage_api_visible_tip"]
assert default_payload["archived_count"] == 1
tip = default_payload["sessions"][0]
assert tip.get("parent_session_id") == "lineage_api_archived_parent"
assert tip.get("_lineage_root_id") == "lineage_api_archived_parent"
assert tip.get("_compression_segment_count") == 2
archived_payload = routes._build_session_list_cache_payload(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=True,
)
assert [row["session_id"] for row in archived_payload["sessions"]] == [
"lineage_api_visible_tip",
"lineage_api_archived_parent",
]
finally:
conn.close()