diff --git a/CHANGELOG.md b/CHANGELOG.md index e9346035..1309c706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/models.py b/api/models.py index 771b7d75..da587bbb 100644 --- a/api/models.py +++ b/api/models.py @@ -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': diff --git a/api/routes.py b/api/routes.py index 07b26844..a96cad24 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 diff --git a/api/streaming.py b/api/streaming.py index b1ed9221..777a94ba 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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 diff --git a/tests/test_issue2508_session_pin_cap.py b/tests/test_issue2508_session_pin_cap.py index b49855c0..385da1f4 100644 --- a/tests/test_issue2508_session_pin_cap.py +++ b/tests/test_issue2508_session_pin_cap.py @@ -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 diff --git a/tests/test_issue2821_session_pin_state_sync.py b/tests/test_issue2821_session_pin_state_sync.py index b54ee7e5..b682f399 100644 --- a/tests/test_issue2821_session_pin_state_sync.py +++ b/tests/test_issue2821_session_pin_state_sync.py @@ -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(): diff --git a/tests/test_issue3238_orphaned_cli_sidecar_prune.py b/tests/test_issue3238_orphaned_cli_sidecar_prune.py new file mode 100644 index 00000000..88219237 --- /dev/null +++ b/tests/test_issue3238_orphaned_cli_sidecar_prune.py @@ -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/.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 diff --git a/tests/test_issue3293_title_language_drift.py b/tests/test_issue3293_title_language_drift.py new file mode 100644 index 00000000..4143362f --- /dev/null +++ b/tests/test_issue3293_title_language_drift.py @@ -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 diff --git a/tests/test_pin_limit_lineage_visibility.py b/tests/test_pin_limit_lineage_visibility.py new file mode 100644 index 00000000..8a10d8e1 --- /dev/null +++ b/tests/test_pin_limit_lineage_visibility.py @@ -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