Files
hermes-webui/tests/test_issue3293_title_language_drift.py
nesquena-hermes cccb97d970
Some checks failed
Release & Docker / release (push) Has been cancelled
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>
2026-06-02 17:35:18 -07:00

142 lines
5.4 KiB
Python

"""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