fix(models): exempt streaming sessions from Untitled+0-message sidebar filter (#1327)

From PR #1330.

Co-authored-by: Frank Song <franksong2702@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-30 15:24:33 +00:00
parent 1ccd958e23
commit 92121324a0
2 changed files with 177 additions and 1 deletions

View File

@@ -769,9 +769,16 @@ def all_sessions():
# No grace window: a 0-message Untitled session is never shown in the list
# regardless of age. This means page refreshes and accidental New Conversation
# clicks never leave orphan entries in the sidebar.
#
# Exception: sessions with active_stream_id set are actively streaming (#1327).
# #1184 deferred the first save() until the first message, so during the
# initial streaming turn the session still looks like Untitled+0-messages.
# Without this exemption, navigating away during a long first turn causes
# the session to vanish from the sidebar.
result = [s for s in result if not (
s.get('title', 'Untitled') == 'Untitled'
and s.get('message_count', 0) == 0
and not s.get('active_stream_id')
)]
result = [s for s in result if not _hide_from_default_sidebar(s)]
# Backfill: sessions created before Sprint 22 have no profile tag.
@@ -796,10 +803,12 @@ def all_sessions():
out.sort(key=lambda s: (getattr(s, 'pinned', False), _session_sort_timestamp(s)), reverse=True)
# Hide empty Untitled sessions from the UI entirely — kept consistent with the
# index-path filter above. No grace window: a 0-message Untitled session is
# never shown regardless of age (#1171).
# never shown regardless of age (#1171). Same streaming exemption as above (#1327).
result = [s.compact(include_runtime=True, active_stream_ids=active_stream_ids) for s in out if not (
s.title == 'Untitled'
and len(s.messages) == 0
and not s.active_stream_id
and not s.pending_user_message
)]
result = [s for s in result if not _hide_from_default_sidebar(s)]
for s in result:

View File

@@ -0,0 +1,167 @@
"""
Regression tests for #1327: streaming sessions must not vanish from sidebar.
PR #1184 deferred the first save() until the session has real state. During the
initial streaming turn, the session still looks like Untitled + 0-messages
(title is derived later, user text is in pending_user_message not messages).
The sidebar filter must exempt actively-streaming sessions from the empty-
Untitled rule so they remain visible while the user navigates away.
"""
import pytest
import api.models as models
from api.models import (
SESSIONS,
STREAMS,
Session,
all_sessions,
new_session,
)
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
"""Redirect SESSION_DIR and SESSION_INDEX_FILE to a fresh tmp 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)
SESSIONS.clear()
STREAMS.clear()
yield session_dir
SESSIONS.clear()
STREAMS.clear()
# ── Helpers ────────────────────────────────────────────────────────────────
def _simulate_first_turn_streaming(session):
"""Simulate the state of a session during its first streaming turn.
After _handle_chat/start sets pending_user_message and calls save(),
but before the first assistant turn completes:
- title is still 'Untitled'
- messages is still empty (user text is in pending_user_message)
- active_stream_id is set
"""
session.pending_user_message = "Hello, this is a long prompt"
session.active_stream_id = f"stream-{session.session_id}"
session.save()
# Register stream so _active_stream_ids() finds it
STREAMS[session.active_stream_id] = session.session_id
# ── Index path (sidebar via index file) ────────────────────────────────────
def test_streaming_session_visible_in_sidebar_index_path(_isolate):
"""A session that is actively streaming its first turn must appear in
all_sessions() even though it is Untitled + 0-messages (#1327)."""
s = new_session()
_simulate_first_turn_streaming(s)
ids = {row["session_id"] for row in all_sessions()}
assert s.session_id in ids, (
"Actively streaming session disappeared from sidebar (index path). "
"The Untitled+0-message filter must exempt sessions with active_stream_id."
)
def test_empty_session_still_hidden_when_not_streaming(_isolate):
"""A plain empty Untitled session (no stream, no pending message) must
still be hidden — the #1171 filter must not be weakened."""
s = new_session()
# No streaming state set — just a bare empty session
ids = {row["session_id"] for row in all_sessions()}
assert s.session_id not in ids, (
"Empty Untitled session should still be hidden from sidebar. "
"Only actively streaming sessions are exempt (#1327)."
)
# ── Full-scan fallback path ────────────────────────────────────────────────
def test_streaming_session_visible_in_sidebar_fullscan(_isolate):
"""Same as above but forces the full-scan fallback path by corrupting
the index file."""
s = new_session()
_simulate_first_turn_streaming(s)
# Corrupt the index to force the full-scan fallback
models.SESSION_INDEX_FILE.write_text("INVALID JSON")
ids = {row["session_id"] for row in all_sessions()}
assert s.session_id in ids, (
"Actively streaming session disappeared from sidebar (full-scan path). "
"The Untitled+0-message filter must exempt sessions with "
"active_stream_id and pending_user_message."
)
def test_empty_session_still_hidden_fullscan(_isolate):
"""Empty Untitled session must still be hidden on the full-scan path."""
s = new_session()
models.SESSION_INDEX_FILE.write_text("INVALID JSON")
ids = {row["session_id"] for row in all_sessions()}
assert s.session_id not in ids, (
"Empty Untitled session should still be hidden from sidebar (full-scan)."
)
# ── Edge cases ────────────────────────────────────────────────────────────
def test_session_visible_after_stream_completes(_isolate):
"""After streaming completes and messages are populated, the session
must remain visible (message_count > 0)."""
s = new_session()
_simulate_first_turn_streaming(s)
# Simulate stream completion: clear pending, add messages
s.active_stream_id = None
s.pending_user_message = None
s.messages.append({"role": "user", "content": "Hello"})
s.messages.append({"role": "assistant", "content": "Hi there"})
s.title = "Greeting"
s.save()
STREAMS.pop(f"stream-{s.session_id}", None)
ids = {row["session_id"] for row in all_sessions()}
assert s.session_id in ids, (
"Session with messages should be visible after stream completes."
)
def test_pending_message_without_stream_still_visible(_isolate):
"""A session with pending_user_message but no active_stream_id (edge case:
stream crashed after setting pending but before setting stream id) should
still be visible on the full-scan path."""
s = new_session()
s.pending_user_message = "Hello"
# No active_stream_id set
s.save()
models.SESSION_INDEX_FILE.write_text("INVALID JSON")
ids = {row["session_id"] for row in all_sessions()}
assert s.session_id in ids, (
"Session with pending_user_message should be visible even without "
"active_stream_id (full-scan path)."
)
def test_compact_output_contains_active_stream_id(_isolate):
"""Verify that compact() output includes active_stream_id so the index
path filter can check it."""
s = new_session()
s.active_stream_id = "test-stream-123"
compact = s.compact()
assert compact.get("active_stream_id") == "test-stream-123", (
"compact() must include active_stream_id for the sidebar filter."
)