diff --git a/gateway/session.py b/gateway/session.py index d03348acb60bd..987756b794e55 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -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: diff --git a/hermes_state.py b/hermes_state.py index 8df3552758873..fd65296c2c6cd 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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 = [] diff --git a/hermes_state_common.py b/hermes_state_common.py index e386c1260b3fe..bb8539e1bdb7d 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -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}))" ) diff --git a/hermes_state_schema.py b/hermes_state_schema.py index 294664fc64396..77d6db87b557f 100644 --- a/hermes_state_schema.py +++ b/hermes_state_schema.py @@ -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(" diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index 629ee7e378550..496fc61f54b5e 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -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 - - diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 2e40ea4cf68e3..fa25b08e2da6b 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -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 "