from __future__ import annotations import io import os from datetime import datetime from pathlib import Path import pytest import nanobot.webui.session_list_index as session_list_index from nanobot.cron.session_turns import CRON_HISTORY_META from nanobot.providers.base import ProviderConversationState from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY from nanobot.session.automation_turns import AUTOMATION_HISTORY_META from nanobot.session.history_visibility import HIDDEN_HISTORY_META from nanobot.session.manager import SessionManager from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY def test_webui_session_list_reuses_valid_index_without_scanning_files( tmp_path: Path, monkeypatch, ) -> None: manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:indexed") session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "fast" session.add_message("user", "indexed preview") manager.save(session) assert list_webui_sessions(manager)[0]["preview"] == "indexed preview" assert list_webui_sessions(manager)[0]["model_preset"] == "fast" def fail_scan(session_manager: SessionManager, path: Path, webui_dir: Path) -> None: raise AssertionError(f"unexpected session file scan: {path}") monkeypatch.setattr(session_list_index, "_scan_session_row", fail_scan) rows = list_webui_sessions(manager) assert rows[0]["key"] == "websocket:indexed" assert rows[0]["preview"] == "indexed preview" assert rows[0]["model_preset"] == "fast" def test_webui_session_list_indexes_workspace_scope_and_preserves_null( tmp_path: Path, ) -> None: manager = SessionManager(tmp_path) project = tmp_path / "project" project.mkdir() scoped = manager.get_or_create("websocket:scoped") scoped.metadata[WORKSPACE_SCOPE_METADATA_KEY] = { "project_path": str(project), "access_mode": "full", "future_extension": "x" * 5000, } manager.save(scoped) explicit_null = manager.get_or_create("websocket:null") explicit_null.metadata[WORKSPACE_SCOPE_METADATA_KEY] = None manager.save(explicit_null) manager.save(manager.get_or_create("websocket:missing")) rows = {row["key"]: row for row in list_webui_sessions(manager)} assert session_list_index.indexed_workspace_scope(rows["websocket:scoped"]) == ( True, {"project_path": str(project), "access_mode": "full"}, ) assert session_list_index.indexed_workspace_scope(rows["websocket:null"]) == (True, None) assert session_list_index.indexed_workspace_scope(rows["websocket:missing"]) == (False, None) scoped.metadata[WORKSPACE_SCOPE_METADATA_KEY]["access_mode"] = "restricted" manager.save(scoped) refreshed = {row["key"]: row for row in list_webui_sessions(manager)} assert session_list_index.indexed_workspace_scope(refreshed["websocket:scoped"])[1] == { "project_path": str(project), "access_mode": "restricted", } def test_webui_session_list_does_not_cache_old_snapshot_with_new_signature( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: manager = SessionManager(tmp_path) session_key = "websocket:scope-race" session = manager.get_or_create(session_key) session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = { "project_path": str(tmp_path), "access_mode": "full", } session.add_message("user", "hello") manager.save(session) session_path = manager._get_session_path(session_key) original_open = open scope_changed = False class RacingReader(io.StringIO): def __next__(self) -> str: nonlocal scope_changed if not scope_changed: scope_changed = True current = manager.get_or_create(session_key) current.metadata[WORKSPACE_SCOPE_METADATA_KEY] = { "project_path": str(tmp_path), "access_mode": "restricted", } manager.save(current) return super().__next__() def racing_open(path, *args, **kwargs): if Path(path) == session_path: with original_open(path, *args, **kwargs) as source: return RacingReader(source.read()) return original_open(path, *args, **kwargs) monkeypatch.setattr(session_list_index, "open", racing_open, raising=False) first = list_webui_sessions(manager)[0] second = list_webui_sessions(manager)[0] assert session_list_index.indexed_workspace_scope(first)[1]["access_mode"] == "full" assert session_list_index.indexed_workspace_scope(second)[1]["access_mode"] == "restricted" def test_webui_session_list_rejects_invalid_internal_model_preset_metadata( tmp_path: Path, ) -> None: manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:custom-metadata") session.metadata["model_preset"] = 7 session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = {"invalid": True} session.add_message("user", "custom metadata") manager.save(session) with pytest.raises(ValueError, match="session model preset must be a non-empty string"): list_webui_sessions(manager) assert manager.get_or_create(session.key).metadata["model_preset"] == 7 def test_webui_session_list_rescans_only_changed_file(tmp_path: Path, monkeypatch) -> None: manager = SessionManager(tmp_path) first = manager.get_or_create("websocket:first") first.add_message("user", "first") manager.save(first) second = manager.get_or_create("websocket:second") second.add_message("user", "second before") manager.save(second) assert {row["preview"] for row in list_webui_sessions(manager)} == {"first", "second before"} second.messages.clear() second.add_message("user", "second after") manager.save(second) original_scan = session_list_index._scan_session_row scanned: list[str] = [] def record_scan( session_manager: SessionManager, path: Path, webui_dir: Path, ) -> dict | None: scanned.append(path.name) return original_scan(session_manager, path, webui_dir) monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan) rows = list_webui_sessions(manager) assert scanned == [manager._get_session_path("websocket:second").name] assert {row["preview"] for row in rows} == {"first", "second after"} def test_webui_session_list_skips_provider_state_before_preview_budget( tmp_path: Path, monkeypatch, ) -> None: monkeypatch.setattr(session_list_index, "_SESSION_LIST_PREVIEW_MAX_CHARS", 100) manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:private-state") session.provider_state = ProviderConversationState( kind="openai_responses", provider="openai:test", model="test-model", version=1, payload={"items": [{"encrypted_content": "x" * 200}]}, ) session.add_message("user", "visible preview") manager.save(session) assert list_webui_sessions(manager)[0]["preview"] == "visible preview" def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None: manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:deleted") session.add_message("user", "gone") manager.save(session) assert list_webui_sessions(manager)[0]["key"] == "websocket:deleted" assert manager.delete_session("websocket:deleted") is True assert list_webui_sessions(manager) == [] def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None: manager = SessionManager(tmp_path) legacy_path = manager.sessions_dir / "websocket_legacy.jsonl" legacy_path.write_text( '{"_type":"metadata","key":"websocket:legacy",' '"created_at":"2025-01-01T00:00:00",' '"updated_at":"2025-01-01T00:00:00","metadata":{}}\n', encoding="utf-8", ) assert list_webui_sessions(manager) == [] assert legacy_path.exists() def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None: manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:cron-preview") session.add_message( "user", "Scheduled cron job triggered: 30s-test\n\nInternal reminder prompt", **{CRON_HISTORY_META: True}, ) session.add_message("assistant", "提醒已经到期。") manager.save(session) assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。" def test_webui_session_list_skips_trigger_internal_user_preview(tmp_path: Path) -> None: manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:trigger-preview") session.add_message( "user", "Local trigger received: PR review", **{AUTOMATION_HISTORY_META: {"kind": "local_trigger", "trigger_id": "trg_123"}}, ) session.add_message("assistant", "PR #4502 已经开始 review。") manager.save(session) assert list_webui_sessions(manager)[0]["preview"] == "PR #4502 已经开始 review。" def test_webui_session_list_skips_hidden_history_user_preview(tmp_path: Path) -> None: manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:hidden-preview") session.add_message( "user", "internal subagent result", **{HIDDEN_HISTORY_META: {"kind": "subagent_result", "subagent_task_id": "sub-1"}}, ) session.add_message("assistant", "subagent summary") manager.save(session) assert list_webui_sessions(manager)[0]["preview"] == "subagent summary" def test_webui_session_list_uses_webui_transcript_activity_for_sort( tmp_path: Path, monkeypatch, ) -> None: webui_dir = tmp_path / "webui" webui_dir.mkdir() monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir) manager = SessionManager(tmp_path) old_session = manager.get_or_create("websocket:old-metadata") old_session.created_at = datetime(2026, 6, 15, 10, 0, 0) old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0) old_session.add_message("user", "old metadata") old_session.messages[-1]["timestamp"] = "2026-06-15T10:00:00" old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0) manager.save(old_session) newer_metadata = manager.get_or_create("websocket:newer-metadata") newer_metadata.created_at = datetime(2026, 6, 15, 11, 0, 0) newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0) newer_metadata.add_message("user", "newer metadata") newer_metadata.messages[-1]["timestamp"] = "2026-06-15T11:00:00" newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0) manager.save(newer_metadata) transcript = webui_dir / "websocket_old-metadata.jsonl" transcript.write_text( '{"event":"turn_end","chat_id":"old-metadata"}\n', encoding="utf-8", ) activity_ns = int(datetime(2026, 6, 15, 12, 0, 0).timestamp() * 1_000_000_000) os.utime(transcript, ns=(activity_ns, activity_ns)) rows = list_webui_sessions(manager) assert [row["key"] for row in rows] == [ "websocket:old-metadata", "websocket:newer-metadata", ] assert rows[0]["updated_at"].startswith("2026-06-15T12:00:00") def test_webui_session_list_rescans_when_transcript_changes( tmp_path: Path, monkeypatch, ) -> None: webui_dir = tmp_path / "webui" webui_dir.mkdir() monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir) manager = SessionManager(tmp_path) session = manager.get_or_create("websocket:transcript-change") session.created_at = datetime(2026, 6, 15, 10, 0, 0) session.updated_at = datetime(2026, 6, 15, 10, 0, 0) session.add_message("user", "preview") session.messages[-1]["timestamp"] = "2026-06-15T10:00:00" session.updated_at = datetime(2026, 6, 15, 10, 0, 0) manager.save(session) assert list_webui_sessions(manager)[0]["preview"] == "preview" transcript = webui_dir / "websocket_transcript-change.jsonl" transcript.write_text( '{"event":"turn_end","chat_id":"transcript-change"}\n', encoding="utf-8", ) activity_ns = int(datetime(2026, 6, 15, 12, 30, 0).timestamp() * 1_000_000_000) os.utime(transcript, ns=(activity_ns, activity_ns)) original_scan = session_list_index._scan_session_row scanned: list[str] = [] def record_scan( session_manager: SessionManager, path: Path, webui_dir: Path, ) -> dict | None: scanned.append(path.name) return original_scan(session_manager, path, webui_dir) monkeypatch.setattr(session_list_index, "_scan_session_row", record_scan) rows = list_webui_sessions(manager) assert scanned == [manager._get_session_path("websocket:transcript-change").name] assert rows[0]["updated_at"].startswith("2026-06-15T12:30:00") def test_webui_session_list_sorts_by_message_activity_not_maintenance_timestamp( tmp_path: Path, ) -> None: manager = SessionManager(tmp_path) old = manager.get_or_create("websocket:old") old.created_at = datetime(2026, 6, 1, 10, 0, 0) old.add_message("user", "old first visible activity") old.messages[-1]["timestamp"] = "2026-06-01T10:00:00" old.add_message("assistant", "automation result") old.messages[-1]["timestamp"] = "2026-06-05T10:00:00" old.updated_at = datetime(2026, 6, 30, 17, 40, 0) manager.save(old) newer = manager.get_or_create("websocket:newer") newer.created_at = datetime(2026, 6, 4, 10, 0, 0) newer.add_message("user", "newer real activity") newer.messages[-1]["timestamp"] = "2026-06-04T10:00:00" newer.updated_at = datetime(2026, 6, 4, 10, 0, 0) manager.save(newer) rows = list_webui_sessions(manager) assert [row["key"] for row in rows] == ["websocket:old", "websocket:newer"] assert rows[0]["updated_at"] == "2026-06-05T10:00:00" def list_webui_sessions(manager: SessionManager) -> list[dict]: return session_list_index.list_webui_sessions(manager) def test_webui_session_list_fallback_time_when_missing(tmp_path: Path) -> None: manager = SessionManager(tmp_path) path = manager._get_session_path("websocket:missing-time") path.write_text( '{"_type": "metadata", "key": "websocket:missing-time"}\n' '{"_type": "message", "role": "user", "content": "hello"}\n', encoding="utf-8", ) rows = list_webui_sessions(manager) assert len(rows) == 1 assert rows[0]["key"] == "websocket:missing-time" assert rows[0]["created_at"] is not None assert rows[0]["updated_at"] is not None datetime.fromisoformat(rows[0]["created_at"]) datetime.fromisoformat(rows[0]["updated_at"]) def test_session_manager_list_sessions_fallback_time_when_missing(tmp_path: Path) -> None: manager = SessionManager(tmp_path) path = manager._get_session_path("websocket:missing-time2") path.write_text( '{"_type": "metadata", "key": "websocket:missing-time2"}\n' '{"_type": "message", "role": "user", "content": "hello"}\n', encoding="utf-8", ) sessions = manager.list_sessions() assert len(sessions) == 1 assert sessions[0]["key"] == "websocket:missing-time2" assert sessions[0]["created_at"] is not None assert sessions[0]["updated_at"] is not None datetime.fromisoformat(sessions[0]["created_at"]) datetime.fromisoformat(sessions[0]["updated_at"])