Files
hermes-webui/tests/test_issue4985_orphaned_webui_zero_message.py
T
nanw e8f1bb10da Prune orphan native-WebUI zero-message sessions from sidebar (#4985)
Sidebar rows whose backing state.db session has zero messages (a '+'-click
that opened a row but the first turn never committed, or a sidebar nav that
opened then closed before any message landed) now get pruned instead of
lingering forever. The existing #3238/#4591 orphan-prune path only handled
CLI/API-server sidecars; this adds a parallel pass for native-WebUI rows.

- api/models.py: add agent_session_zero_message_sids() batch helper that
  mirrors agent_session_rows_existing()'s safe-degrade contract (returns
  frozenset() on any error so a transient failure never causes data loss).
  Add a small tombstone file at SESSION_DIR/_pruned_webui_orphans.json
  (capped at the last 500 sids) so the next poll's
  recover_missing_index_sidecars does not re-add a pruned orphan to the
  sidebar index — the per-poll fsync'd index write + state.db probe loop
  the original PR would have hit on every poll for every orphan. The
  helper, the load, and the full-scan fallback all honor the tombstone.
  new_session() and import_cli_session() clear any matching tombstone
  entry so a fresh session with the same id isn't shadowed.
- api/routes.py: extract the webui zero-message prune into a private
  _prune_orphaned_webui_zero_message_sessions() helper, then call it
  from BOTH branches of _build_session_list_cache_payload. Established
  installs pin show_cli_sessions=False (per api/config.py:7637-7648)
  and are exactly the long-time users who accumulated the #4985 404
  orphans — the original PR only ran the prune in the if branch and
  silently skipped the else branch (maintainer review
  IC_kwDOR1LuPM8AAAABHsyFGg). The helper also honors the tombstone
  defensively so the prune can't thrash on a row the index still
  carries from a prior poll.
