Files
hermes-webui/tests/test_profile_switch_1200.py
T
2026-05-06 06:27:00 +00:00

615 lines
22 KiB
Python

"""
Tests for profile-switch workspace and model fixes (#1200).
Bug 1: switch_profile(process_wide=False) returned the OLD profile's workspace
because get_last_workspace() reads via get_active_profile_name() (TLS/global)
rather than directly from the target profile's home directory.
Bug 2: /api/models returned stale results after a profile switch because the
in-memory model cache (_available_models_cache) was not invalidated.
These tests verify both fixes.
"""
import os
import json
import tempfile
import textwrap
from pathlib import Path
def test_switch_profile_returns_target_workspace_not_current(tmp_path, monkeypatch):
"""
switch_profile(process_wide=False) must return the TARGET profile's workspace,
not the currently-active profile's workspace.
Before the fix, get_last_workspace() was called at the end of switch_profile(),
and it routed through get_active_profile_name() which still pointed to the OLD
profile during a process_wide=False switch. This caused the wrong workspace to
be returned and displayed in the UI immediately after switching.
"""
import api.profiles as profiles
# Build fake profile structure
default_home = tmp_path / '.hermes'
default_home.mkdir()
ayan_home = default_home / 'profiles' / 'ayan'
ayan_home.mkdir(parents=True)
# Give ayan a terminal.cwd config (common case)
ayan_workspace = tmp_path / 'ayan_workspace'
ayan_workspace.mkdir()
ayan_config = ayan_home / 'config.yaml'
ayan_config.write_text(
f'model:\n default: kimi-k2-instruct\n provider: nous\n'
f'terminal:\n cwd: {ayan_workspace}\n',
encoding='utf-8',
)
# Give default profile a different workspace stored in last_workspace.txt
default_ws = tmp_path / 'default_workspace'
default_ws.mkdir()
default_state = default_home / 'webui_state'
default_state.mkdir()
(default_state / 'last_workspace.txt').write_text(str(default_ws), encoding='utf-8')
# Patch _DEFAULT_HERMES_HOME to our tmp dir
orig_default = profiles._DEFAULT_HERMES_HOME
profiles._DEFAULT_HERMES_HOME = default_home
# Ensure _active_profile = 'default'
orig_active = profiles._active_profile
profiles._active_profile = 'default'
# Clear TLS
profiles._tls.profile = None
try:
result = profiles.switch_profile('ayan', process_wide=False)
ws = result.get('default_workspace', '')
# Must be ayan's workspace, NOT default's workspace
assert str(ayan_workspace) in ws or ayan_workspace.resolve() == Path(ws), (
f"Expected ayan's workspace ({ayan_workspace}), got: {ws}"
)
assert str(default_ws) not in ws, (
f"Returned default profile workspace ({default_ws}) instead of ayan's"
)
finally:
profiles._DEFAULT_HERMES_HOME = orig_default
profiles._active_profile = orig_active
profiles._tls.profile = None
def test_switch_profile_uses_last_workspace_txt_over_config(tmp_path, monkeypatch):
"""
If a profile has a last_workspace.txt (previously chosen workspace),
that takes priority over terminal.cwd in config.yaml.
"""
import api.profiles as profiles
default_home = tmp_path / '.hermes'
default_home.mkdir()
target_home = default_home / 'profiles' / 'myprofile'
target_home.mkdir(parents=True)
# config.yaml has terminal.cwd
cfg_ws = tmp_path / 'cfg_workspace'
cfg_ws.mkdir()
(target_home / 'config.yaml').write_text(
f'terminal:\n cwd: {cfg_ws}\n', encoding='utf-8',
)
# last_workspace.txt overrides it
explicit_ws = tmp_path / 'explicit_workspace'
explicit_ws.mkdir()
state_dir = target_home / 'webui_state'
state_dir.mkdir()
(state_dir / 'last_workspace.txt').write_text(str(explicit_ws), encoding='utf-8')
orig_default = profiles._DEFAULT_HERMES_HOME
profiles._DEFAULT_HERMES_HOME = default_home
orig_active = profiles._active_profile
profiles._active_profile = 'default'
profiles._tls.profile = None
try:
result = profiles.switch_profile('myprofile', process_wide=False)
ws = result.get('default_workspace', '')
assert str(explicit_ws) in ws or Path(ws) == explicit_ws.resolve(), (
f"Expected last_workspace.txt ({explicit_ws}), got: {ws}"
)
assert str(cfg_ws) not in ws, (
f"terminal.cwd ({cfg_ws}) should not override last_workspace.txt"
)
finally:
profiles._DEFAULT_HERMES_HOME = orig_default
profiles._active_profile = orig_active
profiles._tls.profile = None
def test_switch_profile_process_wide_false_returns_correct_model(tmp_path, monkeypatch):
"""
switch_profile(process_wide=False) reads the default model from the TARGET
profile's config.yaml directly (not from the process-global _cfg_cache).
"""
import api.profiles as profiles
default_home = tmp_path / '.hermes'
default_home.mkdir()
target_home = default_home / 'profiles' / 'aiprofile'
target_home.mkdir(parents=True)
target_ws = tmp_path / 'ai_ws'
target_ws.mkdir()
(target_home / 'config.yaml').write_text(
f'model:\n default: kimi-k2-instruct\n provider: nous\n'
f'terminal:\n cwd: {target_ws}\n',
encoding='utf-8',
)
orig_default = profiles._DEFAULT_HERMES_HOME
profiles._DEFAULT_HERMES_HOME = default_home
orig_active = profiles._active_profile
profiles._active_profile = 'default'
profiles._tls.profile = None
try:
result = profiles.switch_profile('aiprofile', process_wide=False)
assert result.get('default_model') == 'kimi-k2-instruct', (
f"Expected 'kimi-k2-instruct', got: {result.get('default_model')!r}"
)
finally:
profiles._DEFAULT_HERMES_HOME = orig_default
profiles._active_profile = orig_active
profiles._tls.profile = None
def test_profile_switch_route_invalidates_models_cache(tmp_path):
"""
After a profile switch, the model cache must be invalidated so the next
/api/models request rebuilds from the new profile's config.
This is a unit test verifying that invalidate_models_cache() is called
as part of the /api/profile/switch response flow.
"""
from api.config import invalidate_models_cache, _available_models_cache_lock
import api.config as config_module
# Seed a non-None cache value to simulate a populated cache
with _available_models_cache_lock:
config_module._available_models_cache = {
'active_provider': 'old_provider',
'default_model': 'old-model',
'groups': [],
}
config_module._available_models_cache_ts = 9999999.0
# Verify it's non-None before
assert config_module._available_models_cache is not None
# Call invalidate (the same function called by the route handler)
invalidate_models_cache()
# Must be None after invalidation
assert config_module._available_models_cache is None, (
"invalidate_models_cache() must clear _available_models_cache"
)
assert config_module._available_models_cache_ts == 0.0
"""
Test that syncTopbar() updates the profile chip label even when S.session is null.
Bug: The profile chip label (profileChipLabel) was only updated in the session-present
path of syncTopbar(). When S.session is null (fresh page / after profile switch with
no active session), the early-return branch ran without updating the chip.
This caused the chip to keep showing the old profile name after switchToProfile().
"""
import re
def test_syncTopbar_early_return_updates_profile_chip():
"""
syncTopbar() must update profileChipLabel inside the !S.session early-return block.
Without this, the composer profile chip stays stale when there is no active session.
"""
from pathlib import Path
ui_js = (Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
# Find the syncTopbar function
fn_start = ui_js.find("function syncTopbar(){")
assert fn_start != -1, "syncTopbar function not found in ui.js"
# Find the early-return block (!S.session branch)
early_ret_start = ui_js.find("if(!S.session){", fn_start)
assert early_ret_start != -1, "!S.session early-return block not found in syncTopbar"
# Find where the early return ends (the closing brace + return)
early_ret_end = ui_js.find("return;", early_ret_start)
assert early_ret_end != -1
early_block = ui_js[early_ret_start : early_ret_end + len("return;")]
# The profile chip update must be inside this early-return block
assert "profileChipLabel" in early_block, (
"syncTopbar() early-return block (!S.session) must update profileChipLabel. "
"Without this, switching profiles with no active session leaves the chip stale."
)
assert "S.activeProfile" in early_block, (
"profileChipLabel update in early-return block must read S.activeProfile"
)
# ── Regression guard tests ────────────────────────────────────────────────────
# These tests exist to catch future regressions in profile switching behavior.
# Each one corresponds to a specific bug that was fixed in the #1200 PR.
def test_regression_switch_profile_default_workspace_not_from_process_global(tmp_path, monkeypatch):
"""
REGRESSION GUARD (#1200 Bug 1): switch_profile(process_wide=False) must NOT
return the active (old) profile's workspace via get_last_workspace().
This test proves the fix by setting up a scenario where the old profile has
a known workspace in last_workspace.txt and the target profile has a DIFFERENT
workspace. If the regression returns, this test fails.
"""
import api.profiles as profiles
base = tmp_path / ".hermes"
base.mkdir()
# Old profile (default) has workspace A
old_ws = tmp_path / "old_workspace"
old_ws.mkdir()
default_state = base / "webui_state"
default_state.mkdir()
(default_state / "last_workspace.txt").write_text(str(old_ws), encoding="utf-8")
# Target profile has workspace B
new_ws = tmp_path / "new_workspace"
new_ws.mkdir()
target_home = base / "profiles" / "target"
target_home.mkdir(parents=True)
target_state = target_home / "webui_state"
target_state.mkdir()
(target_state / "last_workspace.txt").write_text(str(new_ws), encoding="utf-8")
(target_home / "config.yaml").write_text(
"model:\n default: some-model\n", encoding="utf-8"
)
orig_default = profiles._DEFAULT_HERMES_HOME
orig_active = profiles._active_profile
profiles._DEFAULT_HERMES_HOME = base
profiles._active_profile = "default"
profiles._tls.profile = None
try:
result = profiles.switch_profile("target", process_wide=False)
ws = result.get("default_workspace", "")
# Must be NEW workspace, not OLD
assert str(new_ws) in ws or str(new_ws.resolve()) == ws, (
f"REGRESSION: Got old workspace ({old_ws}) instead of target ({new_ws}). "
"switch_profile() is reading from the wrong profile."
)
assert str(old_ws) not in ws, (
f"REGRESSION: Returned old profile workspace. Bug 1 regressed."
)
finally:
profiles._DEFAULT_HERMES_HOME = orig_default
profiles._active_profile = orig_active
profiles._tls.profile = None
def test_regression_models_cache_cleared_on_profile_switch():
"""
REGRESSION GUARD (#1200 Bug 2): the model cache must be invalidated after
a profile switch so the next /api/models returns the new profile's models.
Without the invalidate_models_cache() call in the route handler, a populated
cache from the old profile would be served unchanged.
"""
import api.config as config_module
from api.config import invalidate_models_cache, _available_models_cache_lock
# Seed cache with "stale" data
stale = {"active_provider": "stale", "default_model": "stale-model", "groups": []}
with _available_models_cache_lock:
config_module._available_models_cache = stale
config_module._available_models_cache_ts = 9_999_999.0
# Simulate what the route handler does
invalidate_models_cache()
# Cache must be cleared
assert config_module._available_models_cache is None, (
"REGRESSION: model cache not cleared after profile switch. Bug 2 regressed."
)
def test_regression_synctopbar_early_return_updates_profile_chip():
"""
REGRESSION GUARD (#1200 Bug 3): the syncTopbar() early-return branch (when
S.session is null) must update the profileChipLabel.
If this fix is reverted, the profile chip stays on the old profile name even
though S.activeProfile has been updated, because syncTopbar() exits early
before reaching the chip-update code at the end of the function.
"""
from pathlib import Path
ui_js = (Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
fn_start = ui_js.find("function syncTopbar(){")
assert fn_start != -1, "syncTopbar not found — has it been renamed?"
early_start = ui_js.find("if(!S.session){", fn_start)
assert early_start != -1, "!S.session early-return block not found in syncTopbar"
early_end = ui_js.find("return;", early_start)
assert early_end != -1, "return; not found after !S.session block"
early_block = ui_js[early_start : early_end + len("return;")]
assert "profileChipLabel" in early_block, (
"REGRESSION: syncTopbar() early-return no longer updates profileChipLabel. "
"Profile name chip won't update after switching profiles with no active session. "
"Bug 3 regressed."
)
def test_regression_switch_profile_returns_target_model():
"""
REGRESSION GUARD (#1200): switch_profile(process_wide=False) must return the
target profile's default model, not the process-global cached model.
"""
import api.profiles as profiles
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp)
target = base / "profiles" / "myp"
target.mkdir(parents=True)
ws = base / "mypws"; ws.mkdir()
(target / "config.yaml").write_text(
f"model:\n default: my-target-model\nterminal:\n cwd: {ws}\n",
encoding="utf-8",
)
orig = profiles._DEFAULT_HERMES_HOME
orig_act = profiles._active_profile
profiles._DEFAULT_HERMES_HOME = base
profiles._active_profile = "default"
profiles._tls.profile = None
try:
r = profiles.switch_profile("myp", process_wide=False)
assert r.get("default_model") == "my-target-model", (
f"REGRESSION: Got {r.get('default_model')!r} instead of 'my-target-model'. "
"switch_profile() is not reading from target profile's config. Bug 2 regressed."
)
finally:
profiles._DEFAULT_HERMES_HOME = orig
profiles._active_profile = orig_act
profiles._tls.profile = None
def test_get_config_reloads_when_request_profile_changes(tmp_path, monkeypatch):
"""get_config() must follow the per-request profile, not stale global cache."""
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",
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._cfg_cache.clear()
config._cfg_mtime = 0.0
if hasattr(config, "_cfg_path"):
config._cfg_path = None
if hasattr(config, "_cfg_fingerprint"):
config._cfg_fingerprint = None
assert config.get_config()["model"]["provider"] == "openai-codex"
profiles.set_request_profile("work")
assert config._get_config_path() == work_home / "config.yaml"
assert config.get_config()["model"]["provider"] == "openrouter"
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
class FakeSession:
def __init__(self):
self.session_id = "sid-profile-switch"
self.profile = "default"
self.workspace = str(tmp_path)
self.model = "google/gemini-3-flash-preview"
self.model_provider = "openrouter"
self.messages = []
self.context_messages = []
self.tool_calls = []
self.active_stream_id = None
self.pending_user_message = None
self.pending_attachments = []
self.pending_started_at = None
self.saved = False
def save(self):
self.saved = True
fake = FakeSession()
monkeypatch.setattr(routes, "get_session", lambda sid: fake)
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda path: tmp_path)
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
lambda model, provider: (model, provider, False),
)
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: object())
started_threads = []
class FakeThread:
def __init__(self, *args, **kwargs):
started_threads.append((args, kwargs))
def start(self):
pass
monkeypatch.setattr(routes.threading, "Thread", FakeThread)
payloads = []
class Handler:
pass
def fake_j(handler, payload, status=200, **kwargs):
payloads.append((status, payload))
return payload
monkeypatch.setattr(routes, "j", fake_j)
body = {
"session_id": fake.session_id,
"message": "hello",
"workspace": str(tmp_path),
"model": fake.model,
"model_provider": fake.model_provider,
"profile": "work",
}
routes._handle_chat_start(Handler(), body)
assert fake.profile == "work"
assert fake.saved is True
assert started_threads, "chat_start should launch the stream after retagging"
assert payloads and payloads[-1][0] == 200
def test_chat_start_does_not_retag_non_empty_session(monkeypatch, tmp_path):
"""Profile retagging is limited to empty placeholder sessions."""
import api.routes as routes
class FakeSession:
def __init__(self):
self.session_id = "sid-profile-switch-non-empty"
self.profile = "default"
self.workspace = str(tmp_path)
self.model = "google/gemini-3-flash-preview"
self.model_provider = "openrouter"
self.messages = [{"role": "user", "content": "previous turn"}]
self.context_messages = []
self.tool_calls = []
self.active_stream_id = None
self.pending_user_message = None
self.pending_attachments = []
self.pending_started_at = None
self.saved = False
def save(self):
self.saved = True
fake = FakeSession()
monkeypatch.setattr(routes, "get_session", lambda sid: fake)
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda path: tmp_path)
monkeypatch.setattr(
routes,
"_resolve_compatible_session_model_state",
lambda model, provider: (model, provider, False),
)
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: object())
class FakeThread:
def __init__(self, *args, **kwargs):
pass
def start(self):
pass
monkeypatch.setattr(routes.threading, "Thread", FakeThread)
monkeypatch.setattr(routes, "j", lambda handler, payload, status=200, **kwargs: payload)
routes._handle_chat_start(
object(),
{
"session_id": fake.session_id,
"message": "hello",
"workspace": str(tmp_path),
"model": fake.model,
"model_provider": fake.model_provider,
"profile": "work",
},
)
assert fake.profile == "default"
assert fake.saved is True
def test_chat_start_rejects_invalid_request_profile(monkeypatch):
"""chat_start validates the optional profile payload before retagging."""
import api.routes as routes
class FakeSession:
profile = "default"
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
errors = []
def fake_bad(handler, message, status=400):
errors.append((message, status))
return {"error": message}
monkeypatch.setattr(routes, "bad", fake_bad)
result = routes._handle_chat_start(
object(),
{
"session_id": "sid-invalid-profile",
"message": "hello",
"profile": "../etc",
},
)
assert result == {"error": "invalid profile"}
assert errors == [("invalid profile", 400)]