# NousResearch/hermes-agent · Desktop lag and API pagination for long sessions

## Open now

- 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.

## TL;DR

- 7 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #60883. Verify it addresses your case.
- **Recommended:** read PR #60883 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #40692: Bug: Desktop (macOS) Composer typing extremely laggy with long conversation history, Remote mode fine
- #60155: \[Bug\]: GET /api/sessions/{id}/messages has no pagination — loads entire transcript at once, freezes Desktop on large sessions
- #68467: Desktop: long tool-heavy sessions freeze composer (typing/send dead) while backend is idle

## Prior & competing PRs

- **#60883: perf(dashboard): trim session-list payloads + paginate session messages (salvage #60347/#43653/#47437)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.py`
  - fix hunks:
  - `hermes_cli/web_server.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -2175,7 +2175,6 @@ def _git_path(path: str) -> str:
     class GitPathBody(BaseModel):
         path: str
     
    -
     class GitFileBody(BaseModel):
         path: str
         file: Optional[str] = None
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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 = []
    ```

    ```diff
    @@ -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}
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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},
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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},
    ```

    ```diff
    @@ -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())
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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(
    ```
- **#60347: fix(api): add pagination to GET /api/sessions/{id}/messages**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `0d5549a94`, `#60883`
- **#68503: fix(desktop): keep long tool sessions responsive**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `81779fa6a0382a4b0e54742065e218979f6604fe`, `#68467`
- **#69275: fix(desktop): keep long tool sessions responsive (rebased #68503)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7f87b672455c`, `#68467`, `#68503`

## Fix status

- Merged to main in PR #60883 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-08`.
- Subsystem: `apps/desktop`
