mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
* revert(gateway): remove stale-code self-check and auto-restart Removes the _detect_stale_code / _trigger_stale_code_restart mechanism introduced in #17648 and iterated in #19740. On every incoming message the gateway compared the boot-time git HEAD SHA to the current SHA on disk, and if they differed it would reply with Gateway code was updated in the background -- restarting this gateway so your next message runs on the new code. Please retry in a moment. and then kick off a graceful restart. This is unwanted behaviour: users who run a long-lived gateway and do their own ad-hoc git operations on the checkout end up with their chat interrupted and the current message dropped every time HEAD moves, with no way to opt out. If an operator really needs the old protection against stale sys.modules after "hermes update", the SIGKILL-survivor sweep in hermes update (hermes_cli/main.py, also tagged #17648) already handles the supervisor-respawn case on its own. Removed: gateway/run.py: - _STALE_CODE_SENTINELS, _GIT_SHA_CACHE_TTL_SECS - _read_git_head_sha(), _compute_repo_mtime() module helpers - class-level _boot_wall_time / _boot_repo_mtime / _boot_git_sha / _stale_code_restart_triggered defaults - __init__ boot-snapshot block (_boot_*, _cached_current_sha*, _repo_root_for_staleness, _stale_code_notified) - _current_git_sha_cached(), _detect_stale_code(), _trigger_stale_code_restart() methods - stale-code check + user-facing restart notice at the top of _handle_message() tests/gateway/test_stale_code_self_check.py (deleted, 412 lines) No new logic added. Zero remaining references to any removed symbol. Gateway test suite passes the same 4589 tests it passed before; the 3 pre-existing unrelated failures (discord free-channel, feishu bot admission, teams typing) are unchanged by this commit. * feat(i18n): add display.language for static message translation (zh/ja/de/es) Adds a thin-slice i18n layer covering the highest-impact static user-facing messages: the CLI dangerous-command approval prompt and a handful of gateway slash-command replies (restart-drain, goal cleared, approval expired, config read/save errors). Out of scope (stays English): agent responses, log lines, tool outputs, slash-command descriptions, error tracebacks. Infrastructure: - agent/i18n.py: catalog loader, t() helper, language resolution (HERMES_LANGUAGE env var > display.language config > en) - locales/{en,zh,ja,de,es}.yaml: ~19 translated strings per language - display.language in DEFAULT_CONFIG (hermes_cli/config.py) Tests: - tests/agent/test_i18n.py: 21 tests covering catalog parity, placeholder parity across locales, fallback behavior, env-var override, alias normalization, missing-key graceful degradation. Docs: - website/docs/user-guide/configuration.md: display.language entry plus a short section explaining scope so users don't expect agent responses to translate via this knob.
156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""Tests for agent.i18n -- catalog parity, fallback, language resolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from agent import i18n
|
|
|
|
|
|
LOCALES_DIR = Path(__file__).resolve().parents[2] / "locales"
|
|
|
|
|
|
def _load_raw(lang: str) -> dict:
|
|
with (LOCALES_DIR / f"{lang}.yaml").open("r", encoding="utf-8") as f:
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
def _flatten(d, prefix="") -> dict:
|
|
flat = {}
|
|
for k, v in (d or {}).items():
|
|
key = f"{prefix}.{k}" if prefix else k
|
|
if isinstance(v, dict):
|
|
flat.update(_flatten(v, key))
|
|
else:
|
|
flat[key] = v
|
|
return flat
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Catalog completeness -- this is the key invariant test. If someone adds a
|
|
# new key to en.yaml they MUST add it to every other locale, else runtime
|
|
# falls back to English for those users and defeats the feature.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_all_locales_exist():
|
|
"""Every supported language must have a catalog file on disk."""
|
|
for lang in i18n.SUPPORTED_LANGUAGES:
|
|
assert (LOCALES_DIR / f"{lang}.yaml").is_file(), f"missing locales/{lang}.yaml"
|
|
|
|
|
|
@pytest.mark.parametrize("lang", [l for l in i18n.SUPPORTED_LANGUAGES if l != "en"])
|
|
def test_catalog_keys_match_english(lang: str):
|
|
"""Every non-English catalog must have exactly the same key set as English."""
|
|
en_keys = set(_flatten(_load_raw("en")).keys())
|
|
lang_keys = set(_flatten(_load_raw(lang)).keys())
|
|
missing = en_keys - lang_keys
|
|
extra = lang_keys - en_keys
|
|
assert not missing, f"{lang}.yaml missing keys: {sorted(missing)}"
|
|
assert not extra, f"{lang}.yaml has keys not in en.yaml: {sorted(extra)}"
|
|
|
|
|
|
@pytest.mark.parametrize("lang", list(i18n.SUPPORTED_LANGUAGES))
|
|
def test_catalog_placeholders_match_english(lang: str):
|
|
"""Every translated value must use the same {placeholder} tokens as English.
|
|
|
|
A mistranslated placeholder (e.g. ``{description}`` typoed as ``{descricao}``)
|
|
would either raise KeyError at runtime or silently drop the interpolated
|
|
value. Pin parity at the test layer.
|
|
"""
|
|
import re
|
|
placeholder_re = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
|
en_flat = _flatten(_load_raw("en"))
|
|
lang_flat = _flatten(_load_raw(lang))
|
|
for key, en_value in en_flat.items():
|
|
en_placeholders = set(placeholder_re.findall(en_value))
|
|
lang_value = lang_flat.get(key, "")
|
|
lang_placeholders = set(placeholder_re.findall(lang_value))
|
|
assert en_placeholders == lang_placeholders, (
|
|
f"{lang}.yaml key={key!r}: placeholders {lang_placeholders} "
|
|
f"don't match English {en_placeholders}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Language resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_normalize_lang_accepts_supported():
|
|
assert i18n._normalize_lang("zh") == "zh"
|
|
assert i18n._normalize_lang("EN") == "en"
|
|
|
|
|
|
def test_normalize_lang_accepts_aliases():
|
|
assert i18n._normalize_lang("chinese") == "zh"
|
|
assert i18n._normalize_lang("zh-CN") == "zh"
|
|
assert i18n._normalize_lang("Deutsch") == "de"
|
|
assert i18n._normalize_lang("español") == "es"
|
|
assert i18n._normalize_lang("jp") == "ja"
|
|
|
|
|
|
def test_normalize_lang_unknown_falls_back():
|
|
assert i18n._normalize_lang("klingon") == "en"
|
|
assert i18n._normalize_lang("") == "en"
|
|
assert i18n._normalize_lang(None) == "en"
|
|
|
|
|
|
def test_env_var_override(monkeypatch):
|
|
"""HERMES_LANGUAGE wins over config."""
|
|
i18n.reset_language_cache()
|
|
monkeypatch.setenv("HERMES_LANGUAGE", "ja")
|
|
assert i18n.get_language() == "ja"
|
|
|
|
|
|
def test_env_var_normalized(monkeypatch):
|
|
i18n.reset_language_cache()
|
|
monkeypatch.setenv("HERMES_LANGUAGE", "Chinese")
|
|
assert i18n.get_language() == "zh"
|
|
|
|
|
|
def test_default_when_nothing_set(monkeypatch):
|
|
"""With no env var and no config override, falls back to English."""
|
|
monkeypatch.delenv("HERMES_LANGUAGE", raising=False)
|
|
# Force config lookup to return None -- patch the cached reader.
|
|
i18n.reset_language_cache()
|
|
monkeypatch.setattr(i18n, "_config_language_cached", lambda: None)
|
|
assert i18n.get_language() == "en"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# t() semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_t_explicit_lang():
|
|
assert i18n.t("approval.denied", lang="en").endswith("Denied")
|
|
assert i18n.t("approval.denied", lang="zh").endswith("已拒绝")
|
|
|
|
|
|
def test_t_formats_placeholders():
|
|
msg = i18n.t("gateway.draining", lang="en", count=3)
|
|
assert "3" in msg
|
|
|
|
|
|
def test_t_missing_key_returns_key():
|
|
"""A missing key returns its own path -- ugly but never crashes."""
|
|
result = i18n.t("nonexistent.key.path", lang="en")
|
|
assert result == "nonexistent.key.path"
|
|
|
|
|
|
def test_t_missing_key_in_non_english_falls_back_to_english(tmp_path, monkeypatch):
|
|
"""If a key exists in English but not in the target locale, fall back."""
|
|
# Stand up a fake incomplete locale under a temp locales dir.
|
|
fake_locales = tmp_path / "locales"
|
|
fake_locales.mkdir()
|
|
(fake_locales / "en.yaml").write_text("foo: English Foo\n", encoding="utf-8")
|
|
(fake_locales / "zh.yaml").write_text("# intentionally empty\n", encoding="utf-8")
|
|
monkeypatch.setattr(i18n, "_locales_dir", lambda: fake_locales)
|
|
i18n.reset_language_cache()
|
|
assert i18n.t("foo", lang="zh") == "English Foo"
|
|
|
|
|
|
def test_t_unknown_language_uses_english():
|
|
"""Unknown lang codes normalize to English, not to a key-path fallback."""
|
|
assert i18n.t("approval.denied", lang="klingon") == i18n.t("approval.denied", lang="en")
|