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>
153 lines
6.8 KiB
Python
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()
|