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>
79 lines
3.3 KiB
Python
79 lines
3.3 KiB
Python
"""Regression checks for #2821 session pin/unpin state sync."""
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
|
|
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _function_block(src: str, name: str) -> str:
|
|
marker = f"function {name}"
|
|
start = src.find(marker)
|
|
assert start != -1, f"{name} not found"
|
|
brace = src.find("{", start)
|
|
assert brace != -1, f"{name} body not found"
|
|
depth = 1
|
|
i = brace + 1
|
|
while i < len(src) and depth:
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
i += 1
|
|
assert depth == 0, f"{name} body did not close"
|
|
return src[start:i]
|
|
|
|
|
|
def test_session_field_helper_reads_dicts_and_objects():
|
|
from api.routes import _session_field
|
|
|
|
class SessionLike:
|
|
session_id = "obj-1"
|
|
pinned = True
|
|
archived = False
|
|
|
|
assert _session_field({"session_id": "dict-1", "pinned": True}, "pinned", False) is True
|
|
assert _session_field({"session_id": "dict-1"}, "archived", False) is False
|
|
assert _session_field(SessionLike(), "session_id", None) == "obj-1"
|
|
assert _session_field(SessionLike(), "missing", "fallback") == "fallback"
|
|
|
|
|
|
def test_pin_limit_snapshot_counts_index_dict_entries():
|
|
assert "def _session_counts_toward_pin_quota(session)" in ROUTES_PY
|
|
assert "_session_counts_toward_pin_quota(existing)" in ROUTES_PY
|
|
assert "_hide_from_default_sidebar(row)" in ROUTES_PY
|
|
# #3288 replaced the set-of-ids snapshot with a visible-lineage row snapshot.
|
|
# The load-bearing invariant this test guards is unchanged: the persisted pin
|
|
# snapshot is computed BEFORE acquiring LOCK (all_sessions() acquires LOCK
|
|
# internally, so snapshotting inside `with LOCK:` would deadlock).
|
|
start = ROUTES_PY.find("persisted_rows = [")
|
|
assert start != -1, "persisted pin snapshot not found"
|
|
end = ROUTES_PY.find("with LOCK:", start)
|
|
assert end != -1, "persisted pin snapshot should be computed before LOCK"
|
|
persisted_snapshot = ROUTES_PY[start:end]
|
|
# The snapshot must filter via the shared quota helper, not raw getattr checks.
|
|
assert "_session_counts_toward_pin_quota(existing)" in persisted_snapshot
|
|
assert 'getattr(existing, "pinned", False)' not in persisted_snapshot
|
|
assert 'getattr(existing, "archived", False)' not in persisted_snapshot
|
|
# The authoritative count collapses continuation siblings to visible lineages.
|
|
assert "_visible_pinned_lineage_ids(" in ROUTES_PY
|
|
|
|
|
|
def test_pin_action_does_not_short_circuit_on_stale_client_count():
|
|
body = _function_block(SESSIONS_JS, "_openSessionActionMenu")
|
|
assert "const pinLimitReached=" not in body
|
|
assert "if(pinLimitReached)" not in body
|
|
assert "_pinnedSessionCount()>=_getPinnedSessionsLimit()" not in body
|
|
assert "await api('/api/session/pin'" in body
|
|
|
|
|
|
def test_pin_action_refreshes_session_list_after_pin_failure():
|
|
body = _function_block(SESSIONS_JS, "_openSessionActionMenu")
|
|
catch_idx = body.find("}catch(err){")
|
|
assert catch_idx != -1, "Pin/unpin action must have an error path"
|
|
catch_block = body[catch_idx:body.find("}", catch_idx + len("}catch(err){")) + 1]
|
|
assert "showToast(t('session_pin_failed')+err.message)" in catch_block
|
|
assert "await renderSessionList()" in catch_block
|