fix: allow profile switching during active streams
This commit is contained in:
@@ -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):
|
||||
|
||||
BIN
docs/pr-media/1700/profile-switch-away-from-running-session.png
Normal file
BIN
docs/pr-media/1700/profile-switch-away-from-running-session.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
@@ -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 }) });
|
||||
|
||||
95
tests/test_issue1700_parallel_profile_switch.py
Normal file
95
tests/test_issue1700_parallel_profile_switch.py
Normal 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
|
||||
Reference in New Issue
Block a user