From 04b3f195380f5e3ba30dd8cede29215cff8d0042 Mon Sep 17 00:00:00 2001 From: Dan Schnurbusch Date: Mon, 8 Jun 2026 13:31:53 -0500 Subject: [PATCH] fix(sessions): archive compressed conversation lineages --- .../app/session/hooks/use-session-actions.ts | 6 +++ hermes_state.py | 46 +++++++++++++++-- tests/hermes_state/test_session_archiving.py | 51 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/hermes_state/test_session_archiving.py diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 5077226aade..313a5357004 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -866,6 +866,12 @@ export function useSessionActions({ try { await setSessionArchived(storedSessionId, true, archived?.profile) + // A sidebar refresh can race the optimistic removal while the PATCH is + // in flight and briefly reinsert the still-unarchived backend row. Win + // that race after the mutation succeeds so right-click → Archive does + // not appear to do nothing until the next full refresh. + setSessions(prev => prev.filter(s => s.id !== storedSessionId)) + $pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId)) notify({ durationMs: 2_000, kind: 'success', message: copy.archived }) } catch (err) { if (archived) { diff --git a/hermes_state.py b/hermes_state.py index bda6eeacd62..41070e15d18 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1715,15 +1715,51 @@ class SessionDB: """Archive or unarchive a session. Archived sessions are hidden from the default session list but keep all - their messages — this is a soft hide, not a delete. Returns True when a - row was updated. + their messages — this is a soft hide, not a delete. For compression + chains, archive the whole logical conversation. Desktop lists compression + roots projected forward to their latest continuation; updating only the + displayed tip lets the still-unarchived root resurrect it on refresh. + Returns True when at least one row was updated. """ def _do(conn): cursor = conn.execute( - "UPDATE sessions SET archived = ? WHERE id = ?", - (1 if archived else 0, session_id), + """ + WITH RECURSIVE + ancestors(id) AS ( + SELECT ? + UNION + SELECT parent.id + FROM ancestors a + JOIN sessions child ON child.id = a.id + JOIN sessions parent ON parent.id = child.parent_session_id + WHERE parent.end_reason = 'compression' + AND child.started_at >= parent.ended_at + ), + descendants(id) AS ( + SELECT ? + UNION + SELECT child.id + FROM descendants d + JOIN sessions parent ON parent.id = d.id + JOIN sessions child ON child.parent_session_id = parent.id + WHERE parent.end_reason = 'compression' + AND child.started_at >= parent.ended_at + ), + lineage(id) AS ( + SELECT id FROM ancestors + UNION + SELECT id FROM descendants + ) + UPDATE sessions + SET archived = ? + WHERE id IN (SELECT id FROM lineage) + """, + (session_id, session_id, 1 if archived else 0), ) - return cursor.rowcount + rowcount = cursor.rowcount + if rowcount is None or rowcount < 0: + rowcount = conn.execute("SELECT changes()").fetchone()[0] + return rowcount rowcount = self._execute_write(_do) return rowcount > 0 diff --git a/tests/hermes_state/test_session_archiving.py b/tests/hermes_state/test_session_archiving.py new file mode 100644 index 00000000000..36ecb95a17b --- /dev/null +++ b/tests/hermes_state/test_session_archiving.py @@ -0,0 +1,51 @@ +import time + +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + database = SessionDB(tmp_path / "state.db") + try: + yield database + finally: + database.close() + + +def _compression_pair(db: SessionDB): + base = time.time() - 100 + db.create_session("root", source="cli") + db.create_session("tip", source="cli", parent_session_id="root") + db._conn.execute( + "UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'", + (base, base + 10), + ) + db._conn.execute( + "UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'", + (base + 20,), + ) + db._conn.commit() + + +def test_archiving_compression_tip_archives_projected_root(db): + _compression_pair(db) + + assert db.set_session_archived("tip", True) is True + + assert db.get_session("root")["archived"] == 1 + assert db.get_session("tip")["archived"] == 1 + assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == [] + assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True, archived_only=True)] == ["tip"] + + +def test_unarchiving_compression_tip_unarchives_projected_root(db): + _compression_pair(db) + db.set_session_archived("tip", True) + + assert db.set_session_archived("tip", False) is True + + assert db.get_session("root")["archived"] == 0 + assert db.get_session("tip")["archived"] == 0 + assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == ["tip"]