diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f3b81d..aa12cf9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ ## [Unreleased] +## [v0.51.269] — 2026-06-05 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl) + +### Fixed +- **Session search results are now scoped to the active profile** (matching how the sidebar already filters), so a search no longer surfaces sessions belonging to other profiles unless `?all_profiles=1` is requested. (#3646, @Hinotoi-agent) +- **`ctl.sh stop` now tree-kills the WebUI process on Windows (Git Bash/MSYS)** via `taskkill //F //T`, and resolves owned-process paths across Windows/POSIX path forms. POSIX behavior is unchanged (falls through to the existing `kill`/`kill -KILL`). (#3670, @rodboev) + +### Performance +- **The sidebar session list is partitioned in a single pass** instead of chaining five separate `.filter()` passes over the row set on every render, reducing per-render work for large session lists. Filtering/ordering/archived-count behavior is unchanged. (#3658, @pamnard) + ## [v0.51.268] — 2026-06-05 — Release IJ (stage-b1 — low-risk perf + provider/clarify fixes) ### Fixed diff --git a/api/routes.py b/api/routes.py index fc3fab70..433317ae 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8370,6 +8370,15 @@ def _handle_sessions_search(handler, parsed): qs = parse_qs(parsed.query) q = qs.get("q", [""])[0].lower().strip() content_search = qs.get("content", ["1"])[0] == "1" + from api.profiles import get_active_profile_name + active_profile = get_active_profile_name() + all_profiles = _all_profiles_query_flag(parsed) + sessions = all_sessions() + if not all_profiles: + sessions = [ + s for s in sessions + if _profiles_match(s.get("profile"), active_profile) + ] # Reject a malformed depth instead of letting int() raise ValueError and # surface as a confusing 500. Clamp to >= 0 so a negative value can't reach # the messages[:depth] slice below — messages[:-n] would silently exclude @@ -8381,14 +8390,18 @@ def _handle_sessions_search(handler, parsed): depth = 5 if not q: safe_sessions = [] - for s in all_sessions(): + for s in sessions: item = dict(s) if isinstance(item.get("title"), str): item["title"] = _redact_text(item["title"]) safe_sessions.append(item) - return j(handler, {"sessions": safe_sessions}) + return j(handler, { + "sessions": safe_sessions, + "all_profiles": all_profiles, + "active_profile": active_profile, + }) results = [] - for s in all_sessions(): + for s in sessions: title_match = q in (s.get("title") or "").lower() if title_match: item = dict(s, match_type="title") @@ -8413,7 +8426,13 @@ def _handle_sessions_search(handler, parsed): break except (KeyError, Exception): pass - return j(handler, {"sessions": results, "query": q, "count": len(results)}) + return j(handler, { + "sessions": results, + "query": q, + "count": len(results), + "all_profiles": all_profiles, + "active_profile": active_profile, + }) def _handle_list_dir(handler, parsed): diff --git a/ctl.sh b/ctl.sh index 7810473e..71df395a 100755 --- a/ctl.sh +++ b/ctl.sh @@ -165,21 +165,96 @@ _is_alive() { kill -0 "${pid}" >/dev/null 2>&1 } -_proc_args() { +_is_windows_bash() { + [[ "${OS:-}" == "Windows_NT" ]] && return 0 + case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +_windows_bash_path() { + local path="${1//\\//}" drive rest + if [[ "${path}" =~ ^([A-Za-z]):(.*)$ ]]; then + drive="${BASH_REMATCH[1],,}" + rest="${BASH_REMATCH[2]}" + printf '/%s%s\n' "${drive}" "${rest}" + return + fi + printf '%s\n' "${path}" +} + +_windows_pid_for_bash_pid() { local pid="$1" - ps -p "${pid}" -o args= 2>/dev/null || true + ps -p "${pid}" -l 2>/dev/null | awk 'NR == 2 { print $4 }' +} + +_stop_webui_pid() { + local pid="$1" signal="${2:-TERM}" + if _is_windows_bash && command -v taskkill >/dev/null 2>&1; then + local winpid + winpid="$(_windows_pid_for_bash_pid "${pid}")" + if [[ "${winpid}" =~ ^[0-9]+$ ]]; then + taskkill //F //T //PID "${winpid}" >/dev/null 2>&1 || true + return + fi + fi + if [[ "${signal}" == "KILL" ]]; then + kill -KILL "${pid}" >/dev/null 2>&1 || true + else + kill "${pid}" >/dev/null 2>&1 || true + fi +} + +_proc_args() { + local pid="$1" args + args="$(ps -p "${pid}" -o args= 2>/dev/null || true)" + if [[ -n "${args}" ]]; then + printf '%s\n' "${args}" + return + fi + if _is_windows_bash; then + local winpid + winpid="$(_windows_pid_for_bash_pid "${pid}")" + if [[ "${winpid}" =~ ^[0-9]+$ ]] && command -v wmic >/dev/null 2>&1; then + args="$(wmic process where "ProcessId=${winpid}" get CommandLine //value 2>/dev/null | sed -n 's/^CommandLine=//p' | tr -d '\r')" + if [[ -n "${args}" ]]; then + printf '%s\n' "${args}" + return + fi + fi + ps -p "${pid}" -f 2>/dev/null | awk 'NR == 2 { for (i = 8; i <= NF; i++) printf "%s%s", (i == 8 ? "" : " "), $i; print "" }' + fi } _is_owned_webui_pid() { - local pid="$1" args state_repo="" state_python="" + local pid="$1" args args_slash state_repo="" state_repo_slash="" state_repo_win="" state_repo_win_slash="" state_python="" state_python_slash="" state_python_bash="" [[ -f "${STATE_FILE}" ]] || return 1 _load_state_if_present state_repo="${REPO_ROOT:-}" state_python="${PYTHON_EXE:-}" + state_repo_slash="${state_repo//\\//}" + state_python_slash="${state_python//\\//}" + if _is_windows_bash; then + state_repo_win="$(cygpath -w "${state_repo}" 2>/dev/null || true)" + state_repo_win_slash="${state_repo_win//\\//}" + fi + if [[ -n "${state_python}" ]] && _is_windows_bash; then + state_python_bash="$(_windows_bash_path "${state_python}")" + fi [[ "${state_repo}" == "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ]] || return 1 args="$(_proc_args "${pid}")" [[ -n "${args}" ]] || return 1 - [[ "${args}" == *"${state_repo}/bootstrap.py"* || "${args}" == *"${state_repo}/server.py"* || "${args}" == *"${state_repo}/start.sh"* || ( -n "${state_python}" && "${args}" == *"${state_python}"* ) ]] + args_slash="${args//\\//}" + [[ "${args_slash}" == *"${state_repo_slash}/bootstrap.py"* || + "${args_slash}" == *"${state_repo_slash}/server.py"* || + "${args_slash}" == *"${state_repo_slash}/start.sh"* || + ( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/bootstrap.py"* ) || + ( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/server.py"* ) || + ( -n "${state_repo_win_slash}" && "${args_slash}" == *"${state_repo_win_slash}/start.sh"* ) || + ( -n "${state_python}" && "${args}" == *"${state_python}"* ) || + ( -n "${state_python_slash}" && "${args_slash}" == *"${state_python_slash}"* ) || + ( -n "${state_python_bash}" && "${args_slash}" == *"${state_python_bash}"* ) ]] } _current_pid() { @@ -308,7 +383,7 @@ stop_cmd() { fi echo "[ctl] Stopping Hermes WebUI (PID ${pid})" - kill "${pid}" >/dev/null 2>&1 || true + _stop_webui_pid "${pid}" TERM local i for i in {1..50}; do if ! _is_alive "${pid}"; then @@ -320,7 +395,7 @@ stop_cmd() { done echo "[ctl] Process did not exit after SIGTERM; sending SIGKILL" >&2 - kill -KILL "${pid}" >/dev/null 2>&1 || true + _stop_webui_pid "${pid}" KILL rm -f "${PID_FILE}" "${STATE_FILE}" } diff --git a/static/sessions.js b/static/sessions.js index 228d8f27..303dfbc3 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4093,6 +4093,55 @@ function _resyncSessionVirtualWindowAfterRender(list, expectedScrollTop, virtual }); } +function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){ + return (s.message_count||0)>0 || + _sessionAttentionState(s) || + _isSessionEffectivelyStreaming(s) || + !!s.active_stream_id || + !!s.pending_user_message || + (activeSidForSidebar&&s.session_id===activeSidForSidebar) || + (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0); +} + +function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ + let webuiSessionCount=0; + let cliSessionCount=0; + for(const s of allMatched){ + if(!_sidebarRowHasVisibleMessages(s, activeSidForSidebar)) continue; + if(_isCliSession(s)) cliSessionCount++; + else webuiSessionCount++; + } + if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){ + _sessionSourceFilter='webui'; + } + const showCliOnly=_sessionSourceFilter==='cli'; + const profileFiltered=[]; + const sessionsRaw=[]; + let archivedCount=0; + for(const s of allMatched){ + if(!_sidebarRowHasVisibleMessages(s, activeSidForSidebar)) continue; + const isCli=_isCliSession(s); + if(showCliOnly ? !isCli : isCli) continue; + if(s.default_hidden&&!(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)) continue; + profileFiltered.push(s); + if(_activeProject===NO_PROJECT_FILTER){ + if(s.project_id) continue; + } else if(_activeProject){ + if(s.project_id!==_activeProject) continue; + } + if(s.archived) archivedCount++; + if(!_showArchived&&s.archived) continue; + sessionsRaw.push(s); + } + return { + webuiSessionCount, + cliSessionCount, + profileFiltered, + sessionsRaw, + archivedCount, + }; +} + function renderSessionListFromCache(){ // Don't re-render while user is actively renaming a session (would destroy the input) if(_renamingSid) return; @@ -4115,49 +4164,15 @@ function renderSessionListFromCache(){ // session id into another conversation, that content hit should still appear. const searchMatches=_sessionSearchMergeMatches(sidebarRows,searchQueryRaw,_contentSearchResults); const allMatched=_ensureActiveSessionRowPresent(searchMatches,sidebarRows); - // Keep inactive ephemeral 0-message sessions out of the sidebar — they only - // become real once the first message is sent. The server already filters them. - // Exception: the active freshly-created chat is injected above so it remains - // visible/selected until the user sends the first turn or switches away. - const withMessages=allMatched.filter(s=> - (s.message_count||0)>0 || - _sessionAttentionState(s) || - _isSessionEffectivelyStreaming(s) || - !!s.active_stream_id || - !!s.pending_user_message || - (activeSidForSidebar&&s.session_id===activeSidForSidebar) || - (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0) - ); - const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length; - const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length; - if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){ - _sessionSourceFilter='webui'; - } - const sourceFiltered = _sessionSourceFilter==='cli' - ? withMessages.filter(s=>_isCliSession(s)) - : withMessages.filter(s=>!_isCliSession(s)); - // The server is authoritative for profile scoping (#1611): it filters by - // active profile when no query param is set, and returns the aggregate when - // we send ?all_profiles=1. The renamed-root cross-alias (a row tagged - // 'default' matching active 'kinni' when kinni.is_default) lives server-side - // in _profiles_match, and a strict-equality client filter would reject those - // rows incorrectly. So we trust the wire data and skip the redundant client - // filter entirely. - const profileFiltered=sourceFiltered.filter(s=> - !s.default_hidden||(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject) - ); - // Filter by active project. NO_PROJECT_FILTER sentinel asks for sessions - // with no project_id; otherwise filter to the matching project_id, or - // pass through when no filter is active. - const projectFiltered= - _activeProject===NO_PROJECT_FILTER - ?profileFiltered.filter(s=>!s.project_id) - :(_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered); - // Filter archived unless toggle is on - const sessionsRaw=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived); + const { + webuiSessionCount, + cliSessionCount, + profileFiltered, + sessionsRaw, + archivedCount, + }=_partitionSidebarSessionRows(allMatched, activeSidForSidebar); const sessions=_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw); _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); - const archivedCount=projectFiltered.filter(s=>s.archived).length; const list=$('sessionList'); const animateRefresh=_sessionListRefreshAnimationPending; _sessionListRefreshAnimationPending=false; diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index c3725723..fc4dfff5 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -6,6 +6,8 @@ import textwrap import time from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] CTL = REPO_ROOT / "ctl.sh" @@ -78,20 +80,77 @@ def wait_for_pid_file(pid_file: Path, timeout: float = 3.0) -> int: raise AssertionError(f"PID file was not written: {pid_file}") -def wait_for_file_text(path: Path, timeout: float = 3.0) -> str: +def wait_for_file_text(path: Path, timeout: float = 3.0, contains: str | None = None) -> str: deadline = time.time() + timeout while time.time() < deadline: if path.exists(): text = path.read_text(encoding="utf-8") - if text: + if text and (contains is None or contains in text): return text time.sleep(0.05) raise AssertionError(f"File was not written: {path}") +def assert_path_in_text(path: Path, text: str) -> None: + assert str(path).replace("\\", "/") in text.replace("\\", "/") + + +def bash_path(path: Path) -> str: + raw = str(path.resolve()).replace("\\", "/") + if sys.platform == "win32" and len(raw) > 1 and raw[1] == ":": + return f"/{raw[0].lower()}{raw[2:]}" + return raw + + +def bash_pid(pid: int) -> int: + if sys.platform != "win32": + return pid + result = subprocess.run( + [ + "bash", + "-lc", + "ps -W | awk -v winpid=\"$1\" '$4 == winpid { print $1; exit }'", + "_", + str(pid), + ], + text=True, + capture_output=True, + timeout=3, + ) + if result.returncode == 0 and result.stdout.strip(): + return int(result.stdout.strip()) + return pid + + +def windows_pid(pid: int) -> int | None: + if sys.platform != "win32": + return pid + result = subprocess.run( + [ + "bash", + "-lc", + "ps -p \"$1\" -l | awk 'NR == 2 { print $4 }'", + "_", + str(pid), + ], + text=True, + capture_output=True, + timeout=3, + ) + if result.returncode == 0 and result.stdout.strip(): + return int(result.stdout.strip()) + return None + + +def start_fake_launchd_process() -> subprocess.Popen: + return subprocess.Popen(["bash", "-lc", "exec sleep 30"]) + + def _kill_tree(pid: int) -> None: if sys.platform == "win32": - subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True) + winpid = windows_pid(pid) + if winpid is not None: + subprocess.run(["taskkill", "/F", "/T", "/PID", str(winpid)], capture_output=True) else: try: os.kill(pid, 9) @@ -99,12 +158,28 @@ def _kill_tree(pid: int) -> None: pass +def process_exists(pid: int) -> bool: + if sys.platform == "win32": + return ( + subprocess.run( + ["bash", "-lc", "kill -0 \"$1\"", "_", str(pid)], + text=True, + capture_output=True, + timeout=3, + ).returncode + == 0 + ) + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + + def assert_process_exits(pid: int, timeout: float = 3.0) -> None: deadline = time.time() + timeout while time.time() < deadline: - try: - os.kill(pid, 0) - except ProcessLookupError: + if not process_exists(pid): return time.sleep(0.05) _kill_tree(pid) @@ -136,19 +211,20 @@ def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs( try: assert pid > 1 assert log_file.exists() - fake_output = wait_for_file_text(fake_log) + fake_output = wait_for_file_text(fake_log, contains="host=0.0.0.0 port=18991") assert "bootstrap.py --no-browser --foreground" in fake_output assert "host=0.0.0.0 port=18991" in fake_output - assert str(hermes_home / "webui") in fake_output + assert_path_in_text(hermes_home / "webui", fake_output) status = run_ctl(tmp_path, "status") assert status.returncode == 0 assert "running" in status.stdout assert f"PID: {pid}" in status.stdout assert "Bound: 0.0.0.0:18991" in status.stdout - assert f"Log: {log_file}" in status.stdout + assert_path_in_text(log_file, status.stdout) finally: stop = run_ctl(tmp_path, "stop") assert stop.returncode == 0, stop.stderr + stop.stdout + _kill_tree(pid) assert_process_exits(pid) assert not pid_file.exists() @@ -187,12 +263,13 @@ def test_start_can_ignore_repo_dotenv_for_authoritative_test_env(tmp_path): assert result.returncode == 0, result.stderr + result.stdout pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid") try: - fake_output = wait_for_file_text(fake_log) - assert str(tmp_path / ".hermes" / "webui") in fake_output + fake_output = wait_for_file_text(fake_log, contains="host=127.0.0.1 port=8787") + assert_path_in_text(tmp_path / ".hermes" / "webui", fake_output) assert "host-specific-webui" not in fake_output finally: stop = run_ctl(tmp_path, "stop", repo_root=repo_root) assert stop.returncode == 0, stop.stderr + stop.stdout + _kill_tree(pid) assert_process_exits(pid) @@ -225,12 +302,13 @@ def test_start_loads_dotenv_but_inline_overrides_win(tmp_path): assert result.returncode == 0, result.stderr + result.stdout pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid") try: - fake_output = wait_for_file_text(fake_log) + fake_output = wait_for_file_text(fake_log, contains="host=0.0.0.0 port=18888") assert "fake-python args:" in fake_output assert "host=0.0.0.0 port=18888" in fake_output finally: stop = run_ctl(tmp_path, "stop", repo_root=repo_root) assert stop.returncode == 0, stop.stderr + stop.stdout + _kill_tree(pid) assert_process_exits(pid) @@ -283,11 +361,14 @@ def _write_fake_lsof(fake_bin, listening): def test_start_refuses_second_instance_when_launchd_job_owns_the_port(tmp_path): + if sys.platform == "win32": + pytest.skip("launchd conflict guard is a macOS path; fake launchctl PIDs are not stable under Git Bash") + fake_bin = tmp_path / "bin" fake_bin.mkdir() - sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) - _write_fake_launchctl(fake_bin, sleeper.pid) + sleeper = start_fake_launchd_process() + _write_fake_launchctl(fake_bin, bash_pid(sleeper.pid)) # launchd-owned PID IS listening on the requested (default) port → real conflict. _write_fake_lsof(fake_bin, listening=True) @@ -296,7 +377,7 @@ def test_start_refuses_second_instance_when_launchd_job_owns_the_port(tmp_path): tmp_path, "start", env={ - "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}", + "PATH": f"{bash_path(fake_bin)}{os.pathsep}{os.environ.get('PATH', '')}", "HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui", }, ) @@ -320,8 +401,8 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path) fake_bin = tmp_path / "bin" fake_bin.mkdir() - sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) - _write_fake_launchctl(fake_bin, sleeper.pid) + sleeper = start_fake_launchd_process() + _write_fake_launchctl(fake_bin, bash_pid(sleeper.pid)) # launchd-owned PID is alive but NOT listening on our (alternate) port → no conflict. _write_fake_lsof(fake_bin, listening=False) @@ -339,7 +420,7 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path) tmp_path, "start", env={ - "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}", + "PATH": f"{bash_path(fake_bin)}{os.pathsep}{os.environ.get('PATH', '')}", "HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui", "HERMES_WEBUI_PORT": "18992", "HERMES_WEBUI_PYTHON": str(fake_python), @@ -353,10 +434,7 @@ def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path) started_pid = int(pid_file.read_text().strip()) finally: if started_pid: - try: - os.kill(started_pid, 15) - except ProcessLookupError: - pass + _kill_tree(started_pid) sleeper.terminate() try: sleeper.wait(timeout=3) diff --git a/tests/test_issue2351_cli_session_source_filter.py b/tests/test_issue2351_cli_session_source_filter.py index 2e88bf38..e6ba76c2 100644 --- a/tests/test_issue2351_cli_session_source_filter.py +++ b/tests/test_issue2351_cli_session_source_filter.py @@ -18,10 +18,11 @@ def test_sidebar_has_separate_webui_and_cli_session_source_tabs(): def test_cli_filter_keeps_cli_rows_out_of_default_webui_list(): src = SESSIONS_JS.read_text(encoding="utf-8") - assert "const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length" in src - assert "const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length" in src - assert "? withMessages.filter(s=>_isCliSession(s))" in src - assert ": withMessages.filter(s=>!_isCliSession(s))" in src + assert "function _partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in src + assert "webuiSessionCount" in src + assert "cliSessionCount" in src + assert "const showCliOnly=_sessionSourceFilter==='cli';" in src + assert "if(showCliOnly ? !isCli : isCli) continue;" in src def test_session_source_tabs_have_dedicated_sidebar_styles(): diff --git a/tests/test_issue3019_cron_project_sessions.py b/tests/test_issue3019_cron_project_sessions.py index dd112b4b..62add0cd 100644 --- a/tests/test_issue3019_cron_project_sessions.py +++ b/tests/test_issue3019_cron_project_sessions.py @@ -91,4 +91,5 @@ def test_agent_side_cron_rows_keep_project_chip_visibility(): def test_session_list_project_filter_can_reveal_default_hidden_cron_rows(): src = ( __import__("pathlib").Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8") - assert "!s.default_hidden||(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)" in src + assert "function _partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in src + assert "if(s.default_hidden&&!(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)) continue;" in src diff --git a/tests/test_sessions_search_depth_validation.py b/tests/test_sessions_search_depth_validation.py index 2d8156c6..588ab327 100644 --- a/tests/test_sessions_search_depth_validation.py +++ b/tests/test_sessions_search_depth_validation.py @@ -25,7 +25,7 @@ def _run_search(query): lives in its LAST message, capturing the JSON payload/status.""" import api.routes as routes - sessions_meta = [{"session_id": "s1", "title": "Untitled"}] + sessions_meta = [{"session_id": "s1", "title": "Untitled", "profile": "default"}] session = SimpleNamespace( session_id="s1", messages=[ @@ -42,7 +42,9 @@ def _run_search(query): with patch("api.routes.all_sessions", return_value=list(sessions_meta)), patch( "api.routes.get_session", return_value=session - ), patch("api.routes.j", side_effect=fake_j): + ), patch("api.profiles.get_active_profile_name", return_value="default"), patch( + "api.routes.j", side_effect=fake_j + ): routes._handle_sessions_search(SimpleNamespace(), urlparse(query)) return captured diff --git a/tests/test_sessions_search_profile_scope.py b/tests/test_sessions_search_profile_scope.py new file mode 100644 index 00000000..1c8712f3 --- /dev/null +++ b/tests/test_sessions_search_profile_scope.py @@ -0,0 +1,52 @@ +from types import SimpleNamespace +from unittest.mock import patch +from urllib.parse import urlparse + + +def _run_search(query): + import api.routes as routes + sessions_meta = [ + {"session_id": "active-s", "title": "boring", "profile": "default"}, + {"session_id": "other-s", "title": "secret needle title", "profile": "other"}, + {"session_id": "other-content-s", "title": "boring", "profile": "other"}, + ] + sessions = { + "other-content-s": SimpleNamespace(messages=[{"role": "user", "content": "secret needle body"}]), + "active-s": SimpleNamespace(messages=[{"role": "user", "content": "nothing"}]), + "other-s": SimpleNamespace(messages=[]), + } + captured = {} + def fake_j(handler, payload, status=200, extra_headers=None): + captured["status"] = status + captured["payload"] = payload + with patch("api.routes.all_sessions", return_value=list(sessions_meta)), \ + patch("api.routes.get_session", side_effect=lambda sid: sessions[sid]), \ + patch("api.profiles.get_active_profile_name", return_value="default"), \ + patch("api.routes.j", side_effect=fake_j): + routes._handle_sessions_search(SimpleNamespace(), urlparse(query)) + return captured + + +def test_empty_session_search_scopes_to_active_profile(): + captured = _run_search("/api/sessions/search") + assert [s["session_id"] for s in captured["payload"]["sessions"]] == ["active-s"] + + +def test_title_search_should_not_return_other_profile_rows(): + captured = _run_search("/api/sessions/search?q=needle&content=0") + assert captured["payload"]["count"] == 0 + + +def test_content_search_should_not_return_other_profile_rows(): + captured = _run_search("/api/sessions/search?q=needle&content=1&depth=0") + assert captured["payload"]["count"] == 0 + + +def test_all_profiles_opt_in_keeps_aggregate_session_search(): + captured = _run_search("/api/sessions/search?all_profiles=1") + assert captured["payload"]["all_profiles"] is True + assert [s["session_id"] for s in captured["payload"]["sessions"]] == [ + "active-s", + "other-s", + "other-content-s", + ] diff --git a/tests/test_sidebar_session_partition.py b/tests/test_sidebar_session_partition.py new file mode 100644 index 00000000..c99f66b4 --- /dev/null +++ b/tests/test_sidebar_session_partition.py @@ -0,0 +1,37 @@ +"""Regression coverage for single-pass sidebar session partitioning.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _partition_block() -> str: + start = SESSIONS_JS.index("function _partitionSidebarSessionRows(") + end = SESSIONS_JS.index("function renderSessionListFromCache()", start) + return SESSIONS_JS[start:end] + + +def test_render_uses_single_pass_partition_helper(): + render_start = SESSIONS_JS.index("function renderSessionListFromCache()") + render_end = SESSIONS_JS.index("function _showProjectPicker", render_start) + render_body = SESSIONS_JS[render_start:render_end] + + assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body + assert "withMessages.filter(" not in render_body + + +def test_partition_helper_applies_message_source_project_and_archive_gates(): + block = _partition_block() + + assert "function _sidebarRowHasVisibleMessages(s, activeSidForSidebar)" in SESSIONS_JS + assert "_sidebarRowHasVisibleMessages(s, activeSidForSidebar)" in block + assert "if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0)" in block + assert "const showCliOnly=_sessionSourceFilter==='cli';" in block + assert "if(!_showArchived&&s.archived) continue;" in block + assert "if(s.archived) archivedCount++;" in block + assert "return {" in block + assert "profileFiltered," in block + assert "sessionsRaw," in block diff --git a/tests/test_sidebar_unassigned_filter.py b/tests/test_sidebar_unassigned_filter.py index dd543863..0db21751 100644 --- a/tests/test_sidebar_unassigned_filter.py +++ b/tests/test_sidebar_unassigned_filter.py @@ -58,9 +58,12 @@ def test_unassigned_chip_filter_logic(): assert "_activeProject===NO_PROJECT_FILTER" in js, ( "renderSessionListFromCache must branch on the NO_PROJECT_FILTER sentinel" ) - assert "profileFiltered.filter(s=>!s.project_id)" in js, ( + assert "if(_activeProject===NO_PROJECT_FILTER){" in js, ( "The Unassigned filter must select sessions without a project_id" ) + assert "if(s.project_id) continue;" in js, ( + "The Unassigned filter must skip sessions with a project_id" + ) def test_unassigned_chip_only_shown_when_relevant():