"""Regression coverage for issue #1700 parallel profile switching. A WebUI profile switch uses cookie/thread-local profile state, so it should be allowed while another session is streaming. Only process-wide profile switches must remain blocked because they mutate global Hermes runtime state. """ from pathlib import Path import pytest REPO_ROOT = Path(__file__).parent.parent.resolve() PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8") def _extract_switch_to_profile() -> str: marker = "async function switchToProfile(name) {" idx = PANELS_JS.find(marker) assert idx != -1, "switchToProfile() not found in static/panels.js" depth = 0 for i, ch in enumerate(PANELS_JS[idx:], idx): if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return PANELS_JS[idx : i + 1] raise AssertionError("Could not extract switchToProfile() body") def _prepare_profile_tree(tmp_path, monkeypatch): import api.profiles as profiles default_home = tmp_path / ".hermes" target_home = default_home / "profiles" / "writer" target_workspace = tmp_path / "writer-workspace" target_workspace.mkdir(parents=True) target_home.mkdir(parents=True) (target_home / "config.yaml").write_text( f"model:\n provider: openai-codex\n default: gpt-5.5\n" f"terminal:\n cwd: {target_workspace}\n", encoding="utf-8", ) monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home) monkeypatch.setattr(profiles, "_active_profile", "default") monkeypatch.setattr(profiles, "list_profiles_api", lambda: [{"name": "default"}, {"name": "writer"}]) profiles._tls.profile = None return profiles def test_process_wide_switch_still_blocks_when_stream_is_active(tmp_path, monkeypatch): profiles = _prepare_profile_tree(tmp_path, monkeypatch) from api.config import STREAMS STREAMS.clear() STREAMS["stream-default"] = object() try: with pytest.raises(RuntimeError, match="Cannot switch profiles while an agent is running"): profiles.switch_profile("writer", process_wide=True) finally: STREAMS.clear() profiles._tls.profile = None def test_per_client_switch_allowed_when_stream_is_active(tmp_path, monkeypatch): profiles = _prepare_profile_tree(tmp_path, monkeypatch) from api.config import STREAMS STREAMS.clear() STREAMS["stream-default"] = object() try: result = profiles.switch_profile("writer", process_wide=False) finally: STREAMS.clear() profiles._tls.profile = None assert result["active"] == "writer" assert result["default_model"] == "gpt-5.5" def test_frontend_profile_switch_no_longer_blocks_on_busy_state(): fn = _extract_switch_to_profile() assert "profiles_busy_switch" not in fn assert "if (S.busy)" not in fn assert "Profile switches are per-client cookie/TLS scoped" in fn def test_frontend_treats_active_or_pending_session_as_in_progress(): fn = _extract_switch_to_profile() session_block = fn[fn.find("const sessionInProgress") : fn.find("try {", fn.find("const sessionInProgress"))] assert "S.session.active_stream_id" in session_block assert "S.session.pending_user_message" in session_block assert "S.messages.length > 0" in session_block