fix(sessions): keep reset conversations listable
This commit is contained in:
parent
ac9b9f54ca
commit
ce89afa59c
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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}))"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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("
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
Loading…
Reference in New Issue