Files
hermes-webui/tests/test_issue4755_profile_default_workspace.py
T
ruizanthonyandAnthony Ruiz eee72f942e fix(workspace): new chat inherits the current conversation's workspace (#7180)
* fix(workspace): new chat inherits the current conversation's workspace

`newSession()` resolved the workspace of a new chat as:

    switchWs || S._profileDefaultWorkspace || S.session.workspace

`S._profileDefaultWorkspace` is global to the profile, hydrated from
`/api/profile/active`. Preferring it over the current conversation meant a new
chat opened from a MES conversation landed on the profile-global value instead
of MES. With several conversations running in parallel on different workspaces,
the context the user was actually in was systematically lost.

The precedence is worse than it looks, because that "profile default" is not a
stable configured value: `get_profile_default_workspace()` reads
`last_workspace.txt` BEFORE the configured key (api/workspace.py ~419-427), and
for the `default` profile that file is the GLOBAL one. `set_last_workspace()`
rewrites it on every `/api/chat/start` and `/api/session/update` of ANY
conversation. So "profile default wins over the current session" degrades in
practice to "whatever workspace another conversation last used wins over the
conversation I am in" -- observed live: the pointer flipped mid-operation
because a concurrent turn wrote to it.

New precedence:

    switchWs || S.session.workspace || S._profileDefaultWorkspace

