Files
hermes-webui/tests/test_issue5567_profile_home_override.py
T
48b40d930a Release: isolate Hermes home per streaming turn (#5877, @starship-s) (#6379)
* fix(profiles): isolate Hermes home per streaming turn

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.3-codex-spark

* fix(profiles): gate skill isolation by capability

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.3-codex-spark

* fix(profiles): harden fallback lock boundaries

Assisted-by: OpenCode:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol

* test(profiles): adapt streaming isolation harness

Assisted-by: Codex:gpt-5.3-codex-spark
Assisted-by: Hermes Agent:gpt-5.6-sol

* Release: isolate Hermes home per streaming turn (#5877, @starship-s)

---------

Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-21 03:48:00 +00:00

1223 lines
51 KiB
Python

"""Regression test for #5567 — cross-profile HERMES_HOME race at the config reader.
Root cause: `profile_env_for_background_worker` mirrors the profile's HERMES_HOME
into the process-global `os.environ`, and the worker body runs outside the setup
lock. A concurrent cross-profile worker can clobber `os.environ["HERMES_HOME"]`
mid-body, so the agent config reader (`hermes_cli.config.get_config_path` /
`load_config`, which read `get_hermes_home()`) resolves the WRONG profile's
config — intermittent turn-init failures referencing another profile's provider.
Fix (#5567): when hermes-agent >= v0.18.0 exposes the context-local home
override (`hermes_constants.set_hermes_home_override`), the worker scope installs
it so `get_hermes_home()` resolves THIS task's profile home from task-local state,
immune to the process-global clobber — without serializing workers.
Per #2321's acceptance criteria, this exercises the REAL
`hermes_cli.config.load_config()` against a non-default profile with an
intentional mid-body `os.environ` clobber and NO mocking of the production reader.
Degrades gracefully on agents without the override (skips with a clear reason).
"""
import os
import json
import inspect
import textwrap
import contextlib
import queue
import threading
import sys
import types
from pathlib import Path
import pytest
# The production reader — imported unmocked, exactly as #2321 requires. Skip the
# whole module if the agent isn't importable in this environment.
config_mod = pytest.importorskip("hermes_cli.config")
hermes_constants = pytest.importorskip("hermes_constants")
HAS_OVERRIDE = hasattr(hermes_constants, "set_hermes_home_override") and hasattr(
hermes_constants, "get_hermes_home"
)
from api import profiles as profiles_api # noqa: E402
def _seed_profile_home(base: Path, name: str, provider: str, model: str) -> Path:
home = base / name
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(
textwrap.dedent(
f"""\
model:
default: {model}
provider: {provider}
"""
),
encoding="utf-8",
)
return home
@pytest.mark.skipif(
not HAS_OVERRIDE,
reason="hermes-agent < v0.18.0: no set_hermes_home_override; WebUI degrades to the os.environ mirror",
)
def test_load_config_resolves_worker_profile_despite_env_clobber(tmp_path, monkeypatch):
"""The crux (#2321 criterion): inside profile_env_for_background_worker(A),
a concurrent clobber of os.environ['HERMES_HOME']=B must NOT make the real
load_config() read B — the context-local override pins A."""
home_a = _seed_profile_home(tmp_path, "alpha", provider="anthropic", model="claude-x")
home_b = _seed_profile_home(tmp_path, "beta", provider="ollama", model="llama-y")
# The CM's INPUT (which profile home to scope to) — this is not the reader
# under test; the reader is the real hermes_cli.config below.
monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: home_a)
# Establish a benign starting env, then simulate the race: while the worker
# body for profile A runs, a sibling profile-B worker clobbers the global.
monkeypatch.setenv("HERMES_HOME", str(home_a))
# Clear any cached config so load_config actually hits the resolver.
for fn in ("reload_config", "_reset_config_cache", "clear_config_cache"):
if hasattr(config_mod, fn):
try:
getattr(config_mod, fn)()
except Exception:
pass
with profiles_api.profile_env_for_background_worker("alpha", "test worker"):
# The clobber: another profile's worker overwrites the process global.
os.environ["HERMES_HOME"] = str(home_b)
# get_config_path must resolve profile A via the context-local override,
# NOT profile B from the clobbered os.environ.
resolved = config_mod.get_config_path()
assert resolved == home_a / "config.yaml", (
f"config path must resolve profile A ({home_a}) via the context-local "
f"override despite os.environ clobbered to B ({home_b}); got {resolved}"
)
# And the real load_config() must read A's model, not B's.
cfg = config_mod.load_config()
model_default = (cfg.get("model") or {}).get("default")
assert model_default == "claude-x", (
f"load_config must read profile A's model 'claude-x' despite the "
f"HERMES_HOME clobber to B; got {model_default!r} (B is 'llama-y')"
)
@pytest.mark.skipif(
not HAS_OVERRIDE,
reason="requires the v0.18.0 override to assert the override is cleared on exit",
)
def test_override_is_cleared_after_worker_exits(tmp_path, monkeypatch):
"""The context-local override must not leak past the worker scope."""
home_a = _seed_profile_home(tmp_path, "alpha", provider="anthropic", model="claude-x")
monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: home_a)
assert hermes_constants.get_hermes_home_override() is None
with profiles_api.profile_env_for_background_worker("alpha", "test worker"):
assert hermes_constants.get_hermes_home_override() == str(home_a)
# Cleared on exit — no leak into subsequent tasks on this context.
assert hermes_constants.get_hermes_home_override() is None
def test_graceful_degradation_resolver_is_optional():
"""On an agent WITHOUT the override, the resolver returns None and the CM
falls back to the pre-existing os.environ mirror — never raises. We assert
the resolver is import-safe and boolean-clean regardless of agent version."""
mod = profiles_api._resolve_hermes_home_override()
if HAS_OVERRIDE:
assert mod is not None and hasattr(mod, "set_hermes_home_override")
else:
assert mod is None # older agent: graceful no-op, os.environ mirror stays
def test_profile_env_for_background_worker_uses_legacy_skill_module_patching(monkeypatch, tmp_path):
"""When override support is unavailable for this run, fallback still patches skill modules."""
profile_home = tmp_path / "legacy-profile-home"
profile_home.mkdir(parents=True, exist_ok=True)
fake_skill_module = types.ModuleType("tools.skills_tool")
fake_skill_module.HERMES_HOME = "default-home"
fake_skill_module.SKILLS_DIR = "default-home/skills"
fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool")
fake_skill_manager_module.HERMES_HOME = "default-home"
fake_skill_manager_module.SKILLS_DIR = "default-home/skills"
monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module)
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module)
monkeypatch.setenv("HERMES_HOME", "default-home")
monkeypatch.delenv("HERMES_TEST_PROFILE_ENV", raising=False)
monkeypatch.setattr(profiles_api, "_hermes_home_override_available", False)
monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home)
monkeypatch.setattr(
profiles_api,
"get_profile_runtime_env",
lambda home: {"HERMES_TEST_PROFILE_ENV": "legacy-runtime"},
)
monkeypatch.setattr(
profiles_api,
"filter_runtime_env_for_gateway_parity",
lambda env: env,
)
with profiles_api.profile_env_for_background_worker("legacy", "legacy worker"):
assert os.environ.get("HERMES_HOME") == str(profile_home)
assert os.environ.get("HERMES_TEST_PROFILE_ENV") == "legacy-runtime"
assert fake_skill_module.HERMES_HOME == profile_home
assert fake_skill_module.SKILLS_DIR == profile_home / "skills"
assert fake_skill_module.HERMES_HOME == "default-home"
assert fake_skill_module.SKILLS_DIR == "default-home/skills"
assert os.environ.get("HERMES_HOME") == "default-home"
assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None
def test_run_agent_streaming_installs_and_resets_profile_home_override(tmp_path, monkeypatch):
"""Streaming must install the worker home override and clear it in teardown."""
import api.streaming as _streaming
_session_id = "streaming-override-session"
_stream_id = "streaming-override-stream"
_workspace = tmp_path / "workspace"
_workspace.mkdir()
_home = tmp_path / "alpha"
_home.mkdir()
class _Session:
def __init__(self):
self.session_id = _session_id
self.workspace = str(_workspace)
self.profile = "alpha"
self.model = "gpt-4"
self.model_provider = "hermes"
self.messages = []
self.context_messages = []
self.path = str(_workspace / "session.json")
self.active_stream_id = _stream_id
self.pending_user_message = None
self.pending_started_at = None
self.pending_user_source = None
self.pending_attachments = []
def save(self, *args, **kwargs):
return None
_events = {}
class _FakeMeter:
def begin_session(self, *args, **kwargs):
_events["begin_session"] = (_events.get("begin_session", 0) + 1)
def end_session(self, *args, **kwargs):
_events["end_session"] = (_events.get("end_session", 0) + 1)
def get_interval(self):
return 11.0
def set_pending_started_at(self, stream_id, pending_started_at):
_events.setdefault("set_pending_started_at", []).append(
(stream_id, pending_started_at)
)
def get_ttft_ms(self, stream_id):
_events.setdefault("get_ttft_ms", []).append(stream_id)
return None
def get_stats(self):
return {}
def record_token(self, *args, **kwargs):
pass
def record_reasoning(self, *args, **kwargs):
pass
q = queue.Queue()
_streaming.STREAMS[_stream_id] = q
def _set_thread_env(**kwargs):
_events["set_thread_env"] = True
def _set_override(profile_home: str):
_events["set_override_home"] = profile_home
return ("sentinel-module", None, True)
def _reset_override(mod, reset_token, override_installed):
_events["reset_override"] = (mod, reset_token, override_installed)
def _patch_skill_home_modules(*_):
_events["patch_skill_home_modules"] = _events.get("patch_skill_home_modules", 0) + 1
raise AssertionError("patch_skill_home_modules must be skipped when context-local override installs")
class _SentinelAgent:
def __init__(self, *args, **kwargs):
pass
def run_conversation(self, *args, **kwargs):
_events["run_conversation"] = True
raise RuntimeError("streaming test sentinel")
def _get_ai_agent():
return _SentinelAgent
def _discover_mcp_tools():
_events["discover_mcp_tools"] = _events.get("discover_mcp_tools", 0) + 1
monkeypatch.setattr(_streaming, "register_active_run", lambda *args, **kwargs: None)
monkeypatch.setattr(_streaming, "update_active_run", lambda *args, **kwargs: None)
monkeypatch.setattr(_streaming, "get_session", lambda sid: _Session())
monkeypatch.setattr(_streaming, "_get_session_agent_lock", lambda sid: contextlib.nullcontext())
monkeypatch.setattr(_streaming, "_set_streaming_hermes_home_override", _set_override)
monkeypatch.setattr(_streaming, "_reset_streaming_hermes_home_override", _reset_override)
monkeypatch.setattr(_streaming, "_set_thread_env", _set_thread_env)
monkeypatch.setattr(_streaming, "_prewarm_skill_tool_modules", lambda: None)
monkeypatch.setattr(_streaming, "_install_streaming_cronjob_profile_wrapper", lambda: None)
monkeypatch.setattr(_streaming, "_clear_thread_env", lambda: None)
monkeypatch.setattr(_streaming, "_get_ai_agent", _get_ai_agent)
monkeypatch.setattr(_streaming, "_materialize_pending_user_turn_before_error", lambda *a, **k: None)
monkeypatch.setattr(_streaming, "_snapshot_and_append_partial_on_error", lambda *a, **k: None)
monkeypatch.setattr(_streaming, "append_turn_journal_event_for_stream", lambda *a, **k: None)
monkeypatch.setattr(_streaming, "meter", lambda: _FakeMeter())
monkeypatch.setattr(_streaming, "RunJournalWriter", lambda *a, **k: None)
monkeypatch.setattr(_streaming, "_build_agent_thread_env", lambda *a, **k: {})
monkeypatch.setattr(
_streaming,
"resolve_model_provider",
lambda model_with_provider_context, explicitly_picked=None: (model_with_provider_context, None, None),
)
monkeypatch.setattr(_streaming, "_runtime_preferred_base_url", lambda rt, provider, configured_base_url: configured_base_url)
import api.profiles as profiles_api
monkeypatch.setattr(profiles_api, "_skill_modules_support_profile_home", lambda profile_home: True)
import api.config as _config_mod
monkeypatch.setattr(_config_mod, "_resolve_cli_toolsets", lambda cfg: [])
monkeypatch.setattr(_config_mod, "get_config_for_profile_home", lambda profile_home: {})
_fake_mcp_module = types.ModuleType("tools.mcp_tool")
_fake_mcp_module.discover_mcp_tools = _discover_mcp_tools
monkeypatch.setitem(sys.modules, "tools.mcp_tool", _fake_mcp_module)
monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: _home)
monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {})
monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: {})
monkeypatch.setattr(profiles_api, "patch_skill_home_modules", _patch_skill_home_modules)
monkeypatch.setattr(_streaming, "_apply_profile_home_context_to_streaming_model",
lambda model, provider_context, profile_home, has_profile: (model, provider_context, False))
_streaming._run_agent_streaming(
session_id=_session_id,
msg_text="hi",
model="gpt-4",
workspace=str(_workspace),
stream_id=_stream_id,
)
assert _events.get("set_override_home") == str(_home)
assert _events.get("reset_override") == ("sentinel-module", None, True)
assert _events.get("run_conversation") is True
assert _events.get("discover_mcp_tools", 0) == 1
assert _events.get("set_thread_env") is True
assert _events.get("patch_skill_home_modules", 0) == 0
assert _stream_id not in _streaming.STREAMS
def test_run_agent_streaming_falls_back_to_skill_module_patch_for_static_modules(
tmp_path,
monkeypatch,
):
"""Streaming should use snapshot/patch/restore when skill modules are static."""
import api.streaming as _streaming
_session_id = "streaming-override-static-session"
_stream_id = "streaming-override-static-stream"
_workspace = tmp_path / "workspace"
_workspace.mkdir()
_home = tmp_path / "alpha"
_home.mkdir()
class _Session:
def __init__(self):
self.session_id = _session_id
self.workspace = str(_workspace)
self.profile = "alpha"
self.model = "gpt-4"
self.model_provider = "hermes"
self.messages = []
self.context_messages = []
self.path = str(_workspace / "session.json")
self.active_stream_id = _stream_id
self.pending_user_message = None
self.pending_started_at = None
self.pending_user_source = None
self.pending_attachments = []
def save(self, *args, **kwargs):
return None
_events = {}
class _FakeMeter:
def begin_session(self, *args, **kwargs):
_events["begin_session"] = (_events.get("begin_session", 0) + 1)
def end_session(self, *args, **kwargs):
_events["end_session"] = (_events.get("end_session", 0) + 1)
def get_interval(self):
return 11.0
def set_pending_started_at(self, stream_id, pending_started_at):
_events.setdefault("set_pending_started_at", []).append(
(stream_id, pending_started_at)
)
def get_ttft_ms(self, stream_id):
_events.setdefault("get_ttft_ms", []).append(stream_id)
return None
def get_stats(self):
return {}
q = queue.Queue()
_streaming.STREAMS[_stream_id] = q
def _set_thread_env(**kwargs):
_events["set_thread_env"] = True
def _set_override(profile_home: str):
_events["set_override_home"] = profile_home
return ("sentinel-module", None, True)
def _reset_override(mod, reset_token, override_installed):
_events["reset_override"] = (mod, reset_token, override_installed)
def _caller_info():
_frame = inspect.currentframe()
_wrapper = _frame.f_back if _frame is not None else None
_caller = _wrapper.f_back if _wrapper is not None else None
try:
if _caller is None:
return "<unknown>", "<unknown>", -1
return (
Path(_caller.f_code.co_filename).name,
_caller.f_code.co_name,
_caller.f_lineno,
)
finally:
del _wrapper
del _caller
del _frame
def _snapshot_skill_home_modules():
_events.setdefault("snapshot_skill_home_modules", []).append(
{"source": _caller_info(), "args": ()}
)
payload = {"snapshot": True}
return payload
def _patch_skill_home_modules(*args):
_events.setdefault("patch_skill_home_modules", []).append(
{"source": _caller_info(), "args": tuple(args)}
)
def _restore_skill_home_modules(snapshot):
_events.setdefault("restore_skill_home_modules", []).append(
{"source": _caller_info(), "snapshot": snapshot, "args": (snapshot,)}
)
class _SentinelAgent:
def __init__(self, *args, **kwargs):
pass
def run_conversation(self, *args, **kwargs):
_events["run_conversation"] = True
raise RuntimeError("streaming test sentinel")
def _get_ai_agent():
return _SentinelAgent
def _discover_mcp_tools():
_events["discover_mcp_tools"] = _events.get("discover_mcp_tools", 0) + 1
monkeypatch.setattr(_streaming, "register_active_run", lambda *args, **kwargs: None)
monkeypatch.setattr(_streaming, "update_active_run", lambda *args, **kwargs: None)
monkeypatch.setattr(_streaming, "get_session", lambda sid: _Session())
monkeypatch.setattr(_streaming, "_get_session_agent_lock", lambda sid: contextlib.nullcontext())
monkeypatch.setattr(_streaming, "_set_streaming_hermes_home_override", _set_override)
monkeypatch.setattr(_streaming, "_reset_streaming_hermes_home_override", _reset_override)
monkeypatch.setattr(_streaming, "_set_thread_env", _set_thread_env)
monkeypatch.setattr(_streaming, "_prewarm_skill_tool_modules", lambda: None)
monkeypatch.setattr(_streaming, "_install_streaming_cronjob_profile_wrapper", lambda: None)
monkeypatch.setattr(_streaming, "_clear_thread_env", lambda: None)
monkeypatch.setattr(_streaming, "_get_ai_agent", _get_ai_agent)
monkeypatch.setattr(_streaming, "_materialize_pending_user_turn_before_error", lambda *a, **k: None)
monkeypatch.setattr(_streaming, "_snapshot_and_append_partial_on_error", lambda *a, **k: None)
monkeypatch.setattr(_streaming, "append_turn_journal_event_for_stream", lambda *a, **k: None)
monkeypatch.setattr(_streaming, "meter", lambda: _FakeMeter())
monkeypatch.setattr(_streaming, "RunJournalWriter", lambda *a, **k: None)
monkeypatch.setattr(_streaming, "_build_agent_thread_env", lambda *a, **k: {})
monkeypatch.setattr(
_streaming,
"resolve_model_provider",
lambda model_with_provider_context, explicitly_picked=None: (model_with_provider_context, None, None),
)
monkeypatch.setattr(_streaming, "_runtime_preferred_base_url", lambda rt, provider, configured_base_url: configured_base_url)
import api.profiles as profiles_api
monkeypatch.setattr(profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules)
monkeypatch.setattr(profiles_api, "patch_skill_home_modules", _patch_skill_home_modules)
monkeypatch.setattr(profiles_api, "restore_skill_home_modules", _restore_skill_home_modules)
fake_skills_tool = types.ModuleType('tools.skills_tool')
fake_skills_tool.HERMES_HOME = 'default-home'
fake_skills_tool.SKILLS_DIR = 'default-home/skills'
fake_skill_manager_tool = types.ModuleType('tools.skill_manager_tool')
fake_skill_manager_tool.HERMES_HOME = 'default-home'
fake_skill_manager_tool.SKILLS_DIR = 'default-home/skills'
monkeypatch.setitem(sys.modules, 'tools.skills_tool', fake_skills_tool)
monkeypatch.setitem(sys.modules, 'tools.skill_manager_tool', fake_skill_manager_tool)
_fake_mcp_module = types.ModuleType("tools.mcp_tool")
_fake_mcp_module.discover_mcp_tools = _discover_mcp_tools
monkeypatch.setitem(sys.modules, "tools.mcp_tool", _fake_mcp_module)
import api.config as _config_mod
monkeypatch.setattr(_config_mod, "_resolve_cli_toolsets", lambda cfg: [])
monkeypatch.setattr(_config_mod, "get_config_for_profile_home", lambda profile_home: {})
monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: _home)
monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {})
monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: {})
monkeypatch.setattr(
_streaming,
"_apply_profile_home_context_to_streaming_model",
lambda model, provider_context, profile_home, has_profile: (model, provider_context, False),
)
_streaming._run_agent_streaming(
session_id=_session_id,
msg_text="hi",
model="gpt-4",
workspace=str(_workspace),
stream_id=_stream_id,
)
assert _events.get("set_override_home") == str(_home)
assert _events.get("reset_override") == ("sentinel-module", None, True)
assert _events.get("run_conversation") is True
# Two call paths can reach the legacy skill-module helpers now:
# 1) upstream runtime-refresh model-resolution scope, and
# 2) the PR-owned streaming fallback branch at _run_agent_streaming.
# Assert that the streaming-owned fallback still executes once and fully
# restores the snapshot it captured.
_streaming_patch_calls = [
entry["source"]
for entry in _events.get("patch_skill_home_modules", [])
if entry["source"][0] == "streaming.py"
]
_streaming_snapshot_calls = [
entry["source"]
for entry in _events.get("snapshot_skill_home_modules", [])
if entry["source"][0] == "streaming.py"
]
_streaming_restore_calls = [
entry["snapshot"]
for entry in _events.get("restore_skill_home_modules", [])
if entry["source"][0] == "streaming.py"
]
assert len(_streaming_patch_calls) == 1
assert len(_streaming_snapshot_calls) == 1
assert _streaming_restore_calls == [{"snapshot": True}]
assert _stream_id not in _streaming.STREAMS
def test_profile_env_for_background_worker_uses_static_modules_fallback_when_dynamic_checks_fail(
tmp_path,
monkeypatch,
):
"""Background worker falls back to module patching when dynamic checks fail."""
import api.profiles as _profiles_api
profile_home = tmp_path / "legacy-home"
profile_home.mkdir(parents=True, exist_ok=True)
events = {}
fake_skill_module = types.ModuleType("tools.skills_tool")
fake_skill_module.HERMES_HOME = "default-home"
fake_skill_module.SKILLS_DIR = "default-home/skills"
fake_skill_module._SKILLS_DIR_AT_IMPORT = "default-home/skills"
fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool")
fake_skill_manager_module.HERMES_HOME = "default-home"
fake_skill_manager_module.SKILLS_DIR = "default-home/skills"
fake_skill_manager_module._SKILLS_DIR_AT_IMPORT = "default-home/skills"
monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module)
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module)
fake_constants = types.SimpleNamespace()
def _set_override(profile_home: str):
events["set_override_home"] = str(profile_home)
return None
def _reset_override(token):
events["reset_token"] = token
fake_constants.set_hermes_home_override = _set_override
fake_constants.reset_hermes_home_override = _reset_override
monkeypatch.setattr(_profiles_api, "_resolve_hermes_home_override", lambda: fake_constants)
monkeypatch.setattr(_profiles_api, "_hermes_home_override_available", True)
def _snapshot_skill_home_modules():
events["snapshot"] = True
return {"snapshot": True}
def _patch_skill_home_modules(*_):
events["patch"] = events.get("patch", 0) + 1
fake_skill_module.HERMES_HOME = profile_home
fake_skill_module.SKILLS_DIR = profile_home / "skills"
fake_skill_manager_module.HERMES_HOME = profile_home
fake_skill_manager_module.SKILLS_DIR = profile_home / "skills"
def _restore_skill_home_modules(snapshot):
events["restore"] = snapshot
fake_skill_module.HERMES_HOME = "default-home"
fake_skill_module.SKILLS_DIR = "default-home/skills"
fake_skill_manager_module.HERMES_HOME = "default-home"
fake_skill_manager_module.SKILLS_DIR = "default-home/skills"
monkeypatch.setattr(_profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules)
monkeypatch.setattr(_profiles_api, "patch_skill_home_modules", _patch_skill_home_modules)
monkeypatch.setattr(_profiles_api, "restore_skill_home_modules", _restore_skill_home_modules)
monkeypatch.setattr(_profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home)
monkeypatch.setattr(_profiles_api, "get_profile_runtime_env", lambda home: {})
monkeypatch.setattr(_profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env)
with _profiles_api.profile_env_for_background_worker("legacy", "legacy worker"):
assert fake_skill_module.HERMES_HOME == profile_home
assert fake_skill_module.SKILLS_DIR == profile_home / "skills"
assert fake_skill_manager_module.HERMES_HOME == profile_home
assert fake_skill_manager_module.SKILLS_DIR == profile_home / "skills"
assert events.get("snapshot") is True
assert events.get("patch") == 1
assert events.get("restore") == {"snapshot": True}
assert events.get("reset_token") is None
assert fake_skill_module.HERMES_HOME == "default-home"
assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills"
def test_profile_env_for_background_worker_serializes_static_module_scope_with_lock(tmp_path, monkeypatch):
"""`_SKILL_HOME_MODULE_PATCH_LOCK` serializes concurrent static module scopes."""
profile_alpha = tmp_path / "alpha"
profile_beta = tmp_path / "beta"
profile_alpha.mkdir()
profile_beta.mkdir()
fake_skill_module = types.ModuleType("tools.skills_tool")
fake_skill_module.HERMES_HOME = "default-home"
fake_skill_module.SKILLS_DIR = "default-home/skills"
fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool")
fake_skill_manager_module.HERMES_HOME = "default-home"
fake_skill_manager_module.SKILLS_DIR = "default-home/skills"
monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module)
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module)
events = {
"set": [],
"reset": [],
}
fake_constants = types.SimpleNamespace()
def _set_override(profile_home: str):
events["set"].append(str(profile_home))
return None
def _reset_override(reset_token):
events["reset"].append(reset_token)
fake_constants.set_hermes_home_override = _set_override
fake_constants.reset_hermes_home_override = _reset_override
monkeypatch.setattr(profiles_api, "_resolve_hermes_home_override", lambda: fake_constants)
monkeypatch.setattr(profiles_api, "_hermes_home_override_available", True)
monkeypatch.setattr(
profiles_api,
"get_hermes_home_for_profile",
lambda profile: profile_alpha if profile == "alpha" else profile_beta,
)
monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {})
monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env)
alpha_entered = threading.Event()
beta_entered = threading.Event()
alpha_release = threading.Event()
worker_errors: list[tuple[str, BaseException]] = []
def _worker_alpha() -> None:
try:
with profiles_api.profile_env_for_background_worker("alpha", "lock holder"):
assert fake_skill_module.HERMES_HOME == profile_alpha
assert fake_skill_module.SKILLS_DIR == profile_alpha / "skills"
assert fake_skill_manager_module.HERMES_HOME == profile_alpha
assert fake_skill_manager_module.SKILLS_DIR == profile_alpha / "skills"
alpha_entered.set()
assert alpha_release.wait(timeout=5)
raise RuntimeError("alpha worker sentinel")
except BaseException as exc:
worker_errors.append(("alpha", exc))
def _worker_beta() -> None:
try:
with profiles_api.profile_env_for_background_worker("beta", "lock waiter"):
beta_entered.set()
assert fake_skill_module.HERMES_HOME == profile_beta
assert fake_skill_module.SKILLS_DIR == profile_beta / "skills"
assert fake_skill_manager_module.HERMES_HOME == profile_beta
assert fake_skill_manager_module.SKILLS_DIR == profile_beta / "skills"
except BaseException as exc:
worker_errors.append(("beta", exc))
_thread_alpha = threading.Thread(target=_worker_alpha)
_thread_beta = threading.Thread(target=_worker_beta)
_thread_alpha.start()
assert alpha_entered.wait(timeout=5)
_thread_beta.start()
assert not beta_entered.wait(timeout=0.2)
alpha_release.set()
assert beta_entered.wait(timeout=5)
_thread_alpha.join(timeout=5)
_thread_beta.join(timeout=5)
assert not _thread_alpha.is_alive()
assert not _thread_beta.is_alive()
alpha_error = next((exc for name, exc in worker_errors if name == "alpha"), None)
beta_error = next((exc for name, exc in worker_errors if name == "beta"), None)
assert beta_error is None
assert isinstance(alpha_error, RuntimeError)
assert str(alpha_error) == "alpha worker sentinel"
assert fake_skill_module.HERMES_HOME == "default-home"
assert fake_skill_module.SKILLS_DIR == "default-home/skills"
assert fake_skill_manager_module.HERMES_HOME == "default-home"
assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills"
assert sorted(events["set"]) == [str(profile_alpha), str(profile_beta)]
assert len(events["reset"]) == 2
assert all(token is None for token in events["reset"])
with profiles_api.profile_env_for_background_worker("alpha", "same-thread follow up"):
assert fake_skill_module.HERMES_HOME == profile_alpha
assert fake_skill_module.SKILLS_DIR == profile_alpha / "skills"
assert fake_skill_manager_module.HERMES_HOME == profile_alpha
assert fake_skill_manager_module.SKILLS_DIR == profile_alpha / "skills"
assert fake_skill_module.HERMES_HOME == "default-home"
assert fake_skill_module.SKILLS_DIR == "default-home/skills"
assert fake_skill_manager_module.HERMES_HOME == "default-home"
assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills"
@pytest.mark.skipif(
not HAS_OVERRIDE,
reason="requires v0.18.0 context-local home override support",
)
def test_profile_env_for_background_worker_uses_real_modules_and_serializes_overlap(tmp_path, monkeypatch):
"""Real skill modules should serialize static fallback scopes across profiles."""
try:
import tools.skills_tool as skills_tool
import tools.skill_manager_tool as skill_manager_tool
except Exception as exc:
pytest.skip(f"hermes-agent skill modules unavailable for this environment: {exc}")
required_attrs = ("_SKILLS_DIR_AT_IMPORT", "SKILLS_DIR", "HERMES_HOME")
for _name, _mod in (
("tools.skills_tool", skills_tool),
("tools.skill_manager_tool", skill_manager_tool),
):
for _attr in required_attrs:
if not hasattr(_mod, _attr):
pytest.skip(f"{_name} missing {_attr}")
if not hasattr(skills_tool, "skills_list"):
pytest.skip("tools.skills_tool missing skills_list API")
alpha_home = tmp_path / "alpha"
beta_home = tmp_path / "beta"
alpha_home.mkdir()
beta_home.mkdir()
alpha_skill = f"alpha-only-{tmp_path.name}"
beta_skill = f"beta-only-{tmp_path.name}"
def _write_skill(home: Path, name: str) -> None:
skill_dir = home / "skills" / name
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
textwrap.dedent(
f"""\
---
name: {name}
description: skill for overlap test
---
skill body
"""
),
encoding="utf-8",
)
def _parse_skill_names(raw: object) -> set[str]:
payload = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw
data = json.loads(payload) if isinstance(payload, str) else payload
if isinstance(data, dict):
data = data.get("skills", [])
return {
item.get("name")
for item in data or []
if isinstance(item, dict) and item.get("name") is not None
}
_write_skill(alpha_home, alpha_skill)
_write_skill(beta_home, beta_skill)
baseline_override = hermes_constants.get_hermes_home_override()
baseline_env_home = os.environ.get("HERMES_HOME")
baseline_env_has = "HERMES_HOME" in os.environ
baseline_skill_dir = skills_tool.SKILLS_DIR
baseline_skill_home = getattr(skills_tool, "HERMES_HOME", None)
baseline_skill_manager_dir = skill_manager_tool.SKILLS_DIR
baseline_skill_manager_home = getattr(skill_manager_tool, "HERMES_HOME", None)
alpha_skills_dir = str(alpha_home / "skills")
# Start with both modules deliberately patched to alpha.
skills_tool.HERMES_HOME = str(alpha_home)
skills_tool.SKILLS_DIR = alpha_skills_dir
skill_manager_tool.HERMES_HOME = str(alpha_home)
skill_manager_tool.SKILLS_DIR = alpha_skills_dir
monkeypatch.setenv("HERMES_HOME", str(alpha_home))
monkeypatch.setattr(
profiles_api,
"get_hermes_home_for_profile",
lambda profile: alpha_home if profile == "alpha" else beta_home,
)
monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {})
monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env)
alpha_ready = threading.Event()
alpha_listed = threading.Event()
beta_started = threading.Event()
beta_entered = threading.Event()
release_alpha = threading.Event()
seen: dict[str, set[str]] = {}
observed: dict[str, dict[str, object]] = {}
worker_errors: list[tuple[str, BaseException]] = []
def _worker_alpha() -> None:
try:
with profiles_api.profile_env_for_background_worker("alpha", "real alpha worker"):
alpha_ready.set()
names = _parse_skill_names(skills_tool.skills_list())
seen["alpha"] = names
assert alpha_skill in names
assert beta_skill not in names
alpha_listed.set()
assert release_alpha.wait(timeout=5)
except BaseException as exc: # pragma: no cover - defensive
worker_errors.append(("alpha", exc))
finally:
observed["alpha"] = {
"override": hermes_constants.get_hermes_home_override(),
}
def _worker_beta() -> None:
try:
beta_started.set()
with profiles_api.profile_env_for_background_worker("beta", "real beta worker"):
beta_entered.set()
names = _parse_skill_names(skills_tool.skills_list())
seen["beta"] = names
assert beta_skill in names
assert alpha_skill not in names
except BaseException as exc: # pragma: no cover - defensive
worker_errors.append(("beta", exc))
finally:
observed["beta"] = {
"override": hermes_constants.get_hermes_home_override(),
}
thread_alpha = threading.Thread(target=_worker_alpha)
thread_beta = threading.Thread(target=_worker_beta)
try:
thread_alpha.start()
assert alpha_ready.wait(timeout=5), "alpha worker should enter first"
thread_beta.start()
assert beta_started.wait(timeout=5), "beta worker should attempt to start"
assert not beta_entered.wait(0.25), "beta must block on fallback scope lock while alpha holds it"
assert alpha_listed.wait(timeout=5), "alpha must list skills while beta is waiting"
release_alpha.set()
assert beta_entered.wait(timeout=5), "beta should enter after alpha exits"
thread_alpha.join(timeout=5)
thread_beta.join(timeout=5)
assert not thread_alpha.is_alive()
assert not thread_beta.is_alive()
assert not worker_errors
assert seen.get("alpha", set()) == {alpha_skill}
assert seen.get("beta", set()) == {beta_skill}
assert observed["alpha"]["override"] == baseline_override
assert observed["beta"]["override"] == baseline_override
assert hermes_constants.get_hermes_home_override() == baseline_override
assert skills_tool.HERMES_HOME == str(alpha_home)
assert skills_tool.SKILLS_DIR == alpha_skills_dir
assert skill_manager_tool.HERMES_HOME == str(alpha_home)
assert skill_manager_tool.SKILLS_DIR == alpha_skills_dir
assert os.environ.get("HERMES_HOME") == str(alpha_home)
finally:
thread_alpha.join(timeout=1)
thread_beta.join(timeout=1)
release_alpha.set()
if baseline_env_has:
if baseline_env_home is None:
os.environ.pop("HERMES_HOME", None)
else:
os.environ["HERMES_HOME"] = baseline_env_home
else:
os.environ.pop("HERMES_HOME", None)
skills_tool.SKILLS_DIR = baseline_skill_dir
if baseline_skill_home is not None:
skills_tool.HERMES_HOME = baseline_skill_home
else:
skills_tool.__dict__.pop("HERMES_HOME", None)
skill_manager_tool.SKILLS_DIR = baseline_skill_manager_dir
if baseline_skill_manager_home is not None:
skill_manager_tool.HERMES_HOME = baseline_skill_manager_home
else:
skill_manager_tool.__dict__.pop("HERMES_HOME", None)
def test_profile_env_for_background_worker_resets_override_when_dynamic_check_raises(
tmp_path,
monkeypatch,
):
"""A dynamic capability check exception must still clear the override state."""
import api.profiles as _profiles_api
profile_home = tmp_path / "legacy-home"
profile_home.mkdir(parents=True, exist_ok=True)
events = {}
fake_skill_module = types.ModuleType("tools.skills_tool")
fake_skill_module.HERMES_HOME = "default-home"
fake_skill_module.SKILLS_DIR = "default-home/skills"
fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool")
fake_skill_manager_module.HERMES_HOME = "default-home"
fake_skill_manager_module.SKILLS_DIR = "default-home/skills"
monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module)
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module)
fake_constants = types.SimpleNamespace()
def _set_override(profile_home: str):
events["set"] = str(profile_home)
return "override-token"
def _reset_override(reset_token):
events["reset"] = reset_token
fake_constants.set_hermes_home_override = _set_override
fake_constants.reset_hermes_home_override = _reset_override
monkeypatch.setattr(_profiles_api, "_resolve_hermes_home_override", lambda: fake_constants)
monkeypatch.setattr(_profiles_api, "_hermes_home_override_available", True)
def _snapshot_skill_home_modules():
events["snapshot"] = True
return {"snapshot": True}
def _patch_skill_home_modules(*_):
events["patch"] = events.get("patch", 0) + 1
fake_skill_module.HERMES_HOME = profile_home
fake_skill_module.SKILLS_DIR = profile_home / "skills"
fake_skill_manager_module.HERMES_HOME = profile_home
fake_skill_manager_module.SKILLS_DIR = profile_home / "skills"
def _restore_skill_home_modules(snapshot):
events["restore"] = snapshot
fake_skill_module.HERMES_HOME = "default-home"
fake_skill_module.SKILLS_DIR = "default-home/skills"
fake_skill_manager_module.HERMES_HOME = "default-home"
fake_skill_manager_module.SKILLS_DIR = "default-home/skills"
def _raise(*_):
raise RuntimeError("dynamic capability probe failed")
monkeypatch.setattr(_profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules)
monkeypatch.setattr(_profiles_api, "patch_skill_home_modules", _patch_skill_home_modules)
monkeypatch.setattr(_profiles_api, "restore_skill_home_modules", _restore_skill_home_modules)
monkeypatch.setattr(_profiles_api, "_skill_modules_support_profile_home", _raise)
monkeypatch.setattr(_profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home)
monkeypatch.setattr(_profiles_api, "get_profile_runtime_env", lambda home: {})
monkeypatch.setattr(_profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env)
with _profiles_api.profile_env_for_background_worker("legacy", "legacy worker"):
assert fake_skill_module.HERMES_HOME == profile_home
assert fake_skill_module.SKILLS_DIR == profile_home / "skills"
assert events.get("set") == str(profile_home)
assert events.get("reset") == "override-token"
assert events.get("snapshot") is True
assert events.get("patch") == 1
assert events.get("restore") == {"snapshot": True}
assert fake_skill_module.HERMES_HOME == "default-home"
assert fake_skill_module.SKILLS_DIR == "default-home/skills"
assert fake_skill_manager_module.HERMES_HOME == "default-home"
assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills"
@pytest.mark.skipif(
not HAS_OVERRIDE,
reason="requires v0.18.0 context-local home override support",
)
def test_run_agent_streaming_override_helpers_with_concurrent_skills_list_workers(tmp_path, monkeypatch):
"""Foreground streaming override and background profile scope resolve skills correctly.
Foreground: call the streaming override helper for alpha and then clobber
process env to beta.
Background: run `profile_env_for_background_worker('beta', ...)` and then
also clobber env to alpha.
Both threads call real `tools.skills_tool.skills_list()` and should see their
own profile's skill directory.
"""
import api.streaming as _streaming
try:
import tools.skills_tool as skills_tool
import tools.skill_manager_tool as skill_manager_tool
except Exception as exc: # pragma: no cover - hermes-agent dependency probe
pytest.skip(f"hermes-agent skill modules unavailable for this environment: {exc}")
_home_alpha = tmp_path / "alpha"
_home_beta = tmp_path / "beta"
_home_alpha.mkdir()
_home_beta.mkdir()
alpha_name = f"alpha-skill-{tmp_path.name}"
beta_name = f"beta-skill-{tmp_path.name}"
def _write_skill_dir(root: Path, name: str) -> None:
skill_dir = root / "skills" / name
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
textwrap.dedent(
f"""\
---
name: {name}
description: {name} description
---
skill body
"""
),
encoding="utf-8",
)
_write_skill_dir(_home_alpha, alpha_name)
_write_skill_dir(_home_beta, beta_name)
_baseline_override = hermes_constants.get_hermes_home_override()
_baseline_has_env = "HERMES_HOME" in os.environ
_baseline_env = os.environ.get("HERMES_HOME")
_baseline_skill_dir = skills_tool.SKILLS_DIR
_baseline_skill_home = getattr(skills_tool, "HERMES_HOME", None)
_baseline_manager_skill_dir = skill_manager_tool.SKILLS_DIR
_baseline_manager_skill_home = getattr(skill_manager_tool, "HERMES_HOME", None)
# Force deterministic resolution path independent of previous suite side effects.
skills_tool.SKILLS_DIR = skills_tool._SKILLS_DIR_AT_IMPORT
if _baseline_skill_home is not None:
skills_tool.HERMES_HOME = _baseline_skill_home
skill_manager_tool.SKILLS_DIR = getattr(
skill_manager_tool,
"_SKILLS_DIR_AT_IMPORT",
_baseline_manager_skill_dir,
)
if _baseline_manager_skill_home is not None:
skill_manager_tool.HERMES_HOME = _baseline_manager_skill_home
os.environ["HERMES_HOME"] = str(_baseline_skill_home) if _baseline_skill_home else ""
def _get_profile_home(name: str) -> Path:
if name == "beta":
return _home_beta
if name == "alpha":
return _home_alpha
return _home_alpha
# Keep the helper and module state deterministic for this threaded race test.
assert _baseline_override is None
_baseline_skills_tuple = (skills_tool._SKILLS_DIR_AT_IMPORT, _baseline_skill_home)
_baseline_manager_tuple = (
getattr(skill_manager_tool, "_SKILLS_DIR_AT_IMPORT", _baseline_manager_skill_dir),
_baseline_manager_skill_home,
)
monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", _get_profile_home)
start_barrier = threading.Barrier(2)
clobber_barrier = threading.Barrier(2)
_results: dict[str, set[str]] = {}
_worker_errors: list[tuple[str, BaseException]] = []
_lock = threading.Lock()
_post_reset_overrides: dict[str, object | None] = {}
_post_reset_skill_dirs: dict[str, Path] = {}
_post_reset_skill_homes: dict[str, object | None] = {}
_post_reset_manager_skill_dirs: dict[str, Path] = {}
_post_reset_manager_skill_homes: dict[str, object | None] = {}
_beta_scope_snapshots: dict[str, dict[str, tuple[object | None, object | None]]] = {}
def _parse_skills(raw: str) -> set[str]:
payload = json.loads(raw)
return {
item.get("name")
for item in payload.get("skills", [])
if isinstance(item, dict) and "name" in item
}
def _worker_alpha() -> None:
mod_ctx, reset_token, override_installed = _streaming._set_streaming_hermes_home_override(
str(_home_alpha)
)
try:
start_barrier.wait(timeout=5)
os.environ["HERMES_HOME"] = str(_home_beta)
clobber_barrier.wait(timeout=5)
names = _parse_skills(skills_tool.skills_list())
with _lock:
_results["alpha"] = names
except BaseException as exc:
with _lock:
_worker_errors.append(("alpha", exc))
finally:
_streaming._reset_streaming_hermes_home_override(
mod_ctx,
reset_token,
override_installed,
)
with _lock:
_post_reset_overrides["alpha"] = hermes_constants.get_hermes_home_override()
_post_reset_skill_dirs["alpha"] = skills_tool.SKILLS_DIR
_post_reset_skill_homes["alpha"] = getattr(skills_tool, "HERMES_HOME", None)
_post_reset_manager_skill_dirs["alpha"] = skill_manager_tool.SKILLS_DIR
_post_reset_manager_skill_homes["alpha"] = getattr(skill_manager_tool, "HERMES_HOME", None)
def _worker_beta() -> None:
pre_scope = (
skills_tool.SKILLS_DIR,
getattr(skills_tool, "HERMES_HOME", None),
)
try:
with profiles_api.profile_env_for_background_worker(
"beta",
"test-streaming-skill-list",
):
start_barrier.wait(timeout=5)
os.environ["HERMES_HOME"] = str(_home_alpha)
clobber_barrier.wait(timeout=5)
names = _parse_skills(skills_tool.skills_list())
with _lock:
_results["beta"] = names
_beta_scope_snapshots["beta"] = {
"pre": pre_scope,
"during": (
skills_tool.SKILLS_DIR,
getattr(skills_tool, "HERMES_HOME", None),
),
}
except BaseException as exc:
with _lock:
_worker_errors.append(("beta", exc))
finally:
with _lock:
_post_reset_overrides["beta"] = hermes_constants.get_hermes_home_override()
_post_reset_skill_dirs["beta"] = skills_tool.SKILLS_DIR
_post_reset_skill_homes["beta"] = getattr(skills_tool, "HERMES_HOME", None)
_post_reset_manager_skill_dirs["beta"] = skill_manager_tool.SKILLS_DIR
_post_reset_manager_skill_homes["beta"] = getattr(skill_manager_tool, "HERMES_HOME", None)
_thread_alpha = threading.Thread(target=_worker_alpha)
_thread_beta = threading.Thread(target=_worker_beta)
_thread_alpha.start()
_thread_beta.start()
_thread_alpha.join(timeout=10)
_thread_beta.join(timeout=10)
try:
assert not _thread_alpha.is_alive()
assert not _thread_beta.is_alive()
assert _results.get("alpha") is not None
assert _results.get("beta") is not None
if _worker_errors:
raise _worker_errors[0][1]
alpha_names = _results.get("alpha", set())
beta_names = _results.get("beta", set())
assert alpha_name in alpha_names
assert beta_name not in alpha_names
assert beta_name in beta_names
assert alpha_name not in beta_names
assert _post_reset_overrides["alpha"] == _baseline_override
assert _post_reset_overrides["beta"] == _baseline_override
assert _post_reset_skill_dirs["alpha"] == skills_tool._SKILLS_DIR_AT_IMPORT
assert _post_reset_skill_dirs["beta"] == skills_tool._SKILLS_DIR_AT_IMPORT
assert _post_reset_manager_skill_dirs["alpha"] == _baseline_manager_tuple[0]
assert _post_reset_manager_skill_dirs["beta"] == _baseline_manager_tuple[0]
assert _post_reset_skill_homes["alpha"] == _baseline_skill_home
assert _post_reset_skill_homes["beta"] == _baseline_skill_home
assert _post_reset_manager_skill_homes["alpha"] == _baseline_manager_skill_home
assert _post_reset_manager_skill_homes["beta"] == _baseline_manager_skill_home
beta_snapshot = _beta_scope_snapshots.get("beta")
assert beta_snapshot is not None
assert beta_snapshot["pre"] == _baseline_skills_tuple
assert beta_snapshot["during"] == _baseline_skills_tuple
finally:
if _baseline_has_env:
os.environ["HERMES_HOME"] = _baseline_env or ""
else:
os.environ.pop("HERMES_HOME", None)
skills_tool.SKILLS_DIR = _baseline_skill_dir
if _baseline_skill_home is not None:
skills_tool.HERMES_HOME = _baseline_skill_home
skill_manager_tool.SKILLS_DIR = _baseline_manager_skill_dir
if _baseline_manager_skill_home is not None:
skill_manager_tool.HERMES_HOME = _baseline_manager_skill_home