Refresh on upstream/main: resolve conflicts (no behavior change)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 1e70eaa49a)
This commit is contained in:
Omar Baradei 2026-07-05 11:47:14 -07:00 committed by kshitij
parent 0d5549a945
commit 4df16c429b
2 changed files with 93 additions and 0 deletions

View file

@ -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,

View file

@ -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)."""