diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8232ab77c0a5c..8f5b7eca974da 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3601,6 +3601,23 @@ async def get_action_status(name: str, lines: int = 200): } +_SESSION_LIST_OMITTED_FIELDS = ("system_prompt", "model_config") + + +def _trim_session_list_payload(session: Dict[str, Any]) -> Dict[str, Any]: + """Drop heavy/internal fields from session-list rows before JSON encoding. + + ``SessionDB.list_sessions_rich()`` returns the full ``sessions`` row for + CLI/internal consumers, including the cached system prompt. Desktop only + needs list metadata; serializing 40-50 full prompts turns every sidebar + refresh into multi-megabyte JSON and can starve the single uvicorn event + loop while the renderer retries timed-out requests. + """ + for key in _SESSION_LIST_OMITTED_FIELDS: + session.pop(key, None) + return session + + @app.get("/api/sessions") async def get_sessions( limit: int = 20, @@ -3671,6 +3688,7 @@ async def get_sessions( ) now = time.time() for s in sessions: + _trim_session_list_payload(s) s["is_active"] = ( s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 @@ -3783,6 +3801,7 @@ def get_profiles_sessions( total += profile_total profile_totals[name] = profile_total for s in rows: + _trim_session_list_payload(s) s["profile"] = name s["is_default_profile"] = name == "default" s["is_active"] = ( diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 18f5facbb6b64..34cacdb5fa663 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -677,6 +677,32 @@ def close(self): assert captured["list"] == 3 assert captured["count"] == 3 + def test_get_sessions_omits_heavy_internal_prompt_fields(self): + """The Desktop sidebar list must not serialize full cached prompts. + + Real profiles can have 40-50 visible rows with ~40KB cached system + prompts each; returning those made every refresh multi-megabyte JSON. + """ + from hermes_state import SessionDB + + db = SessionDB() + try: + db.create_session( + session_id="heavy-list-row", + source="cli", + system_prompt="prompt" * 10_000, + model_config={"large": "config" * 10_000}, + ) + db.append_message(session_id="heavy-list-row", role="user", content="hi") + finally: + db.close() + + resp = self.client.get("/api/sessions?limit=20&min_messages=0") + assert resp.status_code == 200 + row = next(s for s in resp.json()["sessions"] if s["id"] == "heavy-list-row") + assert "system_prompt" not in row + assert "model_config" not in row + 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).""" @@ -768,7 +794,12 @@ def test_profiles_sessions_tags_default_profile(self): db = SessionDB() try: - db.create_session(session_id="agg-me", source="cli") + db.create_session( + session_id="agg-me", + source="cli", + system_prompt="prompt" * 10_000, + model_config={"large": "config" * 10_000}, + ) db.append_message(session_id="agg-me", role="user", content="hi") finally: db.close() @@ -779,6 +810,8 @@ def test_profiles_sessions_tags_default_profile(self): row = next(s for s in data["sessions"] if s["id"] == "agg-me") assert row["profile"] == "default" assert row["is_default_profile"] is True + assert "system_prompt" not in row + assert "model_config" not in row assert isinstance(data.get("errors"), list) def test_profiles_sessions_rejects_unknown_archived_value(self):