"""Regression coverage for capped CLI/agent session sidebar scans (#2628).""" import sqlite3 import time from contextlib import contextmanager import pytest import api.agent_sessions as agent_sessions _REAL_SQLITE_CONNECT = sqlite3.connect def _make_state_db(path, *, sessions=80, messages_per_session=3, create_messages_index=True, source="cli", session_source="cli"): 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 ); """ ) 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 (?, ?, ?, ?, 'openai/gpt-5', ?, ?, NULL, NULL, NULL) """, (sid, source, session_source, 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), ) if create_messages_index: conn.execute("CREATE INDEX idx_messages_session ON messages(session_id, timestamp)") conn.commit() conn.close() def _newest_first_reference_ids(db_path, *, include_sources=None, exclude_sources=("webui",)): where_clauses = ["s.source IS NOT NULL"] params = [] if include_sources: placeholders = ", ".join("?" for _ in include_sources) where_clauses.append(f"s.source IN ({placeholders})") params.extend(include_sources) if exclude_sources: placeholders = ", ".join("?" for _ in exclude_sources) where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(exclude_sources) conn = sqlite3.connect(f"file:{db_path}?mode=ro&immutable=1", uri=True) conn.row_factory = sqlite3.Row try: rows = conn.execute( f""" SELECT s.id 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, ).fetchall() return [row["id"] for row in rows] finally: conn.close() def _execute_candidate_ordering_baseline_sql( db_path, candidate_limit, *, budget_ops, interval=1, include_sources=None, exclude_sources=("webui",), ): where_clauses = ["s.source IS NOT NULL"] params = [] if include_sources: placeholders = ", ".join("?" for _ in include_sources) where_clauses.append(f"s.source IN ({placeholders})") params.extend(include_sources) if exclude_sources: placeholders = ", ".join("?" for _ in exclude_sources) where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(exclude_sources) def _on_progress(): nonlocal steps steps += 1 return 1 if steps > budget_ops else 0 steps = 0 conn = sqlite3.connect(f"file:{db_path}?mode=ro&immutable=1", uri=True) conn.set_progress_handler(_on_progress, interval) try: return conn.execute( f""" WITH candidates AS ( SELECT s.id FROM sessions s WHERE {' AND '.join(where_clauses)} ORDER BY COALESCE( (SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id), s.started_at ) DESC, s.started_at DESC LIMIT ? ) SELECT s.id 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], ).fetchall() finally: conn.close() def _make_connect_with_progress_budget(*, budget_ops, interval=1): def _connect(database, *_, **__): steps = {"count": 0} database_uri = str(database) if database_uri.startswith("file:"): target_uri = database_uri else: target_uri = f"file:{database_uri}?mode=ro&immutable=1" def _on_progress(): steps["count"] += 1 return 1 if steps["count"] > budget_ops else 0 conn = _REAL_SQLITE_CONNECT( target_uri, uri=True, ) conn.set_progress_handler(_on_progress, interval) return conn return _connect @contextmanager def _capture_listing_sql(): """Record the SQL statements the listing runs (trace callback, read-only).""" statements = [] def _tracing_connect(*args, **kwargs): conn = _REAL_SQLITE_CONNECT(*args, **kwargs) conn.set_trace_callback(statements.append) return conn original_connect = agent_sessions.sqlite3.connect agent_sessions.sqlite3.connect = _tracing_connect try: yield statements finally: agent_sessions.sqlite3.connect = original_connect def _make_connect_with_progress_counter(*, interval=1): steps = {"count": 0} def _connect(database, *_, **__): database_uri = str(database) if database_uri.startswith("file:"): target_uri = database_uri else: target_uri = f"file:{database_uri}?mode=ro&immutable=1" def _on_progress(): steps["count"] += 1 return 0 conn = _REAL_SQLITE_CONNECT( target_uri, uri=True, ) conn.set_progress_handler(_on_progress, interval) return conn return _connect, steps 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) with _capture_listing_sql() as statements: 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} candidates_sql = next( statement for statement in statements if "LIMIT" in statement and "FROM sessions s" in statement ) # The cap is applied inside the candidate subquery, not in Python after an # unbounded aggregate: the oversampled window (limit * 8 = 160 here) is # part of the statement SQLite actually runs, and it bounds the candidate # subquery that the outer projection joins. assert candidates_sql.count("LIMIT") == 1, candidates_sql window_end = candidates_sql.index("LIMIT") window = candidates_sql[:window_end] assert "JOIN candidates" not in window, candidates_sql cap = int(candidates_sql[window_end:].split(None, 2)[1]) assert cap == 160, candidates_sql def test_importable_agent_rows_candidate_ordering_stays_under_progress_budget(tmp_path, monkeypatch): """Cron-only missing-index scans should fail under the old shape budget, then pass after pre-aggregation.""" db = tmp_path / "state.db" _make_state_db( db, sessions=120, messages_per_session=900, create_messages_index=False, source="cron", session_source="cron", ) reference_ids = _newest_first_reference_ids(db, include_sources=("cron",), exclude_sources=None) candidate_limit = max(20 * 8, 20) progress_interval = 100 original_connect = agent_sessions.sqlite3.connect connect_with_progress_counter, progress_counter = _make_connect_with_progress_counter( interval=progress_interval ) monkeypatch.setattr(agent_sessions.sqlite3, "connect", connect_with_progress_counter) try: measured_rows = agent_sessions.read_importable_agent_session_rows( db, limit=20, exclude_sources=None, include_sources=("cron",), ) assert [row["id"] for row in measured_rows] == reference_ids[:20] finally: # Keep this helper isolated; the baseline must still run without the # counting handler to validate raw cost differences. monkeypatch.setattr(agent_sessions.sqlite3, "connect", original_connect) # Give the head path a small deterministic margin, then require the old # correlated query to exceed the same budget on the missing-index branch. progress_budget_ops = max(progress_counter["count"] + 200, 1) with pytest.raises(sqlite3.OperationalError, match="interrupted"): _execute_candidate_ordering_baseline_sql( db, candidate_limit, budget_ops=progress_budget_ops, interval=progress_interval, include_sources=("cron",), exclude_sources=None, ) monkeypatch.setattr( agent_sessions.sqlite3, "connect", _make_connect_with_progress_budget( budget_ops=progress_budget_ops, interval=progress_interval, ), ) rows = agent_sessions.read_importable_agent_session_rows( db, limit=20, exclude_sources=None, include_sources=("cron",), ) assert [row["id"] for row in rows] == reference_ids[:20] def test_importable_agent_rows_limit_includes_resumed_old_session(tmp_path): """The capped candidate window must not hide old sessions resumed recently.""" db = tmp_path / "state.db" _make_state_db(db, sessions=200, messages_per_session=1) old_started = time.time() - 60 * 60 * 24 * 30 recent_activity = time.time() + 60 conn = sqlite3.connect(str(db)) conn.execute( """ INSERT INTO sessions (id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) VALUES ('cli_resumed_old', 'cli', 'cli', 'Old resumed session', 'openai/gpt-5', ?, 2, NULL, NULL, NULL) """, (old_started,), ) conn.execute( "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES ('old_msg_1', 'cli_resumed_old', 'user', 'old hello', ?)", (old_started,), ) conn.execute( "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES ('old_msg_2', 'cli_resumed_old', 'assistant', 'recent reply', ?)", (recent_activity,), ) conn.commit() conn.close() rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",)) assert rows[0]["id"] == "cli_resumed_old" assert rows[0]["actual_message_count"] == 2 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",)) == [] def _lineage_and_subagent_db(path): """Two 12-segment compression lineages own the 24 newest raw rows. They collapse to 2 logical conversations, so a ``limit=3`` request consumes the whole ``limit * 8`` window and still under-delivers. A hot subagent leaf and its quiet subagent parent sit BELOW that window, reachable only once ``CANDIDATE_WINDOW_MULTIPLIERS`` re-widens it. """ conn = sqlite3.connect(str(path)) conn.executescript( """ CREATE TABLE sessions ( id TEXT PRIMARY KEY, 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 TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, timestamp REAL ); """ ) def add(sid, title, started, source, parent=None, ended_at=None, end_reason=None): conn.execute( "INSERT INTO sessions (id, source, title, model, started_at, message_count, " "parent_session_id, ended_at, end_reason) VALUES (?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)", (sid, source, title, started, parent, ended_at, end_reason), ) for index, role in enumerate(("user", "assistant")): conn.execute( "INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, 'hi', ?)", (f"{sid}_m{index}", sid, role, started + (index + 4) / 10), ) for name, base in (("a", 9000.0), ("b", 8000.0)): for index in range(12): last = index == 11 started = base + index add( f"lin{name}_seg{index}", f"Lineage {name} part {index}", started, "cli", parent=None if index == 0 else f"lin{name}_seg{index - 1}", ended_at=None if last else started + 0.9, end_reason=None if last else "compression", ) add("hot_leaf", "Hot leaf", 7000.0, "subagent", parent="quiet_orch") add("quiet_orch", "Quiet orchestrator", 100.0, "subagent") conn.commit() conn.close() def test_rewidened_candidate_window_still_recovers_subagent_parents(tmp_path): """The parent-recovery walk must run on the window that ACTUALLY executed. ``read_importable_agent_session_rows`` re-adds subagent parents that the oversampled candidate set already projected, so a frozen orchestrator is not evicted while its streaming leaves stay (upstream #7089 / supersedes #7031). That walk reads the projection of the candidate window, and #6659 turned the single window into a re-widening loop. This pins that the walk consumes the LAST window rather than a stale first pass: on the 8x window neither the leaf nor its parent is even fetched, and only the widened window can anchor them. """ db = tmp_path / "state.db" _lineage_and_subagent_db(db) rows = agent_sessions.read_importable_agent_session_rows(db, limit=3, exclude_sources=None) ids = [row["id"] for row in rows] # The 8x window (24 raw rows) is exactly the two lineages: under-delivered. assert ids[:2] == ["lina_seg11", "linb_seg11"] # The widened window reaches the leaf... assert "hot_leaf" in ids # ...and the walk re-adds its quiet parent even though the recency slice # (limit=3) had already dropped it, so the leaf still renders as a child. assert "quiet_orch" in ids assert next(row for row in rows if row["id"] == "hot_leaf")["parent_session_id"] == "quiet_orch" # Contract from the docstring: the anchor pushes the result past ``limit``. assert len(rows) == 4 def test_first_candidate_window_alone_cannot_anchor_the_subagent_parent(tmp_path): """Negative control: without the re-widening pass the same db under-delivers. Pins that the previous test is not vacuous — the 8x window really does stop at the two lineages, so the parent recovery above is attributable to the widened window and not to the first one. """ db = tmp_path / "state.db" _lineage_and_subagent_db(db) single_window = agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS[:1] original = agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS = single_window try: rows = agent_sessions.read_importable_agent_session_rows(db, limit=3, exclude_sources=None) finally: agent_sessions.CANDIDATE_WINDOW_MULTIPLIERS = original ids = [row["id"] for row in rows] assert ids == ["lina_seg11", "linb_seg11"] assert "hot_leaf" not in ids assert "quiet_orch" not in ids