fix: allow profile switching during active streams

This commit is contained in:
Michael Lam
2026-05-06 09:05:25 -07:00
committed by test
parent acc76a500c
commit fdd6b83acb
4 changed files with 115 additions and 9 deletions

View File

@@ -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):

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -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 }) });

View File

@@ -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