Release v0.51.269 — Release IK (stage-b2 — sidebar perf + search scope + Windows ctl) (#3672)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
* perf(ui): single-pass sidebar session row partitioning (#3658) Co-authored-by: Pamnard <pamnard@users.noreply.github.com> * fix(search): scope session search to active profile (#3646) Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com> * fix(ctl): tree-kill ctl.sh stop on Windows (#3670) Co-authored-by: Rod Boev <rod.boev@gmail.com> * docs(changelog): v0.51.269 — Release IK (stage-b2) --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: Pamnard <pamnard@users.noreply.github.com> Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com> Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
87
ctl.sh
87
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}"
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
52
tests/test_sessions_search_profile_scope.py
Normal file
52
tests/test_sessions_search_profile_scope.py
Normal file
@@ -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",
|
||||
]
|
||||
37
tests/test_sidebar_session_partition.py
Normal file
37
tests/test_sidebar_session_partition.py
Normal file
@@ -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
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user