diff --git a/api/profiles.py b/api/profiles.py index fe0e1023..f9744c27 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -679,13 +679,18 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: # Import here to avoid circular import at module load from api.config import STREAMS, STREAMS_LOCK, reload_config - # Block if agent is running - with STREAMS_LOCK: - if len(STREAMS) > 0: - raise RuntimeError( - 'Cannot switch profiles while an agent is running. ' - 'Cancel or wait for it to finish.' - ) + # Process-wide profile switches mutate HERMES_HOME, module-level path caches, + # os.environ-backed .env keys, and the global config cache. Keep those blocked + # while any agent stream is active. Per-client WebUI switches are cookie/TLS + # scoped (process_wide=False) and do not mutate those globals, so users can + # leave a running session in one profile and start work in another (#1700). + if process_wide: + with STREAMS_LOCK: + if len(STREAMS) > 0: + raise RuntimeError( + 'Cannot switch profiles while an agent is running. ' + 'Cancel or wait for it to finish.' + ) # Resolve profile directory if _is_root_profile(name): diff --git a/docs/pr-media/1700/profile-switch-away-from-running-session.png b/docs/pr-media/1700/profile-switch-away-from-running-session.png new file mode 100644 index 00000000..a6c1f178 Binary files /dev/null and b/docs/pr-media/1700/profile-switch-away-from-running-session.png differ diff --git a/static/panels.js b/static/panels.js index c07e6229..12531130 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3766,7 +3766,9 @@ window.addEventListener('resize',()=>{ }); async function switchToProfile(name) { - if (S.busy) { showToast(t('profiles_busy_switch')); return; } + // Profile switches are per-client cookie/TLS scoped, so a running stream in + // the current session can safely continue while this tab moves to another + // profile. The in-flight session stays attached to its original profile. // ── Loading indicator ─────────────────────────────────────────────────── // Show spinner on the profile chip immediately so the user gets visual @@ -3781,7 +3783,11 @@ async function switchToProfile(name) { // Determine whether the current session has any messages. // A session with messages is "in progress" and belongs to the current profile — // we must not retag it. We'll start a fresh session for the new profile instead. - const sessionInProgress = S.session && S.messages && S.messages.length > 0; + const sessionInProgress = S.session && ( + (S.messages && S.messages.length > 0) || + S.session.active_stream_id || + S.session.pending_user_message + ); try { const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) }); diff --git a/tests/test_issue1700_parallel_profile_switch.py b/tests/test_issue1700_parallel_profile_switch.py new file mode 100644 index 00000000..7928b9a7 --- /dev/null +++ b/tests/test_issue1700_parallel_profile_switch.py @@ -0,0 +1,95 @@ +"""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