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