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