Compare commits

...

3 Commits

Author SHA1 Message Date
nesquena-hermes
59de540b3d Release v0.51.320 — Release KJ (Phase 2: Polish (pl) language support, #3781) (#3796)
Some checks failed
Release & Docker / release (push) Has been cancelled
Complete Polish locale. #3781 (@leszek3737). Full suite green, CI 11/11, 180 locale/parity tests pass. Co-authored-by: leszek3737 <leszek3737@users.noreply.github.com>
2026-06-07 13:48:52 -07:00
nesquena-hermes
2e1aa3c99c Release v0.51.319 — Release KI (Phase 3 light: refresh stale continuation metadata, #3789) (#3795)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-3-LOW. #3789 (@ai-ag2026, refs #3740): refresh stale-indexed compression continuation rows from sidecar; gate excludes session_source='fork' (release-gate MUST-FIX). Full suite 8213, Opus SHIP, Codex MUST-FIX applied, CI 11/11. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-06-07 13:12:23 -07:00
nesquena-hermes
ce9adc5e2c Release v0.51.318 — Release KH (Phase 3 light: warm account-usage probe worker pool, #3722) (#3792)
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-3-LOW backend refactor. #3722 (@rodboev, #1912): per-probe subprocess.run -> warm worker pool for codex/anthropic quota probes; all hardening retained + idle reaper + credential invalidation + fallback. Codex SAFE, Opus SHIP, real-thread self-verify clean, CI 11/11. Follow-up #3787. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-07 12:07:13 -07:00
20 changed files with 2300 additions and 126 deletions

View File

@@ -3,6 +3,21 @@
## [Unreleased]
## [v0.51.320] — 2026-06-07 — Release KJ (Phase 2 — Polish (pl) language support)
### Added
- **Polish (pl) language support.** Adds a complete Polish translation set to the in-app localization, a Polish login-page locale, and resolver coverage for `pl` / `pl-PL` / `pl_PL`, with the locale registered consistently across every key group (appearance skins, approval/clarify prompts, tooltips, cache-usage labels, profile skill counts) and parity tests asserting the new locale is complete and actually translated. (#3781, @leszek3737)
## [v0.51.319] — 2026-06-07 — Release KI (Phase 3 light — refresh stale continuation metadata)
### Fixed
- **A compression continuation no longer looks like it lost recent messages when `_index.json` is stale.** Complementing the snapshot-side fix, the sidebar now also refreshes a continuation row's sidecar metadata when the row is part of a compression lineage and its sidecar file is newer than the indexed timestamp — so recent turns that landed in the sidecar after the last index write are reflected in the row's count/last-activity instead of showing a stale (lower) message count. The refresh stays scoped to lineage-shaped rows, so ordinary sidebar polls don't hydrate every historical transcript. (#3789, refs #3740, @ai-ag2026)
## [v0.51.318] — 2026-06-07 — Release KH (Phase 3 light — warm account-usage probe worker pool)
### Changed
- **Account-usage quota probes now reuse a warm worker pool instead of spawning a fresh interpreter per probe.** Quota checks for the providers that need an isolated subprocess previously started a new Python interpreter — and re-imported the agent account-usage stack — on every uncached probe, which is slow when the quota UI is opened across several profiles/providers. A profile-home-keyed pool of persistent warm workers (with a 5-minute idle shutdown) eliminates the repeated spawn/import overhead while keeping every existing safety property: the concurrency semaphore cap, the LRU result cache, the parent-death-signal hardening, and subprocess env scrubbing. Workers are invalidated when provider credentials change so a child process can't retain stale keys, contention falls back to a one-shot probe, and all workers are cleaned up at process exit. (#3722 / #1912, @rodboev)
## [v0.51.317] — 2026-06-07 — Release KG (Phase 3 light — align CSP enforcement with report policy)
### Fixed

View File

@@ -2610,11 +2610,27 @@ def _row_may_need_sidecar_metadata_refresh(
)
if is_runtime_row:
return True
sid = str(session.get('session_id') or '')
if not session.get('pre_compression_snapshot'):
return False
# Refresh a stale-indexed COMPRESSION CONTINUATION row from its sidecar.
# Gate tightly: a plain /branch fork also carries parent_session_id
# (#1342) but has no compression sidecar drift to correct, and its file
# mtime routinely exceeds the indexed logical last_message_at — so
# including forks here would call load_metadata_only() on every fork row
# on every /api/sessions poll (the molasses #3770 guards against, per the
# #3789 release gate). Exclude session_source == 'fork'
# (the marker /api/session/branch stamps; see _is_continuation_session)
# so only true continuations are eligible.
if str(session.get('session_source') or '').strip().lower() == 'fork':
return False
lineage_shaped = bool(
session.get('parent_session_id')
or session.get('_lineage_root_id')
or session.get('_compression_segment_count')
)
return bool(lineage_shaped and sid and _sidecar_mtime_after_index_timestamp(session))
if session.get('message_count') is None or session.get('last_message_at') is None:
return True
sid = str(session.get('session_id') or '')
return bool(sid and stale_snapshot_ids and sid in stale_snapshot_ids)

View File

@@ -7,6 +7,7 @@ multi-provider support).
from __future__ import annotations
import atexit
import base64
import hashlib
import json
@@ -75,6 +76,7 @@ _PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0
_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS = 35.0
_ACCOUNT_USAGE_CACHE_TTL_SECONDS = 45.0
_ACCOUNT_USAGE_CACHE_MAX_ENTRIES = 64
_ACCOUNT_USAGE_WORKER_IDLE_SECONDS = 5 * 60
_ACCOUNT_USAGE_PROVIDERS = frozenset({"openai-codex", "anthropic"})
# Upper bound on simultaneous profile-isolated quota probe subprocesses.
@@ -129,6 +131,8 @@ _account_usage_probe_semaphore: threading.BoundedSemaphore | None = None
# represented as non-None snapshots and remain cacheable.
_account_usage_status_cache: dict[tuple[str, str, str], tuple[float, Any]] = {}
_account_usage_status_cache_lock = threading.Lock()
_account_usage_worker_pool: dict[str, "_AccountUsageProbeWorker"] = {}
_account_usage_worker_pool_lock = threading.Lock()
def _get_account_usage_probe_semaphore() -> threading.BoundedSemaphore:
@@ -146,7 +150,7 @@ def _get_account_usage_probe_semaphore() -> threading.BoundedSemaphore:
# code (_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP) also covers the grandchild
# fork inside the child, but this preexec_fn handles the direct child-process
# case. Returns None on non-POSIX or when prctl is unavailable so that
# subprocess.run() works on Windows/macOS without changes.
# subprocess startup works on Windows/macOS without changes.
def _account_usage_preexec_fn() -> None:
try:
import ctypes
@@ -159,6 +163,7 @@ def _account_usage_preexec_fn() -> None:
_ACCOUNT_USAGE_SUBPROCESS_CODE = r"""
import base64
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
@@ -627,17 +632,48 @@ def _fetch_codex_account_usage_from_pool():
return None
provider = sys.argv[1]
api_key = sys.argv[2] or None
try:
snapshot = fetch_account_usage(provider, api_key=api_key)
except Exception:
snapshot = None
if str(provider or "").strip().lower() == "openai-codex":
pool_snapshot = _fetch_codex_account_usage_from_pool()
if isinstance(getattr(pool_snapshot, "pool", None), dict):
snapshot = pool_snapshot
print(json.dumps(_snapshot_payload(snapshot)))
def _fetch_snapshot(provider, api_key, env_var=None):
previous = os.environ.get(env_var) if env_var else None
had_previous = bool(env_var and env_var in os.environ)
if env_var and api_key:
os.environ[env_var] = api_key
try:
try:
snapshot = fetch_account_usage(provider, api_key=api_key)
except Exception:
snapshot = None
if str(provider or "").strip().lower() == "openai-codex":
pool_snapshot = _fetch_codex_account_usage_from_pool()
if isinstance(getattr(pool_snapshot, "pool", None), dict):
snapshot = pool_snapshot
return _snapshot_payload(snapshot)
finally:
if env_var and api_key:
if had_previous:
os.environ[env_var] = previous
else:
os.environ.pop(env_var, None)
def _run_worker():
for raw_line in sys.stdin:
try:
request = json.loads(raw_line)
provider = request.get("provider")
api_key = request.get("api_key") or None
env_var = request.get("env_var") or None
payload = _fetch_snapshot(provider, api_key, env_var=env_var)
except Exception:
payload = None
print(json.dumps(payload), flush=True)
if len(sys.argv) > 1 and sys.argv[1] == "--worker":
_run_worker()
else:
provider = sys.argv[1]
api_key = sys.argv[2] or None
print(json.dumps(_fetch_snapshot(provider, api_key)), flush=True)
"""
# SECTION: Provider ↔ env var mapping
@@ -1186,6 +1222,267 @@ def _account_usage_payload_to_snapshot(payload: Any) -> Any:
)
class _AccountUsageProbeWorker:
def __init__(self, home: Path):
self.home = Path(home)
self.last_used = time.monotonic()
self._lock = threading.RLock()
self._proc: subprocess.Popen[str] | None = None
def close(self) -> None:
with self._lock:
proc = self._proc
self._proc = None
self._close_process(proc)
@staticmethod
def _close_process(proc: subprocess.Popen[str] | None) -> None:
if proc is None:
return
for stream_name in ("stdin", "stdout"):
stream = getattr(proc, stream_name, None)
try:
if stream is not None:
stream.close()
except Exception:
pass
try:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=1.0)
except Exception:
proc.kill()
except Exception:
pass
def fetch(self, provider: str, *, api_key: str | None = None) -> Any:
if not self._lock.acquire(blocking=False):
return _fetch_account_usage_once_for_home(provider, self.home, api_key=api_key)
try:
return self._fetch_locked(provider, api_key=api_key)
finally:
self._lock.release()
def _fetch_locked(self, provider: str, *, api_key: str | None = None) -> Any:
self.last_used = time.monotonic()
proc = self._ensure_process(provider)
if proc is None or proc.stdin is None or proc.stdout is None:
return None
request = json.dumps({
"provider": provider,
"api_key": api_key or "",
"env_var": _provider_env_var_for((provider or "").strip().lower()),
}) + "\n"
result: dict[str, Any] = {}
def round_trip() -> None:
try:
proc.stdin.write(request)
proc.stdin.flush()
result["line"] = proc.stdout.readline()
except Exception as exc:
result["error"] = exc
thread = threading.Thread(target=round_trip, daemon=True)
thread.start()
thread.join(_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS)
self.last_used = time.monotonic()
if thread.is_alive():
self.close()
thread.join(timeout=1.0)
logger.debug("Account usage worker for %s timed out", provider)
return None
if result.get("error") is not None:
exc = result["error"]
self.close()
logger.debug(
"Account usage worker for %s failed",
provider,
exc_info=(type(exc), exc, exc.__traceback__),
)
return None
line = str(result.get("line") or "").strip()
if not line:
self.close()
logger.debug("Account usage worker for %s exited before responding", provider)
return None
try:
payload = json.loads(line)
except json.JSONDecodeError:
self.close()
logger.debug("Account usage worker for %s returned invalid JSON", provider)
return None
return _account_usage_payload_to_snapshot(payload)
def _ensure_process(self, provider: str) -> subprocess.Popen[str] | None:
if self._proc is not None and self._proc.poll() is None:
return self._proc
old_proc = self._proc
self._proc = None
self._close_process(old_proc)
try:
from api.config import PYTHON_EXE
except Exception:
PYTHON_EXE = sys.executable or "python3"
kwargs: dict[str, Any] = {
"stdin": subprocess.PIPE,
"stdout": subprocess.PIPE,
"stderr": subprocess.DEVNULL,
"text": True,
"bufsize": 1,
}
if hasattr(os, "fork"): # POSIX
kwargs["preexec_fn"] = _account_usage_preexec_fn
try:
self._proc = subprocess.Popen(
[
PYTHON_EXE,
"-c",
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
"--worker",
],
env=_account_usage_subprocess_env(self.home, provider, None),
**kwargs,
)
except Exception:
self._proc = None
logger.debug("Account usage worker for %s failed to launch", provider, exc_info=True)
return self._proc
def _launch_account_usage_worker_process(
home: Path,
provider: str,
*,
stdin: Any = subprocess.PIPE,
stdout: Any = subprocess.PIPE,
) -> subprocess.Popen[str] | None:
try:
from api.config import PYTHON_EXE
except Exception:
PYTHON_EXE = sys.executable or "python3"
kwargs: dict[str, Any] = {
"stdin": stdin,
"stdout": stdout,
"stderr": subprocess.DEVNULL,
"text": True,
"bufsize": 1,
}
if hasattr(os, "fork"): # POSIX
kwargs["preexec_fn"] = _account_usage_preexec_fn
try:
return subprocess.Popen(
[
PYTHON_EXE,
"-c",
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
"--worker",
],
env=_account_usage_subprocess_env(home, provider, None),
**kwargs,
)
except Exception:
logger.debug("Account usage worker for %s failed to launch", provider, exc_info=True)
return None
def _fetch_account_usage_once_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any:
proc = _launch_account_usage_worker_process(Path(home), provider)
if proc is None or proc.stdin is None or proc.stdout is None:
_AccountUsageProbeWorker._close_process(proc)
return None
request = json.dumps({
"provider": provider,
"api_key": api_key or "",
"env_var": _provider_env_var_for((provider or "").strip().lower()),
}) + "\n"
try:
stdout, _stderr = proc.communicate(request, timeout=_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
_AccountUsageProbeWorker._close_process(proc)
return None
except Exception:
_AccountUsageProbeWorker._close_process(proc)
return None
try:
line = str(stdout or "").splitlines()[0]
payload = json.loads(line.strip())
except json.JSONDecodeError:
return None
except IndexError:
return None
return _account_usage_payload_to_snapshot(payload)
def _get_account_usage_probe_worker(home: Path) -> _AccountUsageProbeWorker:
key = str(Path(home))
with _account_usage_worker_pool_lock:
worker = _account_usage_worker_pool.get(key)
if worker is None:
worker = _AccountUsageProbeWorker(Path(home))
_account_usage_worker_pool[key] = worker
return worker
def _cleanup_account_usage_probe_workers(
*,
now: float | None = None,
idle_seconds: float = _ACCOUNT_USAGE_WORKER_IDLE_SECONDS,
) -> None:
cutoff = time.monotonic() if now is None else now
stale: list[tuple[str, _AccountUsageProbeWorker]] = []
with _account_usage_worker_pool_lock:
for key, worker in list(_account_usage_worker_pool.items()):
if worker._lock.acquire(blocking=False):
try:
proc = worker._proc
is_dead = proc is None or proc.poll() is not None
if is_dead or cutoff - worker.last_used >= idle_seconds:
stale.append((key, worker))
_account_usage_worker_pool.pop(key, None)
finally:
worker._lock.release()
for _key, worker in stale:
worker.close()
def _close_account_usage_probe_workers() -> None:
with _account_usage_worker_pool_lock:
workers = list(_account_usage_worker_pool.values())
_account_usage_worker_pool.clear()
_close_account_usage_probe_worker_list(workers)
def _close_account_usage_probe_worker_list(workers: list[_AccountUsageProbeWorker]) -> None:
for worker in workers:
worker.close()
def _close_account_usage_probe_workers_async() -> None:
with _account_usage_worker_pool_lock:
workers = list(_account_usage_worker_pool.values())
_account_usage_worker_pool.clear()
if not workers:
return
thread = threading.Thread(
target=_close_account_usage_probe_worker_list,
args=(workers,),
daemon=True,
name="account-usage-worker-close",
)
thread.start()
atexit.register(_close_account_usage_probe_workers)
def _account_usage_cache_key(provider: str, home: Path, api_key: str | None) -> tuple[str, str, str]:
key_fingerprint = ""
if api_key:
@@ -1211,10 +1508,11 @@ def invalidate_account_usage_status_cache(provider_id: str | None = None) -> Non
with _account_usage_status_cache_lock:
if not normalized:
_account_usage_status_cache.clear()
return
for key in list(_account_usage_status_cache):
if key[0] == normalized:
_account_usage_status_cache.pop(key, None)
else:
for key in list(_account_usage_status_cache):
if key[0] == normalized:
_account_usage_status_cache.pop(key, None)
_close_account_usage_probe_workers_async()
def _set_cached_account_usage(
@@ -1246,51 +1544,11 @@ def _set_cached_account_usage(
def _agent_fetch_account_usage_for_home(provider: str, home: Path, *, api_key: str | None = None) -> Any:
try:
from api.config import PYTHON_EXE
_cleanup_account_usage_probe_workers()
return _get_account_usage_probe_worker(home).fetch(provider, api_key=api_key)
except Exception:
PYTHON_EXE = sys.executable or "python3"
try:
# On POSIX (Linux/macOS), wire parent-death signal so the child dies
# cleanly if the WebUI parent terminates. preexec_fn is not safe on
# Windows, where OS-level process-tree cleanup handles child orphans.
kwargs: dict[str, Any] = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
"text": True,
"timeout": _ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS,
"check": False,
}
if hasattr(os, "fork"): # POSIX
kwargs["preexec_fn"] = _account_usage_preexec_fn
proc = subprocess.run(
[
PYTHON_EXE, "-c",
_ACCOUNT_USAGE_PARENT_DEATHSIG_BOOTSTRAP + _ACCOUNT_USAGE_SUBPROCESS_CODE,
provider,
api_key or "",
],
env=_account_usage_subprocess_env(home, provider, api_key),
**kwargs,
)
except subprocess.TimeoutExpired:
logger.debug("Account usage probe for %s timed out", provider)
logger.debug("Account usage probe for %s failed", provider, exc_info=True)
return None
except Exception:
logger.debug("Account usage probe for %s failed to launch", provider, exc_info=True)
return None
if proc.returncode != 0:
logger.debug("Account usage probe for %s exited with status %s", provider, proc.returncode)
return None
try:
payload = json.loads((proc.stdout or "").strip() or "null")
except json.JSONDecodeError:
logger.debug("Account usage probe for %s returned invalid JSON", provider)
return None
return _account_usage_payload_to_snapshot(payload)
def _fetch_account_usage_with_profile_context(provider: str, *, refresh: bool = False) -> Any:
@@ -1301,8 +1559,7 @@ def _fetch_account_usage_with_profile_context(provider: str, *, refresh: bool =
memory by spawning more than _MAX_CONCURRENT_ACCOUNT_USAGE_PROBES probe
subprocesses simultaneously. Each probe runs up to 35 s.
A warm worker-pool (reuse of persistent subprocess handles) is a natural
follow-up if this first slice proves insufficient in production.
Warm per-profile worker processes handle the actual probe requests.
"""
home = _get_hermes_home()
api_key = _get_provider_api_key(provider)
@@ -2122,6 +2379,7 @@ def set_provider_key(provider_id: str, api_key: str | None) -> dict[str, Any]:
# Using invalidate_models_cache() instead of reload_config() to avoid
# disrupting active streaming sessions that may be reading config.cfg.
invalidate_models_cache()
invalidate_account_usage_status_cache(provider_id)
return {
"ok": True,

View File

@@ -3561,6 +3561,15 @@ _LOGIN_LOCALE = {
"invalid_pw": "Ge\u00e7ersiz \u015fifre",
"conn_failed": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z",
},
"pl": {
"lang": "pl-PL",
"title": "Zaloguj si\u0119",
"subtitle": "Wpisz has\u0142o, aby kontynuowa\u0107",
"placeholder": "Has\u0142o",
"btn": "Zaloguj si\u0119",
"invalid_pw": "Nieprawid\u0142owe has\u0142o",
"conn_failed": "Po\u0142\u0105czenie nie powiod\u0142o si\u0119",
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -229,7 +229,7 @@ class TestOpenInVsCodeI18n:
"""open_in_vscode key must appear exactly once per locale (11 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode:")
assert count == 11, (
assert count == 12, (
f"Expected 11 open_in_vscode: entries (one per locale), found {count}"
)
@@ -237,7 +237,7 @@ class TestOpenInVsCodeI18n:
"""open_in_vscode_failed key must appear exactly once per locale (11 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode_failed:")
assert count == 11, (
assert count == 12, (
f"Expected 11 open_in_vscode_failed: entries (one per locale), found {count}"
)

View File

@@ -145,8 +145,15 @@ def test_audit_embedded_worker_import_line_anchors_are_source_lines():
if finding["path"] == "api/providers.py" and finding["anchor"] == "agent.account_usage"
)
assert account_usage["line"] == 168
assert account_usage["text"] == "from agent.account_usage import fetch_account_usage"
expected_text = "from agent.account_usage import fetch_account_usage"
assert account_usage["text"] == expected_text
# The reported line number must be an ACCURATE source anchor — i.e. that line
# in the real file actually contains the import. Asserting the exact number is
# brittle (any edit above it shifts it); asserting the line CONTENT at the
# reported line is the real invariant ("line anchors are source lines").
source_lines = (REPO / account_usage["path"]).read_text(encoding="utf-8").splitlines()
assert account_usage["line"] >= 1
assert source_lines[account_usage["line"] - 1].strip() == expected_text
def test_audit_reports_runtime_state_and_provider_imports():

View File

@@ -162,7 +162,7 @@ class TestAuxiliaryModelsI18n:
"""Count of each key should equal the number of locales (12 with Turkish)."""
for key in self.REQUIRED_KEYS:
count = I18N_JS.count(f"{key}:")
assert count == 12, (
assert count == 13, (
f"i18n key '{key}' found {count} times — expected 12 (one per locale)"
)

View File

@@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n:
"voice_mode_toggle_active",
)
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl")
def test_legacy_voice_toggle_key_removed(self):
"""The old key whose string was 'Voice input' caused the duplicate-
@@ -171,7 +171,7 @@ class TestComposerVoiceButtonI18n:
class TestVoiceModePreferenceGate:
"""boot.js must hide btnVoiceMode by default, surface it via Preferences."""
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl")
def test_voice_mode_pref_is_localstorage_backed(self):
"""The pref reads from localStorage key 'hermes-voice-mode-button'."""

View File

@@ -63,8 +63,8 @@ def test_context_indicator_surfaces_cache_hit_rate():
def test_cache_usage_labels_are_localized():
src = (ROOT / "static" / "i18n.js").read_text()
assert src.count("usage_cache_hit_detail:") == 12
assert src.count("usage_cached_percent:") == 12
assert src.count("usage_cache_hit_detail:") == 13
assert src.count("usage_cached_percent:") == 13
assert "usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)'" in src
assert "usage_cached_percent: '{0}% cached'" in src

View File

@@ -50,8 +50,8 @@ def test_panels_round_trip_and_hot_apply_hide_suggestions():
def test_hide_suggestions_i18n_all_locales_and_changelog():
js = I18N.read_text(encoding="utf-8")
assert js.count("settings_label_hide_suggestions:") == 12
assert js.count("settings_desc_hide_suggestions:") == 12
assert js.count("settings_label_hide_suggestions:") == 13
assert js.count("settings_desc_hide_suggestions:") == 13
changelog = CHANGELOG.read_text(encoding="utf-8")
assert "#2679" in changelog
assert "hide_empty_state_suggestions" in changelog

View File

@@ -5,7 +5,7 @@ BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
LOCALE_COUNT = 12
LOCALE_COUNT = 13
def test_raw_audio_active_recording_uses_dedicated_i18n_key():

View File

@@ -267,7 +267,7 @@ def test_login_locale_count_matches_or_exceeds_floor():
assert k in login, f"_LOGIN_LOCALE missing core locale {k!r}"
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
def test_login_locale_entry_well_formed(loc_key: str):
"""Each _LOGIN_LOCALE entry must have all required sub-keys and non-empty string values."""
login = _load_login_locale()
@@ -281,7 +281,7 @@ def test_login_locale_entry_well_formed(loc_key: str):
def test_login_locale_resolver_handles_new_locales():
"""_resolve_login_locale_key() must map ja/pt/ko (and common BCP-47 variants) to their entries."""
"""_resolve_login_locale_key() must map ja/pt/ko/pl (and common BCP-47 variants) to their entries."""
sys.path.insert(0, str(REPO))
from api.routes import _resolve_login_locale_key
@@ -293,6 +293,9 @@ def test_login_locale_resolver_handles_new_locales():
assert _resolve_login_locale_key("pt-PT") == "pt"
assert _resolve_login_locale_key("ko") == "ko"
assert _resolve_login_locale_key("ko-KR") == "ko"
assert _resolve_login_locale_key("pl") == "pl"
assert _resolve_login_locale_key("pl-PL") == "pl"
assert _resolve_login_locale_key("pl_PL") == "pl"
assert _resolve_login_locale_key("fr") == "fr"
assert _resolve_login_locale_key("fr-FR") == "fr"
assert _resolve_login_locale_key("fr-CA") == "fr"
@@ -310,7 +313,7 @@ def _value_of(seg: str, key: str) -> str | None:
return None
@pytest.mark.parametrize("loc_key", ["es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
@pytest.mark.parametrize("loc_key", ["es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
def test_login_flow_keys_are_translated(loc_key: str):
"""Login/sign-out/password keys in static/i18n.js must NOT equal the English value.
@@ -351,7 +354,7 @@ SESSION_MANAGEMENT_KEYS = (
)
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
def test_session_management_keys_present(loc_key: str):
"""Every locale block must define all session-management keys (no fallback to English)."""
seg = _i18n_locale_block(loc_key)

201
tests/test_polish_locale.py Normal file
View File

@@ -0,0 +1,201 @@
from collections import Counter
from pathlib import Path
import re
REPO = Path(__file__).resolve().parent.parent
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def extract_locale_block(src: str, locale_key: str) -> str:
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
assert start_match, f"{locale_key} locale block not found"
start = start_match.end() - 1
depth = 0
in_single = False
in_double = False
in_backtick = False
escape = False
for i in range(start, len(src)):
ch = src[i]
if escape:
escape = False
continue
if in_single:
if ch == "\\":
escape = True
elif ch == "'":
in_single = False
continue
if in_double:
if ch == "\\":
escape = True
elif ch == '"':
in_double = False
continue
if in_backtick:
if ch == "\\":
escape = True
elif ch == "`":
in_backtick = False
continue
if ch == "'":
in_single = True
continue
if ch == '"':
in_double = True
continue
if ch == "`":
in_backtick = True
continue
if ch == "{":
depth += 1
continue
if ch == "}":
depth -= 1
if depth == 0:
return src[start + 1 : i]
raise AssertionError(f"{locale_key} locale block braces are not balanced")
def locale_keys(src: str, locale_key: str) -> list[str]:
key_pattern = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*:", re.MULTILINE)
return key_pattern.findall(extract_locale_block(src, locale_key))
def test_polish_locale_block_exists():
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
assert pl_block
assert "_lang: 'pl'" in pl_block
assert "_label: 'Polski'" in pl_block
assert "_speech: 'pl-PL'" in pl_block
def test_polish_locale_includes_representative_translations():
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
expected = [
"settings_title: 'Ustawienia'",
"settings_label_language: 'Język'",
"login_title: 'Zaloguj się'",
"approval_heading: 'Wymagana aprobata'",
"tab_chat: 'Czat'",
"tab_tasks: 'Zadania'",
"tab_profiles: 'Profile'",
"empty_title: 'W czym mogę pomóc?'",
"onboarding_title: 'Witaj w Hermes Web UI'",
]
for entry in expected:
assert entry in pl_block
def test_polish_settings_detail_descriptions_are_translated():
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
expected = [
"settings_desc_workspace_panel_open: 'Gdy ta opcja jest włączona, panel obszaru roboczego / przeglądarki plików otwiera się automatycznie przy każdej nowej sesji. Nadal możesz go zamknąć ręcznie w dowolnym momencie.'",
"settings_desc_notifications: 'Pokaż powiadomienie systemowe, gdy odpowiedź zostanie ukończona, podczas gdy aplikacja działa w tle.'",
"settings_desc_token_usage: 'Wyświetla liczbę tokenów wejściowych/wyjściowych pod każdą odpowiedzią asystenta. Można też przełączyć za pomocą /usage.'",
"settings_desc_sidebar_density: 'Kontroluje, ile metadanych wyświetla lista sesji na lewym pasku bocznym.'",
"settings_desc_auto_title_refresh: 'Automatycznie generuje na nowo tytuł konwersacji na podstawie najnowszej wymiany, utrzymując go adekwatnym w miarę rozwoju rozmowy. Wymaga skonfigurowanego modelu LLM do generowania tytułów.'",
"settings_desc_external_sessions: 'Pokaż konwersacje z CLI, Telegrama, Discorda, Slacka i innych kanałów na liście sesji. Kliknij, aby zaimportować i kontynuować.'",
"settings_desc_cron_sessions: 'Wyświetlaj wyjście zadań cron jako konwersacje na pasku bocznym. Aktywne tylko wtedy, gdy włączone są sesje spoza WebUI. Domyślnie wyłączone; zadania o wysokiej częstotliwości mogą zalać pasek boczny.'",
"settings_desc_sync_insights: 'Odzwierciedla zużycie tokenów WebUI w state.db, dzięki czemu hermes /insights uwzględnia dane sesji przeglądarki. Domyślnie wyłączone.'",
"settings_desc_check_updates: 'Pokaż baner, gdy dostępne są nowsze wersje WebUI lub Agenta. Okresowo uruchamia pobieranie git fetch w tle.'",
"settings_desc_bot_name: 'Używane tylko dla profilu domyślnego. Inne profile używają własnych nazw profilu.'",
"settings_desc_password: 'Wpisz nowe hasło, aby je ustawić lub zmienić. Pozostaw puste, aby zachować obecne ustawienie.'",
]
for entry in expected:
assert entry in pl_block
def test_polish_locale_matches_english_key_coverage():
src = read(REPO / "static" / "i18n.js")
en_keys = set(locale_keys(src, "en"))
pl_keys = set(locale_keys(src, "pl"))
assert sorted(en_keys - pl_keys) == []
assert sorted(pl_keys - en_keys) == []
def test_polish_locale_has_no_duplicate_keys():
src = read(REPO / "static" / "i18n.js")
keys = locale_keys(src, "pl")
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
assert not duplicates, f"Polish locale has duplicate keys: {duplicates}"
def test_polish_locale_keys_use_standard_indentation():
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
# Enforce strict 4-space indentation for keys.
badly_indented = []
for line in pl_block.splitlines():
m = re.match(r"^(\s*)[a-zA-Z0-9_]+\s*:", line)
if m and len(m.group(1)) != 4:
badly_indented.append(f"{len(m.group(1))} spaces: {line.strip()}")
assert badly_indented == []
def test_polish_locale_arrow_function_values_mirror_english():
src = read(REPO / "static" / "i18n.js")
en_block = extract_locale_block(src, "en")
pl_block = extract_locale_block(src, "pl")
value_re = re.compile(r"^\s+([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)", re.MULTILINE)
arrow_re = re.compile(r"^\s*\(?[a-zA-Z_,\s]*\)?\s*=>")
def arrows(block):
return {k for k, v in value_re.findall(block) if arrow_re.match(v)}
assert arrows(pl_block) == arrows(en_block)
def test_polish_locale_preserves_placeholder_patterns():
src = read(REPO / "static" / "i18n.js")
en_block = extract_locale_block(src, "en")
pl_block = extract_locale_block(src, "pl")
value_re = re.compile(r"^\s+([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)", re.MULTILINE)
placeholder_re = re.compile(r"\{[0-9]+\}|\$\{[a-zA-Z_][a-zA-Z0-9_]*\}")
def kv(block):
out = {}
for k, v in value_re.findall(block):
out[k] = v
return out
en_kv = kv(en_block)
pl_kv = kv(pl_block)
for key, en_val in en_kv.items():
if key not in pl_kv:
continue
en_vars = sorted(placeholder_re.findall(en_val))
pl_vars = sorted(placeholder_re.findall(pl_kv[key]))
# Skip arrow functions which might contain duplicate conditional logic and thus more template vars
if "=>" not in pl_kv[key]:
if en_vars or pl_vars:
assert pl_vars == en_vars, f"Key '{key}' missing placeholders in pl locale"
def test_polish_locale_has_no_double_escaped_unicode_sequences():
"""JSON-style double escapes (\\\\u2026) render literal backslash-u in the UI."""
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
for bad in ("\\\\u2026", "\\\\u2192", "\\\\u2713"):
assert bad not in pl_block, f"Polish locale must not contain {bad!r}"

View File

@@ -112,5 +112,5 @@ def test_rtl_in_config_defaults_and_writable_keys():
def test_rtl_localized_in_all_locales():
js = I18N.read_text(encoding="utf-8")
# Count occurrences — should match the 11 locale blocks
assert js.count("settings_label_rtl:") == 12
assert js.count("settings_desc_rtl:") == 12
assert js.count("settings_label_rtl:") == 13
assert js.count("settings_desc_rtl:") == 13

View File

@@ -1307,37 +1307,252 @@ def test_provider_quota_styles_exist():
# ── Regression tests for #1912 ────────────────────────────────────────────────
def test_account_usage_subprocess_uses_devnull_stdin(monkeypatch):
"""Account-usage probe subprocess must receive stdin=DEVNULL.
DEVNULL prevents the child from inheriting any pipe that could block or
leak data. This is a defence-in-depth measure beyond the parent-death
signal; it is tested separately to make the invariant explicit.
"""
class _FakeAccountUsageWorkerProcess:
def __init__(self, payloads=None):
self.payloads = list(payloads or [])
self.requests = []
self.returncode = None
self.terminated = False
self.killed = False
self.stdin = self._Stdin(self)
self.stdout = self._Stdout(self)
class _Stdin:
def __init__(self, proc):
self.proc = proc
self.closed = False
def write(self, value):
self.proc.requests.append(json.loads(value))
def flush(self):
pass
def close(self):
self.closed = True
class _Stdout:
def __init__(self, proc):
self.proc = proc
self.closed = False
def readline(self):
request = self.proc.requests[-1]
if self.proc.payloads:
payload = self.proc.payloads.pop(0)
else:
payload = {
"provider": request["provider"],
"source": "usage_api",
"title": "Account limits",
"windows": [],
"details": [],
"available": True,
"unavailable_reason": None,
"fetched_at": "2030-03-17T12:30:00Z",
}
return json.dumps(payload) + "\n"
def close(self):
self.closed = True
def poll(self):
return self.returncode
def terminate(self):
self.terminated = True
self.returncode = -15
def wait(self, timeout=None):
return self.returncode
def kill(self):
self.killed = True
self.returncode = -9
def test_account_usage_worker_reuses_process_for_same_home(monkeypatch, tmp_path):
import api.providers as providers
import subprocess
launched = []
def fake_popen(*args, **kwargs):
proc = _FakeAccountUsageWorkerProcess()
launched.append((args, kwargs, proc))
return proc
monkeypatch.setattr(subprocess, "Popen", fake_popen)
providers._close_account_usage_probe_workers()
try:
first = providers._agent_fetch_account_usage_for_home("openai-codex", tmp_path)
second = providers._agent_fetch_account_usage_for_home("anthropic", tmp_path, api_key="sk-test")
finally:
providers._close_account_usage_probe_workers()
assert len(launched) == 1
assert first.provider == "openai-codex"
assert second.provider == "anthropic"
assert launched[0][2].requests == [
{"provider": "openai-codex", "api_key": "", "env_var": None},
{"provider": "anthropic", "api_key": "sk-test", "env_var": "ANTHROPIC_API_KEY"},
]
def test_account_usage_worker_pool_is_keyed_by_home(monkeypatch, tmp_path):
import api.providers as providers
import subprocess
launched = []
def fake_popen(*args, **kwargs):
proc = _FakeAccountUsageWorkerProcess()
launched.append((args, kwargs, proc))
return proc
home_a = tmp_path / "a"
home_b = tmp_path / "b"
monkeypatch.setattr(subprocess, "Popen", fake_popen)
providers._close_account_usage_probe_workers()
try:
providers._agent_fetch_account_usage_for_home("openai-codex", home_a)
providers._agent_fetch_account_usage_for_home("openai-codex", home_b)
finally:
providers._close_account_usage_probe_workers()
assert len(launched) == 2
def test_account_usage_worker_idle_cleanup_closes_stale_process(monkeypatch, tmp_path):
import api.providers as providers
import subprocess
launched = []
def fake_popen(*args, **kwargs):
proc = _FakeAccountUsageWorkerProcess()
launched.append((args, kwargs, proc))
return proc
monkeypatch.setattr(subprocess, "Popen", fake_popen)
providers._close_account_usage_probe_workers()
try:
providers._agent_fetch_account_usage_for_home("openai-codex", tmp_path)
worker = providers._account_usage_worker_pool[str(tmp_path)]
providers._cleanup_account_usage_probe_workers(
now=worker.last_used + providers._ACCOUNT_USAGE_WORKER_IDLE_SECONDS + 1
)
providers._agent_fetch_account_usage_for_home("openai-codex", tmp_path)
finally:
providers._close_account_usage_probe_workers()
assert len(launched) == 2
assert launched[0][2].terminated is True
def test_busy_account_usage_worker_uses_one_shot_fallback(monkeypatch, tmp_path):
import api.providers as providers
worker = providers._AccountUsageProbeWorker(tmp_path)
calls = []
def fake_one_shot(provider, home, *, api_key=None):
calls.append((provider, Path(home), api_key))
return SimpleNamespace(provider=provider, source="usage_api", windows=(), details=(), available=True)
monkeypatch.setattr(providers, "_fetch_account_usage_once_for_home", fake_one_shot)
locked = threading.Event()
release = threading.Event()
def hold_lock():
with worker._lock:
locked.set()
release.wait(timeout=5)
holder = threading.Thread(target=hold_lock)
holder.start()
assert locked.wait(timeout=5)
try:
snapshot = worker.fetch("anthropic", api_key="sk-test")
finally:
release.set()
holder.join(timeout=5)
worker.close()
assert snapshot.provider == "anthropic"
assert calls == [("anthropic", tmp_path, "sk-test")]
def test_account_usage_cleanup_removes_null_proc_worker(monkeypatch, tmp_path):
import api.providers as providers
import subprocess
launched = []
def fake_popen(*args, **kwargs):
proc = _FakeAccountUsageWorkerProcess()
launched.append(proc)
return proc
monkeypatch.setattr(subprocess, "Popen", fake_popen)
providers._close_account_usage_probe_workers()
try:
providers._agent_fetch_account_usage_for_home("openai-codex", tmp_path)
worker = providers._account_usage_worker_pool[str(tmp_path)]
worker.close()
providers._cleanup_account_usage_probe_workers()
assert str(tmp_path) not in providers._account_usage_worker_pool
finally:
providers._close_account_usage_probe_workers()
assert len(launched) == 1
def test_provider_key_mutation_invalidates_warm_account_usage_workers(monkeypatch, tmp_path):
import api.providers as providers
invalidated = []
monkeypatch.setattr(providers, "_get_hermes_home", lambda: tmp_path)
monkeypatch.setattr(providers, "invalidate_models_cache", lambda: None)
monkeypatch.setattr(
providers,
"invalidate_account_usage_status_cache",
lambda provider_id=None: invalidated.append(provider_id),
)
updated = providers.set_provider_key("anthropic", "sk-test-quota-worker")
removed = providers.set_provider_key("anthropic", None)
assert updated["ok"] is True
assert removed["ok"] is True
assert invalidated == ["anthropic", "anthropic"]
def test_account_usage_worker_uses_controlled_pipe_stdin(monkeypatch):
"""Account-usage probe workers must not inherit process stdin."""
import api.providers as providers
import subprocess
seen_stdin = None
def capturing_run(*args, **kwargs):
def capturing_popen(*args, **kwargs):
nonlocal seen_stdin
seen_stdin = kwargs.get('stdin')
class FakeProc:
returncode = 0
stdout = '{}'
stderr = ''
return FakeProc()
return _FakeAccountUsageWorkerProcess()
monkeypatch.setattr(subprocess, 'run', capturing_run)
monkeypatch.setattr(subprocess, 'Popen', capturing_popen)
providers._close_account_usage_probe_workers()
try:
providers._agent_fetch_account_usage_for_home(
'openai-codex', Path('/nonexistent'), api_key=None
)
except Exception:
pass # errors are expected on a fake env; we only care about stdin
finally:
providers._close_account_usage_probe_workers()
assert seen_stdin is subprocess.DEVNULL, (
f'expected stdin=subprocess.DEVNULL, got {seen_stdin!r}'
assert seen_stdin is subprocess.PIPE, (
f'expected stdin=subprocess.PIPE, got {seen_stdin!r}'
)
@@ -1387,24 +1602,21 @@ def test_account_usage_preexec_fn_is_wired_on_posix(monkeypatch):
captured_kwargs = {}
def capture_run(*args, **kwargs):
def capture_popen(*args, **kwargs):
captured_kwargs.update(kwargs)
class FakeProc:
returncode = 0
stdout = '{}'
stderr = ''
return FakeProc()
return _FakeAccountUsageWorkerProcess()
monkeypatch.setattr(subprocess, 'run', capture_run)
monkeypatch.setattr(subprocess, 'Popen', capture_popen)
providers._close_account_usage_probe_workers()
try:
providers._agent_fetch_account_usage_for_home(
'openai-codex', Path('/nonexistent'), api_key=None
)
except Exception:
pass
finally:
providers._close_account_usage_probe_workers()
assert 'preexec_fn' in captured_kwargs, (
'preexec_fn should be in subprocess.run kwargs on POSIX'
'preexec_fn should be in subprocess.Popen kwargs on POSIX'
)
assert captured_kwargs['preexec_fn'] is providers._account_usage_preexec_fn
@@ -1421,12 +1633,27 @@ def test_account_usage_semaphore_caps_concurrency(monkeypatch, tmp_path):
monkeypatch.setattr(profiles, 'get_active_hermes_home', lambda: tmp_path)
old_cfg, old_mtime = _with_config(model={'provider': 'openai-codex'})
barrier = threading.Barrier(2, timeout=2)
active = 0
max_active = 0
entered = 0
first_two_entered = threading.Event()
third_entered = threading.Event()
lock = threading.Lock()
unblock = threading.Event()
def slow_fetch(provider, home, api_key=None):
barrier.wait()
nonlocal active, max_active, entered
with lock:
active += 1
entered += 1
max_active = max(max_active, active)
if entered == providers._MAX_CONCURRENT_ACCOUNT_USAGE_PROBES:
first_two_entered.set()
if entered > providers._MAX_CONCURRENT_ACCOUNT_USAGE_PROBES:
third_entered.set()
unblock.wait(timeout=5)
with lock:
active -= 1
return None
monkeypatch.setattr(providers, '_agent_fetch_account_usage_for_home', slow_fetch)
@@ -1442,16 +1669,22 @@ def test_account_usage_semaphore_caps_concurrency(monkeypatch, tmp_path):
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(2)]
threads = [
threading.Thread(target=worker)
for _ in range(providers._MAX_CONCURRENT_ACCOUNT_USAGE_PROBES + 1)
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
unblock.set()
try:
assert first_two_entered.wait(timeout=2), 'first probe batch did not start'
assert not third_entered.wait(timeout=0.2), 'third probe bypassed semaphore'
unblock.set()
for t in threads:
t.join(timeout=10)
assert not errors, f'workers raised: {errors}'
assert len(results) == 2, f'expected 2 results, got {len(results)}'
assert len(results) == len(threads), f'expected {len(threads)} results, got {len(results)}'
assert max_active <= providers._MAX_CONCURRENT_ACCOUNT_USAGE_PROBES
finally:
unblock.set()
_restore_config(old_cfg, old_mtime)

View File

@@ -88,5 +88,5 @@ def test_quota_chip_panels_round_trip():
def test_quota_chip_localized_in_all_locales():
js = I18N.read_text(encoding="utf-8")
assert js.count("settings_label_quota_chip:") == 12, "12 locales expected"
assert js.count("settings_desc_quota_chip:") == 12, "12 locales expected"
assert js.count("settings_label_quota_chip:") == 13, "12 locales expected"
assert js.count("settings_desc_quota_chip:") == 13, "12 locales expected"

View File

@@ -779,8 +779,8 @@ def test_all_sessions_sidecar_refresh_stays_metadata_only(monkeypatch):
assert rows[0]["last_message_at"] == 102.0
def test_all_sessions_does_not_refresh_lineage_rows_from_sidecars(monkeypatch):
"""Lineage rows are enriched from state.db; do not read every sidecar per poll."""
def test_all_sessions_does_not_refresh_fresh_lineage_rows_from_sidecars(monkeypatch):
"""Fresh lineage rows are enriched from state.db; do not read every sidecar per poll."""
_write_index_file(
models.SESSION_INDEX_FILE,
[
@@ -789,8 +789,8 @@ def test_all_sessions_does_not_refresh_lineage_rows_from_sidecars(monkeypatch):
"title": "Lineage Row",
"message_count": 7,
"created_at": 100.0,
"updated_at": 101.0,
"last_message_at": 101.0,
"updated_at": time.time() + 60.0,
"last_message_at": time.time() + 60.0,
"pinned": False,
"archived": False,
"parent_session_id": "parent_sid",
@@ -813,13 +813,117 @@ def test_all_sessions_does_not_refresh_lineage_rows_from_sidecars(monkeypatch):
)
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
with patch.object(Session, "load_metadata_only", side_effect=AssertionError("lineage rows must not refresh sidecars")):
with patch.object(Session, "load_metadata_only", side_effect=AssertionError("fresh lineage rows must not refresh sidecars")):
rows = models.all_sessions()
assert rows[0]["session_id"] == "lineage_sid"
assert rows[0]["message_count"] == 7
def test_all_sessions_refreshes_stale_visible_continuation_metadata(monkeypatch):
"""A visible continuation whose sidecar advanced after _index.json must refresh metadata.
Compression lineage rows can remain the active sidebar representative while
their sidecar gains the latest assistant turn. If the index row stays stale,
the sidebar/topbar reports an old message count and the UI can look like the
newest messages disappeared.
"""
session = Session(
session_id="stale_visible_child",
title="Long Conversation",
messages=[
{"role": "user", "content": "first", "timestamp": 100.0},
{"role": "assistant", "content": "second", "timestamp": 101.0},
{"role": "user", "content": "latest", "timestamp": 102.0},
{"role": "assistant", "content": "latest answer", "timestamp": 103.0},
],
parent_session_id="snapshot_parent",
updated_at=103.0,
last_message_at=103.0,
)
session.save(touch_updated_at=False)
_write_index_file(
models.SESSION_INDEX_FILE,
[
{
"session_id": "stale_visible_child",
"title": "Long Conversation",
"message_count": 2,
"created_at": 100.0,
"updated_at": 100.0,
"last_message_at": 100.0,
"pinned": False,
"archived": False,
"parent_session_id": "snapshot_parent",
"_lineage_root_id": "snapshot_parent",
"_compression_segment_count": 2,
}
],
)
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
rows = models.all_sessions()
assert rows[0]["session_id"] == "stale_visible_child"
assert rows[0]["message_count"] == 4
assert rows[0]["last_message_at"] == 103.0
def test_all_sessions_does_not_refresh_plain_branch_fork_from_sidecar(monkeypatch):
"""A plain /branch fork (session_source='fork') must NOT trigger a sidecar refresh.
Forks carry parent_session_id (#1342) but have no compression sidecar drift
to correct. Including them in the continuation refresh gate would call
load_metadata_only() on every fork row on every /api/sessions poll (the
molasses #3770 guards against). The gate must exclude session_source='fork'
so a fork's stale-looking index row is left alone.
"""
session = Session(
session_id="plain_fork_child",
title="Forked Conversation",
messages=[
{"role": "user", "content": "a", "timestamp": 100.0},
{"role": "assistant", "content": "b", "timestamp": 101.0},
{"role": "user", "content": "c", "timestamp": 102.0},
{"role": "assistant", "content": "d", "timestamp": 103.0},
],
parent_session_id="some_parent",
session_source="fork",
updated_at=103.0,
last_message_at=103.0,
)
session.save(touch_updated_at=False)
_write_index_file(
models.SESSION_INDEX_FILE,
[
{
"session_id": "plain_fork_child",
"title": "Forked Conversation",
"message_count": 2,
"created_at": 100.0,
"updated_at": 100.0,
"last_message_at": 100.0,
"pinned": False,
"archived": False,
"parent_session_id": "some_parent",
"session_source": "fork",
}
],
)
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
# A fork that has not been hydrated must not be promoted from the (stale)
# indexed count — the row stays as the index reports it (no sidecar refresh).
def _fail_load(_sid):
raise AssertionError("plain fork must not trigger load_metadata_only refresh")
monkeypatch.setattr(models.Session, "load_metadata_only", staticmethod(_fail_load))
rows = models.all_sessions()
fork_row = next(r for r in rows if r["session_id"] == "plain_fork_child")
assert fork_row["message_count"] == 2 # left at the indexed value, not refreshed
def test_load_metadata_only_skips_index_read_when_sidecar_has_message_count(monkeypatch):
"""Modern sidecars already carry message_count; avoid an _index.json read per row."""
session = Session(

View File

@@ -38,4 +38,4 @@ def test_verdigris_has_no_light_variant():
def test_verdigris_i18n_lists_skin_in_all_locales():
# There are 12 locales; each should now include verdigris as the trailing skin.
# 10 locales use ASCII closing paren, 2 Chinese locales use full-width paren.
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris") == 12
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris") == 13

View File

@@ -45,4 +45,4 @@ def test_zeus_modals_are_not_navy():
def test_zeus_i18n_lists_skin_in_all_locales():
# There are 12 locales; each should include zeus in the skin list.
assert I18N_JS.count("/zeus/") == 12
assert I18N_JS.count("/zeus/") == 13