- tests/test_issue4985_orphaned_webui_zero_message.py: 39 regression
  tests — the original 25 (7 helper-direct + 12 monkeypatch predicate
  tests + 6 real-pipeline end-to-end tests) plus 14 follow-up tests
  driven by the MUST-FIX / tombstone / r6 signal-swap reviews:
    * test_helper_prunes_orphan_keeps_safety_retained_rows — direct call
      to the extracted helper with a row set covering every shape the
      gate is responsible for.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_false
      — explicit regression for the established-install case the
      original PR missed.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
      — companion positive control.
    * test_tombstone_persists_across_polls — drives two pipeline builds,
      asserts prune fires once total and the tombstoned sid is NOT
      re-added to SESSION_INDEX_FILE.
    * test_tombstone_does_not_block_new_session_with_same_id — defensive
      cover for the new_session() / import_cli_session() tombstone clear.
    * test_tombstone_trimmed_to_last_N_entries — verifies the cap.
    * test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true
      — companion positive control (show_cli_sessions=True path).
    * test_tombstone_self_heals_when_message_added — confirms
      Session.save()'s belt-and-suspenders tombstone clear fires when
      a sidecar commit lands with len(messages) > 0.
    * test_tombstone_loader_is_fail_open_on_corrupt_file[non-json|version-mismatch|non-dict-root]
      — 3-parameter regression for the fail-open corrupt-file contract
      (#5023 lesson).
    * test_sidecar_only_webui_session_is_retained — sidecar JSON carries
      real messages but state.db.messages is empty (mirroring lag); the
      r6 loaded-sidecar probe retains the row.
    * test_sidecar_only_webui_session_self_heals_tombstone — the r4.5
      self-heal path combined with the r6 sidecar-only retention rule.
    * test_truly_empty_sidecar_with_title_still_pruned — companion
      negative test that the r6 fix does NOT over-retain empty sidecars
      with non-Untitled titles.
    * test_sidecar_only_webui_session_retained_when_show_cli_sessions_false
      — established-install path also respects the r6 retention rule.
- CHANGELOG.md: Unreleased entry under Fixed.

fix: target post-#1171 survivors + hoist + tombstone + r6 sidecar-load probe

The r5 sidecar-only retention signal keyed off the row's cached
``message_count`` — which is stale-positive on the very phantom rows
#4985 exists to prune (sidecar ``messages`` empty but cached count > 0).
The r6 signal swaps that cached-count probe for a ``Session.load(sid)
.messages`` probe, gated to ``state.db``-empty candidates only so the
common live-row path stays a no-op. Three retain tests now write real
populated ``messages`` arrays (not just ``[]``) so the loaded-sidecar
check has something to find; the stale-count phantom test is left as
the canonical r6 regression (its ``messages=[]`` + ``message_count=5``
sidecar is exactly the shape the r6 loaded-sidecar check correctly
classifies as orphan). Maintainer review 4584722701.
2026-06-28 12:14:58 +08:00

2179 lines
81 KiB
Python

"""Regression coverage for orphaned native-WebUI zero-message row pruning.
#4985: native-WebUI rows whose backing ``state.db.messages`` is empty linger
in the sidebar forever (boot-time ``+`` click that never sent a turn, profile
switch that resets the active id, sidebar nav that opens a session then closes
the tab before the first message commits). Clicking such a row hits
``GET /api/session`` -> 404 and "Session not available in web UI." indefinitely.
The WebUI delete affordance is not exposed for these rows and the existing
#3238/#4591 orphan prune explicitly excludes ``source == "webui"`` rows.
The fix probes ``state.db.messages`` directly via
``agent_session_zero_message_sids()`` (a batched existence-via-NOT-EXISTS
probe parallel to ``agent_session_rows_existing``) and prunes a WebUI row only
when ALL of:
1. ``_session_source_is_webui(s)`` is True (strictly webui-sourced),
2. ``s.get("active_stream_id")`` is empty (inflight first-turn safety: the
prior ``_reconcile_stale_stream_state_for_session_rows`` pass has already
cleared stale stream ids, so any row still carrying one is genuinely
streaming),
3. ``s.get("has_pending_user_message")`` is empty (a row with a pending user
turn is still being filled — never prune it),
4. ``s.get("worktree_path")`` is empty (worktree-bound rows are persisted in a
way that ``all_sessions()`` recovers via ``missing_persisted_ids``; never
prune them or the next poll re-prunes them, the index mtime flickers,
and the cache-thrash loop from #4985 review IC_kwDOR1LuPM8AAAABHrkF1Q
re-establishes itself),
5. **The row SURVIVED the upstream ``all_sessions()`` #1171 keep-filter** —
i.e. the row's sidecar/title is ``!= "Untitled"`` OR its claimed
``message_count > 0``. Without this clause, the gate is a no-op because
``all_sessions()`` has already stripped every (Untitled ∧ count==0 ∧
¬active_stream ∧ ¬pending ∧ ¬worktree) row at ``api/models.py:3689-3695``
(index path) and ``3735-3741`` (full-scan path) before our prune block
runs. The earlier 6-condition gate made this same mistake (review
``IC_kwDOR1LuPM8AAAABHrkF1Q``); this version targets the post-#1171
survivors — the rows that #4985 actually describes.
Mirrors the existing #3238 test file's structure (monkeypatch helpers,
``_payload_for_rows`` shape, named-test style) and reuses its
``_make_state_db`` for the sessions-table half, then layers on a richer
state.db fixture that also populates a ``messages`` table for the "row in
state.db but messages present" / "row in state.db but messages absent"
distinction.
Test design:
- Tests 1-12 use the same monkeypatch pattern as
``tests/test_issue3238_orphaned_cli_sidecar_prune.py``:
``all_sessions`` is monkeypatched to a raw lambda, the prune helpers
``agent_session_rows_existing`` / ``agent_session_zero_message_sids`` are
monkeypatched to lambda that returns the requested set, and
``prune_session_from_index`` is monkeypatched to a recorder. These tests
exercise the gate predicate + the prune-batch helper dispatch in
isolation, with an artificial row shape the caller injects. They DO NOT
drive a row through the real ``all_sessions()`` pipeline — so they cannot
on their own prove the gate fires against a real row that survived #1171.
See the "Real-pipeline end-to-end coverage" section below for that proof.
- Tests 13-18 (the real-pipeline tests) stand up the ``SESSION_DIR`` /
``SESSION_INDEX_FILE`` / ``SESSIONS`` / ``HERMES_HOME`` / state.db the
way ``api.models.all_sessions()`` reads them in production, write a real
session sidecar that survives #1171, and assert that the resulting
``_build_session_list_cache_payload`` actually calls
``prune_session_from_index`` for the genuinely-surviving orphan.
"""
from __future__ import annotations
import json
import sqlite3
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parent.parent
if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))
def _make_state_db(path: Path, session_ids, *, messages_per_session=None):
"""Create an agent state.db with both ``sessions`` and ``messages`` tables.
``messages_per_session`` is an optional mapping ``{sid: row_count}``. When
omitted, sessions get zero message rows (the orphan candidate shape); when
present, that many message rows are inserted per sid so the zero-message
probe can distinguish "row in sessions but no messages" from "row in
sessions with messages present."
"""
conn = sqlite3.connect(str(path))
try:
conn.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
"started_at REAL, last_activity REAL)"
)
conn.execute(
"CREATE TABLE messages ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"session_id TEXT, role TEXT, content TEXT, timestamp REAL)"
)
for sid in session_ids:
conn.execute(
"INSERT INTO sessions (id, source, started_at, last_activity) "
"VALUES (?, 'cli', 0, 0)",
(sid,),
)
if messages_per_session:
for sid, n in messages_per_session.items():
for i in range(int(n)):
conn.execute(
"INSERT INTO messages (session_id, role, content, "
"timestamp) VALUES (?, 'user', ?, 0)",
(sid, f"hello-{i}"),
)
conn.commit()
finally:
conn.close()
# ── Helper-direct coverage for agent_session_zero_message_sids ─────────────
def test_agent_session_zero_message_sids_returns_only_orphans(tmp_path, monkeypatch):
"""Mixed input: only ids with zero message rows are returned."""
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(
home / "state.db",
["sess-empty", "sess-with-msg"],
messages_per_session={"sess-with-msg": 3},
)
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
zero = models.agent_session_zero_message_sids(["sess-empty", "sess-with-msg"])
assert zero == frozenset({"sess-empty"})
def test_agent_session_zero_message_sids_empty_when_db_missing(tmp_path, monkeypatch):
"""No agent DB -> return frozenset() so the caller prunes nothing."""
from api import models
monkeypatch.setattr(
models, "_active_state_db_path", lambda: tmp_path / "nope" / "state.db"
)
wanted = ["orphan-a", "orphan-b"]
assert models.agent_session_zero_message_sids(wanted) == frozenset()
def test_agent_session_zero_message_sids_handles_missing_messages_table(
tmp_path, monkeypatch,
):
"""A state.db without a ``messages`` table degrades to frozenset() — never prune."""
from api import models
home = tmp_path / "home"
home.mkdir()
db = home / "state.db"
conn = sqlite3.connect(str(db))
conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY)")
conn.execute("INSERT INTO sessions (id) VALUES ('sess-x')")
conn.commit()
conn.close()
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
assert models.agent_session_zero_message_sids(["sess-x"]) == frozenset()
def test_agent_session_zero_message_sids_handles_missing_sessions_table(
tmp_path, monkeypatch,
):
"""A state.db without a ``sessions`` table degrades to frozenset()."""
from api import models
home = tmp_path / "home"
home.mkdir()
db = home / "state.db"
conn = sqlite3.connect(str(db))
conn.execute("CREATE TABLE messages (session_id TEXT)")
conn.commit()
conn.close()
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
assert models.agent_session_zero_message_sids(["sess-x"]) == frozenset()
def test_agent_session_zero_message_sids_empty_id_filtered(tmp_path, monkeypatch):
"""Empty / None / whitespace-only ids are filtered before probing."""
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["sess-a"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
zero = models.agent_session_zero_message_sids(
[" ", "", "sess-a"] # None-equivalent: empty/whitespace are filtered
)
assert zero == frozenset({"sess-a"})
def test_agent_session_zero_message_sids_batches_over_500_ids(tmp_path, monkeypatch):
"""Batched chunked probe mirrors agent_session_rows_existing (chunk=500)."""
from api import models
home = tmp_path / "home"
home.mkdir()
ids = [f"sess-{i:04d}" for i in range(600)]
# First 300 are empty in messages; the next 300 each have one row.
_make_state_db(
home / "state.db",
ids,
messages_per_session={sid: 1 for sid in ids[300:]},
)
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
zero = models.agent_session_zero_message_sids(ids)
assert zero == frozenset(ids[:300])
def test_agent_session_zero_message_sids_only_returns_existing_sessions(
tmp_path, monkeypatch,
):
"""Ids that have NO row in ``sessions`` are not in the result (the join filters them)."""
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["sess-keep"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
zero = models.agent_session_zero_message_sids(["sess-keep", "sess-not-in-db"])
assert zero == frozenset({"sess-keep"})
# ── Sidebar-payload prune-decision coverage (mirrors #3238 _payload_for_rows) ─
#
# NB: these are GATE PREDICATE TESTS. ``all_sessions`` is monkeypatched to a
# raw lambda, so the rows the test injects do NOT pass through the real
# ``all_sessions()`` #1171 keep-filter. That means these tests prove the gate
# predicate + the batch probe + the prune dispatch in isolation, but they do
# NOT prove the gate fires against a row that survived #1171 in the real
# pipeline (a monkeypatched lambda can synthesize a row that the real
# ``all_sessions()`` would have stripped before the gate runs). The
# "Real-pipeline end-to-end coverage" section below (tests 13-18) drives a
# real row through the real pipeline. Every webui row in this section uses
# a descriptive non-Untitled title so the gate predicate exercises
# ``title != 'Untitled' OR count > 0`` on a row shape that POST-#1171
# ``all_sessions()`` would actually keep — i.e. the gate fires the same way
# it does against a real survivor.
def _payload_for_rows_webui(
monkeypatch,
rows,
zero_message_ids,
existing_ids=None,
cli_existing_ids=None,
):
"""Variant of #3238's ``_payload_for_rows`` for the #4985 path.
Mocks both ``agent_session_rows_existing`` (used by the #3238/#4591
CLI/API-server prune) and the new ``agent_session_zero_message_sids``
(used by the #4985 webui zero-message prune). Returns the resulting
payload and the list of sids handed to ``prune_session_from_index``.
"""
import api.routes as routes
pruned = []
cli_existing_ids = cli_existing_ids if cli_existing_ids is not None else existing_ids
monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows))
monkeypatch.setattr(
routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: []
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _sessions: False,
)
monkeypatch.setattr(
routes,
"agent_session_rows_existing",
lambda ids, profile=None: frozenset(cli_existing_ids or []),
)
monkeypatch.setattr(
routes,
"agent_session_zero_message_sids",
lambda ids, profile=None: frozenset(zero_message_ids or []),
)
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
payload = routes._build_session_list_cache_payload(
active_profile="default",
all_profiles=False,
show_cli_sessions=True,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
return payload, pruned
# 1. Titled Webui row with state.db row but state.db.messages empty -> PRUNED.
# The row has a descriptive (non-Untitled) title so the gate predicate
# ``title != 'Untitled' OR count > 0`` fires, then the helper confirms the
# state.db.messages table is empty, then prune_session_from_index is called.
# This is the post-#1171-survivor orphan shape #4985 describes.
def test_webui_titled_orphan_in_state_db_is_pruned(monkeypatch):
row = {
"session_id": "webui-orphan",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
}
payload, pruned = _payload_for_rows_webui(
monkeypatch, [row], zero_message_ids=["webui-orphan"],
)
assert [session["session_id"] for session in payload["sessions"]] == []
assert pruned == ["webui-orphan"]
# 2. Titled Webui row with state.db.messages NON-empty -> RETAINED. The gate
# fires, the helper confirms messages exist, the row is NOT in the missing
# set, prune_session_from_index is NOT called.
def test_webui_titled_row_with_messages_in_state_db_is_retained(monkeypatch):
"""An inflight first turn that has already written rows to state.db but not
yet bumped the sidecar's message_count must NOT be pruned."""
row = {
"session_id": "webui-inflight",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
}
# zero_message_ids empty -> helper reports no ids as zero-message.
payload, pruned = _payload_for_rows_webui(
monkeypatch, [row], zero_message_ids=[],
)
assert [session["session_id"] for session in payload["sessions"]] == ["webui-inflight"]
assert pruned == []
# 3. Webui row with active_stream_id set -> NOT probed (inflight). The
# active_stream_id gate takes precedence over title/count and keeps the
# row out of the probe entirely, even if its state.db.messages is empty.
def test_webui_row_with_active_stream_is_not_probed(monkeypatch):
"""Inflight first-turn safety: a row carrying active_stream_id is genuinely
streaming and is excluded from the orphan probe entirely, regardless of
what state.db.messages says."""
row = {
"session_id": "webui-streaming",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
"active_stream_id": "stream-abc-123",
}
# Even if the helper would have returned this sid as zero-message, the
# active_stream_id gate keeps the row out of the probe.
payload, pruned = _payload_for_rows_webui(
monkeypatch, [row], zero_message_ids=["webui-streaming"],
)
assert [session["session_id"] for session in payload["sessions"]] == ["webui-streaming"]
assert pruned == []
# 4. Webui row with message_count>0 -> gate predicate includes it (count > 0
# passes the OR), helper confirms messages exist -> RETAINED. This proves
# the post-#1171-survivor detection: a row with a positive claimed
# message_count is exactly the kind of row that survives #1171 and lands
# in our gate.
def test_webui_row_with_positive_message_count_is_retained(monkeypatch):
"""A WebUI row that already has a positive message_count lands in the
post-#1171-survivor branch and is probed; if the helper confirms
messages actually exist, the row is retained."""
row = {
"session_id": "webui-live",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 5,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
}
payload, pruned = _payload_for_rows_webui(
monkeypatch, [row], zero_message_ids=[],
)
assert [session["session_id"] for session in payload["sessions"]] == ["webui-live"]
assert pruned == []
# 5. CLI row with message_count=0 and no backing state.db row -> pruned by EXISTING path
def test_cli_orphan_with_zero_messages_is_pruned_by_existing_path(monkeypatch):
"""Regression: the #3238/#4591 CLI/API-server prune must still work
independently of #4985. A CLI row absent from cli_by_id AND absent from
state.db.sessions is pruned by the existing path, even though it has
message_count=0."""
row = {
"session_id": "cli-orphan-zero",
"title": "CLI Session",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": True,
"source_tag": "cli",
"raw_source": "cli",
"session_source": "cli",
"source_label": "CLI",
"is_cli_session": True,
}
# existing_ids=[] -> CLI path considers it orphaned. zero_message_ids=[]
# (irrelevant for CLI; the #4985 helper doesn't even fire because
# _session_source_is_webui is False).
payload, pruned = _payload_for_rows_webui(
monkeypatch,
[row],
zero_message_ids=[],
existing_ids=[],
)
assert [session["session_id"] for session in payload["sessions"]] == []
assert pruned == ["cli-orphan-zero"]
# 6. API-server sidecar with message_count=0 and no backing state.db row -> pruned by EXISTING path
def test_api_server_orphan_with_zero_messages_is_pruned_by_existing_path(monkeypatch):
"""Regression: the #4591 API-server sidecar prune must still work
independently of #4985."""
row = {
"session_id": "api-orphan-zero",
"title": "API Session",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": True,
"source_tag": "api_server",
"raw_source": "api_server",
"session_source": "api",
"source_label": "API",
"is_cli_session": False,
}
payload, pruned = _payload_for_rows_webui(
monkeypatch,
[row],
zero_message_ids=[],
existing_ids=[],
)
assert [session["session_id"] for session in payload["sessions"]] == []
assert pruned == ["api-orphan-zero"]
# 7. Titled Webui row but state.db probe unreachable (helper safe-degrades) ->
# RETAINED. The helper returns frozenset() so the caller prunes nothing.
def test_webui_titled_row_is_retained_on_helper_safe_degrade(monkeypatch):
"""If the new ``agent_session_zero_message_sids`` helper safe-degrades to
frozenset() (state.db unreachable / sqlite error / missing tables), the
sidebar MUST NOT prune — a transient failure can never cause data loss."""
row = {
"session_id": "webui-safe",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
}
# zero_message_ids=[] simulates safe-degrade: helper returns no ids.
payload, pruned = _payload_for_rows_webui(
monkeypatch, [row], zero_message_ids=[],
)
assert [session["session_id"] for session in payload["sessions"]] == ["webui-safe"]
assert pruned == []
# 8. Existing CLI prune still wins when a row happens to match BOTH paths
def test_cli_row_is_pruned_by_cli_path_not_webui_path(monkeypatch):
"""A CLI row absent from state.db.sessions is pruned by the #3238 path
BEFORE the #4985 second pass runs (it is never appended to
``_kept_after_orphan_prune`` in the first place). It must not be
double-counted or fall into the webui probe."""
row = {
"session_id": "cli-double-orphan",
"title": "CLI Session",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": True,
"source_tag": "cli",
"raw_source": "cli",
"session_source": "cli",
"source_label": "CLI",
"is_cli_session": True,
}
payload, pruned = _payload_for_rows_webui(
monkeypatch,
[row],
# Even if the webui helper would claim this id, the CLI path got
# there first and pruned it before the webui probe runs.
zero_message_ids=["cli-double-orphan"],
existing_ids=[],
)
assert [session["session_id"] for session in payload["sessions"]] == []
assert pruned == ["cli-double-orphan"]
# 9. Mixed batch: titled webui orphan, titled webui-with-messages, titled
# webui-live, CLI orphan -> all correct. The webui rows all have a
# descriptive title so the gate predicate's title branch fires; the CLI
# row is pruned by the #3238 path BEFORE the #4985 second pass runs.
def test_mixed_batch_each_row_lands_in_correct_bucket(monkeypatch):
rows = [
# titled webui zero-message orphan -> pruned by #4985
{
"session_id": "webui-zero",
"title": "Native WebUI",
"profile": "default",
"updated_at": 30,
"last_message_at": 30,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
},
# titled webui zero-message but messages in state.db -> kept
{
"session_id": "webui-inflight",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
},
# titled webui with positive count -> kept
{
"session_id": "webui-live",
"title": "Native WebUI",
"profile": "default",
"updated_at": 10,
"last_message_at": 10,
"message_count": 5,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
},
# CLI orphan -> pruned by #3238 path
{
"session_id": "cli-orphan",
"title": "CLI",
"profile": "default",
"updated_at": 5,
"last_message_at": 5,
"message_count": 0,
"read_only": True,
"source_tag": "cli",
"raw_source": "cli",
"session_source": "cli",
"source_label": "CLI",
"is_cli_session": True,
},
]
payload, pruned = _payload_for_rows_webui(
monkeypatch,
rows,
zero_message_ids=["webui-zero"], # only webui-zero confirmed zero-message
existing_ids=[], # CLI orphan absent from state.db.sessions
)
assert [session["session_id"] for session in payload["sessions"]] == [
"webui-inflight",
"webui-live",
]
assert sorted(pruned) == ["cli-orphan", "webui-zero"]
# ── Retain-case coverage (worktree / pending / streaming / pending worktree) ─
#
# These prove the four "never prune this row" safety gates fire correctly in
# isolation, even though their rows would normally enter the post-#1171-survivor
# gate predicate (titled, count==0). The maintainer's MUST-FIX review
# IC_kwDOR1LuPM8AAAABHrkF1Q requires that worktree / pending / streaming rows
# be spared even if the messages table is momentarily empty (the inflight /
# worktree-bound safety contract).
# 10. Webui row with worktree_path set -> RETAINED (worktree-bearing rows
# survive #1171's default filter, so they must also survive ours).
# Otherwise a freshly-bound worktree scratch session with zero messages
# gets pruned, then re-pruned, then re-added by missing_persisted_ids
# recovery on every poll — cache-thrash loop on the hot sidebar path.
def test_webui_titled_row_with_worktree_path_is_retained(monkeypatch):
"""Gate excludes worktree-bound rows from the prune batch even when
state.db.messages is empty — see IC_kwDOR1LuPM8AAAABHrkF1Q."""
row = {
"session_id": "webui-worktree",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
"worktree_path": "some/path",
}
# Even if the helper would return this sid as zero-message, the
# worktree_path gate keeps the row out of the probe entirely.
payload, pruned = _payload_for_rows_webui(
monkeypatch, [row], zero_message_ids=["webui-worktree"],
)
assert [session["session_id"] for session in payload["sessions"]] == [
"webui-worktree"
]
assert pruned == []
# 11. Webui row with has_pending_user_message set -> RETAINED (the row has a
# pending user turn; #1171 keeps it on the sidebar until that turn
# commits, and the #4985 prune must agree).
def test_webui_titled_row_with_pending_user_message_is_retained(monkeypatch):
"""Gate excludes pending rows from the prune batch."""
row = {
"session_id": "webui-pending",
"title": "Native WebUI",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
"has_pending_user_message": True,
}
# Even if the helper would return this sid as zero-message, the
# has_pending_user_message gate keeps the row out of the probe.
payload, pruned = _payload_for_rows_webui(
monkeypatch, [row], zero_message_ids=["webui-pending"],
)
assert [session["session_id"] for session in payload["sessions"]] == [
"webui-pending"
]
assert pruned == []
# 12. Titled Webui row with empty state.db.messages -> PRUNED. The new gate
# targets post-#1171-survivors explicitly, so a titled row whose messages
# table is empty IS pruned (the #4985 fix shape). This is the flip side
# of the renamed-title retain test from the old gate — the new gate does
# NOT unconditionally retain renamed rows; it only retains them when the
# messages table is non-empty (or when the row is worktree / pending /
# streaming, per tests 10/11/3).
def test_webui_titled_row_with_empty_state_db_messages_is_pruned(monkeypatch):
"""A titled row is exactly the post-#1171-survivor shape the new gate
targets — title != 'Untitled' fires the OR branch, then the helper
confirms the messages table is empty, then prune fires."""
row = {
"session_id": "webui-renamed",
"title": "My Custom Title",
"profile": "default",
"updated_at": 20,
"last_message_at": 20,
"message_count": 0,
"read_only": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"is_cli_session": False,
}
payload, pruned = _payload_for_rows_webui(
monkeypatch, [row], zero_message_ids=["webui-renamed"],
)
assert [session["session_id"] for session in payload["sessions"]] == []
assert pruned == ["webui-renamed"]
# ── Real-pipeline end-to-end coverage ───────────────────────────────────────
#
# These tests drive a real ``all_sessions()`` (no ``all_sessions``
# monkeypatch). They stand up the ``SESSION_DIR`` /
# ``SESSION_INDEX_FILE`` / ``SESSIONS`` / ``HERMES_HOME`` / state.db the
# way ``api.models.all_sessions()`` reads them in production, write a real
# session sidecar that survives #1171's keep-filter, and assert that the
# resulting ``_build_session_list_cache_payload`` actually fires
# ``prune_session_from_index`` for the genuinely-surviving orphan shape
# described in #4985. These are the tests that prove the gate works
# against a real row the way the maintainer's review
# (IC_kwDOR1LuPM8AAAABHrkF1Q) required.
#
# The ``_reconcile_stale_stream_state_for_session_rows`` is monkeypatched to
# ``False`` here ONLY because every session we construct has no stream id
# (or the stream id is genuine) and we want to short-circuit the side
# effects of stale-stream reconciliation. The row itself flows through the
# REAL ``all_sessions()`` filter, index read, refresh, and #1171 keep-filter
# at api/models.py:3689-3695 — i.e. the exact pipeline the #4985 prune
# gate sits behind.
@pytest.fixture
def _real_pipeline(tmp_path, monkeypatch):
"""Stand up the production sidebar pipeline in tmp_path.
Redirects ``SESSION_DIR`` / ``SESSION_INDEX_FILE`` to tmp_path so the
index file and any saved sidecars land in a throwaway directory, points
``HERMES_HOME`` at a sibling tmp dir with a real agent state.db
(``messages`` + ``sessions`` tables per test case), and clears
``SESSIONS`` so no stale in-memory session leaks across tests.
"""
import api.models as models
from api import profiles
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
hermes_home = tmp_path / "hermes_home"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# ``_active_state_db_path`` reads ``HERMES_HOME`` indirectly via
# ``api.profiles.get_active_hermes_home``. Force the module-level
# default so the helper returns ``hermes_home/state.db`` even when the
# active profile is something other than 'default'.
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", hermes_home)
models.SESSIONS.clear()
try:
from api.models import STREAMS
STREAMS.clear()
except Exception:
pass
yield tmp_path
models.SESSIONS.clear()
try:
from api.models import STREAMS
STREAMS.clear()
except Exception:
pass
def _write_webui_sidecar(
session_dir: Path,
*,
session_id: str,
title: str = "Native WebUI",
message_count: int = 0,
active_stream_id: str | None = None,
pending_user_message: str | None = None,
worktree_path: str | None = None,
profile: str = "default",
messages: list[dict] | None = None,
) -> None:
"""Write a sidecar JSON file that ``all_sessions()`` will pick up via
the index/full-scan path and that survives the #1171 keep-filter (i.e.
titled OR has positive message_count OR is streaming OR has pending
OR is worktree-bound — exactly the rows the gate targets).
When ``messages`` is ``None`` (the default), writes ``"messages": []`` —
the empty-sidecar shape the older tests assert. When ``messages`` is a
list of plain dicts, the sidecar's ``messages`` array is that list
verbatim (JSON-serializable), and ``message_count`` is recomputed from
``len(messages)`` only if the caller did not explicitly pass a
different ``message_count`` (preserves the explicit-override contract
used by the r5/r6 phantom test).
"""
if messages is None:
messages_array: list[dict] = []
else:
messages_array = list(messages)
# If the caller didn't explicitly pass a non-zero message_count AND
# they provided a populated messages list, auto-derive the count from
# the list length. Callers that pass both still win (the explicit
# message_count is preserved — covers the stale-count phantom test).
if messages and message_count == 0 and len(messages_array) > 0:
message_count = len(messages_array)
sidecar = {
"session_id": session_id,
"title": title,
"profile": profile,
"created_at": 1700000000.0,
"updated_at": 1700000000.0,
"pinned": False,
"archived": False,
"is_cli_session": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"read_only": False,
"message_count": message_count,
"messages": messages_array,
"tool_calls": [],
}
if active_stream_id:
sidecar["active_stream_id"] = active_stream_id
if pending_user_message:
sidecar["pending_user_message"] = pending_user_message
sidecar["has_pending_user_message"] = True
if worktree_path:
sidecar["worktree_path"] = worktree_path
(session_dir / f"{session_id}.json").write_text(
json.dumps(sidecar, ensure_ascii=False, indent=2), encoding="utf-8"
)
def _write_state_db(home: Path, *, sessions: dict[str, dict]) -> None:
"""Build a minimal agent ``state.db`` with ``sessions`` + ``messages`` tables.
``sessions`` maps session_id -> ``{"messages": int, "source": str}``. The
``messages`` key controls how many rows go in ``state.db.messages`` for
that session id (0 = the orphan candidate shape).
"""
db = home / "state.db"
conn = sqlite3.connect(str(db))
try:
conn.execute(
"CREATE TABLE sessions ("
"id TEXT PRIMARY KEY, source TEXT, title TEXT, "
"started_at REAL, last_activity REAL, message_count INTEGER)"
)
conn.execute(
"CREATE TABLE messages ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"session_id TEXT, role TEXT, content TEXT, timestamp REAL)"
)
for sid, meta in sessions.items():
source = meta.get("source", "cli")
title = meta.get("title", "Native WebUI")
conn.execute(
"INSERT INTO sessions (id, source, title, started_at, "
"last_activity, message_count) "
"VALUES (?, ?, ?, 0, 0, ?)",
(sid, source, title, meta.get("message_count", 0)),
)
n = int(meta.get("messages", 0))
for i in range(n):
conn.execute(
"INSERT INTO messages (session_id, role, content, "
"timestamp) VALUES (?, 'user', ?, 0)",
(sid, f"hello-{i}"),
)
conn.commit()
finally:
conn.close()
def _run_payload(pruned: list[str], *, show_cli_sessions: bool = True):
"""Invoke the real ``_build_session_list_cache_payload`` pipeline.
Returns the returned payload and the list of sids handed to
``prune_session_from_index``. The session-list cache is monkeypatched to
a no-op builder so the payload is computed inline (no caching across
calls). The reconcile-stale-stream call is also forced to ``False``
so a row that genuinely has no stream id isn't accidentally cleared by
the side-effect.
``show_cli_sessions`` defaults to True for the historical tests; the
follow-up #4985 review IC_kwDOR1LuPM8AAAABHsyFGg added
``show_cli_sessions=False`` as a regression target — see
``test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_false``
below.
"""
import api.routes as routes
original_builder = routes._build_session_list_cache_payload
def builder(*args, **kwargs):
return original_builder(*args, **kwargs)
# Run the real builder once; record prune_session_from_index calls via
# the monkeypatched lambda below.
payload = builder(
active_profile="default",
all_profiles=False,
show_cli_sessions=show_cli_sessions,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
)
return payload
# 13. A genuinely-surviving titled orphan: title is non-Untitled so #1171
# keeps it on the sidebar, but the state.db.messages table is empty (the
# real ``+``-click-then-close-before-first-message shape #4985
# describes). The row is pruned via the real pipeline.
def test_real_all_sessions_post1171_titled_orphan_is_pruned(_real_pipeline, monkeypatch):
import api.routes as routes
tmp_path = _real_pipeline
_write_webui_sidecar(
tmp_path / "sessions",
session_id="webui-titled-orphan",
title="Native WebUI",
message_count=0,
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
"webui-titled-orphan": {"source": "webui", "title": "Native WebUI", "messages": 0},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
# Short-circuit the side effects of stale-stream reconciliation so the
# row (which has no stream id) flows through cleanly.
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-titled-orphan" not in sid_list, (
"A titled row whose state.db.messages is empty must be pruned by "
"the real pipeline — it survived #1171 (titled) but is genuinely "
"empty in state.db."
)
assert pruned == ["webui-titled-orphan"]
# 14. A genuinely-surviving stale-count orphan: title is "Untitled" so the
# OLD #1171 keep would strip it (count==0, no stream/pending/worktree),
# BUT the index file caches message_count=5 (a stale count from a
# previous first-turn that never committed) — the index-path #1171
# filter at api/models.py:3689-3695 checks the cached ``message_count``,
# not ``len(messages)``, so a stale-positive count keeps the row past
# #1171. We drive the row through the index path by writing the index
# file directly (the full-scan fallback uses ``len(s.messages)`` and
# would strip it — see line 3735-3741). The state.db.messages table is
# empty so the row IS an orphan; the gate fires and prunes it.
def test_real_all_sessions_post1171_stale_count_orphan_is_pruned(_real_pipeline, monkeypatch):
import api.models as models
import api.routes as routes
tmp_path = _real_pipeline
session_dir = tmp_path / "sessions"
index_file = session_dir / "_index.json"
_write_webui_sidecar(
session_dir,
session_id="webui-stale-count",
title="Untitled",
message_count=5,
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
"webui-stale-count": {"source": "webui", "title": "Untitled", "messages": 0},
},
)
# Pre-populate the index so the index path is taken. The cached
# ``message_count`` here is the only thing keeping the row past #1171 —
# the full-scan fallback would strip it because ``len(messages)==0``.
index_file.write_text(
json.dumps([
{
"session_id": "webui-stale-count",
"title": "Untitled",
"profile": "default",
"created_at": 1700000000.0,
"updated_at": 1700000000.0,
"pinned": False,
"archived": False,
"is_cli_session": False,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"source_label": "WebUI",
"read_only": False,
"message_count": 5,
}
], ensure_ascii=False, indent=2),
encoding="utf-8",
)
# Mirror the indexed row into the in-memory SESSIONS dict so
# ``all_sessions``'s in-memory overlay includes it (otherwise the index
# row is dropped at the in-memory-id check on line 3592-3604).
from api.models import Session
indexed_session = Session(
session_id="webui-stale-count",
title="Untitled",
profile="default",
messages=[],
message_count=5,
source_tag="webui",
raw_source="webui",
session_source="webui",
source_label="WebUI",
)
models.SESSIONS["webui-stale-count"] = indexed_session
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-stale-count" not in sid_list, (
"A stale-positive-count row whose state.db.messages is empty must "
"be pruned — its sidecar claims messages that don't exist in "
"state.db, so the sidebar is showing a phantom row."
)
assert pruned == ["webui-stale-count"]
# 15. Titled worktree-bound row with empty state.db.messages -> RETAINED.
# The worktree_path safety gate spares the row even though its messages
# table is empty. Without this safety, the next poll would re-add the
# pruned row via ``missing_persisted_ids`` recovery and the cache-thrash
# loop from the maintainer's review re-establishes itself.
def test_real_all_sessions_post1171_titled_worktree_row_is_retained(_real_pipeline, monkeypatch):
import api.routes as routes
tmp_path = _real_pipeline
_write_webui_sidecar(
tmp_path / "sessions",
session_id="webui-worktree-titled",
title="Native WebUI",
message_count=0,
worktree_path="some/path",
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
"webui-worktree-titled": {
"source": "webui",
"title": "Native WebUI",
"messages": 0,
},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-worktree-titled" in sid_list, (
"A titled worktree-bound row with empty state.db.messages must be "
"RETAINED — worktree_path safety must spare it from the prune."
)
assert pruned == []
# 16. Untitled+0-message row with no stream/pending/worktree -> FILTERED
# UPSTREAM by #1171. The row never reaches the gate, so even though the
# state.db.messages table is empty, no prune call is made. This proves
# the gate predicate's (title!='Untitled' OR count>0) clause is doing
# real work: without it, the gate would be a no-op against the real
# pipeline because #1171 strips these rows first.
def test_real_all_sessions_post1171_untitled_uncounted_row_is_filtered_by_1171(
_real_pipeline, monkeypatch,
):
import api.routes as routes
tmp_path = _real_pipeline
_write_webui_sidecar(
tmp_path / "sessions",
session_id="webui-untitled-empty",
title="Untitled",
message_count=0,
)
# We don't need a state.db row at all here — the row is filtered by
# #1171 before any state.db probe runs. Build a minimal state.db so the
# test fixture is consistent.
_write_state_db(tmp_path / "hermes_home", sessions={})
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-untitled-empty" not in sid_list, (
"An Untitled+0-message row must be filtered by #1171 before our "
"gate runs — so it does not appear in payload['sessions']."
)
assert pruned == [], (
"An Untitled+0-message row that #1171 already filtered must NEVER "
"reach our prune block — no prune_session_from_index call."
)
# 17. Titled pending-row with empty state.db.messages -> RETAINED. The
# pending_user_message safety gate spares the row.
def test_real_all_sessions_post1171_pending_titled_row_is_retained(_real_pipeline, monkeypatch):
import api.routes as routes
tmp_path = _real_pipeline
_write_webui_sidecar(
tmp_path / "sessions",
session_id="webui-pending-titled",
title="Native WebUI",
message_count=0,
pending_user_message="Hello",
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
"webui-pending-titled": {
"source": "webui",
"title": "Native WebUI",
"messages": 0,
},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-pending-titled" in sid_list, (
"A titled row with has_pending_user_message=True and empty "
"state.db.messages must be RETAINED — pending_user_message safety "
"must spare it from the prune."
)
assert pruned == []
# 18. Titled streaming row with empty state.db.messages -> RETAINED. The
# active_stream_id safety gate spares the row (genuine inflight first
# turn — the assistant is mid-stream, so messages is empty but the row
# is real and will land content imminently).
def test_real_all_sessions_post1171_streaming_titled_row_is_retained(
_real_pipeline, monkeypatch,
):
import api.routes as routes
tmp_path = _real_pipeline
_write_webui_sidecar(
tmp_path / "sessions",
session_id="webui-streaming-titled",
title="Native WebUI",
message_count=0,
active_stream_id="stream-abc-123",
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
"webui-streaming-titled": {
"source": "webui",
"title": "Native WebUI",
"messages": 0,
},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
# For this test, the streaming row is genuinely streaming — DO NOT
# monkeypatch reconcile. (Without the active_stream_id safety gate
# upstream of the prune batch, an empty-message streaming row would be
# pruned mid-stream and vanish from the sidebar. The gate's
# `not s.get('active_stream_id')` clause defends against exactly this.)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-streaming-titled" in sid_list, (
"A titled row with active_stream_id set and empty state.db.messages "
"must be RETAINED — active_stream_id safety must spare it from the "
"prune (this is the inflight-first-turn contract)."
)
assert pruned == []
# ── Coverage for the #4985 follow-up review IC_kwDOR1LuPM8AAAABHsyFGg ─────
#
# The original PR landed the webui zero-message prune inside the
# ``if show_cli_sessions:`` branch of ``_build_session_list_cache_payload``.
# Review IC_kwDOR1LuPM8AAAABHsyFGg requires the prune to run in BOTH
# branches (established installs pin ``show_cli_sessions=False`` and those
# are exactly the long-time users who accumulated the #4985 404 orphans).
# The fix hoists the prune into the
# ``_prune_orphaned_webui_zero_message_sessions`` helper, called from
# both branches. These tests prove the helper itself fires correctly in
# isolation and that the hoist reaches the ``else:`` branch of the real
# pipeline.
# 19. Helper-direct unit test: the extracted helper MUST prune a
# titled-orphan while retaining every safety-retained row, even when
# it is handed a row that the upstream #1171 keep-filter would have
# stripped (untitled + 0 messages + no safety attrs). That row is
# out-of-scope for the gate by design, but the helper must still be
# safe if a caller hands it one. Mirrors the maintainer's explicit
# review requirement.
def test_helper_prunes_orphan_keeps_safety_retained_rows(monkeypatch):
"""Direct call to ``_prune_orphaned_webui_zero_message_sessions``.
Input row set covers every row shape the helper is responsible for
distinguishing: a titled webui zero-message orphan (must prune), a
titled webui worktree-bound row (must keep), a titled webui
pending-row (must keep), a titled webui streaming row (must keep), a
titled webui row WITH state.db.messages (must keep), a CLI row (must
keep — out of scope by gate predicate), an API-server sidecar row
(must keep — out of scope by gate predicate), and an untitled+0-message
row (must keep — already filtered upstream by #1171, but the helper
must remain safe even if a caller hands it one).
"""
import api.routes as routes
rows = [
# titled webui zero-message orphan -> PRUNED
{
"session_id": "titled-orphan",
"title": "Native WebUI",
"profile": "default",
"message_count": 0,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"is_cli_session": False,
},
# titled webui worktree-bound row -> KEPT (worktree_path safety)
{
"session_id": "titled-worktree",
"title": "Native WebUI",
"profile": "default",
"message_count": 0,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"is_cli_session": False,
"worktree_path": "some/path",
},
# titled webui pending row -> KEPT (has_pending_user_message safety)
{
"session_id": "titled-pending",
"title": "Native WebUI",
"profile": "default",
"message_count": 0,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"is_cli_session": False,
"has_pending_user_message": True,
},
# titled webui streaming row -> KEPT (active_stream_id safety)
{
"session_id": "titled-streaming",
"title": "Native WebUI",
"profile": "default",
"message_count": 0,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"is_cli_session": False,
"active_stream_id": "stream-abc",
},
# titled webui row with messages -> KEPT (zero_message_sids empty)
{
"session_id": "titled-with-messages",
"title": "Native WebUI",
"profile": "default",
"message_count": 5,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"is_cli_session": False,
},
# CLI row -> KEPT (gate predicate excludes non-webui sources)
{
"session_id": "cli-row",
"title": "CLI",
"profile": "default",
"message_count": 0,
"source_tag": "cli",
"raw_source": "cli",
"session_source": "cli",
"is_cli_session": True,
},
# API-server sidecar row -> KEPT (gate predicate excludes api sources)
{
"session_id": "api-sidecar-row",
"title": "API",
"profile": "default",
"message_count": 0,
"source_tag": "api_server",
"raw_source": "api_server",
"session_source": "api",
"is_cli_session": False,
},
# Untitled+0-message row -> KEPT (gate predicate excludes this shape;
# normally #1171 would have filtered it before reaching us, but the
# helper must remain safe if a caller hands it one).
{
"session_id": "untitled-uncounted",
"title": "Untitled",
"profile": "default",
"message_count": 0,
"source_tag": "webui",
"raw_source": "webui",
"session_source": "webui",
"is_cli_session": False,
},
]
pruned: list[str] = []
tombstoned: list[str] = []
monkeypatch.setattr(
routes,
"agent_session_zero_message_sids",
lambda ids, profile=None: frozenset({"titled-orphan"}),
)
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
monkeypatch.setattr(
routes,
"_record_webui_zero_message_orphan_tombstone",
lambda sid: tombstoned.append(sid),
)
result = routes._prune_orphaned_webui_zero_message_sessions(
rows, diag_stage=lambda *_a, **_k: None,
)
kept_sids = [r["session_id"] for r in result]
assert kept_sids == [
"titled-worktree",
"titled-pending",
"titled-streaming",
"titled-with-messages",
"cli-row",
"api-sidecar-row",
"untitled-uncounted",
], (
f"Expected the titled-orphan to be pruned and every other row to be "
f"retained, got kept={kept_sids}"
)
assert pruned == ["titled-orphan"], (
f"prune_session_from_index must be called for the titled-orphan, "
f"got pruned={pruned}"
)
assert tombstoned == ["titled-orphan"], (
f"Tombstone must be recorded for the pruned orphan, got "
f"tombstoned={tombstoned}"
)
# 20. Real-pipeline regression for the MUST-FIX #1 hoist: the prune MUST
# fire when ``settings.show_cli_sessions`` is pinned to False (the
# established-install case from api/config.py:7637-7648). Without the
# hoist, the ``else:`` branch silently skipped the prune and the
# sidebar kept dangling 404 rows (review IC_kwDOR1LuPM8AAAABHsyFGg).
def test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_false(
_real_pipeline, monkeypatch,
):
"""Mirror of test 13 with ``show_cli_sessions=False``.
Same titled-orphan shape; same state.db setup; but the real pipeline is
invoked via the ``else:`` branch (the ``show_cli_sessions=False`` case
that the original PR missed). The row MUST still be pruned, and the
prune MUST be persisted via ``prune_session_from_index``.
"""
import api.routes as routes
tmp_path = _real_pipeline
_write_webui_sidecar(
tmp_path / "sessions",
session_id="webui-titled-orphan-cli-off",
title="Native WebUI",
message_count=0,
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
"webui-titled-orphan-cli-off": {
"source": "webui",
"title": "Native WebUI",
"messages": 0,
},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned, show_cli_sessions=False)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-titled-orphan-cli-off" not in sid_list, (
"A titled webui zero-message orphan MUST be pruned even when "
"show_cli_sessions=False — established installs (settings pinned "
"to False per api/config.py:7637-7648) are exactly the long-time "
"users who accumulated the #4985 404 orphans (review "
"IC_kwDOR1LuPM8AAAABHsyFGg)."
)
assert pruned == ["webui-titled-orphan-cli-off"], (
"prune_session_from_index MUST be called even in the "
"show_cli_sessions=False branch (the hoist)"
)
# 21. Companion positive control: the prune MUST also fire when
# ``show_cli_sessions=True`` (the original PR's happy path). Explicit
# test name so future readers can grep for the boolean pair rather
# than relying on implicit coverage from test 13's name.
def test_real_all_sessions_post1171_prune_fires_when_show_cli_sessions_true(
_real_pipeline, monkeypatch,
):
"""Mirror of test 13 with explicit ``show_cli_sessions=True`` assertion."""
import api.routes as routes
tmp_path = _real_pipeline
_write_webui_sidecar(
tmp_path / "sessions",
session_id="webui-titled-orphan-cli-on",
title="Native WebUI",
message_count=0,
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
"webui-titled-orphan-cli-on": {
"source": "webui",
"title": "Native WebUI",
"messages": 0,
},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned, show_cli_sessions=True)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-titled-orphan-cli-on" not in sid_list
assert pruned == ["webui-titled-orphan-cli-on"]
# ── Tombstone coverage (#4985 follow-up: no-resurrect thrash prevention) ───
#
# The orphan's sidecar JSON stays on disk after ``prune_session_from_index``
# removes it from SESSION_INDEX_FILE. On the next ``/api/sessions`` poll,
# ``recover_missing_index_sidecars`` re-adds the pruned orphan to
# SESSION_INDEX_FILE (via ``Session.load_metadata_only`` +
# ``_write_session_index``). N orphans therefore cost 2N fsync'd index
# writes + N state.db probes per poll, forever.
#
# The fix: persist a small tombstone set at
# ``SESSION_DIR / _pruned_webui_orphans.json``. ``recover_missing_index_sidecars``
# skips any sid in the tombstone (no re-add to index). On
# ``new_session()`` / ``import_cli_session()``, the tombstone entry is
# cleared so a user can re-use an id after the old one was killed.
# 22. The tombstone PERSISTS across polls: drive a full pipeline prune,
# then drive a second pipeline build, assert that
# ``recover_missing_index_sidecars`` does NOT re-add the pruned sid
# (so only one fsync per orphan total) AND the prune path skips the
# row entirely on the second poll.
def test_tombstone_persists_across_polls(_real_pipeline, monkeypatch):
import api.routes as routes
tmp_path = _real_pipeline
sid = "webui-tombstoned-orphan"
_write_webui_sidecar(
tmp_path / "sessions",
session_id=sid,
title="Native WebUI",
message_count=0,
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
sid: {"source": "webui", "title": "Native WebUI", "messages": 0},
},
)
# Short-circuit reconcile so the row flows through cleanly.
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
# ── Poll 1: real pipeline prunes the orphan and tombstones it. ────
pruned_poll1: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned_poll1.append(s)
)
payload_poll1 = _run_payload(pruned_poll1, show_cli_sessions=False)
assert pruned_poll1 == [sid], (
f"Poll 1 must prune the orphan, got {pruned_poll1}"
)
assert sid not in [s["session_id"] for s in payload_poll1["sessions"]]
# Tombstone file must exist after poll 1.
tombstone_file = tmp_path / "sessions" / "_pruned_webui_orphans.json"
assert tombstone_file.exists(), (
"Tombstone file must be written when the orphan is pruned."
)
# ── Poll 2: prune path must SKIP the tombstoned row entirely. ────
# The sidecar is still on disk, so without the tombstone filter
# ``recover_missing_index_sidecars`` would re-add it to
# SESSION_INDEX_FILE (cache-thrash loop). With the filter, prune
# gets called once total and the row never reappears.
pruned_poll2: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned_poll2.append(s)
)
payload_poll2 = _run_payload(pruned_poll2, show_cli_sessions=False)
assert pruned_poll2 == [], (
f"Poll 2 must NOT re-prune the tombstoned orphan — that's the "
f"thrash loop the tombstone prevents. Got pruned={pruned_poll2}"
)
assert sid not in [s["session_id"] for s in payload_poll2["sessions"]], (
"Poll 2 must not re-surface the tombstoned orphan in the sidebar."
)
# 23. Defensive: clearing the tombstone on a brand-new Session with the
# same id must let that id show up in the sidebar again on the next
# poll. Without this, a real new session would be shadowed by a stale
# tombstone entry. (In practice sids are random UUIDs so this is
# defensive, but cheap to test.)
def test_tombstone_does_not_block_new_session_with_same_id(
_real_pipeline, monkeypatch,
):
import api.models as models
tmp_path = _real_pipeline
sid = "webui-reused-sid"
# ── Pre-populate a tombstone for the sid. ─────────────────────────
_write_webui_sidecar(
tmp_path / "sessions",
session_id=sid,
title="Native WebUI",
message_count=0,
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
sid: {"source": "webui", "title": "Native WebUI", "messages": 0},
},
)
import api.routes as routes
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
# Poll 1: prune the orphan and tombstone it.
pruned_poll1: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned_poll1.append(s)
)
_run_payload(pruned_poll1, show_cli_sessions=False)
assert pruned_poll1 == [sid]
tombstone_file = tmp_path / "sessions" / "_pruned_webui_orphans.json"
assert tombstone_file.exists()
# ── Brand-new Session created with the same sid. ─────────────────
# The ``new_session()`` tombstone clear must let the new session
# take the id back. We assert by polling again and seeing the row
# NOT get pruned (because the sidecar is on disk but the row is no
# longer tombstoned, ``recover_missing_index_sidecars`` re-adds it
# via ``Session.load_metadata_only``).
# First, update the sidecar AND state.db so the new session looks
# "alive" — non-zero message_count on the sidecar AND a row in
# state.db.messages (real new sessions have content, so the #4985
# prune helper would skip the row on the state.db.messages probe).
_write_webui_sidecar(
tmp_path / "sessions",
session_id=sid,
title="Native WebUI",
message_count=1, # mark as live
)
# Add a row to state.db.messages for this sid (the existing
# _write_state_db fixture would fail because the table already
# exists; here we just append a single message row inline).
db_path = tmp_path / "hermes_home" / "state.db"
_live_conn = sqlite3.connect(str(db_path))
try:
_live_conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (?, 'user', ?, 0)",
(sid, "live-message"),
)
_live_conn.execute(
"UPDATE sessions SET message_count = 1 WHERE id = ?",
(sid,),
)
_live_conn.commit()
finally:
_live_conn.close()
# Mimic what ``new_session()`` does: clear the tombstone for the sid.
models._clear_webui_zero_message_orphan_tombstone(sid)
# Poll 2: the tombstone must be empty so ``recover_missing_index_sidecars``
# is free to re-add the (now live) row.
pruned_poll2: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned_poll2.append(s)
)
_run_payload(pruned_poll2, show_cli_sessions=False)
# The row should NOT be re-pruned (it has messages now). The row
# MAY or MAY NOT show up in the sidebar (depends on whether the
# index path picks it up), but it must not be pruned and the
# tombstone must be empty.
assert sid not in pruned_poll2, (
f"After _clear_webui_zero_message_orphan_tombstone({sid!r}), the "
f"row must not be re-pruned. Got pruned={pruned_poll2}"
)
# 24. Size cap: the tombstone file is bounded to the last N entries
# (cap = WEBUI_ZERO_MESSAGE_ORPHAN_TOMBSTONE_CAP) so a long-running
# install with millions of prunes does not grow the file without
# bound. Verify by directly calling _save with > N ids.
def test_tombstone_trimmed_to_last_N_entries(monkeypatch, tmp_path):
from api import models
cap = models.WEBUI_ZERO_MESSAGE_ORPHAN_TOMBSTONE_CAP
oversized = [f"sid-{i:06d}" for i in range(cap + 250)]
# ── In-memory logic check: trim to last N. ────────────────────────
sorted_ids = sorted(set(
str(sid).strip() for sid in oversized if str(sid or "").strip()
))
if len(sorted_ids) > cap:
sorted_ids = sorted_ids[-cap:]
payload = {
"version": models.WEBUI_ZERO_MESSAGE_ORPHAN_TOMBSTONE_VERSION,
"ids": sorted_ids,
}
assert len(payload["ids"]) == cap, (
f"Tombstone ids list must be capped at {cap}, got {len(payload['ids'])}"
)
# The first (cap+250-cap)=250 entries must be dropped; the last cap
# entries (sid-(250) through sid-(cap+249)) must be kept.
assert payload["ids"][0] == f"sid-{250:06d}", (
f"Trim must keep the LAST {cap} entries; expected first id "
f"sid-{250:06d}, got {payload['ids'][0]}"
)
assert payload["ids"][-1] == f"sid-{cap + 249:06d}", (
f"Trim must keep the LAST {cap} entries; expected last id "
f"sid-{cap + 249:06d}, got {payload['ids'][-1]}"
)
# ── On-disk I/O check: the file is bounded when written via the
# real save path. The file path is resolved at call time from
# ``SESSION_DIR`` so patching ``SESSION_DIR`` is sufficient — there
# is no module-level ``WEBUI_ZERO_MESSAGE_ORPHAN_TOMBSTONE_FILE``
# constant to patch separately.
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
models._save_webui_zero_message_orphan_tombstone(oversized)
import json as _json
on_disk = _json.loads(
(session_dir / "_pruned_webui_orphans.json").read_text(encoding="utf-8")
)
assert len(on_disk["ids"]) == cap, (
f"On-disk tombstone must be capped at {cap}, got "
f"{len(on_disk['ids'])}"
)
# ── Self-heal + fail-open coverage (review IC_kwDOR1LuPM8AAAABHvY-dw) ──────
#
# MUST-FIX #1 in the review: the tombstone must NOT be a blind-drop filter.
# A sid that legitimately gains real ``state.db.messages`` content after
# being tombstoned MUST re-surface in the sidebar on the same poll, and the
# tombstone file MUST drop that sid. The previous behavior kept the row
# hidden forever (strictly worse than the orphan it suppressed).
#
# MUST-FIX #2 in the review: ``_load_webui_zero_message_orphan_tombstone``
# must be fail-open on a corrupt file (non-JSON, version mismatch, non-dict
# root) so a transient crash or hand-edit can never accidentally admit
# rows that should stay tombstoned. This is the #5023 lesson the
# maintainer explicitly cited.
# 25. Self-heal: a tombstoned sid whose state.db.messages now contains rows
# MUST appear in the sidebar payload on the next poll AND the tombstone
# file MUST drop the sid. This is the regression the maintainer's review
# IC_kwDOR1LuPM8AAAABHvY-dw explicitly asked for. Uses the same
# ``_real_pipeline`` fixture as tests 13-24 so the row flows through the
# REAL ``all_sessions()`` index recovery + prune helper path.
def test_tombstone_self_heals_when_message_added(_real_pipeline, monkeypatch):
import api.routes as routes
tmp_path = _real_pipeline
sid = "webui-resurrected"
# Pre-populate the sidecar AND state.db so the row has BOTH:
# - a real ``state.db.sessions`` row (so it can be an orphan candidate)
# - a real ``state.db.messages`` row (so the post-probe self-heal
# branch fires — probe says NOT empty AND sid IS tombstoned)
_write_webui_sidecar(
tmp_path / "sessions",
session_id=sid,
title="Native WebUI",
message_count=1,
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
sid: {"source": "webui", "title": "Native WebUI", "messages": 1},
},
)
# Pre-populate the tombstone file with the sid so the row enters the
# helper already tombstoned. Mirror the production file format so the
# loader accepts it.
import api.models as models
session_dir = tmp_path / "sessions"
tombstone_file = session_dir / "_pruned_webui_orphans.json"
tombstone_file.write_text(
json.dumps(
{
"version": models.WEBUI_ZERO_MESSAGE_ORPHAN_TOMBSTONE_VERSION,
"ids": [sid],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
# Sanity: loader returns the tombstoned sid before the poll.
assert sid in models._load_webui_zero_message_orphan_tombstone()
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned.append(s)
)
payload = _run_payload(pruned, show_cli_sessions=False)
# The tombstoned-but-now-live row MUST appear in the payload. The
# previous (broken) behavior was to keep it hidden forever even after
# messages were added — a strictly-worse data loss than the orphan it
# suppressed.
sid_list = [s["session_id"] for s in payload["sessions"]]
assert sid in sid_list, (
f"A tombstoned sid whose state.db.messages now contains rows MUST "
f"re-surface in the sidebar on the next poll (review "
f"IC_kwDOR1LuPM8AAAABHvY-dw self-heal regression). Got "
f"sid_list={sid_list}"
)
# And the prune helper must NOT have called prune_session_from_index
# for the now-live row — it was self-healed, not pruned.
assert sid not in pruned, (
f"Self-healing path must clear the tombstone instead of pruning. "
f"Got pruned={pruned}"
)
# The tombstone file must no longer contain the sid (or be missing
# entirely — both are valid end-states once the set is empty).
if tombstone_file.exists():
on_disk = json.loads(tombstone_file.read_text(encoding="utf-8"))
assert sid not in on_disk.get("ids", []), (
f"Self-heal must drop the sid from the tombstone, got "
f"on_disk={on_disk}"
)
# 26. Fail-open: a corrupt tombstone file MUST degrade to ``frozenset()``
# instead of raising, so the recovery path never accidentally admits a
# row that should stay tombstoned AND a transient crash or hand-edit
# can never break the sidebar. This is the explicit #5023 lesson the
# maintainer cited. Three corruption variants are exercised so we catch
# any future regression that special-cases one shape.
@pytest.mark.parametrize(
"corrupt_payload",
[
pytest.param("not even json {{", id="non-json"),
pytest.param(
'{"version": "wrong", "ids": ["sess-a"]}',
id="version-mismatch",
),
pytest.param("[1, 2, 3]", id="non-dict-root"),
],
)
def test_tombstone_loader_is_fail_open_on_corrupt_file(
_real_pipeline, monkeypatch, corrupt_payload,
):
import api.models as models
import api.routes as routes
tmp_path = _real_pipeline
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True, exist_ok=True)
tombstone_file = session_dir / "_pruned_webui_orphans.json"
tombstone_file.write_text(corrupt_payload, encoding="utf-8")
# Loader MUST return an empty set (not raise, not return partial data).
loaded = models._load_webui_zero_message_orphan_tombstone()
assert loaded == frozenset(), (
f"Loader must fail-open to frozenset() on corrupt file "
f"(review #5023 lesson), got {loaded!r}"
)
# A full sidebar build MUST complete without raising — even with the
# tombstone unreadable, the prune path can still do its job (it just
# sees no tombstone entries and relies on its own state.db probe).
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned.append(s)
)
payload = _run_payload(pruned, show_cli_sessions=False)
# Sanity: payload is a dict with the expected key shape.
assert isinstance(payload, dict)
assert "sessions" in payload
# 27. Sidecar-only retention: sidecar JSON has message_count=3 (real
# transcript on disk) but state.db.messages is empty (mirroring lag
# or non-mirroring provider). The row passes the gate via
# message_count > 0, the state.db probe says empty — without the
# sidecar-only fix the row would be pruned + tombstoned and the
# user would lose access to a real conversation. With the fix the
# row is retained (maintainer review IC_kwDOR1LuPM8AAAABHvx9Hw).
def test_sidecar_only_webui_session_is_retained(_real_pipeline, monkeypatch):
import api.routes as routes
tmp_path = _real_pipeline
_write_webui_sidecar(
tmp_path / "sessions",
session_id="webui-sidecar-only",
title="Native WebUI",
message_count=3,
messages=[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "more"},
],
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
# state.db mirrors the count so #1171 keeps the row past the
# keep-filter (count > 0 → row survives), but messages=0 means
# the state.db.messages table is empty — the orphan shape.
"webui-sidecar-only": {"source": "webui", "title": "Native WebUI", "messages": 0},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda sid: pruned.append(sid)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert "webui-sidecar-only" in sid_list, (
"Sidecar-only session (sidecar message_count>0 but state.db.messages "
"empty) MUST be retained — the conversation is real, just not yet "
"mirrored into state.db (maintainer review "
"IC_kwDOR1LuPM8AAAABHvx9Hw)."
)
assert pruned == [], (
f"prune_session_from_index must NOT be called for a sidecar-only "
f"session, got pruned={pruned}"
)
# Tombstone file must not exist (nothing tombstoned this poll).
tombstone_file = tmp_path / "sessions" / "_pruned_webui_orphans.json"
assert not tombstone_file.exists() or \
json.loads(tombstone_file.read_text(encoding="utf-8")).get("ids", []) == [], (
f"Sidecar-only retention must not tombstone, got "
f"{tombstone_file.read_text() if tombstone_file.exists() else 'missing'}"
)
# 28. Sidecar-only self-heal: a tombstoned sid whose sidecar has messages
# but state.db is empty MUST self-heal on the next poll (tombstone
# cleared, row returned to the sidebar). Combines the r4.5 self-heal
# logic with the r5 sidecar-only retention rule.
def test_sidecar_only_webui_session_self_heals_tombstone(_real_pipeline, monkeypatch):
import api.models as models
import api.routes as routes
tmp_path = _real_pipeline
sid = "webui-sidecar-only-tombstoned"
_write_webui_sidecar(
tmp_path / "sessions",
session_id=sid,
title="Native WebUI",
message_count=3,
messages=[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "more"},
],
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
sid: {"source": "webui", "title": "Native WebUI", "messages": 0},
},
)
# Pre-tombstone the sid (the r4.5 path).
tombstone_file = tmp_path / "sessions" / "_pruned_webui_orphans.json"
models._record_webui_zero_message_orphan_tombstone(sid)
assert tombstone_file.exists()
pre = json.loads(tombstone_file.read_text(encoding="utf-8"))
assert sid in pre.get("ids", [])
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned.append(s)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert sid in sid_list, (
"Tombstoned sidecar-only session MUST self-heal — the sidecar has "
"real messages so the row belongs in the sidebar."
)
assert pruned == [], (
f"Self-healed sidecar-only session must not be re-pruned, "
f"got pruned={pruned}"
)
assert not tombstone_file.exists() or \
sid not in json.loads(tombstone_file.read_text(encoding="utf-8")).get("ids", []), (
"Tombstone must be cleared after self-heal."
)
# 29. Truly-empty sidecar with renamed title is still pruned. This is
# the companion negative test: sidecar message_count=0 + non-Untitled
# title is a real orphan (user renamed an empty session). The r5 fix
# must NOT accidentally retain ALL title-renamed rows — only the ones
# where the sidecar actually has messages.
def test_truly_empty_sidecar_with_title_still_pruned(_real_pipeline, monkeypatch):
import api.routes as routes
tmp_path = _real_pipeline
sid = "webui-truly-empty-titled"
_write_webui_sidecar(
tmp_path / "sessions",
session_id=sid,
title="My Renamed Empty Session",
message_count=0, # <-- the sidecar is genuinely empty
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
sid: {"source": "webui", "title": "My Renamed Empty Session", "messages": 0},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned.append(s)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert sid not in sid_list, (
"A sidecar with message_count=0 and a non-Untitled title is a "
"genuine orphan — the r5 fix must not over-retain."
)
assert pruned == [sid]
# 30. Sidecar-only with show_cli_sessions=False (the r4 hoist applies).
# Combined regression: the established-install path (show_cli_sessions
# pinned False) must also respect sidecar-only retention.
def test_sidecar_only_webui_session_retained_when_show_cli_sessions_false(
_real_pipeline, monkeypatch,
):
import api.routes as routes
tmp_path = _real_pipeline
sid = "webui-sidecar-only-established"
_write_webui_sidecar(
tmp_path / "sessions",
session_id=sid,
title="Native WebUI",
message_count=5,
messages=[
{"role": "user", "content": "msg-1"},
{"role": "assistant", "content": "reply-1"},
{"role": "user", "content": "msg-2"},
{"role": "assistant", "content": "reply-2"},
{"role": "user", "content": "msg-3"},
],
)
_write_state_db(
tmp_path / "hermes_home",
sessions={
sid: {"source": "webui", "title": "Native WebUI", "messages": 0},
},
)
pruned: list[str] = []
monkeypatch.setattr(
routes, "prune_session_from_index", lambda s: pruned.append(s)
)
monkeypatch.setattr(
routes,
"_reconcile_stale_stream_state_for_session_rows",
lambda _rows: False,
)
payload = _run_payload(pruned, show_cli_sessions=False)
sid_list = [s["session_id"] for s in payload["sessions"]]
assert sid in sid_list, (
"Sidecar-only retention must hold even on the established-install "
"path (show_cli_sessions=False)."
)
assert pruned == []