From 4df16c429bca248095a92d707ddaf4c1ec9ed2a2 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Sun, 5 Jul 2026 11:47:14 -0700 Subject: [PATCH] Refresh on upstream/main: resolve conflicts (no behavior change) Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 1e70eaa49a5319dc1ea4c9131610d638b7327e7a) --- hermes_cli/web_server.py | 29 +++++++++++++ tests/hermes_cli/test_web_server.py | 64 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5069828d8bf..de3657812e9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3835,6 +3835,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 +3862,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 +3876,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( @@ -3914,6 +3935,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 +3957,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 +3967,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") @@ -4033,6 +4060,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 87860b5d9ff..77086732e44 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -976,6 +976,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)."""