Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: reject cross-script drifted auto-generated session titles (#3293) The title-language mismatch guard only knew two states: German (de) or empty, and _title_language_mismatch early-returned False whenever the user start wasn't German. So an English conversation whose LLM-generated title came back in Chinese / Spanish / Russian sailed through and persisted with llm_title_generated=true. The German case was the only one covered because that's the one prior report it was built for. Generalize from a German-specific binary to a language-agnostic cross-script check. Add _script_counts() + _dominant_script() (cheap, dependency-free Unicode-block classification: latin / cjk / cyrillic / arabic / hebrew / greek / devanagari). _title_language_mismatch now rejects a title that introduces a substantial amount (>=35% of alphabetic chars, min 2) of a script different from the conversation start's dominant script — so short titles that embed a borrowed Latin technical term still trip, while an English title with a single foreign place-name does not. The legacy German->English same-script heuristic is preserved verbatim. Kept api/streaming.py ASCII-only (the test_title_generation_source_has_no_cjk_ literals guard) — all CJK examples live in the test file, not the source. Closes #3293 Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com> * fix: prune orphaned imported-CLI sidecars from the WebUI sidebar (#3238) When a CLI/agent session is opened in WebUI it gets a WebUI-owned sidecar (webui/sessions/<id>.json + _index.json row) so it can render and reopen; all_sessions() then returns it independently of the agent state.db. If the user later deletes that session from the CLI / local Hermes storage, nothing pruned the sidecar — the merge loop only overlays CLI metadata when a matching state.db row exists and otherwise continues, so the stale row lingered in the sidebar indefinitely (there is no WebUI delete affordance for CLI rows). Add api.models.agent_session_row_exists(): an exact, uncapped existence probe against the state.db sessions table. The sidebar merge loop now drops a row that is_cli_session_row + not WebUI-native + absent from cli_by_id + whose state.db row is genuinely gone, and calls prune_session_from_index() so _index.json self-heals. The state.db probe is deliberate: get_cli_sessions() caps at CLI_VISIBLE_SESSION_LIMIT (20), so a still-existing session can fall out of that window and look deleted — pruning on cli_by_id absence alone would delete live sessions. WebUI-native rows with a CLI ancestor are never pruned, and any probe error degrades to keep-the-row so a transient failure can't lose data. Closes #3238 Co-authored-by: Luxciax <Luxciax@users.noreply.github.com> * fix: count pin quota by visible session lineage * docs(changelog): v0.51.222 — backend bugfix batch (#3293 title drift, #3238 sidecar prune, #3288 pin lineage) * fix(pins): forks count as own pin lineage, not collapsed to parent (#3288 Codex follow-up) Codex review of the batch found a pin-limit UNDERCOUNT: _session_row_lineage_root_id followed any parent_session_id to the root, but /api/session/branch creates independent visible fork sessions that also carry parent_session_id (session_source= 'fork'). Two pinned forks of the same parent collapsed to one quota lineage, letting a user exceed pinned_sessions_limit with no 400. Fix: a fork returns its own id as its lineage root (it's a separately-visible session); only compression/continuation rows still collapse to a shared root. Adds a regression test with two pinned forks + the parent counting as three distinct lineages, and confirms the existing pre-compression-snapshot collapse case still passes. * test(pins): update #2508/#2821 source-match tests for #3288 lineage rename #3288 replaced the raw-session-id pin counter (pinned_ids set) with a visible-lineage counter (pinned_lineage_ids via _visible_pinned_lineage_ids over persisted_rows/candidate_rows). Two pre-existing source-string-matching tests asserted the OLD implementation literals (pinned_ids = {, _session_field(existing, session_id...), len(pinned_ids) >=). Updated both to assert the new mechanism while preserving the invariants they actually guard: snapshot computed BEFORE LOCK (no all_sessions()-inside-LOCK deadlock), quota filtering via the shared _session_counts_toward_pin_quota helper, and the limit/400 guard. Behaviour unchanged; these were implementation-detail assertions, not behaviour tests. --------- Co-authored-by: nesquena-hermes <[email protected]> Co-authored-by: andrewkangkr <andrewkangkr@users.noreply.github.com> Co-authored-by: Luxciax <Luxciax@users.noreply.github.com> Co-authored-by: Andy Kang <andrewkang.kr@gmail.com>
196 lines
7.4 KiB
Python
196 lines
7.4 KiB
Python
"""Regression checks for issue #2508 session pinning bounds and context menu access."""
|
|
|
|
import json
|
|
import pathlib
|
|
import time
|
|
from types import SimpleNamespace
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from tests._pytest_port import BASE, TEST_STATE_DIR
|
|
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
ROUTES_PY = (ROOT / "api" / "routes.py").read_text()
|
|
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text()
|
|
STYLE_CSS = (ROOT / "static" / "style.css").read_text()
|
|
|
|
|
|
def post(path, body=None):
|
|
data = json.dumps(body or {}).encode()
|
|
req = urllib.request.Request(
|
|
BASE + path,
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return json.loads(r.read()), r.status
|
|
except urllib.error.HTTPError as e:
|
|
return json.loads(e.read()), e.code
|
|
|
|
|
|
def make_session(created):
|
|
payload = {
|
|
"title": f"Pin cap {len(created) + 1}",
|
|
"messages": [{"role": "user", "content": "keep this conversation handy"}],
|
|
"model": "test/pin-cap",
|
|
}
|
|
d, status = post("/api/session/import", payload)
|
|
assert status == 200
|
|
sid = d["session"]["session_id"]
|
|
created.append(sid)
|
|
return sid
|
|
|
|
|
|
|
|
def inject_hidden_pinned_snapshot(sid="hidden-pinned-snapshot"):
|
|
"""Add a persisted legacy hidden snapshot without touching server memory."""
|
|
sessions_dir = TEST_STATE_DIR / "sessions"
|
|
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
now = time.time()
|
|
row = {
|
|
"session_id": sid,
|
|
"title": "Hidden pinned snapshot",
|
|
"workspace": str(TEST_STATE_DIR / "test-workspace"),
|
|
"model": "test/pin-cap",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"last_message_at": now,
|
|
"message_count": 1,
|
|
"messages": [{"role": "user", "content": "legacy hidden snapshot"}],
|
|
"tool_calls": [],
|
|
"pinned": True,
|
|
"archived": False,
|
|
"pre_compression_snapshot": True,
|
|
"_show_pre_compression_snapshot": False,
|
|
}
|
|
(sessions_dir / f"{sid}.json").write_text(json.dumps(row), encoding="utf-8")
|
|
index_path = sessions_dir / "_index.json"
|
|
try:
|
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
index = []
|
|
compact = {k: v for k, v in row.items() if k not in {"messages", "tool_calls"}}
|
|
index = [item for item in index if item.get("session_id") != sid]
|
|
index.append(compact)
|
|
index_path.write_text(json.dumps(index), encoding="utf-8")
|
|
return sid
|
|
|
|
|
|
def test_session_pin_endpoint_caps_pinned_sessions_at_three():
|
|
created = []
|
|
try:
|
|
pinned = [make_session(created) for _ in range(3)]
|
|
for sid in pinned:
|
|
d, status = post("/api/session/pin", {"session_id": sid, "pinned": True})
|
|
assert status == 200
|
|
assert d["session"]["pinned"] is True
|
|
|
|
fourth = make_session(created)
|
|
d, status = post("/api/session/pin", {"session_id": fourth, "pinned": True})
|
|
assert status == 400
|
|
assert "3 sessions" in d.get("error", "")
|
|
|
|
d, status = post("/api/session/pin", {"session_id": pinned[0], "pinned": False})
|
|
assert status == 200
|
|
assert d["session"]["pinned"] is False
|
|
|
|
d, status = post("/api/session/pin", {"session_id": fourth, "pinned": True})
|
|
assert status == 200
|
|
assert d["session"]["pinned"] is True
|
|
finally:
|
|
for sid in created:
|
|
post("/api/session/delete", {"session_id": sid})
|
|
|
|
|
|
def test_session_pin_endpoint_ignores_hidden_snapshot_when_enforcing_cap():
|
|
created = []
|
|
hidden_sid = "hidden-pinned-snapshot-quota-route"
|
|
try:
|
|
hidden = inject_hidden_pinned_snapshot(hidden_sid)
|
|
pinned = [make_session(created) for _ in range(2)]
|
|
for sid in pinned:
|
|
d, status = post("/api/session/pin", {"session_id": sid, "pinned": True})
|
|
assert status == 200
|
|
assert d["session"]["pinned"] is True
|
|
|
|
third_visible = make_session(created)
|
|
d, status = post("/api/session/pin", {"session_id": third_visible, "pinned": True})
|
|
assert status == 200, d
|
|
assert d["session"]["pinned"] is True
|
|
assert hidden not in {third_visible, *pinned}
|
|
finally:
|
|
for sid in created:
|
|
post("/api/session/delete", {"session_id": sid})
|
|
(TEST_STATE_DIR / "sessions" / f"{hidden_sid}.json").unlink(missing_ok=True)
|
|
index_path = TEST_STATE_DIR / "sessions" / "_index.json"
|
|
try:
|
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
|
index = [item for item in index if item.get("session_id") != hidden_sid]
|
|
index_path.write_text(json.dumps(index), encoding="utf-8")
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
pass
|
|
|
|
|
|
def test_hidden_pre_compression_snapshot_does_not_count_toward_pin_quota():
|
|
from api.routes import _session_counts_toward_pin_quota
|
|
|
|
assert _session_counts_toward_pin_quota({
|
|
"session_id": "hidden-snapshot",
|
|
"pinned": True,
|
|
"archived": False,
|
|
"pre_compression_snapshot": True,
|
|
}) is False
|
|
assert _session_counts_toward_pin_quota({
|
|
"session_id": "visible-session",
|
|
"pinned": True,
|
|
"archived": False,
|
|
"pre_compression_snapshot": False,
|
|
}) is True
|
|
|
|
|
|
def test_hidden_in_memory_snapshot_does_not_count_toward_pin_quota():
|
|
from api.routes import _session_counts_toward_pin_quota
|
|
|
|
snapshot = SimpleNamespace(
|
|
session_id="hidden-memory-snapshot",
|
|
pinned=True,
|
|
archived=False,
|
|
pre_compression_snapshot=True,
|
|
)
|
|
assert _session_counts_toward_pin_quota(snapshot) is False
|
|
|
|
|
|
def test_session_pin_cap_has_backend_and_frontend_guards():
|
|
# #3288 renamed the in-LOCK pin counter to count visible lineages
|
|
# (pinned_lineage_ids) instead of raw session ids (pinned_ids), so a
|
|
# continuation lineage no longer consumes multiple pin slots. The guard
|
|
# behaviour (snapshot, merge under LOCK, compare against the limit, 400) is
|
|
# unchanged.
|
|
assert 'persisted_rows = [' in ROUTES_PY
|
|
assert 'candidate_rows.extend(' in ROUTES_PY
|
|
assert 'pinned_lineage_ids = _visible_pinned_lineage_ids(candidate_rows)' in ROUTES_PY
|
|
assert 'pinned_sessions_limit = int(load_settings().get("pinned_sessions_limit", 3) or 3)' in ROUTES_PY
|
|
assert 'if len(pinned_lineage_ids) >= pinned_sessions_limit:' in ROUTES_PY
|
|
assert 'Up to {pinned_sessions_limit} sessions can be pinned' in ROUTES_PY
|
|
|
|
assert 'function _pinnedSessionCount()' in SESSIONS_JS
|
|
assert 'function _getPinnedSessionsLimit()' in SESSIONS_JS
|
|
assert 'function _pinnedSessionsLimit()' not in SESSIONS_JS
|
|
assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();' not in SESSIONS_JS
|
|
assert 'if(pinLimitReached)' not in SESSIONS_JS
|
|
assert "await api('/api/session/pin'" in SESSIONS_JS
|
|
assert 'Only ${limit} conversations can be pinned' in SESSIONS_JS
|
|
assert ".session-action-opt.is-disabled{opacity:.55;cursor:not-allowed;}" in STYLE_CSS
|
|
|
|
|
|
def test_session_rows_open_action_menu_from_right_click():
|
|
assert 'el.oncontextmenu=(e)=>{' in SESSIONS_JS
|
|
context_idx = SESSIONS_JS.find('el.oncontextmenu=(e)=>{')
|
|
assert context_idx != -1
|
|
block = SESSIONS_JS[context_idx:SESSIONS_JS.find('};', context_idx) + 2]
|
|
assert 'e.preventDefault();' in block
|
|
assert 'e.stopPropagation();' in block
|
|
assert '_openSessionActionMenu(s, actions||el);' in block
|