Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5821,6 +5821,35 @@ def _notify_session_boundary(self, event_type: str) -> None:
except Exception:
pass

def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:
"""Drop a just-ended session row when it never gained content.

Starting the CLI and immediately quitting (or rotating with /new,
/clear) used to leave an empty untitled row behind that clutters
``/resume`` and ``hermes sessions list``. Delegates the
check-and-delete to ``SessionDB.delete_session_if_empty``, which
only removes rows with no messages, no title, and no child
sessions. Ported from google-gemini/gemini-cli#27770.
"""
if not self._session_db or not session_id:
return False
# In-memory transcript is authoritative: if this CLI object holds
# conversation messages (flushed to the DB or not), the session is
# not empty. Protects against pruning a real conversation whose DB
# flush failed or hasn't happened yet.
if getattr(self, "conversation_history", None):
return False
try:
from hermes_constants import get_hermes_home as _ghh
return self._session_db.delete_session_if_empty(
session_id, sessions_dir=_ghh() / "sessions"
)
except Exception:
logger.debug(
"Could not prune empty session %s", session_id, exc_info=True
)
return False

def new_session(self, silent=False, title=None):
"""Start a fresh session with a new session ID and cleared agent state."""
if self.agent and self.conversation_history:
Expand All @@ -5837,6 +5866,9 @@ def new_session(self, silent=False, title=None):
self._session_db.end_session(old_session_id, "new_session")
except Exception:
pass
# Don't let immediately-rotated empty sessions pile up in
# /resume and `hermes sessions list` (gemini-cli#27770 port).
self._discard_session_if_empty(old_session_id)

self.session_start = datetime.now()
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
Expand Down Expand Up @@ -13074,6 +13106,15 @@ def new_event_loop(self):
self._session_db.end_session(self.agent.session_id, "cli_close")
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not close session in DB: %s", e)
# Started-and-immediately-quit sessions never gained content;
# drop the empty row so /resume and `hermes sessions list`
# stay clean (gemini-cli#27770 port). No-op for resumed or
# titled sessions and anything with messages or children.
if not getattr(self, '_delete_session_on_exit', False):
try:
self._discard_session_if_empty(self.agent.session_id)
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not prune empty session: %s", e)
# /exit --delete: also remove the current session's transcripts
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
if getattr(self, '_delete_session_on_exit', False):
Expand Down
42 changes: 42 additions & 0 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3658,6 +3658,48 @@ def _do(conn):
self._remove_session_files(sessions_dir, session_id)
return deleted

def delete_session_if_empty(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
) -> bool:
"""Delete *session_id* only when it never gained resumable content.

A session is considered empty when it has no messages and no
user-assigned title. Used by CLI exit / session-rotation paths so
immediately-started-and-quit sessions don't pile up in ``/resume``
and ``hermes sessions list`` output. (Pattern ported from
google-gemini/gemini-cli#27770.)

The emptiness check and delete run in one transaction, so a message
flushed concurrently by another writer can't be lost. Sessions with
children (delegate subagent runs) are preserved — a parent that
spawned work is not "empty" even if its own transcript never
flushed. Returns True if the session was deleted.
"""
def _do(conn):
cursor = conn.execute(
"""
DELETE FROM sessions
WHERE id = ?
AND title IS NULL
AND NOT EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
)
AND NOT EXISTS (
SELECT 1 FROM sessions child
WHERE child.parent_session_id = sessions.id
)
""",
(session_id,),
)
return cursor.rowcount > 0

deleted = self._execute_write(_do)
if deleted:
self._remove_session_files(sessions_dir, session_id)
return bool(deleted)

