Three interrelated fixes:
1. api/workspace.py — clean workspace isolation with auto-migration
_clean_workspace_list(): sanitizes any workspace list by:
- Removing test artifacts (webui-mvp-test, test-workspace paths)
- Removing paths that no longer exist on disk
- Removing cross-profile leaks (paths under ~/.hermes/profiles/*)
- Renaming 'default' workspace label to 'Home' (avoids confusion
with the 'default' profile name)
_migrate_global_workspaces(): one-time migration for upgrading users.
Reads the legacy global workspaces.json, runs _clean_workspace_list,
rewrites it cleaned. This runs automatically on first load after upgrade
for the default profile only.
load_workspaces(): now cleans every read and persists cleaned version
if anything changed. Named profiles always start fresh (no global leak).
Empty results fall back to 'Home' entry pointing at profile's workspace.
Default label for auto-generated single-entry lists is 'Home', not 'default'.
2. api/models.py — legacy session profile backfill (already committed,
this commit adds the sessions.js filter tightening counterpart)
3. static/sessions.js — strict profile filter
Removed the '!s.profile' escape hatch from the profile filter.
Server now backfills profile='default' on legacy sessions, so every
session has an explicit tag. Filter is now exact:
s.profile === S.activeProfile
Named profiles see zero legacy clutter. Default profile sees its own
sessions. 'All profiles' toggle still shows everything.
Migration story for users pulling this update:
- Existing sessions (profile=null) -> attributed to 'default' at read time
- Global workspaces.json -> cleaned of test artifacts and cross-profile paths
on first server start after upgrade
- Named profile workspace files -> cleaned on first read, persisted clean
- No manual intervention needed
Tests: 426 passed, 0 failed.
1. _profile_default_workspace() now checks terminal.cwd
Profile config.yaml files don't have a 'workspace' or 'default_workspace' key
— they store the working directory as terminal.cwd (the hermes-agent CLI
setting). Added it as the third fallback after 'workspace' and
'default_workspace', so switching to camanji correctly resolves
~/Camanji, webui resolves ~/webui-mvp, etc.
2. Workspace dropdown opens upward (bottom: calc(100% + 4px))
The dropdown is now anchored at the bottom of the sidebar. Opening it
downward (top: 100%) caused it to clip off screen. Flipped to open upward
with an upward shadow so it expands into the session list area instead.
Tests: 426 passed, 0 failed.
BUG-1 (critical): api/profiles.py _DEFAULT_HERMES_HOME used Path.home()/.hermes
hardcoded, ignoring the HERMES_HOME env var. conftest.py sets HERMES_HOME to a
test-isolated state dir -- but profiles.py bypassed it and read/wrote real ~/.hermes
during every test run (active_profile file, .env loading). Fixed by reading
os.getenv('HERMES_HOME', ...) at module load time.
BUG-7 (medium): api/workspace.py load_workspaces() fell back to the global
workspaces.json for ALL profiles when their profile-local file didn't exist yet.
New named profiles silently inherited the default profile's workspace list instead
of starting clean. Fixed: the global file fallback now only applies to the default
profile (migration path); named profiles start with a fresh default entry.
BUG-4 (high): test_sessions_list_includes_profile had a vacuous 'if matching:'
guard -- if the session wasn't found the assert was silently skipped and the test
passed. Fixed with hard assert. Also changed to use /api/session?session_id=
directly instead of scanning /api/sessions (which filters out empty Untitled
sessions with 0 messages, causing the test to always see an empty match list).
BUG-5 / test ordering regression: test_profile_switch_returns_default_model_and_workspace
failed with 409 because test_chat_stream_opens_successfully (runs earlier in the
suite) starts a real LLM stream that stays alive in STREAMS. Added a wait loop
(up to 30s) polling /health active_streams before attempting the profile switch.
BUG-8 (low): Removed dead import _profile_default_workspace in switch_profile()
-- was imported but never used (get_last_workspace() already delegates to it).
Also: test_profile_active_endpoint hardcoded assert data['name'] == 'default'
which fails if a prior run left a non-default active_profile on disk. Changed
to assert name is a non-empty string (the endpoint contract), not a specific value.
Tests: 423 passed, 0 failed.
Fix five coherence bugs in profile switching:
1. Model picker ignored profile default (localStorage stale key)
2. Workspace list was global (not profile-scoped)
3. DEFAULT_WORKSPACE was a boot-time singleton
4. Session list showed all profiles (no filtering)
5. switchToProfile() didn't refresh workspaces or sessions
Backend: workspace storage is now profile-local for named profiles,
switch_profile() returns default_model and default_workspace.
Frontend: switchToProfile() clears stale model pref, refreshes
workspace list and session list, sessions.js filters by active profile
with 'Show N from other profiles' toggle.
8 new tests. 400 pass / 23 fail (identical to baseline).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>