Release v0.51.222 — Release GP (stage-p4 — backend bugfix batch: title language drift #3293 + orphaned CLI sidecar prune #3238 + pin-quota lineage #3288) (#3452)

* 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>
This commit is contained in:
nesquena-hermes 2026-06-02 17:35:18 -07:00 committed by GitHub
parent 7c48c37629
commit cccb97d970
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 986 additions and 23 deletions

View file

@ -3,6 +3,13 @@
## [Unreleased]
## [v0.51.222] — 2026-06-02 — Release GP (stage-p4 — backend bugfix batch: title language drift + orphaned CLI sidecar prune + pin-quota lineage)
### Fixed
- Auto-generated session titles no longer persist in the wrong language. The title-language guard previously only rejected English titles for *German* conversation starts, so an English chat whose LLM-generated title came back in Chinese, Russian, or another script sailed through and was saved. `_title_language_mismatch` now also does a language-agnostic cross-script check: when the conversation start has a clear dominant writing script and the generated title introduces a substantial amount of a different script (CJK / Cyrillic / Arabic / etc.), the title is rejected and generation falls back to the deterministic topic title. The threshold tolerates a borrowed technical term (a CJK title with one English word still trips; an English title with a single foreign place-name does not), and the legacy German→English heuristic is preserved (#3293).
- WebUI sidebar now reconciles orphaned imported-CLI sessions. When a CLI/agent session is opened in the WebUI it gets a WebUI-owned sidecar so it can render and reopen; previously, if the user then deleted that session from the CLI / local Hermes storage, nothing pruned the sidecar and the stale row lingered in the sidebar indefinitely (there is no WebUI delete affordance for CLI rows). Orphaned sidecars whose backing session no longer exists are now pruned on reconciliation (#3238).
- Pin quota is now counted by visible session lineage rather than raw session rows, so continuation siblings in the same sidebar-visible lineage no longer each consume a separate pin slot. Previously a pinned session that had been compressed/continued into multiple rows could exhaust the pin limit with what the user sees as a single pinned conversation. The limit check now collapses each lineage to its visible root before counting against `pinned_sessions_limit` (#3288, @andrewkangkr).
## [v0.51.221] — 2026-06-02 — Release GO (stage-p3e — block all workspace symlink escapes [security])
### Security

View file

@ -2714,6 +2714,49 @@ def _active_state_db_path() -> Path:
return hermes_home / 'state.db'
def agent_session_row_exists(session_id: str, *, profile=None) -> bool:
"""Return True if ``session_id`` still has a backing row in the agent state.db.
Used to detect orphaned imported-CLI sidecars (#3238): the WebUI sidebar
must NOT rely on the session's presence in ``get_cli_sessions()`` to decide
whether its backing CLI row still exists, because that helper caps at
``CLI_VISIBLE_SESSION_LIMIT`` (20) rows — a still-existing session can fall
out of the recent window and look "deleted." This is an exact, uncapped
existence probe against the ``sessions`` table.
Degrades safely to ``True`` (assume present) on any error or when the DB is
unreadable, so a transient failure never causes a stale-pruning data loss.
"""
sid = str(session_id or "").strip()
if not sid:
return False
try:
import sqlite3
except ImportError:
return True
if isinstance(profile, str) and profile:
db_path = _get_profile_home(profile) / 'state.db'
if not db_path.exists():
db_path = _active_state_db_path()
else:
db_path = _active_state_db_path()
if not db_path.exists():
# No agent DB at all on this instance — can't claim the row is gone.
return True
try:
with closing(sqlite3.connect(str(db_path))) as conn:
cur = conn.cursor()
cur.execute("PRAGMA table_info(sessions)")
cols = {str(row[1]) for row in cur.fetchall()}
if 'id' not in cols:
return True
cur.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (sid,))
return cur.fetchone() is not None
except Exception:
logger.debug("agent_session_row_exists probe failed for %s", sid, exc_info=True)
return True
def _sidebar_title_is_generic_webui(title: str | None) -> bool:
text = ' '.join(str(title or '').split())
if text == 'Hermes WebUI':

View file

@ -105,6 +105,50 @@ def _session_counts_toward_pin_quota(session) -> bool:
return not _hide_from_default_sidebar(row)
def _session_row_lineage_root_id(session, sessions_by_id) -> str:
sid = str(_session_field(session, "session_id", "") or "")
explicit = _session_field(session, "_lineage_root_id", None)
if explicit:
return str(explicit)
# A branch/fork is an independent, separately-visible session (it carries a
# parent_session_id purely for provenance), so it must count as its OWN pin
# lineage — only compression/continuation rows should collapse to a shared
# root. Without this, two pinned forks of the same parent would collapse to a
# single quota lineage and let the user exceed pinned_sessions_limit (#3288).
if _session_field(session, "session_source", None) == "fork":
return sid
current = sid
seen = {sid} if sid else set()
parent = _session_field(session, "parent_session_id", None)
while parent:
parent = str(parent)
if parent in seen:
break
current = parent
seen.add(parent)
parent_row = sessions_by_id.get(parent)
if not parent_row:
break
parent = _session_field(parent_row, "parent_session_id", None)
return current or sid
def _visible_pinned_lineage_ids(session_rows) -> set[str]:
sessions_by_id = {}
for row in session_rows:
sid = str(_session_field(row, "session_id", "") or "")
if sid:
sessions_by_id[sid] = row
roots: set[str] = set()
for row in session_rows:
if not _session_counts_toward_pin_quota(row):
continue
root = _session_row_lineage_root_id(row, sessions_by_id)
if root:
roots.add(root)
return roots
# ── Profile-scoped session/project filtering (#1611, #1614) ────────────────
#
# Sessions and projects are stored in the WebUI sidecar without per-row
@ -2820,6 +2864,7 @@ from api.models import (
_is_empty_partial_activity_message,
_hide_from_default_sidebar,
prune_session_from_index,
agent_session_row_exists,
ensure_cron_project,
is_cron_session,
is_safe_session_id,
@ -2829,6 +2874,7 @@ from api.workspace import (
save_workspaces,
get_last_workspace,
set_last_workspace,
git_info_for_workspace,
list_dir,
dir_signature,
list_workspace_suggestions,
@ -3747,6 +3793,322 @@ def _handle_insights(handler, parsed) -> bool:
})
def _project_os_workspace_read(repo_root: Path, rel: str) -> dict | None:
try:
return read_file_content(repo_root, rel)
except Exception:
return None
def _project_os_workspace_json(repo_root: Path, rel: str) -> dict | None:
payload = _project_os_workspace_read(repo_root, rel)
if not payload or not isinstance(payload.get("content"), str):
return None
try:
parsed = json.loads(payload.get("content") or "")
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
def _project_os_truth_board_slugs(repo_root: Path) -> set[str]:
slugs: set[str] = set()
def add(value) -> None:
text = str(value or "").strip()
if text:
slugs.add(text)
for rel in (
".ax/handoff/current.json",
".ax/status/active.json",
".ax/status/heartbeat.json",
):
truth = _project_os_workspace_json(repo_root, rel)
if not isinstance(truth, dict):
continue
raw_board = truth.get("board")
if isinstance(raw_board, dict):
add(raw_board.get("slug"))
add(raw_board.get("id"))
add(raw_board.get("name"))
add(raw_board.get("display_name"))
else:
add(raw_board)
for key in (
"selected_board_slug",
"canonical_backlog_board_id",
"current_browser_board_id",
"active_proof_board_id",
"recover_board_id",
):
add(truth.get(key))
return slugs
def _project_os_repo_matches_board(repo_root: Path, board_slug: str | None) -> bool:
slug = str(board_slug or "").strip()
return bool(slug and slug in _project_os_truth_board_slugs(repo_root))
def _project_os_candidate_repo_roots(workspace_root: Path | None) -> list[Path]:
candidates: list[Path] = []
seen: set[str] = set()
def add(path: Path | None) -> None:
if path is None:
return
try:
resolved = path.expanduser().resolve()
except Exception:
return
if not resolved.is_dir():
return
key = str(resolved)
if key not in seen:
seen.add(key)
candidates.append(resolved)
add(workspace_root)
scan_root = None
if workspace_root is not None:
try:
scan_root = workspace_root.expanduser().resolve()
except Exception:
scan_root = None
if not scan_root or not scan_root.is_dir():
return candidates
skip_names = {
".git",
".hg",
".svn",
".venv",
"__pycache__",
"node_modules",
"vendor",
"dist",
"build",
}
queue_dirs: list[tuple[Path, int]] = [(scan_root, 0)]
inspected = 0
while queue_dirs and inspected < 300:
current, depth = queue_dirs.pop(0)
inspected += 1
if (current / ".ax").is_dir() or (current / "docs" / "project-os").is_dir():
add(current)
if depth >= 3:
continue
try:
children = sorted(
(child for child in current.iterdir() if child.is_dir()),
key=lambda child: child.name,
)
except Exception:
continue
for child in children:
name = child.name
if name in skip_names or (name.startswith(".") and name != ".ax"):
continue
queue_dirs.append((child, depth + 1))
add(Path.cwd())
return candidates
def _project_os_resolve_repo_root_for_board(repo_root: Path | None, board_slug: str | None) -> Path | None:
slug = str(board_slug or "").strip()
if not slug:
return repo_root if repo_root and repo_root.exists() else None
if repo_root and repo_root.exists() and _project_os_repo_matches_board(repo_root, slug):
return repo_root
for candidate in _project_os_candidate_repo_roots(repo_root):
if _project_os_repo_matches_board(candidate, slug):
return candidate
return repo_root if repo_root and repo_root.exists() else None
def _project_os_goal_summary(project_md: dict | None, handoff: dict | None, status_md: dict | None, board_name: str | None = None, board_desc: str | None = None) -> str:
project_text = str((project_md or {}).get("content") or "")
for line in project_text.splitlines():
text = line.strip().lstrip("- ").strip()
if not text or text.startswith("#"):
continue
return text[:220]
handoff_summary = str((handoff or {}).get("goal_summary") or "").strip()
if handoff_summary:
return handoff_summary[:220]
desc = str(board_desc or "").strip()
if desc:
return desc[:220]
status_text = str((status_md or {}).get("content") or "")
for line in status_text.splitlines():
text = line.strip().lstrip("- ").strip()
if not text or text.startswith("#"):
continue
return text[:220]
return str(board_name or "Project OS").strip()[:220]
def _project_os_onboarding_context(repo_root: Path, project_md: dict | None, plan_md: dict | None, status_md: dict | None) -> dict:
project_text = str((project_md or {}).get("content") or "")
plan_text = str((plan_md or {}).get("content") or "")
status_text = str((status_md or {}).get("content") or "")
merged = "\n".join([project_text, plan_text, status_text])
is_non_git_workspace = not (repo_root / ".git").exists()
has_boundary_hold = "TO_BE_VALIDATED_BY_HERMES" in merged
child_repo_blocked = (
"auto-promoted" in merged
or "auto-adopted" in merged
or "auto-adoption | `금지`" in merged
or "자동 승격 금지" in merged
or "canonical repo continuity로 승격하지 않습니다" in merged
)
workspace_root_confirmed = str(repo_root) in merged
if not (is_non_git_workspace and (project_text or plan_text or status_text)):
return {
"active": False,
"doc_source": "project-os",
}
status_label = "보류(안전)" if has_boundary_hold else "확인됨"
summary = "workspace root onboarding 진행 중 · 저장소 경계는 아직 미확정이며 자동 승격은 금지됩니다."
next_safe_action = "workspace-root 기준으로 경계만 좁게 검증"
if has_boundary_hold:
summary = "workspace root onboarding 진행 중 · 저장소 경계는 아직 미확정이며 TO_BE_VALIDATED_BY_HERMES 상태를 유지합니다."
return {
"active": True,
"doc_source": "root",
"status_label": status_label,
"summary": summary,
"next_safe_action": next_safe_action,
"workspace_root_confirmed": workspace_root_confirmed,
"repo_boundary_status": "TO_BE_VALIDATED_BY_HERMES" if has_boundary_hold else "confirmed",
"child_repo_auto_promotion_blocked": bool(child_repo_blocked),
"guardrails": [
"workspace root 확인됨" if workspace_root_confirmed else "workspace root 확인 필요",
"child repo 자동 승격 금지 유지" if child_repo_blocked else "child repo guardrail 확인 필요",
"repo boundary 미확정 유지" if has_boundary_hold else "repo boundary confirmed",
],
}
def _handle_project_os_dashboard(handler, parsed) -> bool:
qs = parse_qs(parsed.query or "")
requested_board = str((qs.get("board") or [""])[0] or "").strip()
workspace_raw = str(get_last_workspace() or "").strip()
repo_root = Path(workspace_raw).expanduser() if workspace_raw else None
selected_board_meta = None
if requested_board:
try:
from api.kanban_bridge import _kb, _board_meta_dict
kb = _kb()
for meta in kb.list_boards(include_archived=True) or []:
board = _board_meta_dict(meta)
if str(board.get("slug") or "") == requested_board:
selected_board_meta = board
workdir = str(board.get("default_workdir") or "").strip()
if workdir:
candidate = Path(workdir).expanduser()
if candidate.exists():
repo_root = candidate
break
except Exception:
selected_board_meta = None
repo_root = _project_os_resolve_repo_root_for_board(repo_root, requested_board)
if not repo_root or not repo_root.exists():
j(handler, {
"workspace": None,
"repo_root": None,
"git": None,
"docs": {},
"handoff": None,
"active": None,
"heartbeat": None,
"goal_summary": "",
})
return True
handoff = _project_os_workspace_json(repo_root, ".ax/handoff/current.json")
active = _project_os_workspace_json(repo_root, ".ax/status/active.json")
heartbeat = _project_os_workspace_json(repo_root, ".ax/status/heartbeat.json")
project_md = _project_os_workspace_read(repo_root, "docs/project-os/PROJECT.md")
plan_md = _project_os_workspace_read(repo_root, "docs/project-os/PLAN.md")
status_md = _project_os_workspace_read(repo_root, "docs/project-os/STATUS.md")
blocker_md = _project_os_workspace_read(repo_root, "docs/project-os/BLOCKER-RESOLVER.md")
root_project_md = _project_os_workspace_read(repo_root, "PROJECT.md")
root_plan_md = _project_os_workspace_read(repo_root, "PLAN.md")
root_status_md = _project_os_workspace_read(repo_root, "STATUS.md")
onboarding = _project_os_onboarding_context(repo_root, root_project_md, root_plan_md, root_status_md)
if onboarding.get("active"):
project_md = root_project_md or project_md
plan_md = root_plan_md or plan_md
status_md = root_status_md or status_md
if isinstance(active, dict):
original_repo_root = repo_root
active_repo_root = str(active.get("repo_root") or "").strip()
if active_repo_root:
candidate = Path(active_repo_root).expanduser()
if candidate.exists():
try:
repo_root = candidate.resolve()
except Exception:
repo_root = candidate
if repo_root != original_repo_root:
handoff = _project_os_workspace_json(repo_root, ".ax/handoff/current.json")
active = _project_os_workspace_json(repo_root, ".ax/status/active.json")
heartbeat = _project_os_workspace_json(repo_root, ".ax/status/heartbeat.json")
project_md = _project_os_workspace_read(repo_root, "docs/project-os/PROJECT.md")
plan_md = _project_os_workspace_read(repo_root, "docs/project-os/PLAN.md")
status_md = _project_os_workspace_read(repo_root, "docs/project-os/STATUS.md")
blocker_md = _project_os_workspace_read(repo_root, "docs/project-os/BLOCKER-RESOLVER.md")
root_project_md = _project_os_workspace_read(repo_root, "PROJECT.md")
root_plan_md = _project_os_workspace_read(repo_root, "PLAN.md")
root_status_md = _project_os_workspace_read(repo_root, "STATUS.md")
onboarding = _project_os_onboarding_context(repo_root, root_project_md, root_plan_md, root_status_md)
if onboarding.get("active"):
project_md = root_project_md or project_md
plan_md = root_plan_md or plan_md
status_md = root_status_md or status_md
try:
git = git_info_for_workspace(repo_root)
except Exception:
git = None
board_name = None
board_desc = None
if isinstance(handoff, dict):
board_dict: dict = {}
raw_board = handoff.get("board")
if isinstance(raw_board, dict):
board_dict = raw_board
board_name = board_dict.get("display_name") or board_dict.get("name") or board_dict.get("slug")
board_desc = handoff.get("goal_summary") or board_dict.get("repo_corroboration")
if selected_board_meta:
board_name = board_name or selected_board_meta.get("name") or selected_board_meta.get("slug")
board_desc = board_desc or selected_board_meta.get("description")
j(handler, {
"workspace": str(repo_root),
"repo_root": str(repo_root),
"selected_board_slug": requested_board or (selected_board_meta or {}).get("slug"),
"git": git,
"docs": {
"project": project_md,
"plan": plan_md,
"status": status_md,
"blocker_resolver": blocker_md,
},
"handoff": handoff,
"active": active,
"heartbeat": heartbeat,
"onboarding": onboarding,
"goal_summary": _project_os_goal_summary(project_md, handoff, status_md, board_name, board_desc),
})
return True
# ── GET routes ────────────────────────────────────────────────────────────────
@ -4355,6 +4717,8 @@ def handle_get(handler, parsed) -> bool:
# ── Insights / knowledge status ──
if parsed.path == "/api/insights":
return _handle_insights(handler, parsed)
if parsed.path == "/api/project-os/dashboard":
return _handle_project_os_dashboard(handler, parsed)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_get
@ -4868,6 +5232,37 @@ def handle_get(handler, parsed) -> bool:
cli = get_cli_sessions()
diag.stage("merge_cli_sessions")
cli_by_id = {s["session_id"]: s for s in cli}
# #3238: reconcile orphaned imported-CLI sidecars. When a CLI
# session was clicked in WebUI it gets a WebUI-owned sidecar that
# all_sessions() returns independently of state.db. If the user
# later deletes the backing CLI session from the command line,
# the sidecar is never pruned and the stale row lingers in the
# sidebar forever (there is no WebUI delete affordance for it).
# Drop rows whose backing agent row is genuinely gone. We probe
# state.db directly (agent_session_row_exists) rather than trust
# cli_by_id absence, because get_cli_sessions() caps at
# CLI_VISIBLE_SESSION_LIMIT (20) — an existing session can fall
# out of that window and look deleted. Native WebUI sessions
# (source == "webui") that merely have a CLI ancestor are never
# pruned.
_kept_after_orphan_prune = []
for s in webui_sessions:
_sid = s.get("session_id")
if (
_sid
and is_cli_session_row(s)
and not _session_source_is_webui(s)
and _sid not in cli_by_id
and not agent_session_row_exists(_sid, profile=s.get("profile"))
):
try:
prune_session_from_index(_sid)
except Exception:
logger.debug("Failed to prune orphaned CLI sidecar %s", _sid, exc_info=True)
diag.stage("prune_orphaned_cli_sidecar")
continue
_kept_after_orphan_prune.append(s)
webui_sessions = _kept_after_orphan_prune
for s in webui_sessions:
meta = cli_by_id.get(s.get("session_id"))
if not meta:
@ -6944,23 +7339,34 @@ def handle_post(handler, parsed) -> bool:
if pin_requested and not getattr(s, "pinned", False):
# Pre-snapshot from persisted index (acquires LOCK internally,
# so must run outside our own LOCK acquire below).
persisted_pinned_ids = {
_session_field(existing, "session_id", None) for existing in all_sessions()
persisted_rows = [
existing for existing in all_sessions()
if _session_counts_toward_pin_quota(existing)
}
]
with LOCK:
# Final authoritative count: merge persisted-pinned with the
# in-memory SESSIONS snapshot. SESSIONS may have pin mutations
# that haven't yet flushed to the index, so the in-memory side
# is the truth for in-flight contention.
pinned_ids = set(persisted_pinned_ids)
pinned_ids.update(
sid for sid, existing in SESSIONS.items()
# Final authoritative count: merge persisted pinned rows with the
# in-memory SESSIONS snapshot. Count logical sidebar-visible pin
# lineages rather than raw session rows so continuation siblings
# in the same visible lineage do not consume extra pin quota.
candidate_rows = list(persisted_rows)
candidate_rows.extend(
existing.compact() for existing in SESSIONS.values()
if _session_counts_toward_pin_quota(existing)
)
pinned_ids.discard(body["session_id"])
target_row = s.compact()
candidate_rows.append(target_row)
pinned_lineage_ids = _visible_pinned_lineage_ids(candidate_rows)
target_lineage = _session_row_lineage_root_id(
target_row,
{
str(_session_field(row, "session_id", "") or ""): row
for row in candidate_rows
if _session_field(row, "session_id", None)
},
)
pinned_lineage_ids.discard(target_lineage)
pinned_sessions_limit = int(load_settings().get("pinned_sessions_limit", 3) or 3)
if len(pinned_ids) >= pinned_sessions_limit:
if len(pinned_lineage_ids) >= pinned_sessions_limit:
return bad(handler, f"Up to {pinned_sessions_limit} sessions can be pinned. Unpin one before pinning another.", 400)
# Mark in-memory pin state under LOCK so concurrent pin
# requests see the increment immediately, even before

View file

@ -1576,24 +1576,109 @@ def _detect_title_language(text: str) -> str:
return ''
def _script_counts(text: str) -> dict:
"""Return per-script alphabetic character counts for *text*.
Buckets: ``latin``, ``cjk`` (Han/Hiragana/Katakana/Hangul), ``cyrillic``,
``arabic``, ``hebrew``, ``greek``, ``devanagari``. Non-alphabetic and
unclassified characters are ignored.
"""
counts: dict[str, int] = {}
for ch in str(text or ''):
if not ch.isalpha():
continue
o = ord(ch)
if (0x0041 <= o <= 0x024F) or (0x1E00 <= o <= 0x1EFF):
bucket = 'latin'
elif (
(0x4E00 <= o <= 0x9FFF) or (0x3400 <= o <= 0x4DBF) # Han
or (0x3040 <= o <= 0x30FF) # Hiragana/Katakana
or (0xAC00 <= o <= 0xD7A3) or (0x1100 <= o <= 0x11FF) # Hangul
):
bucket = 'cjk'
elif 0x0400 <= o <= 0x04FF:
bucket = 'cyrillic'
elif (0x0600 <= o <= 0x06FF) or (0x0750 <= o <= 0x077F):
bucket = 'arabic'
elif 0x0590 <= o <= 0x05FF:
bucket = 'hebrew'
elif 0x0370 <= o <= 0x03FF:
bucket = 'greek'
elif 0x0900 <= o <= 0x097F:
bucket = 'devanagari'
else:
continue
counts[bucket] = counts.get(bucket, 0) + 1
return counts
def _dominant_script(text: str) -> str:
"""Return a coarse writing-script bucket for *text*, or '' when undecidable.
Script-level (not language-level) classification is cheap and dependency-free.
Returns the dominant script only when it holds a clear (≥60%) majority of the
alphabetic characters, so mixed/borrowed text doesn't flip the bucket. Used
to establish the conversation start's expected script for cross-script title
drift detection (#3293).
"""
counts = _script_counts(text)
total = sum(counts.values())
if total < 2:
return ''
top, top_n = max(counts.items(), key=lambda kv: kv[1])
if top_n / total >= 0.6:
return top
return ''
def _title_prompt_language_rule(user_text: str) -> str:
return "Match the language of the user question.\n"
def _title_language_mismatch(user_text: str, title: str) -> bool:
"""Reject obvious English titles for German conversation starts."""
if _detect_title_language(user_text) != 'de':
return False
candidate = str(title or '').strip().lower()
"""Reject titles whose language clearly diverges from the conversation start.
Two independent signals:
1. Cross-script drift (#3293): when the conversation start has a clear
dominant writing script (e.g. latin/English) and the generated title
introduces a *substantial* amount of a different script (e.g. CJK or
Cyrillic), reject. This is language-agnostic and catches the common
"English chat -> Chinese/Spanish/Russian title" drift. Because titles are
short and frequently embed a borrowed Latin technical term (e.g. a CJK
title containing the word "Python"), the title side uses a proportion
threshold (>=35% of the title's alphabetic characters in a non-start
script, min 2 chars) rather than a strict majority -- so a CJK title with
one English word still trips, while an English title with a single
foreign place-name does not.
2. The legacy German-start → English-title heuristic, preserved verbatim so
the original behavior keeps working for same-script (latin) drift that
the script check can't see.
"""
candidate = str(title or '').strip()
if not candidate:
return False
if _detect_title_language(candidate) == 'de':
# (1) Cross-script mismatch — language-agnostic.
user_script = _dominant_script(user_text)
if user_script:
title_counts = _script_counts(candidate)
title_total = sum(title_counts.values())
if title_total >= 2:
for script, n in title_counts.items():
if script != user_script and n >= 2 and (n / title_total) >= 0.35:
return True
# (2) Legacy same-script German→English heuristic.
if _detect_title_language(user_text) != 'de':
return False
candidate_lower = candidate.lower()
if _detect_title_language(candidate_lower) == 'de':
return False
english_markers = {
'old', 'image', 'display', 'issue', 'problem', 'discussion', 'conversation',
'session', 'title', 'fix', 'bug', 'attachment', 'attachments', 'context',
}
tokens = re.findall(r'[a-z]+', candidate)
tokens = re.findall(r'[a-z]+', candidate_lower)
english_hits = sum(1 for tok in tokens if tok in english_markers)
return english_hits >= 2

View file

@ -163,10 +163,16 @@ def test_hidden_in_memory_snapshot_does_not_count_toward_pin_quota():
def test_session_pin_cap_has_backend_and_frontend_guards():
assert 'pinned_ids = {' in ROUTES_PY
assert 'pinned_ids.update(' in ROUTES_PY
# #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_ids) >= pinned_sessions_limit:' 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

View file

@ -42,16 +42,23 @@ def test_session_field_helper_reads_dicts_and_objects():
def test_pin_limit_snapshot_counts_index_dict_entries():
assert "def _session_counts_toward_pin_quota(session)" in ROUTES_PY
assert "_session_field(existing, \"session_id\", None)" in ROUTES_PY
assert "_session_counts_toward_pin_quota(existing)" in ROUTES_PY
assert "_hide_from_default_sidebar(row)" in ROUTES_PY
start = ROUTES_PY.find("persisted_pinned_ids = {")
# #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():

View file

@ -0,0 +1,156 @@
"""Regression coverage for #3238 — WebUI does not sync when CLI sessions are
deleted outside WebUI.
When a CLI/agent session is clicked in the WebUI sidebar it gets a WebUI-owned
sidecar (`webui/sessions/<id>.json` + an `_index.json` row) so it can render and
be reopened. From then on `all_sessions()` returns it independently of the agent
`state.db`. If the user later deletes that session from the CLI / local Hermes
storage, nothing prunes the orphaned sidecar, so the stale row lingers in the
sidebar forever — there is no WebUI delete affordance for CLI rows.
The fix probes `state.db` directly via `agent_session_row_exists()` (an exact,
uncapped existence check) and prunes the sidecar only when the backing row is
genuinely gone. It must NOT rely on the session's presence in
`get_cli_sessions()`, which caps at `CLI_VISIBLE_SESSION_LIMIT` (20) — an
existing session can fall out of that window and look deleted.
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))
def _make_state_db(path: Path, session_ids):
"""Create a minimal agent state.db with a `sessions` table + given ids."""
conn = sqlite3.connect(str(path))
try:
conn.execute(
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
"started_at REAL, last_activity REAL)"
)
for sid in session_ids:
conn.execute(
"INSERT INTO sessions (id, source, started_at, last_activity) "
"VALUES (?, 'cli', 0, 0)",
(sid,),
)
conn.commit()
finally:
conn.close()
def test_agent_session_row_exists_true_for_present_row(tmp_path, monkeypatch):
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["sess-present"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
assert models.agent_session_row_exists("sess-present") is True
def test_agent_session_row_exists_false_for_deleted_row(tmp_path, monkeypatch):
"""The core fix: a session id that is NOT in state.db is reported gone."""
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["other-session"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
assert models.agent_session_row_exists("sess-deleted") is False
def test_agent_session_row_exists_safe_true_when_db_missing(tmp_path, monkeypatch):
"""No agent DB on this instance -> never claim a row is gone (no data loss)."""
from api import models
monkeypatch.setattr(
models, "_active_state_db_path", lambda: tmp_path / "nope" / "state.db"
)
assert models.agent_session_row_exists("anything") is True
def test_agent_session_row_exists_empty_id_is_false(tmp_path, monkeypatch):
from api import models
home = tmp_path / "home"
home.mkdir()
_make_state_db(home / "state.db", ["x"])
monkeypatch.setattr(models, "_active_state_db_path", lambda: home / "state.db")
assert models.agent_session_row_exists("") is False
assert models.agent_session_row_exists(None) is False
def test_agent_session_row_exists_handles_missing_sessions_table(tmp_path, monkeypatch):
"""A state.db without a `sessions` table degrades to safe-True."""
from api import models
home = tmp_path / "home"
home.mkdir()
db = home / "state.db"
conn = sqlite3.connect(str(db))
conn.execute("CREATE TABLE unrelated (x TEXT)")
conn.commit()
conn.close()
monkeypatch.setattr(models, "_active_state_db_path", lambda: db)
assert models.agent_session_row_exists("whatever") is True
# ── Orphan-prune decision predicate (mirrors the sidebar merge-loop guard) ──
def _is_orphaned_cli_sidecar(row, cli_by_id, exists_fn):
"""Replicates the routes.py merge-loop predicate so the decision logic is
covered without standing up the full HTTP sidebar endpoint."""
from api.agent_sessions import is_cli_session_row
from api.routes import _session_source_is_webui
sid = row.get("session_id")
return bool(
sid
and is_cli_session_row(row)
and not _session_source_is_webui(row)
and sid not in cli_by_id
and not exists_fn(sid)
)
def test_orphaned_imported_cli_sidecar_is_pruned():
"""import-sourced CLI row, not in cli_by_id, backing state.db row gone."""
row = {"session_id": "cli-orphan", "source_tag": "cli", "is_cli_session": True}
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: False) is True
def test_native_webui_session_with_cli_ancestor_is_never_pruned():
"""Regression guard: a WebUI-native row must survive even if absent from
cli_by_id and its (ancestor) id isn't in state.db."""
row = {
"session_id": "webui-native",
"source_tag": "webui",
"session_source": "webui",
"is_cli_session": False,
}
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: False) is False
def test_cli_row_still_backed_in_state_db_is_not_pruned():
"""Falls out of the recent-20 window (absent from cli_by_id) BUT still exists
in state.db -> must NOT be pruned. This is the cap-window false-positive the
direct state.db probe defends against."""
row = {"session_id": "cli-old", "source_tag": "cli", "is_cli_session": True}
assert _is_orphaned_cli_sidecar(row, {}, exists_fn=lambda sid: True) is False
def test_cli_row_present_in_cli_by_id_is_not_pruned():
"""Still in the live CLI list -> obviously not orphaned."""
row = {"session_id": "cli-live", "source_tag": "cli", "is_cli_session": True}
cli_by_id = {"cli-live": {"session_id": "cli-live"}}
assert _is_orphaned_cli_sidecar(row, cli_by_id, exists_fn=lambda sid: False) is False

View file

@ -0,0 +1,141 @@
"""Regression coverage for #3293 — auto-generated WebUI titles drift into the
wrong language.
`_title_language_mismatch` previously only rejected English titles for *German*
conversation starts (`_detect_title_language` returns 'de' or ''). An English
start whose LLM-generated title came back in Chinese / Spanish / Russian sailed
through and persisted with a mismatched language.
The fix generalizes from a German-specific binary to a language-agnostic
cross-script check: when the conversation start has a clear dominant writing
script and the title introduces a substantial amount of a different script, the
title is rejected (and generation falls back to the deterministic topic title).
The legacy German→English same-script heuristic is preserved.
"""
from __future__ import annotations
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
if str(REPO) not in sys.path:
sys.path.insert(0, str(REPO))
# ── _dominant_script ────────────────────────────────────────────────────────
def test_dominant_script_basic_buckets():
from api.streaming import _dominant_script
assert _dominant_script("How do I fix this bug") == "latin"
assert _dominant_script("如何修复这个错误问题") == "cjk"
assert _dominant_script("Привет как дела сегодня") == "cyrillic"
assert _dominant_script("日本語のテキストです") == "cjk" # JP folds into cjk
def test_dominant_script_undecidable_returns_empty():
from api.streaming import _dominant_script
# No meaningful alphabetic signal.
assert _dominant_script("") == ""
assert _dominant_script("12345 !@#") == ""
assert _dominant_script("a") == "" # below the 2-char floor
# Evenly mixed text has no clear majority (2 latin / 2 cjk = 0.5 < 0.6).
assert _dominant_script("ab字漢") == ""
# ── _title_language_mismatch: the #3293 cross-script drift ──────────────────
def test_english_start_chinese_title_is_rejected():
"""The reporter's exact class: English conversation, Chinese title (even
with a borrowed Latin technical term embedded)."""
from api.streaming import _title_language_mismatch
assert _title_language_mismatch(
"How do I fix this Python bug in my code?", "修复 Python 代码错误"
) is True
def test_english_start_cyrillic_title_is_rejected():
from api.streaming import _title_language_mismatch
assert _title_language_mismatch(
"What time does the meeting start tomorrow?", "Встреча Завтра Утром"
) is True
def test_cjk_start_english_title_is_rejected():
from api.streaming import _title_language_mismatch
assert _title_language_mismatch("如何修复这个错误问题", "Fixing the Bug") is True
# ── regression guards: legitimate same-script titles must NOT be rejected ───
def test_english_start_english_title_allowed():
from api.streaming import _title_language_mismatch
assert _title_language_mismatch(
"Why are old images not displayed here?", "Old Image Display Issue"
) is False
def test_english_start_spanish_title_allowed():
"""Same (latin) script — language differs but the script check must not flag
it; only a clearly different script is a mismatch signal."""
from api.streaming import _title_language_mismatch
assert _title_language_mismatch(
"How do I fix this Python bug in my code?", "Arreglar error de Python"
) is False
def test_english_title_with_one_foreign_placename_allowed():
"""An otherwise-English title containing a single CJK place name stays below
the proportion threshold and is not flagged."""
from api.streaming import _title_language_mismatch
assert _title_language_mismatch(
"What is the best dataset for model training?", "Using 北京 Dataset Notes"
) is False
def test_same_cjk_script_title_allowed():
from api.streaming import _title_language_mismatch
assert _title_language_mismatch("如何修复这个错误问题", "代码错误修复") is False
assert _title_language_mismatch("日本語で質問があります", "日本語のチャット") is False
def test_empty_title_is_not_a_mismatch():
from api.streaming import _title_language_mismatch
assert _title_language_mismatch("Hello there my friend", "") is False
assert _title_language_mismatch("Hello there", " ") is False
def test_tiny_start_without_script_signal_allows_title():
"""A start too short to establish a dominant script must not gate the title."""
from api.streaming import _title_language_mismatch
assert _title_language_mismatch("hi", "Quick Chat") is False
# ── legacy German→English heuristic preserved ───────────────────────────────
def test_legacy_german_start_english_title_still_rejected():
from api.streaming import _title_language_mismatch
assert _title_language_mismatch(
"Warum werden alte Bilder hier nicht mehr angezeigt?",
"Old Image Display Issue",
) is True
def test_legacy_german_start_german_title_allowed():
from api.streaming import _title_language_mismatch
assert _title_language_mismatch(
"Warum werden alte Bilder angezeigt?", "Alte Bilder Anzeige"
) is False

View file

@ -0,0 +1,112 @@
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from api.routes import _session_row_lineage_root_id, _visible_pinned_lineage_ids
def test_visible_pinned_lineage_ids_dedupes_multiple_pinned_continuations():
rows = [
{
"session_id": "gov-new",
"title": "Project OS Governor",
"pinned": True,
"archived": False,
"parent_session_id": "gov-mid",
},
{
"session_id": "gov-mid",
"title": "Project OS Governor",
"pinned": True,
"archived": False,
"parent_session_id": "gov-root",
},
{
"session_id": "gov-root",
"title": "Project OS Governor",
"pinned": True,
"archived": False,
"parent_session_id": None,
},
{
"session_id": "other-pin",
"title": "Import Preview",
"pinned": True,
"archived": False,
"parent_session_id": None,
},
]
roots = _visible_pinned_lineage_ids(rows)
assert roots == {"gov-root", "other-pin"}
def test_visible_pinned_lineage_ids_ignores_hidden_precompression_snapshots():
rows = [
{
"session_id": "snap-root",
"title": "Project OS Governor",
"pinned": True,
"archived": False,
"pre_compression_snapshot": True,
"parent_session_id": None,
},
{
"session_id": "live-root",
"title": "Project OS Governor",
"pinned": True,
"archived": False,
"parent_session_id": "snap-root",
},
]
roots = _visible_pinned_lineage_ids(rows)
assert roots == {"snap-root"}
def test_session_row_lineage_root_uses_explicit_root_when_present():
row = {
"session_id": "tip",
"_lineage_root_id": "root-123",
"parent_session_id": "older",
}
assert _session_row_lineage_root_id(row, {"tip": row}) == "root-123"
def test_pinned_forks_of_same_parent_count_as_separate_lineages():
"""A branch/fork (session_source='fork') is an independent visible session and
must consume its own pin slot — two pinned forks of the same parent must NOT
collapse to one quota lineage (would let the user exceed the pin limit). #3288."""
rows = [
{
"session_id": "parent-root",
"title": "Original",
"pinned": True,
"archived": False,
"parent_session_id": None,
},
{
"session_id": "fork-a",
"title": "Fork A",
"pinned": True,
"archived": False,
"session_source": "fork",
"parent_session_id": "parent-root",
},
{
"session_id": "fork-b",
"title": "Fork B",
"pinned": True,
"archived": False,
"session_source": "fork",
"parent_session_id": "parent-root",
},
]
# Each fork is its own lineage root; the parent is its own root too → 3 lineages.
assert _session_row_lineage_root_id(rows[1], {r["session_id"]: r for r in rows}) == "fork-a"
assert _session_row_lineage_root_id(rows[2], {r["session_id"]: r for r in rows}) == "fork-b"
roots = _visible_pinned_lineage_ids(rows)
assert roots == {"parent-root", "fork-a", "fork-b"}
# Three distinct pinned lineages → would exceed a limit of 2 (no false collapse).
assert len(roots) == 3