def delete_sessions(
self,
session_ids: List[str],
Expand Down
161 changes: 161 additions & 0 deletions tests/test_empty_session_hygiene.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Tests for empty-session hygiene — gemini-cli#27770 port.

Starting the CLI and immediately quitting (or rotating sessions with /new)
used to leave empty untitled rows in the session DB that clutter /resume
and `hermes sessions list`. ``SessionDB.delete_session_if_empty`` removes
a just-ended session row only when it never gained resumable content:
no messages, no title, and no child sessions.
"""

import pytest

from hermes_state import SessionDB


@pytest.fixture()
def db(tmp_path):
session_db = SessionDB(db_path=tmp_path / "state.db")
yield session_db
session_db.close()


class TestDeleteSessionIfEmpty:
def test_deletes_empty_untitled_session(self, db):
db.create_session(session_id="empty", source="cli", model="test")
db.end_session("empty", "cli_close")

assert db.delete_session_if_empty("empty") is True
assert db.get_session("empty") is None

def test_keeps_session_with_messages(self, db):
db.create_session(session_id="busy", source="cli", model="test")
db.append_message("busy", role="user", content="hello")
db.end_session("busy", "cli_close")

assert db.delete_session_if_empty("busy") is False
assert db.get_session("busy") is not None

def test_keeps_titled_session(self, db):
"""A user-assigned title is resumable content even without messages."""
db.create_session(session_id="titled", source="cli", model="test")
db.set_session_title("titled", "Important plans")
db.end_session("titled", "cli_close")

assert db.delete_session_if_empty("titled") is False
assert db.get_session("titled") is not None

def test_keeps_session_with_children(self, db):
"""A parent that spawned delegate subagent runs is not empty."""
db.create_session(session_id="parent", source="cli", model="test")
db.create_session(
session_id="child",
source="tool",
model="test",
parent_session_id="parent",
)
db.end_session("parent", "cli_close")

assert db.delete_session_if_empty("parent") is False
assert db.get_session("parent") is not None
assert db.get_session("child") is not None

def test_unknown_session_returns_false(self, db):
assert db.delete_session_if_empty("nope") is False

def test_removes_on_disk_transcripts(self, db, tmp_path):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
(sessions_dir / "empty.json").write_text("{}", encoding="utf-8")
(sessions_dir / "empty.jsonl").write_text("", encoding="utf-8")

db.create_session(session_id="empty", source="cli", model="test")
db.end_session("empty", "cli_close")

assert db.delete_session_if_empty("empty", sessions_dir=sessions_dir)
assert not (sessions_dir / "empty.json").exists()
assert not (sessions_dir / "empty.jsonl").exists()

def test_no_file_cleanup_when_kept(self, db, tmp_path):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
(sessions_dir / "busy.json").write_text("{}", encoding="utf-8")

db.create_session(session_id="busy", source="cli", model="test")
db.append_message("busy", role="user", content="hello")

assert not db.delete_session_if_empty("busy", sessions_dir=sessions_dir)
assert (sessions_dir / "busy.json").exists()

def test_empty_session_disappears_from_listing(self, db):
"""The user-facing symptom: empty rows polluting session lists."""
db.create_session(session_id="real", source="cli", model="test")
db.append_message("real", role="user", content="do the thing")
db.end_session("real", "cli_close")

db.create_session(session_id="ghost", source="cli", model="test")
db.end_session("ghost", "cli_close")

ids_before = {s["id"] for s in db.list_sessions_rich(source="cli")}
assert {"real", "ghost"} <= ids_before

db.delete_session_if_empty("ghost")

ids_after = {s["id"] for s in db.list_sessions_rich(source="cli")}
assert "real" in ids_after
assert "ghost" not in ids_after


class TestCLIDiscardSessionIfEmpty:
"""Wiring tests for HermesCLI._discard_session_if_empty."""

def _make_cli(self, db):
from cli import HermesCLI

cli = HermesCLI.__new__(HermesCLI)
cli._session_db = db
cli.conversation_history = []
return cli

def test_discards_empty(self, db):
db.create_session(session_id="empty", source="cli", model="test")
db.end_session("empty", "cli_close")

cli = self._make_cli(db)
assert cli._discard_session_if_empty("empty") is True
assert db.get_session("empty") is None

def test_keeps_nonempty(self, db):
db.create_session(session_id="busy", source="cli", model="test")
db.append_message("busy", role="user", content="hi")

cli = self._make_cli(db)
assert cli._discard_session_if_empty("busy") is False
assert db.get_session("busy") is not None

def test_no_db_is_noop(self):
cli = self._make_cli(None)
assert cli._discard_session_if_empty("anything") is False

def test_none_session_id_is_noop(self, db):
cli = self._make_cli(db)
assert cli._discard_session_if_empty(None) is False

def test_db_error_swallowed(self, db):
class Boom:
def delete_session_if_empty(self, *a, **k):
raise RuntimeError("locked")

cli = self._make_cli(Boom())
assert cli._discard_session_if_empty("x") is False

def test_in_memory_history_blocks_prune(self, db):
"""The live transcript is authoritative: even if the DB row has no
flushed messages yet, a CLI holding conversation history must not
prune the session (covers flush-failed / not-yet-flushed turns)."""
db.create_session(session_id="unflushed", source="cli", model="test")
db.end_session("unflushed", "new_session")

cli = self._make_cli(db)
cli.conversation_history = [{"role": "user", "content": "hello"}]
assert cli._discard_session_if_empty("unflushed") is False
assert db.get_session("unflushed") is not None
Loading