Preserved invariants:
- the one-shot profile-switch flag still wins first and is still consumed (#823);
- a blank page (no loaded session) still falls back to the profile default,
  which remains required for the empty-composer workspace chip (#804/#5169);
- `_profileDefaultWorkspace` is never nulled by `newSession()` (#823).

This deliberately diverges from upstream #4755, which made the profile default
win. That decision assumed a stable configured default; on a shared/global
`last_workspace.txt` it inverts its own intent. The upstream test is rewritten
with the rationale inline so a future rebase does not silently restore the bug,
plus a new test that evaluates the real expression under node and proves the
blank-page fallback still holds.

Pre-existing failures in tests/test_workspace_stale_recovery.py (3) were
verified identical with and without this change.

* fix(workspace): recover stale inherited workspace

---------

Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
2026-08-20 16:36:39 -07:00

251 lines
9.4 KiB
Python

"""Behavior coverage for #4755 profile default workspace precedence."""
from __future__ import annotations
import json
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = ROOT / "static" / "sessions.js"
PANELS_JS = ROOT / "static" / "panels.js"
NODE = shutil.which("node")
node_test = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _extract_async_function(source: str, name: str) -> str:
marker = f"async function {name}("
start = source.find(marker)
assert start >= 0, f"{name}() function must exist"
brace = source.find("{", source.find(")", start))
assert brace > start, f"{name}() function body must start"
depth = 0
in_string = None
escaped = False
in_line_comment = False
in_block_comment = False
for idx in range(brace, len(source)):
ch = source[idx]
nxt = source[idx + 1] if idx + 1 < len(source) else ""
if in_line_comment:
if ch == "\n":
in_line_comment = False
continue
if in_block_comment:
if ch == "*" and nxt == "/":
in_block_comment = False
continue
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == in_string:
in_string = None
continue
if ch == "/" and nxt == "/":
in_line_comment = True
continue
if ch == "/" and nxt == "*":
in_block_comment = True
continue
if ch in ("'", '"', "`"):
in_string = ch
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : idx + 1]
raise AssertionError(f"could not extract {name}()")
def _run_node(script: str) -> dict:
result = subprocess.run(
[NODE, "-e", script],
cwd=ROOT,
text=True,
capture_output=True,
timeout=30,
check=False,
)
assert result.returncode == 0, result.stderr or result.stdout
return json.loads(result.stdout)
def _new_session_driver(session_workspace: str, default_workspace: str, switch_workspace: str | None) -> str:
new_session = _extract_async_function(SESSIONS_JS.read_text(encoding="utf-8"), "newSession")
return textwrap.dedent(
f"""
let captured=null;
var _newSessionInFlight=null;
var _messagesTruncated=false;
var _oldestIdx=0;
var _activeProject=null;
var NO_PROJECT_FILTER='__all__';
var _sessionSourceFilter='webui';
var S={{
session:{{session_id:'previous-session',workspace:{json.dumps(session_workspace)}}},
_profileDefaultWorkspace:{json.dumps(default_workspace)},
_profileSwitchWorkspace:{json.dumps(switch_workspace)},
activeProfile:'default',
toolCalls:[],
}};
global.window={{}};
global.document={{createElement:()=>({{dataset:{{}},appendChild:()=>{{}}}})}};
global.localStorage={{setItem:()=>{{}}}};
function $(id){{return null;}}
function _newSessionPendingText(){{return 'Creating';}}
function _setNewSessionPending(){{}}
function updateQueueBadge(){{}}
function clearLiveToolCards(){{}}
function api(path,opts){{
captured={{path,body:JSON.parse(opts.body)}};
return Promise.resolve({{session:{{session_id:'new-session',messages:[],workspace:captured.body.workspace}}}});
}}
function _rememberNewChatDraftSession(){{}}
function _setActiveSessionUrl(){{}}
function _setSessionViewedCount(){{}}
function updateSendBtn(){{}}
function setStatus(){{}}
function setComposerStatus(){{}}
function syncTopbar(){{}}
function renderMessages(){{}}
function loadDir(){{return Promise.resolve();}}
{new_session}
newSession().then(()=>{{
process.stdout.write(JSON.stringify({{
captured,
switchWorkspace:S._profileSwitchWorkspace,
profileDefaultWorkspace:S._profileDefaultWorkspace,
}}));
}}).catch(err=>{{
console.error(err && err.stack || err);
process.exit(1);
}});
"""
)
@node_test
def test_new_session_prefers_current_session_workspace_over_profile_default():
"""Deliberate divergence from the precedence introduced in #4755.
#4755 made the "profile default" win over the current conversation's workspace.
That precedence assumes the profile default is a stable, configured value. It is
not, for a structural reason:
``api/workspace.py::get_profile_default_workspace()`` reads ``last_workspace.txt``
BEFORE the configured value, and for the ``default`` profile that file is the
GLOBAL one. ``set_last_workspace()`` rewrites it on every ``/api/chat/start`` and
``/api/session/update`` from ANY conversation.
So ``S._profileDefaultWorkspace`` — hydrated from ``/api/profile/active`` — is not a
configured default: it is a volatile shared pointer. "profile default wins over the
current conversation" therefore degrades into "the workspace last used by ANOTHER
conversation wins over the conversation I am actually in". With several
conversations open on different workspaces, opening a new chat from conversation A
created it on conversation B's workspace.
Precedence used here: one-shot profile switch -> current conversation -> profile
default. The profile default remains the blank-page fallback (#804/#5169) and is
never consumed (#823).
MAINTAINER NOTE: this intentionally diverges from #4755 and will conflict if that
precedence is restored. Please do not "fix" it by reinstating the previous order
without first addressing the volatility of ``get_profile_default_workspace()``.
"""
payload = _run_node(_new_session_driver(
session_workspace="/current-workspace",
default_workspace="/profile-default",
switch_workspace=None,
))
assert payload["captured"]["path"] == "/api/session/new"
assert payload["captured"]["body"]["workspace"] == "/current-workspace"
assert payload["captured"]["body"]["prev_session_id"] == "previous-session"
assert payload["captured"]["body"]["workspace_inherited_from_prev_session"] is True
assert payload["profileDefaultWorkspace"] == "/profile-default"
@node_test
def test_new_session_blank_page_falls_back_to_profile_default():
"""Sans conversation chargée, le défaut de profil reste le repli (#804/#5169)."""
driver = _new_session_driver(
session_workspace="/current-workspace",
default_workspace="/profile-default",
switch_workspace=None,
).replace(
"session:{session_id:'previous-session',workspace:\"/current-workspace\"},",
"session:null,",
)
payload = _run_node(driver)
assert payload["captured"]["body"]["workspace"] == "/profile-default"
assert "workspace_inherited_from_prev_session" not in payload["captured"]["body"]
assert payload["profileDefaultWorkspace"] == "/profile-default"
@node_test
def test_new_session_one_shot_switch_workspace_still_wins_and_clears():
payload = _run_node(_new_session_driver(
session_workspace="/current-workspace",
default_workspace="/profile-default",
switch_workspace="/explicit-switch",
))
assert payload["captured"]["body"]["workspace"] == "/explicit-switch"
assert "workspace_inherited_from_prev_session" not in payload["captured"]["body"]
assert payload["switchWorkspace"] is None
assert payload["profileDefaultWorkspace"] == "/profile-default"
@node_test
def test_parallel_conversations_each_inherit_their_own_workspace():
first = _run_node(_new_session_driver(
session_workspace="/workspace-a",
default_workspace="/shared-profile-pointer",
switch_workspace=None,
))
second = _run_node(_new_session_driver(
session_workspace="/workspace-b",
default_workspace="/shared-profile-pointer",
switch_workspace=None,
))
assert first["captured"]["body"]["workspace"] == "/workspace-a"
assert second["captured"]["body"]["workspace"] == "/workspace-b"
assert first["captured"]["body"]["workspace_inherited_from_prev_session"] is True
assert second["captured"]["body"]["workspace_inherited_from_prev_session"] is True
@node_test
def test_busy_workspace_switch_returns_before_session_update():
switch_to_workspace = _extract_async_function(PANELS_JS.read_text(encoding="utf-8"), "switchToWorkspace")
script = textwrap.dedent(
f"""
const calls=[];
var S={{busy:true,session:{{session_id:'session-1',workspace:'/old',model:'gpt-5',model_provider:null}}}};
function t(key){{return key;}}
function showToast(message){{calls.push(['toast',message]);}}
function api(path,opts){{calls.push(['api',path]);return Promise.resolve({{}});}}
{switch_to_workspace}
switchToWorkspace('/new','New').then(()=>{{
process.stdout.write(JSON.stringify({{calls}}));
}}).catch(err=>{{
console.error(err && err.stack || err);
process.exit(1);
}});
"""
)
payload = _run_node(script)
assert payload["calls"] == [["toast", "workspace_busy_switch"]]