From 312a493a72d930152c7bba3e80c61f2d805c1a99 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 21 Apr 2026 10:08:52 -0700 Subject: [PATCH] fix(sessions): new sessions appear immediately in sidebar (#806) Closes #789 Bug A. 60-second exemption in all_sessions() filter. --- CHANGELOG.md | 7 +- api/models.py | 15 +++- tests/test_issue789.py | 194 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue789.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 63608c1..a1699ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Hermes Web UI -- Changelog +## [v0.50.130] — 2026-04-21 + +### Fixed +- **New sessions now appear immediately in the sidebar** — the zero-message Untitled filter now exempts sessions younger than 60 seconds, so clicking New Chat shows the session right away instead of waiting for the first message. Sessions older than 60 seconds that are still Untitled with 0 messages continue to be suppressed (ghost sessions from test runs / accidental page reloads). Addresses Bug A only of #789; Bug B (SSE refetch resetting sidebar mid-interaction) is a separate fix. (#806) + ## [v0.50.129] — 2026-04-21 ### Fixed -- **Profile isolation: complete fix via cookie + thread-local context** — PR #800 (v0.50.127) only fixed `POST /api/session/new`. `GET /api/profile/active` still read the process-level `_active_profile` global, so a page refresh while another client had a different profile active would corrupt `S.activeProfile` in JS, defeating the session-creation fix on the next new chat. This release completes the isolation: profile switches now set a `hermes_profile` cookie (HttpOnly, SameSite=Lax) and never mutate the process global. Every request handler reads the cookie into a thread-local; all server functions (`get_active_profile_name()`, `get_active_hermes_home()`, `list_profiles_api()`, memory endpoints, model loading) automatically see the per-client profile. `switch_profile()` gains a `process_wide` kwarg — the HTTP route passes `False`, keeping the global clean; CLI callers default to `True` (unchanged behaviour). Absorbed from PR #803 by @bergeouss with correctness fixes reviewed by Opus. (#804-not-yet — see PR #803 absorption) +- **Profile isolation: complete fix via cookie + thread-local context** — PR #800 (v0.50.127) only fixed `POST /api/session/new`. `GET /api/profile/active` still read the process-level `_active_profile` global, so a page refresh while another client had a different profile active would corrupt `S.activeProfile` in JS, defeating the session-creation fix on the next new chat. This release completes the isolation: profile switches now set a `hermes_profile` cookie (HttpOnly, SameSite=Lax) and never mutate the process global. Every request handler reads the cookie into a thread-local; all server functions (`get_active_profile_name()`, `get_active_hermes_home()`, `list_profiles_api()`, memory endpoints, model loading) automatically see the per-client profile. `switch_profile()` gains a `process_wide` kwarg — the HTTP route passes `False`, keeping the global clean; CLI callers default to `True` (unchanged behaviour). Absorbed from PR #803 by @bergeouss with correctness fixes reviewed by Opus. (#805) ## [v0.50.128] — 2026-04-21 diff --git a/api/models.py b/api/models.py index aa95af0..d0ec9ae 100644 --- a/api/models.py +++ b/api/models.py @@ -218,7 +218,13 @@ def all_sessions(): index_map[s.session_id] = s.compact() result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True) # Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.) - result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)] + # Exempt sessions younger than 60 s so a brand-new session stays visible (#789) + _now = time.time() + result = [s for s in result if not ( + s.get('title', 'Untitled') == 'Untitled' + and s.get('message_count', 0) == 0 + and (_now - s.get('updated_at', _now)) > 60 + )] # Backfill: sessions created before Sprint 22 have no profile tag. # Attribute them to 'default' so the client profile filter works correctly. for s in result: @@ -239,7 +245,12 @@ def all_sessions(): for s in SESSIONS.values(): if all(s.session_id != x.session_id for x in out): out.append(s) out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True) - result = [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)] + _now = time.time() + result = [s.compact() for s in out if not ( + s.title == 'Untitled' + and len(s.messages) == 0 + and (_now - s.updated_at) > 60 + )] for s in result: if not s.get('profile'): s['profile'] = 'default' diff --git a/tests/test_issue789.py b/tests/test_issue789.py new file mode 100644 index 0000000..5c45e7f --- /dev/null +++ b/tests/test_issue789.py @@ -0,0 +1,194 @@ +""" +Regression tests for GitHub issue #789. + +Bug: every brand-new session immediately disappeared from the sidebar because +all_sessions() filtered out sessions where title == 'Untitled' AND +message_count == 0. Since every new session starts with those values, it was +filtered out of /api/sessions on the next refresh. + +Fix: exempt sessions younger than 60 seconds from that filter. Sessions older +than 60 seconds that are still Untitled with 0 messages are still suppressed +(ghost sessions from test runs / accidental reloads). +""" +import json +import time + +import pytest + +import api.models as models +from api.models import Session, all_sessions + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + """Redirect SESSION_DIR and SESSION_INDEX_FILE to a temp dir.""" + session_dir = tmp_path / "sessions" + session_dir.mkdir() + index_file = session_dir / "_index.json" + + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file) + + models.SESSIONS.clear() + yield + models.SESSIONS.clear() + + +def _make_untitled_session(age_seconds, messages=None, session_id=None): + """Create a Session with title='Untitled', updated_at set to age_seconds ago.""" + now = time.time() + s = Session( + session_id=session_id or None, + title="Untitled", + messages=messages or [], + updated_at=now - age_seconds, + created_at=now - age_seconds, + ) + # Persist to disk so the full-scan fallback can also find it + s.path.write_text( + json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return s + + +def _make_titled_session(age_seconds, session_id=None): + """Create a Session with a real title and one message.""" + now = time.time() + s = Session( + session_id=session_id or None, + title="My conversation", + messages=[{"role": "user", "content": "hello"}], + updated_at=now - age_seconds, + created_at=now - age_seconds, + ) + s.path.write_text( + json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return s + + +# ── Test 1: brand-new Untitled 0-message session IS included ───────────────── + +def test_new_untitled_session_is_visible_in_sidebar(): + """A session created just now (0 seconds old) must appear in all_sessions().""" + new_session = _make_untitled_session(age_seconds=0) + + result = all_sessions() + ids = {s["session_id"] for s in result} + + assert new_session.session_id in ids, ( + "Brand-new Untitled 0-message session must be visible in the sidebar " + "(fix for issue #789)" + ) + + +def test_recent_untitled_session_under_60s_is_visible(): + """A session 30 seconds old should still be visible.""" + recent_session = _make_untitled_session(age_seconds=30) + + result = all_sessions() + ids = {s["session_id"] for s in result} + + assert recent_session.session_id in ids, ( + "Untitled 0-message session younger than 60 s must be visible (#789)" + ) + + +# ── Test 2: old Untitled 0-message session IS still filtered ───────────────── + +def test_old_untitled_session_over_60s_is_filtered(): + """A ghost session (Untitled, 0 messages, >60 s old) must be hidden.""" + old_session = _make_untitled_session(age_seconds=120) + + result = all_sessions() + ids = {s["session_id"] for s in result} + + assert old_session.session_id not in ids, ( + "Ghost Untitled 0-message session older than 60 s must be filtered out" + ) + + +def test_session_exactly_at_boundary_is_filtered(): + """A session just over 60 seconds old should be filtered.""" + boundary_session = _make_untitled_session(age_seconds=61) + + result = all_sessions() + ids = {s["session_id"] for s in result} + + assert boundary_session.session_id not in ids, ( + "Untitled 0-message session older than 60 s must be filtered out" + ) + + +# ── Test 3: session with messages is always visible regardless of age ───────── + +def test_session_with_messages_always_visible_new(): + """A session with messages (even Untitled) is always visible when new.""" + s = Session( + title="Untitled", + messages=[{"role": "user", "content": "hello"}], + ) + s.path.write_text( + json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + result = all_sessions() + ids = {r["session_id"] for r in result} + assert s.session_id in ids, "Session with messages must always appear in sidebar" + + +def test_session_with_messages_always_visible_old(): + """An old session with messages is always visible.""" + now = time.time() + s = Session( + title="Untitled", + messages=[{"role": "user", "content": "hello"}], + updated_at=now - 3600, + created_at=now - 3600, + ) + s.path.write_text( + json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + result = all_sessions() + ids = {r["session_id"] for r in result} + assert s.session_id in ids, ( + "Old session with messages must always appear in sidebar" + ) + + +def test_titled_session_with_no_messages_old_is_visible(): + """A titled session with 0 messages (old) should not be filtered — filter + only targets Untitled sessions.""" + now = time.time() + s = Session( + title="Project Alpha", + messages=[], + updated_at=now - 3600, + created_at=now - 3600, + ) + s.path.write_text( + json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + result = all_sessions() + ids = {r["session_id"] for r in result} + assert s.session_id in ids, ( + "A titled session must always appear regardless of message count" + ) + + +# ── Test 4: mixed bag — only old Untitled empty sessions are filtered ───────── + +def test_mixed_sessions_correct_visibility(): + """With a mix of sessions, only old+Untitled+empty ones are suppressed.""" + new_ghost = _make_untitled_session(age_seconds=5, session_id="new_ghost") + old_ghost = _make_untitled_session(age_seconds=200, session_id="old_ghost") + real_session = _make_titled_session(age_seconds=500, session_id="real_session") + + result = all_sessions() + ids = {s["session_id"] for s in result} + + assert "new_ghost" in ids, "New Untitled session (5s old) must be visible" + assert "old_ghost" not in ids, "Old Untitled session (200s old) must be hidden" + assert "real_session" in ids, "Titled session with messages must be visible"