Desktop lag and API pagination for long sessions
NousResearch/hermes-agent · cluster 40692 · apps/desktop
Data as of
Nothing open on GitHub. Fix merged in #60883. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #60883. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes address UI freezing and typing lag in the desktop composer during long or tool-heavy sessions, alongside missing pagination on the session messages API endpoint that causes large transcripts to load all at once.
Open issues0
Open PRs0
Fixes on record
-
#60883 perf(dashboard): trim session-list payloads + paginate session messages (salvage #60347/#43653/#47437)
files:
hermes_cli/web_server.py,hermes_state.py,scripts/release.py,tests/hermes_cli/test_web_server.py,tests/test_hermes_state.py,tests/test_tui_gateway_server.py,tui_gateway/server.pyShow the diff
-
hermes_cli/web_server.py@@ -1193,7 +1193,7 @@ def _count_status_active_sessions() -> int: db = SessionDB(read_only=True) try: - sessions = db.list_sessions_rich(limit=50) + sessions = db.list_sessions_rich(limit=50, compact_rows=True) now = time.time() return sum( 1 for s in sessions @@ -2175,7 +2175,6 @@ def _git_path(path: str) -> str: class GitPathBody(BaseModel): path: str - class GitFileBody(BaseModel): path: str file: Optional[str] = None @@ -3835,6 +3834,23 @@ async def get_action_status(name: str, lines: int = 200): } +# Per-row fields that no session LIST consumer reads but that dominate the +# payload. ``system_prompt`` is the fully rendered prompt — tens of KB per +# row — and made a 21-row /api/sessions response 528KB (96% dead weight), +# re-fetched by the desktop sidebar on every refresh. The desktop's +# SessionInfo type doesn't declare either field and the web UI never touches +# them; ``GET /api/sessions/{id}`` detail reads stay complete. List callers +# that genuinely need the full rows can pass ``?full=1``. +_SESSION_LIST_HEAVY_FIELDS = ("system_prompt", "model_config") + + +def _strip_session_list_rows(sessions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + for s in sessions: + for key in _SESSION_LIST_HEAVY_FIELDS: + s.pop(key, None) + return sessions + + @app.get("/api/sessions") def get_sessions( limit: int = 20, @@ -3845,6 +3861,7 @@ def get_sessions( source: str = None, exclude_sources: str = None, cwd_prefix: str = None, + full: bool = False, profile: Optional[str] = None, ): """List sessions. @@ -3858,6 +3875,9 @@ def get_sessions( start time) or ``recent`` (by latest activity across the compression chain). ``recent`` keeps a long-running conversation on the first page after it auto-compresses into a fresh continuation id. + + Rows omit ``system_prompt``/``model_config`` (the payload-dominating + fields no list UI reads) unless ``full=1`` is passed. """ if archived not in ("exclude", "only", "include"): raise HTTPException( @@ -3893,6 +3913,10 @@ def get_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", + # SQL-level projection: when the caller didn't ask for full + # rows, skip the system_prompt blob inside SQLite too (pairs + # with the API-level _strip_session_list_rows below). + compact_rows=not full, ) total = db.session_count( source=source or None, @@ -3914,6 +3938,8 @@ def get_sessions( s["is_default_profile"] = profile_name == "default" # SQLite stores the flag as 0/1; expose a real JSON boolean. s["archived"] = bool(s.get("archived")) + if not full: + _strip_session_list_rows(sessions) return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} finally: db.close() @@ -3934,6 +3960,7 @@ def get_profiles_sessions( profile: str = "all", source: str = None, exclude_sources: str = None, + full: bool = False, ): """Unified, read-only session list aggregated across ALL profiles. @@ -3943,6 +3970,9 @@ def get_profiles_sessions( browsable list and only spins up a profile's backend when the user actually interacts (sends a message). A user with a single (default) profile gets the same rows as ``/api/sessions``, just tagged ``profile="default"``. + + Rows omit ``system_prompt``/``model_config`` unless ``full=1`` — same + list projection as ``/api/sessions``. """ if archived not in ("exclude", "only", "include"): raise HTTPException(status_code=400, detail="archived must be one of: exclude, only, include") @@ -4005,6 +4035,8 @@ def get_profiles_sessions( include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", + # Same SQL-level blob skip as /api/sessions (see above). + compact_rows=not full, ) profile_total = db.session_count( source=source_filter, @@ -4033,6 +4065,8 @@ def get_profiles_sessions( sort_key = "last_active" if order == "recent" else "started_at" merged.sort(key=lambda s: s.get(sort_key) or s.get("started_at") or 0, reverse=True) window = merged[offset:offset + limit] + if not full: + _strip_session_list_rows(window) return { "sessions": window, "total": total, @@ -9328,7 +9362,7 @@ def row_get(row, key, index): "started_at": row_get(row, "started_at", 2), }) else: - rows = db.list_sessions_rich(limit=10000, offset=0) + rows = db.list_sessions_rich(limit=10000, offset=0, compact_rows=True) children = {} for row in rows: @@ -9482,7 +9516,7 @@ async def get_session_stats(profile: Optional[str] = None): messages = db.message_count() by_source: Dict[str, int] = {} try: - for s in db.list_sessions_rich(limit=10000, include_archived=True): + for s in db.list_sessions_rich(limit=10000, include_archived=True, compact_rows=True): src = str(s.get("source") or "cli") by_source[src] = by_source.get(src, 0) + 1 except Exception: @@ -9549,15 +9583,30 @@ async def get_session_latest_descendant( db.close() @app.get("/api/sessions/{session_id}/messages") -async def get_session_messages(session_id: str, profile: Optional[str] = None): +async def get_session_messages( + session_id: str, + profile: Optional[str] = None, + limit: Optional[int] = None, + offset: int = 0, +): db = _open_session_db_for_profile(profile) try: sid = db.resolve_session_id(session_id) if not sid: raise HTTPException(status_code=404, detail="Session not found") sid = db.resolve_resume_session_id(sid) - messages = db.get_messages(sid) - return {"session_id": sid, "messages": messages} + # Clamp limit to prevent abuse (max 500 per page) + _limit = min(limit, 500) if limit is not None else None + messages = db.get_messages(sid, limit=_limit, offset=offset) + return { + "session_id": sid, + "messages": messages, + "pagination": { + "limit": _limit, + "offset": offset, + "returned": len(messages), + }, + } finally: db.close() -
hermes_state.py@@ -2947,6 +2947,28 @@ def get_compression_tip(self, session_id: str) -> Optional[str]: current = child_id return current + # Columns excluded from compact_rows projections: only the payload-heavy + # blob no list consumer renders. Everything else — including gateway + # routing fields and desktop sidebar fields like git_branch — stays, and + # the projection is derived from SCHEMA_SQL so columns added later via + # declarative reconciliation are included automatically instead of + # silently dropping out of list rows. + _SESSION_COMPACT_EXCLUDED = frozenset({"system_prompt"}) + _session_compact_cols_sql: Optional[str] = None + + @classmethod + def _compact_session_cols(cls) -> str: + """SELECT list for compact_rows: every ``sessions`` column declared in + SCHEMA_SQL except the ``system_prompt`` blob, aliased with the ``s`` + prefix used by list_sessions_rich/_get_session_rich_row queries.""" + if cls._session_compact_cols_sql is None: + declared = cls._parse_schema_columns(SCHEMA_SQL)["sessions"] + cls._session_compact_cols_sql = ", ".join( + f"s.{name}" for name in declared + if name not in cls._SESSION_COMPACT_EXCLUDED + ) + return cls._session_compact_cols_sql + def distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]: """Distinct non-empty session cwds with usage stats, for repo discovery. @@ -2988,6 +3010,7 @@ def list_sessions_rich( archived_only: bool = False, id_query: str = None, search_query: str = None, + compact_rows: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -3021,6 +3044,12 @@ def list_sessions_rich( its forward compression chain). A punctuation-stripped variant is also matched so e.g. ``an94`` finds ``AN-94``. Only honored in the ``order_by_last_active`` path. + + Pass ``compact_rows=True`` for dashboard and picker callers that only + need lightweight metadata. This omits the ``system_prompt`` blob from + the SELECT so SQLite never copies it out of the B-tree page — a + significant I/O saving on large databases where the blob routinely + runs to tens of kilobytes per row. """ where_clauses = [] params = [] @@ -3138,6 +3167,7 @@ def _like_pattern(needle: str) -> str: outer_where = ( f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}" ) + _sel = self._compact_session_cols() if compact_rows else "s.*" query = f""" WITH RECURSIVE chain(root_id, cur_id) AS ( SELECT s.id, s.id FROM sessions s {where_sql} @@ -3161,7 +3191,7 @@ def _like_pattern(needle: str) -> str: FROM chain GROUP BY root_id ) - SELECT s.*, + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -3184,8 +3214,9 @@ def _like_pattern(needle: str) -> str: # only applies to the outer select. params = params + params + id_params + [limit, offset] else: + _sel = self._compact_session_cols() if compact_rows else "s.*" query = f""" - SELECT s.*, + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -3236,7 +3267,7 @@ def _like_pattern(needle: str) -> str: if tip_id == s["id"]: projected.append(s) continue - tip_row = self._get_session_rich_row(tip_id) + tip_row = self._get_session_rich_row(tip_id, compact_rows=compact_rows) if not tip_row: projected.append(s) continue @@ -3322,13 +3353,17 @@ def list_cron_job_runs( runs.append(s) return runs - def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]: + def _get_session_rich_row(self, session_id: str, compact_rows: bool = False) -> Optional[Dict[str, Any]]: """Fetch a single session with the same enriched columns as ``list_sessions_rich`` (preview + last_active). Returns None if the session doesn't exist. + + Pass ``compact_rows=True`` to omit the ``system_prompt`` blob (see + ``list_sessions_rich`` for details). """ - query = """ - SELECT s.*, + _sel = self._compact_session_cols() if compact_rows else "s.*" + query = f""" + SELECT {_sel}, COALESCE( (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) FROM messages m @@ -3710,7 +3745,11 @@ def _do(conn): def get_messages( - self, session_id: str, include_inactive: bool = False + self, + session_id: str, + include_inactive: bool = False, + limit: Optional[int] = None, + offset: int = 0, ) -> List[Dict[str, Any]]: """Load messages for a session in insertion order. @@ -3721,14 +3760,25 @@ def get_messages( Ordered by AUTOINCREMENT id (true insertion order) rather than timestamp — see c03acca50 for the WSL2 clock-regression rationale. + + When ``limit`` is provided, returns at most ``limit`` messages + starting from ``offset`` (0-based, in insertion order). Enables + pagination for the API endpoint to avoid loading entire transcripts. + ``offset`` alone (without ``limit``) also pages — SQLite requires a + LIMIT clause for OFFSET, so it's emitted as ``LIMIT -1`` (unbounded). """ active_clause = "" if include_inactive else " AND active = 1" + sql = ( + "SELECT * FROM messages WHERE session_id = ?" + f"{active_clause} ORDER BY id" + ) + params: list = [session_id] + if limit is not None or offset: + # SQLite's OFFSET requires LIMIT; -1 means "no limit". + sql += " LIMIT ? OFFSET ?" + params.extend([-1 if limit is None else limit, offset]) with self._lock: - cursor = self._conn.execute( - "SELECT * FROM messages WHERE session_id = ?" - f"{active_clause} ORDER BY id", - (session_id,), - ) + cursor = self._conn.execute(sql, params) rows = cursor.fetchall() result = [] for row in rows: -
scripts/release.py@@ -1929,6 +1929,8 @@ "[redacted:email]": "0disoft", # PR #53511 salvage (gateway PID probe TTL cache) "[redacted:email]": "0-CYBERDYNE-SYSTEMS-0", # PR #53966 salvage (session DB reads off event loop) "[redacted:email]": "sebastianlutycz", # PR #39140 salvage (descendant CTE); bare noreply (no NNN+ prefix) needs explicit mapping + "[redacted:email]": "mahdiwafy", # PR #60347 salvage (session messages pagination) + "[redacted:email]": "CodeForgeNet", # PR #47437 salvage (compact_rows blob skip) } -
tests/hermes_cli/test_web_server.py@@ -283,8 +283,9 @@ class _FakeDB: def __init__(self, *args, **kwargs): captured["read_only"] = kwargs.get("read_only") - def list_sessions_rich(self, limit): + def list_sessions_rich(self, limit, compact_rows=False): captured["limit"] = limit + captured["compact_rows"] = compact_rows return [ {"ended_at": None, "last_active": 95}, {"ended_at": 99, "last_active": 99}, @@ -298,7 +299,9 @@ def close(self): monkeypatch.setattr(web_server.time, "time", lambda: 100) assert web_server._count_status_active_sessions() == 1 - assert captured == {"read_only": True, "limit": 50, "closed": True} + assert captured == { + "read_only": True, "limit": 50, "compact_rows": True, "closed": True + } def test_status_active_session_count_fresh_install_returns_zero(self, monkeypatch, tmp_path): """No state.db yet (fresh install): return 0 without attempting a @@ -976,6 +979,70 @@ def close(self): assert captured["list"] == 3 assert captured["count"] == 3 + def _create_session_with_heavy_fields(self, session_id: str) -> None: + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session( + session_id=session_id, + source="cli", + system_prompt="# SOUL.md\n" + ("prompt body " * 500), + model_config={"temperature": 0.7, "notes": "x" * 200}, + ) + finally: + db.close() + + def test_get_sessions_strips_heavy_fields_by_default(self): + """List rows must omit system_prompt/model_config. + + system_prompt is the fully rendered prompt (tens of KB per row) and + dominated the sidebar payload (96% of a 528KB response); no list UI + reads it. Detail reads (GET /api/sessions/{id}) stay complete. + """ + self._create_session_with_heavy_fields("lean-list-row") + + resp = self.client.get("/api/sessions?limit=20&offset=0") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "lean-list-row"] + assert rows, "created session missing from list" + row = rows[0] + assert "system_prompt" not in row + assert "model_config" not in row + # The light fields the sidebar actually renders must survive. + for key in ("id", "source", "started_at", "message_count", "is_active"): + assert key in row + + def test_get_sessions_full_param_keeps_heavy_fields(self): + """?full=1 is the escape hatch for callers that need complete rows.""" + self._create_session_with_heavy_fields("full-list-row") + + resp = self.client.get("/api/sessions?limit=20&offset=0&full=1") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "full-list-row"] + assert rows, "created session missing from list" + row = rows[0] + assert row["system_prompt"].startswith("# SOUL.md") + assert "temperature" in (row["model_config"] or "") + + def test_profiles_sessions_strips_heavy_fields_by_default(self): + """The cross-profile aggregate applies the same list projection.""" + self._create_session_with_heavy_fields("lean-profiles-row") + + resp = self.client.get("/api/profiles/sessions?limit=20&offset=0") + assert resp.status_code == 200 + rows = [s for s in resp.json()["sessions"] if s["id"] == "lean-profiles-row"] + assert rows, "created session missing from profiles list" + row = rows[0] + assert "system_prompt" not in row + assert "model_config" not in row + assert row["profile"] == "default" + + full = self.client.get("/api/profiles/sessions?limit=20&offset=0&full=1") + assert full.status_code == 200 + full_rows = [s for s in full.json()["sessions"] if s["id"] == "lean-profiles-row"] + assert full_rows and full_rows[0]["system_prompt"].startswith("# SOUL.md") + def test_rename_session_updates_title(self): """PATCH /api/sessions/{id} renames a session (regression: the route was missing entirely, so the desktop rename dialog got a 405).""" -
tests/test_hermes_state.py@@ -5362,3 +5362,154 @@ def test_refresh_compression_lock_requires_holder_and_preserves_reclaimability(d monkeypatch.setattr(hermes_state.time, "time", lambda: 1016.0) assert db.try_acquire_compression_lock("s1", "holder-b", ttl_seconds=10.0) is True + + +# ========================================================================= +# compact_rows — lightweight column projection (issue #47414) +# ========================================================================= + +class TestCompactRows: + """list_sessions_rich and _get_session_rich_row with compact_rows=True + must omit system_prompt but return all other metadata fields.""" + + def _create(self, db, sid, *, system_prompt="big blob " * 500): + db.create_session(session_id=sid, source="cli", model="m") + db.update_system_prompt(sid, system_prompt) + return sid + + def test_compact_rows_omits_system_prompt(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + assert len(rows) == 1 + assert "system_prompt" not in rows[0] + + def test_full_rows_include_system_prompt(self, db): + self._create(db, "s1", system_prompt="keep me") + rows = db.list_sessions_rich(compact_rows=False) + assert rows[0]["system_prompt"] == "keep me" + + def test_compact_rows_preserves_metadata_fields(self, db): + self._create(db, "s1") + rows = db.list_sessions_rich(compact_rows=True) + row = rows[0] + for field in ("id", "source", "model", "started_at", "message_count", + "input_tokens", "output_tokens", "title", "cwd", + "archived", "preview", "last_active"): + assert field in row, f"missing field: {field}" + + def test_compact_rows_order_by_last_active(self, db): + """compact_rows=True also works with the CTE / order_by_last_active path.""" + self._create(db, "s1") + self._create(db, "s2") + rows = db.list_sessions_rich(compact_rows=True, order_by_last_active=True) + assert len(rows) == 2 + assert all("system_prompt" not in r for r in rows) + + def test_get_session_rich_row_compact_omits_system_prompt(self, db): + self._create(db, "s1", system_prompt="should be gone") + row = db._get_session_rich_row("s1", compact_rows=True) + assert row is not None + assert "system_prompt" not in row + assert row["id"] == "s1" + + def test_get_session_rich_row_full_includes_system_prompt(self, db): + self._create(db, "s1", system_prompt="stay") + row = db._get_session_rich_row("s1", compact_rows=False) + assert row["system_prompt"] == "stay" + + def test_compact_rows_default_is_false(self, db): + """Default behaviour (compact_rows not passed) is unchanged — full rows.""" + self._create(db, "s1", system_prompt="present") + rows = db.list_sessions_rich() + assert "system_prompt" in rows[0] + + def test_compact_projection_tracks_schema(self, db): + """Behavior contract: compact rows carry EVERY sessions column except + the excluded blob — including gateway/desktop fields (git_branch, + session_key) and any column added later via declarative + reconciliation. Guards against a hardcoded column list going stale.""" + self._create(db, "s1") + live_cols = { + row[1] for row in db._conn.execute("PRAGMA table_info(sessions)") + } + row = db.list_sessions_rich(compact_rows=True)[0] + # Hardcode the one sanctioned exclusion: if the excluded set ever + # widens (or the projection silently drops a column), this fails and + # forces a conscious review of what list consumers lose. + missing = live_cols - set(row) - {"system_prompt"} + assert not missing, f"compact projection lost schema columns: {missing}" + assert "system_prompt" not in row + + def test_compact_rows_tip_projection_omits_system_prompt(self, db): + """Compression-tip projection must not reintroduce the blob: the + merged tip row is fetched with the same compact_rows flag (salvage + follow-up for #47437).""" + import time as _time + t0 = _time.time() - 3600 + db.create_session("root", "cli") + db.update_system_prompt("root", "root blob " * 200) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root")) + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?", + (t0 + 100, "root"), + ) + db.create_session("tip", "cli", parent_session_id="root") + db.update_system_prompt("tip", "tip blob " * 200) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 200, "tip")) + db._conn.commit() + + rows = db.list_sessions_rich(compact_rows=True) + projected = [r for r in rows if r.get("_lineage_root_id") == "root"] + assert projected, "compression root should be projected to its tip" + assert all("system_prompt" not in r for r in rows) + + +# ========================================================================= +# get_messages pagination (salvage follow-up for #60347) +# ========================================================================= + +class TestGetMessagesPagination: + """get_messages(limit=, offset=) pages in insertion order; the default + (limit=None) returns the full transcript unchanged.""" + + def _seed(self, db, n=10): + db.create_session(session_id="s1", source="cli") + for i in range(n): + db.append_message("s1", "user" if i % 2 == 0 else "assistant", f"msg-{i}") + + def test_default_returns_all_messages(self, db): + self._seed(db) + messages = db.get_messages("s1") + assert [m["content"] for m in messages] == [f"msg-{i}" for i in range(10)] + + def test_limit_pages_in_insertion_order(self, db): + self._seed(db) + page1 = db.get_messages("s1", limit=4, offset=0) + page2 = db.get_messages("s1", limit=4, offset=4) + page3 = db.get_messages("s1", limit=4, offset=8) + assert [m["content"] for m in page1] == ["msg-0", "msg-1", "msg-2", "msg-3"] + assert [m["content"] for m in page2] == ["msg-4", "msg-5", "msg-6", "msg-7"] + assert [m["content"] for m in page3] == ["msg-8", "msg-9"] + + def test_offset_past_end_returns_empty(self, db): + self._seed(db, n=3) + assert db.get_messages("s1", limit=5, offset=10) == [] + + def test_pagination_respects_active_flag(self, db): + """Soft-deleted (inactive) rows must not consume page slots.""" + self._seed(db, n=6) + # Soft-delete the first two rows the way rewind does. + db._conn.execute( + "UPDATE messages SET active = 0 WHERE session_id = 's1' " + "AND id IN (SELECT id FROM messages WHERE session_id = 's1' ORDER BY id LIMIT 2)" + ) + db._conn.commit() + page = db.get_messages("s1", limit=3, offset=0) + assert [m["content"] for m in page] == ["msg-2", "msg-3", "msg-4"] + + def test_offset_without_limit_pages(self, db): + """offset alone must not be silently ignored (review finding): + SQLite needs LIMIT for OFFSET, emitted as LIMIT -1.""" + self._seed(db, n=5) + rows = db.get_messages("s1", offset=3) + assert [m["content"] for m in rows] == ["msg-3", "msg-4"] -
tests/test_tui_gateway_server.py@@ -6590,7 +6590,7 @@ def test_session_most_recent_returns_first_non_denied(monkeypatch): """Drops `tool` rows like session.list does, returns the first hit.""" class _DB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): return [ {"id": "tool-1", "source": "tool", "title": "noise", "started_at": 100}, {"id": "tui-1", "source": "tui", "title": "real", "started_at": 99}, @@ -6609,7 +6609,7 @@ def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=Fal def test_session_most_recent_returns_null_when_only_tool_rows(monkeypatch): class _DB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): return [{"id": "tool-1", "source": "tool", "started_at": 1}] monkeypatch.setattr(server, "_get_db", lambda: _DB()) @@ -6627,7 +6627,7 @@ def test_session_most_recent_folds_db_exception_into_null_result(monkeypatch): 'no answer' (Copilot review on #17130).""" class _BrokenDB: - def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False): + def list_sessions_rich(self, *, source=None, limit=200, order_by_last_active=False, compact_rows=False): raise RuntimeError("db locked") monkeypatch.setattr(server, "_get_db", lambda: _BrokenDB()) -
tui_gateway/server.py@@ -5314,7 +5314,7 @@ def _(rid, params: dict) -> dict: fetch_limit = max(limit * 2, 200) rows = [ s - for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True) + for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True, compact_rows=True) if (s.get("source") or "").strip().lower() not in deny ][:limit] return _ok( @@ -5361,7 +5361,7 @@ def _(rid, params: dict) -> dict: # users (lots of recent ``tool`` rows) don't get a false # "no eligible session" answer. ``session.list`` uses a # similar over-fetch strategy. - rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True) + rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True, compact_rows=True) for row in rows: src = (row.get("source") or "").strip().lower() if src in deny: @@ -13409,7 +13409,7 @@ def _(rid, params: dict) -> dict: cutoff = time.time() - days * 86400 rows = [ s - for s in db.list_sessions_rich(limit=500) + for s in db.list_sessions_rich(limit=500, compact_rows=True) if (s.get("started_at") or 0) >= cutoff ] return _ok(
-
3 PRs closed without merging
From the close comments: done elsewhere 2 · merged via another change 1
-
#60347 fix(api): add pagination to GET /api/sessions/{id}/messages · merged via another change →
0d5549a,#60883 -
#68503 fix(desktop): keep long tool sessions responsive · done elsewhere →
81779fa,#68467 -
#69275 fix(desktop): keep long tool sessions responsive (rebased #68503) · done elsewhere →
7f87b67,#68467,#68503
Closed issues · 3
- Closed#40692Bug: Desktop (macOS) Composer typing extremely laggy with long conversation history, Remote mode fine
- Closed#60155[Bug]: GET /api/sessions/{id}/messages has no pagination — loads entire transcript at once, freezes Desktop on large sessions
- Closed#68467Desktop: long tool-heavy sessions freeze composer (typing/send dead) while backend is idle
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.27: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #60883
- Closed issues
- 3
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 8 Jul 2026