diff --git a/CHANGELOG.md b/CHANGELOG.md index 4758934d..e48115b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Fixed + +- Limit the CLI/agent session sidebar bridge to a recent candidate window before joining message rows, so `show_cli_sessions` no longer aggregates every historical `state.db` message before applying the visible sidebar cap. Closes #2628. ## [v0.51.96] — 2026-05-20 — Release BT (stage-389 — 8-PR batch — IPv6 dashboard link normalization + configured title-generation provider routing + sidebar pinned-session 3-cap + external-refresh sidecar count preference + Hermes overview docs relocation + legacy dedup timestamp granularity + custom provider /models endpoint error surfacing + RuntimeAdapter Slice 4c harness gate RFC) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 641e3a22..059fef3e 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -401,7 +401,7 @@ def read_importable_agent_session_rows( ) where_clauses = ["s.source IS NOT NULL"] - params: list[str] = [] + params: list[object] = [] if exclude_sources: excluded = tuple(str(source) for source in exclude_sources if source) if excluded: @@ -409,8 +409,7 @@ def read_importable_agent_session_rows( where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(excluded) - cur.execute( - f""" + select_sql = f""" SELECT s.id, s.title, s.model, s.message_count, s.started_at, s.source, {session_source_expr}, @@ -428,14 +427,47 @@ def read_importable_agent_session_rows( COUNT(m.id) AS actual_message_count, {user_message_count_expr} AS actual_user_message_count, MAX(m.timestamp) AS last_activity - FROM sessions s - LEFT JOIN messages m ON m.session_id = s.id - WHERE {' AND '.join(where_clauses)} - GROUP BY s.id - ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC - """, - params, - ) + """ + if limit is not None: + result_limit = max(0, int(limit)) + if result_limit == 0: + return [] + # The sidebar only needs a small visible window. Bound the expensive + # messages join to a recent-session candidate set instead of + # aggregating every historical Hermes state.db session before + # slicing in Python. Oversampling preserves room for hidden + # compression segments or other rows filtered after projection. + candidate_limit = max(result_limit * 8, result_limit) + cur.execute( + f""" + WITH candidates AS ( + SELECT s.id + FROM sessions s + WHERE {' AND '.join(where_clauses)} + ORDER BY s.started_at DESC + LIMIT ? + ) + {select_sql} + FROM sessions s + JOIN candidates c ON c.id = s.id + LEFT JOIN messages m ON m.session_id = s.id + GROUP BY s.id + ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC + """, + [*params, candidate_limit], + ) + else: + cur.execute( + f""" + {select_sql} + FROM sessions s + LEFT JOIN messages m ON m.session_id = s.id + WHERE {' AND '.join(where_clauses)} + GROUP BY s.id + ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC + """, + params, + ) projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()]) projected = [_with_normalized_source(row) for row in projected] projected = [row for row in projected if is_cli_session_row_visible(row)] diff --git a/tests/test_issue2628_cli_sessions_perf.py b/tests/test_issue2628_cli_sessions_perf.py new file mode 100644 index 00000000..c97b5591 --- /dev/null +++ b/tests/test_issue2628_cli_sessions_perf.py @@ -0,0 +1,81 @@ +"""Regression coverage for capped CLI/agent session sidebar scans (#2628).""" + +import pathlib +import sqlite3 +import time + +import api.agent_sessions as agent_sessions + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def _make_state_db(path, *, sessions=80, messages_per_session=3): + conn = sqlite3.connect(str(path)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + session_source TEXT, + title TEXT, + model TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + CREATE INDEX idx_sessions_started ON sessions(started_at); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL + ); + CREATE INDEX idx_messages_session ON messages(session_id, timestamp); + """ + ) + base = time.time() - sessions + for i in range(sessions): + sid = f"cli_perf_{i:04d}" + started = base + i + conn.execute( + """ + INSERT INTO sessions + (id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) + VALUES (?, 'cli', 'cli', ?, 'openai/gpt-5', ?, ?, NULL, NULL, NULL) + """, + (sid, sid, started, messages_per_session), + ) + for j in range(messages_per_session): + conn.execute( + "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, 'hello', ?)", + (f"msg_{i:04d}_{j:02d}", sid, "user" if j == 0 else "assistant", started + j / 10), + ) + conn.commit() + conn.close() + + +def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path): + """A capped sidebar scan should not aggregate the entire state.db first.""" + db = tmp_path / "state.db" + _make_state_db(db, sessions=120, messages_per_session=5) + + rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",)) + + assert len(rows) == 20 + assert [row["id"] for row in rows][:3] == ["cli_perf_0119", "cli_perf_0118", "cli_perf_0117"] + assert {row["actual_message_count"] for row in rows} == {5} + + src = (REPO_ROOT / "api" / "agent_sessions.py").read_text() + assert "WITH candidates AS" in src + assert "JOIN candidates c ON c.id = s.id" in src + assert "candidate_limit = max(result_limit * 8, result_limit)" in src + + +def test_importable_agent_rows_zero_limit_skips_query_work(tmp_path): + db = tmp_path / "state.db" + _make_state_db(db, sessions=5, messages_per_session=1) + + assert agent_sessions.read_importable_agent_session_rows(db, limit=0, exclude_sources=("webui",)) == []