diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 45e5d7b8496..e4787ebea94 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3585,6 +3585,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") async def get_sessions( limit: int = 20, @@ -3595,6 +3612,7 @@ async def get_sessions( source: str = None, exclude_sources: str = None, cwd_prefix: str = None, + full: bool = False, profile: Optional[str] = None, ): """List sessions. @@ -3608,6 +3626,9 @@ async 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( @@ -3664,6 +3685,8 @@ async 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() @@ -3684,6 +3707,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. @@ -3693,6 +3717,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") @@ -3783,6 +3810,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 --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 7797be63177..d61c9c5b741 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -677,6 +677,70 @@ class TestWebServerEndpoints: 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)."""