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

0open issues
0open PRs
7items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #60883 perf(dashboard): trim session-list payloads + paginate session messages (salvage #60347/#43653/#47437)

    merged to main

    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

    Show 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.00 means 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 Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#60883
Closed issues
3
Attempts
3 PRs closed without merging
Fix link checked
8 Jul 2026

For your agent

The same facts, typed.