Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1636,13 +1636,31 @@ 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,
offset: int = 0,
min_messages: int = 0,
archived: str = "exclude",
order: str = "created",
full: bool = False,
):
"""List sessions.

Expand All @@ -1655,6 +1673,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(
Expand Down Expand Up @@ -1695,6 +1716,8 @@ async def get_sessions(
)
# 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()
Expand All @@ -1711,6 +1734,7 @@ async def get_profiles_sessions(
archived: str = "exclude",
order: str = "recent",
profile: str = "all",
full: bool = False,
):
"""Unified, read-only session list aggregated across ALL profiles.

Expand All @@ -1720,6 +1744,9 @@ async 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")
Expand Down Expand Up @@ -1812,6 +1839,8 @@ def _query_profile(name: str, home: str) -> tuple:
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,
Expand Down
64 changes: 64 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,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)."""
Expand Down
Loading