fix(sessions): keep reset conversations listable

This commit is contained in:
embwl0x 2026-08-11 11:42:35 -05:00 committed by kshitij
parent ac9b9f54ca
commit ce89afa59c
6 changed files with 197 additions and 21 deletions

View File

@ -2756,6 +2756,11 @@ class SessionStore:
"origin_json": _origin_json,
"display_name": source.chat_name,
"parent_session_id": prev_session_id,
"model_config": (
{"_reset_from": prev_session_id}
if prev_session_id
else None
),
}
if _needs_save:
@ -3251,6 +3256,7 @@ class SessionStore:
"origin_json": _reset_origin_json,
"display_name": old_entry.display_name,
"parent_session_id": db_end_session_id,
"model_config": {"_reset_from": db_end_session_id},
}
if self._db and db_end_session_id:

View File

@ -4096,7 +4096,25 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
model = COALESCE(sessions.model, excluded.model),
model_config = COALESCE(sessions.model_config, excluded.model_config),
model_config = CASE
WHEN excluded.model_config IS NOT NULL
AND json_type(
sessions.model_config, '$._reset_from'
) IS NOT NULL
AND json_remove(
sessions.model_config, '$._reset_from'
) = '{}'
THEN json_set(
excluded.model_config,
'$._reset_from',
json_extract(
sessions.model_config, '$._reset_from'
)
)
ELSE COALESCE(
sessions.model_config, excluded.model_config
)
END,
system_prompt_hash = COALESCE(
sessions.system_prompt_hash,
excluded.system_prompt_hash
@ -7478,8 +7496,10 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
Uses a single query with correlated subqueries instead of N+2 queries.
By default, child sessions (subagent runs, compression continuations)
are excluded. Pass ``include_children=True`` to include them.
By default, child sessions that represent implementation details
(subagent runs, compression continuations) are excluded. User-visible
branch and reset children remain listable. Pass ``include_children=True``
to include every child.
With ``project_compression_tips=True`` (default), sessions that are
roots of compression chains are projected forward to their latest
@ -7528,10 +7548,10 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
params = []
if not include_children:
# Show root sessions and branch sessions, while still hiding
# sub-agent runs and compression continuations (which also carry a
# parent_session_id but were spawned while the parent was still
# live — i.e., started_at < parent.ended_at).
# Show roots and user-visible branch/reset sessions, while still
# hiding sub-agent runs and compression continuations. All four
# carry parent_session_id, so the shared predicate classifies the
# edge from stable markers plus legacy-compatible parent metadata.
#
# Branch sessions are identified two ways, OR'd for robustness:
# 1. A stable ``_branched_from`` marker in model_config, written
@ -7599,8 +7619,8 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
# level instead of fetching every row and sorting in Python, while
# still surfacing old compression roots whose live tip is fresh.
#
# The CTE seeds from rows the outer WHERE admits (roots + branch
# children), then recursively joins forward through robust
# The CTE seeds from rows the outer WHERE admits (roots +
# user-visible branch/reset children), then recursively joins through
# compression-continuation edges. Do NOT require
# child.started_at >= parent.ended_at here: real desktop/gateway
# races can insert the continuation row before the parent's
@ -9609,7 +9629,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
"""Count sessions, optionally filtered by source.
Pass ``exclude_children=True`` to count only the conversations that
``list_sessions_rich`` surfaces (root + branch sessions), hiding
``list_sessions_rich`` surfaces (root + branch/reset sessions), hiding
sub-agent runs and compression continuations. Use it whenever the count
is paired with a ``list_sessions_rich`` page (e.g. sidebar "load more"
totals) so the total matches the number of listable rows otherwise the
@ -9625,8 +9645,8 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
if exclude_children:
# Mirror list_sessions_rich's child-exclusion clause exactly so the
# count lines up with the rows: roots (no parent) plus branch
# children (parent ended with end_reason='branched').
# count lines up with the rows: roots plus user-visible branch/reset
# children.
where_clauses.append(_LISTABLE_CHILD_SQL)
where_clauses.append(f"{_delegate_from_json('s.model_config')} IS NULL")
include_sources = [source] if source else list(sources or [])
@ -9688,8 +9708,8 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
``list_sessions_rich``).
``exclude_children=True`` mirrors ``list_sessions_rich`` visibility
(roots + branch sessions, excluding sub-agent runs, delegates, and
compression continuations) so the source counts match what the
(roots + branch/reset sessions, excluding sub-agent runs, delegates,
and compression continuations) so the source counts match what the
Sessions page actually lists.
"""
where_clauses = []

View File

@ -98,19 +98,50 @@ _COMPRESSION_CHILD_SQL = (
)
# Rows that surface in pickers: roots + branch children (subagent runs and
# compression continuations stay hidden).
_LISTABLE_CHILD_SQL = f"(s.parent_session_id IS NULL OR {_BRANCH_CHILD_SQL.format(a='s')})"
_RESET_END_REASONS = (
"session_reset",
"idle",
"daily",
"suspended",
"resume_pending_expired",
)
_RESET_END_REASONS_SQL = ", ".join(f"'{reason}'" for reason in _RESET_END_REASONS)
# A reset starts a separate user-visible conversation even though gateway rows
# retain parent_session_id for durable lineage. New rows carry the stable
# marker; the same-key fallback recovers rows written before the marker existed.
# Requiring the exact non-empty routing key keeps ordinary child/subagent rows
# out even when their parent is later reset.
_RESET_CHILD_SQL = (
"json_extract(COALESCE({a}.model_config, '{{}}'), '$._reset_from') IS NOT NULL"
" OR EXISTS (SELECT 1 FROM sessions p"
" WHERE p.id = {a}.parent_session_id"
f" AND p.end_reason IN ({_RESET_END_REASONS_SQL})"
" AND {a}.session_key IS NOT NULL"
" AND {a}.session_key != ''"
" AND {a}.session_key = p.session_key)"
)
# Rows that surface in pickers: roots + branch/reset children. Subagent runs
# and compression continuations stay hidden.
_LISTABLE_CHILD_SQL = (
f"(s.parent_session_id IS NULL OR {_BRANCH_CHILD_SQL.format(a='s')}"
f" OR {_RESET_CHILD_SQL.format(a='s')})"
)
def _ephemeral_child_sql(alias: str = "s") -> str:
"""Subagent runs (cascade-delete targets), not branches or compression tips."""
"""Subagent runs, not branch, reset, or compression children."""
branch = _BRANCH_CHILD_SQL.format(a=alias)
compression = _COMPRESSION_CHILD_SQL.format(a=alias)
reset = _RESET_CHILD_SQL.format(a=alias)
return (
f"({alias}.parent_session_id IS NOT NULL"
f" AND NOT ({branch})"
f" AND NOT ({compression}))"
f" AND NOT ({compression})"
f" AND NOT ({reset}))"
)

View File

@ -911,6 +911,7 @@ class SessionSchemaMixin:
if current_version < 16:
# v16: tag delegate subagent rows so pickers stay clean after
# parent deletes that used to orphan them (parent_session_id → NULL).
# The shared predicate excludes user-visible reset children.
try:
cursor.execute(
"UPDATE sessions SET model_config = json_set("

View File

@ -375,6 +375,72 @@ class TestHandleResumeCommand:
class TestHandleSessionsCommand:
"""Tests for GatewayRunner._handle_sessions_command."""
@pytest.mark.asyncio
async def test_sessions_full_lists_conversations_created_by_gateway_resets(
self, tmp_path
):
import json
from gateway.config import GatewayConfig
from gateway.session import AsyncSessionStore, SessionStore
from hermes_state import AsyncSessionDB
event = _make_event(text="/sessions full")
store = SessionStore(
sessions_dir=tmp_path / "sessions",
config=GatewayConfig(),
)
db = store._db
assert db is not None
entry = store.get_or_create_session(event.source)
db.set_session_title(entry.session_id, "Greeting via Telegram")
for title in (
"Store memories with priority",
"Extract AI news to Telegram",
"Current Telegram work",
):
previous_id = entry.session_id
entry = store.reset_session(entry.session_key)
assert entry is not None
db.set_session_title(entry.session_id, title)
reset_row = db.get_session(entry.session_id)
assert reset_row is not None
assert json.loads(reset_row["model_config"])["_reset_from"] == previous_id
# The gateway creates the identity row before the agent exists. Its
# first-turn create_session upsert must enrich the marker-only config,
# while later bare/retry upserts must not replace the established data.
db.create_session(
entry.session_id,
"telegram",
model_config={"max_iterations": 60},
)
enriched = json.loads(db.get_session(entry.session_id)["model_config"])
assert enriched == {
"max_iterations": 60,
"_reset_from": previous_id,
}
db.create_session(
entry.session_id,
"telegram",
model_config={"max_iterations": 999},
)
assert json.loads(db.get_session(entry.session_id)["model_config"]) == enriched
runner = _make_runner(session_db=None, event=event)
runner.session_store = store
runner._async_session_store = AsyncSessionStore(store)
runner._session_db = AsyncSessionDB(db)
result = await runner._handle_sessions_command(event)
assert "Greeting via Telegram" in result
assert "Store memories with priority" in result
assert "Extract AI news to Telegram" in result
assert "Current Telegram work" not in result
db.close()
@pytest.mark.asyncio
async def test_sessions_busy_platform_lists_exact_lane_and_excludes_current_tip(
self, tmp_path
@ -766,5 +832,3 @@ class TestSameMatrixRoomThreadScoping:
caller = self._msrc(thread_id="thread-a")
victim_origin = self._msrc(thread_id="thread-b")
assert runner._same_matrix_room(caller, victim_origin) is False

View File

@ -1850,6 +1850,60 @@ class TestListSessionsRich:
assert [session["id"] for session in sessions] == ["lane_tip"]
assert sessions[0]["_lineage_root_id"] == "lane_root"
@pytest.mark.parametrize(
"end_reason",
[
"session_reset",
"idle",
"daily",
"suspended",
"resume_pending_expired",
],
)
def test_rich_list_keeps_legacy_reset_children_visible(self, db, end_reason):
from hermes_state_common import _ephemeral_child_sql
lane_key = "agent:main:telegram:dm:lane"
parent_id = f"parent_{end_reason}"
child_id = f"child_{end_reason}"
db.create_session(parent_id, "telegram", session_key=lane_key)
db.end_session(parent_id, end_reason)
# No _reset_from marker: this is the on-disk shape written before the
# marker existed. The unchanged routing key proves a reset boundary.
db.create_session(
child_id,
"telegram",
session_key=lane_key,
parent_session_id=parent_id,
)
listed = [row["id"] for row in db.list_sessions_rich(source="telegram")]
assert {parent_id, child_id}.issubset(listed)
assert db.session_count(source="telegram", exclude_children=True) == 2
assert db.session_count_by_source(exclude_children=True)["telegram"] == 2
ephemeral = db._conn.execute(
f"SELECT s.id FROM sessions s WHERE {_ephemeral_child_sql('s')}"
).fetchall()
assert child_id not in {row["id"] for row in ephemeral}
def test_reset_parent_does_not_surface_unrelated_child(self, db):
db.create_session(
"reset_parent",
"telegram",
session_key="agent:main:telegram:dm:lane",
)
db.end_session("reset_parent", "session_reset")
db.create_session(
"unrelated_child",
"tool",
session_key="delegate:other",
parent_session_id="reset_parent",
)
listed = [row["id"] for row in db.list_sessions_rich()]
assert "unrelated_child" not in listed
assert db.session_count(exclude_children=True) == 1
def test_session_key_predicate_can_use_session_key_index(self, db):
plan = db._conn.execute(
"EXPLAIN QUERY PLAN "