Files
hermes-agent/tests/hermes_cli/test_web_server_sessions_compression.py
Austin Pickett aaf7f199d5 fix(state): chain-aware dashboard session deletes; stats exclude compression children
Dashboard sessions endpoints treated compression-continuation children as
independent rows in two places:

- /api/sessions/stats counted every physical row, so total/active_store/
  archived were inflated by the hidden chain links while the paired
  list_sessions_rich page collapses each chain to one row (#54298).
- The bulk and single delete endpoints removed only the selected physical
  row. A list row carries the chain tip's id, so the root survived and the
  next list reload re-projected it onto the previous link — deleted
  conversations reappeared (#57543).

delete_session/delete_sessions gain include_compression_chain (opt-in; the
dashboard endpoints pass True). Lineage expansion walks compression edges in
both directions with the same edge definition as get_compression_tip and the
list-projection CTE (branch/reset/delegate/tool children excluded), collects
stale sibling continuations, threads the write-guard pass through the
expanded lineage, sweeps transcript files of every chain member, and keeps
the returned count equal to the selected rows. The default stays chain-blind
for existing callers (CLI /exit --delete, ACP, gateway API).

Fixes #54298, Fixes #57543

Salvaged from #54300 (stats exclude_children; @liuhao1024) and #57996
(lineage expansion; @draix), adapted onto the web_routers split and the
guard-aware delete paths.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: draix <draixagent@gmail.com>
2026-10-06 12:03:11 -04:00

153 lines
6.8 KiB
Python

"""Dashboard sessions endpoints vs compression-continuation children (#54298, #57543).
The sessions list collapses each compression chain to one row carrying the *tip's*
id; these tests pin the paired stats count and both delete endpoints to that
projection. Kept in a topical sibling (not ``test_web_server.py``, which is over
its file-lines ratchet cap and may only go down).
"""
import pytest
class _SessionsCompressionEndpoints:
"""Shared harness: TestClient pair against the isolated HERMES_HOME store."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
class TestSessionStatsCompressionChildren(_SessionsCompressionEndpoints):
"""``GET /api/sessions/stats`` must pair with the list (#54298)."""
def test_session_stats_excludes_compression_children(self):
"""Stats counts every physical row while the paired list collapses each
chain to one row — total/active_store/archived were inflated by the
hidden chain links and never matched the listed rows."""
from hermes_state import SessionDB
db = SessionDB()
try:
# compression chain: root -> child1 -> child2 -> live tip
db.create_session(session_id="comp-root", source="cli")
db.append_message("comp-root", role="user", content="root")
db.end_session("comp-root", end_reason="compression")
db.create_session(session_id="comp-child1", source="cli", parent_session_id="comp-root")
db.append_message("comp-child1", role="user", content="child1")
db.end_session("comp-child1", end_reason="compression")
db.create_session(session_id="comp-child2", source="cli", parent_session_id="comp-child1")
db.append_message("comp-child2", role="user", content="child2")
db.end_session("comp-child2", end_reason="compression")
db.create_session(session_id="comp-tip", source="cli", parent_session_id="comp-child2")
db.append_message("comp-tip", role="user", content="tip")
# an archived standalone session
db.create_session(session_id="archived", source="cli")
db.append_message("archived", role="user", content="old")
db.set_session_archived("archived", True)
# an active standalone session
db.create_session(session_id="standalone", source="cli")
db.append_message("standalone", role="user", content="solo")
finally:
db.close()
stats = self.auth_client.get("/api/sessions/stats").json()
# 6 rows in the DB; the list surfaces 3 conversations: the compression
# chain (collapsed to its tip, counted once at the root), the archived
# standalone, and the active standalone.
assert stats["total"] == 3, stats
assert stats["active_store"] == 2, stats
assert stats["archived"] == 1, stats
class TestDeleteSessionCompressionChain(_SessionsCompressionEndpoints):
"""``DELETE /api/sessions/{session_id}`` — single-row flavour of #57543."""
def test_delete_removes_whole_compression_chain(self):
"""The list row the user clicked carries the chain tip's id; deleting it
must take the root with it, or the conversation resurfaces as the
previous chain link on the next reload."""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session("conv_root", source="tui")
db.end_session("conv_root", end_reason="compression")
db.create_session(
session_id="conv_tip", source="tui", parent_session_id="conv_root"
)
finally:
db.close()
resp = self.auth_client.delete("/api/sessions/conv_tip")
assert resp.status_code == 200
assert resp.json().get("ok") is True
db = SessionDB()
try:
assert db.get_session("conv_tip") is None
assert db.get_session("conv_root") is None
finally:
db.close()
class TestBulkDeleteCompressionChain(_SessionsCompressionEndpoints):
"""``POST /api/sessions/bulk-delete`` — the #57543 multi-select repro."""
def test_deletes_whole_compression_chain_of_each_selected_root(self):
"""The sessions list surfaces a compressed conversation as ONE row
carrying the chain tip's id. Bulk-deleting that id must remove the whole
chain — otherwise the root resurfaces as the previous link on the next
reload and the user sees the delete "not work". ``deleted`` counts
selected rows, and other lineages / standalone rows survive untouched."""
from hermes_state import SessionDB
db = SessionDB()
try:
# chain A (selected): a_root -> a_tip
db.create_session("a_root", source="tui")
db.end_session("a_root", end_reason="compression")
db.create_session("a_tip", source="tui", parent_session_id="a_root")
# chain B (selected): b_root -> b_mid -> b_tip
db.create_session("b_root", source="tui")
db.end_session("b_root", end_reason="compression")
db.create_session("b_mid", source="tui", parent_session_id="b_root")
db.end_session("b_mid", end_reason="compression")
db.create_session("b_tip", source="tui", parent_session_id="b_mid")
# chain C — NOT selected
db.create_session("c_root", source="tui")
db.end_session("c_root", end_reason="compression")
db.create_session("c_tip", source="tui", parent_session_id="c_root")
# standalones — NOT selected
db.create_session("solo", source="tui")
finally:
db.close()
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": ["a_tip", "b_tip"]}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2, "skipped_active": []}
db = SessionDB()
try:
for gone in ("a_root", "a_tip", "b_root", "b_mid", "b_tip"):
assert db.get_session(gone) is None, gone
for kept in ("c_root", "c_tip", "solo"):
assert db.get_session(kept) is not None, kept
finally:
db.close()