From 4c3a4e011eb08400e25ade1a02c4647e045f5a4f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 5 Jul 2026 16:22:35 -0400 Subject: [PATCH] fix(#5619): reload config cache on profile switch --- api/config.py | 10 +-- tests/test_profile_switch_1200.py | 65 +++++++++++++++++++ ...est_stage302_config_override_regression.py | 26 +++++--- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/api/config.py b/api/config.py index 9a5f06cd0..8afb84297 100644 --- a/api/config.py +++ b/api/config.py @@ -450,8 +450,9 @@ def reload_config_if_stale() -> None: current_mtime = config_path.stat().st_mtime except OSError: current_mtime = 0.0 - cache_stale = current_mtime != _cfg_mtime or _cfg_path != config_path - if not _cfg_cache or (cache_stale and not _cfg_has_in_memory_overrides()): + path_changed = _cfg_path != config_path + mtime_stale = current_mtime != _cfg_mtime + if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()): _refresh_config_cache(config_path) @@ -462,8 +463,9 @@ def get_config() -> dict: current_mtime = config_path.stat().st_mtime except OSError: current_mtime = 0.0 - cache_stale = current_mtime != _cfg_mtime or _cfg_path != config_path - if not _cfg_cache or (cache_stale and not _cfg_has_in_memory_overrides()): + path_changed = _cfg_path != config_path + mtime_stale = current_mtime != _cfg_mtime + if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()): reload_config_if_stale() # When a test (or runtime caller) has rebound ``cfg`` to a different dict # via monkeypatch.setattr(config, "cfg", ...), return that override rather diff --git a/tests/test_profile_switch_1200.py b/tests/test_profile_switch_1200.py index 82a1d9de6..31c90b42c 100644 --- a/tests/test_profile_switch_1200.py +++ b/tests/test_profile_switch_1200.py @@ -453,6 +453,71 @@ def test_get_config_reloads_when_request_profile_changes(tmp_path, monkeypatch): config._cfg_fingerprint = orig_fingerprint +def test_get_config_reloads_when_request_profile_changes_even_with_in_memory_override( + tmp_path, monkeypatch +): + """A pinned override on one profile must not survive a request-profile switch.""" + monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False) + import api.config as config + import api.profiles as profiles + + default_home = tmp_path / ".hermes" + work_home = default_home / "profiles" / "work" + work_home.mkdir(parents=True) + default_home.mkdir(exist_ok=True) + (default_home / "config.yaml").write_text( + "model:\n provider: openai-codex\n default: gpt-5.5\n", + encoding="utf-8", + ) + (work_home / "config.yaml").write_text( + "model:\n provider: openrouter\n default: google/gemini-3-flash-preview\n" + "custom_providers:\n llamacpp:\n api_key: work-key\n", + encoding="utf-8", + ) + same_mtime = 1_700_000_000 + os.utime(default_home / "config.yaml", (same_mtime, same_mtime)) + os.utime(work_home / "config.yaml", (same_mtime, same_mtime)) + + monkeypatch.setattr( + config, + "_get_config_path", + lambda: profiles.get_active_hermes_home() / "config.yaml", + ) + + orig_default_home = profiles._DEFAULT_HERMES_HOME + orig_active = profiles._active_profile + orig_cache = dict(config._cfg_cache) + orig_mtime = config._cfg_mtime + orig_path = getattr(config, "_cfg_path", None) + orig_fingerprint = getattr(config, "_cfg_fingerprint", None) + profiles._tls.profile = None + try: + profiles._DEFAULT_HERMES_HOME = default_home + profiles._active_profile = "default" + config.reload_config() + config._cfg_cache["__test_override"] = "pinned-default" + assert config._cfg_has_in_memory_overrides() is True + assert config.get_config()["model"]["provider"] == "openai-codex" + + profiles.set_request_profile("work") + assert config._get_config_path() == work_home / "config.yaml" + result = config.get_config() + assert result["model"]["provider"] == "openrouter" + assert result["model"]["default"] == "google/gemini-3-flash-preview" + assert result["custom_providers"]["llamacpp"]["api_key"] == "work-key" + finally: + profiles.clear_request_profile() + profiles._DEFAULT_HERMES_HOME = orig_default_home + profiles._active_profile = orig_active + config._cfg_cache.clear() + config._cfg_cache.update(orig_cache) + config._cfg_mtime = orig_mtime + if hasattr(config, "_cfg_path"): + config._cfg_path = orig_path + if hasattr(config, "_cfg_fingerprint"): + config._cfg_fingerprint = orig_fingerprint + + def test_chat_start_retags_empty_session_to_request_profile(monkeypatch, tmp_path): """An empty session created under profile A can be sent under profile B after a switch.""" import api.routes as routes diff --git a/tests/test_stage302_config_override_regression.py b/tests/test_stage302_config_override_regression.py index d733134d5..4c7ebb3f7 100644 --- a/tests/test_stage302_config_override_regression.py +++ b/tests/test_stage302_config_override_regression.py @@ -26,6 +26,8 @@ These tests pin both prongs. """ from __future__ import annotations +import os + import api.config as config @@ -74,22 +76,30 @@ def test_cfg_has_in_memory_overrides_detects_in_place_mutation(monkeypatch): config._cfg_cache.pop("__test_key", None) -def test_get_config_does_not_reload_when_only_in_memory_override(monkeypatch, tmp_path): - """A test that sets cfg + leaves disk untouched must not trigger reload.""" +def test_get_config_does_not_reload_when_only_in_memory_override_same_path_mtime_only( + monkeypatch, tmp_path +): + """same_path cfg overrides must survive mtime-only staleness.""" + config_path = tmp_path / "config.yaml" + base_mtime = 1_700_000_000.0 + config_path.write_text( + "model:\n provider: openrouter\n default: test/model-x\n", + encoding="utf-8", + ) + os.utime(config_path, (base_mtime, base_mtime)) + monkeypatch.setattr(config, "_get_config_path", lambda: config_path) config.reload_config() - # Fake a config path that will have a different mtime than what's cached - fake_path = tmp_path / "missing.yaml" - monkeypatch.setattr(config, "_get_config_path", lambda: fake_path) - # Override cfg via attr rebind. test_override = { "model": {"provider": "openai", "default": "gpt-test"}, "providers": {}, } monkeypatch.setattr(config, "cfg", test_override, raising=False) - # The path-aware reload would normally trigger reload (path changed), - # but the override-detection should suppress it. + os.utime(config_path, (base_mtime + 1.0, base_mtime + 1.0)) + + # The same_path mtime-only reload would normally trigger reload, but the + # override-detection should suppress it. result = config.get_config() assert result is test_override assert result["model"]["provider"] == "openai"