feat: Sprint 23 -- profile/workspace/model coherence
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>
This commit is contained in:
36
CHANGELOG.md
36
CHANGELOG.md
@@ -5,6 +5,42 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence
|
||||||
|
*April 3, 2026 | 423 tests*
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Profile-local workspace storage.** Each named profile now stores its own
|
||||||
|
`workspaces.json` and `last_workspace.txt` under `{profile_home}/webui_state/`.
|
||||||
|
Default profile continues using the global STATE_DIR for backward compat.
|
||||||
|
- **Profile switch returns defaults.** `POST /api/profile/switch` response now
|
||||||
|
includes `default_model` and `default_workspace` from the new profile's
|
||||||
|
config.yaml, enabling one-round-trip state sync.
|
||||||
|
- **Session profile filter.** Session sidebar filters to the active profile by
|
||||||
|
default. "Show N from other profiles" toggle reveals sessions from all
|
||||||
|
profiles, modeled on the existing archived toggle. Resets on profile switch.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **Model picker ignores profile on switch.** `switchToProfile()` now clears
|
||||||
|
the `hermes-webui-model` localStorage key so the profile's default model
|
||||||
|
applies instead of a stale preference from another profile.
|
||||||
|
- **Workspace list was global.** Switching profiles no longer shows the wrong
|
||||||
|
profile's workspaces.
|
||||||
|
- **`DEFAULT_WORKSPACE` was a boot-time singleton.** Now resolved dynamically
|
||||||
|
through `_profile_default_workspace()`.
|
||||||
|
- **Session list showed all profiles.** Now filtered to active profile.
|
||||||
|
- **`switchToProfile()` didn't refresh workspaces or sessions.** Now refreshes
|
||||||
|
workspace list, session list, and resets profile filter on switch.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- `api/workspace.py` rewritten with profile-aware path resolution.
|
||||||
|
- `api/profiles.py`: `switch_profile()` returns `default_model` and
|
||||||
|
`default_workspace`.
|
||||||
|
- `static/sessions.js`: Profile filter with toggle UI.
|
||||||
|
- `static/panels.js`: Full cascade refresh on profile switch.
|
||||||
|
- 8 new tests in `test_sprint23.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v0.24] Sprint 22 -- Multi-Profile Support (Issue #28)
|
## [v0.24] Sprint 22 -- Multi-Profile Support (Issue #28)
|
||||||
*April 3, 2026 | 415 tests*
|
*April 3, 2026 | 415 tests*
|
||||||
|
|
||||||
|
|||||||
62
SPRINTS.md
62
SPRINTS.md
@@ -511,7 +511,63 @@ single default profile, blocking multi-persona workflows.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sprint 23 -- Desktop Application (PLANNED)
|
## Sprint 23 -- Profile/Workspace/Model Coherence (COMPLETED)
|
||||||
|
|
||||||
|
**Theme:** Make profiles, workspaces, models, and sessions coherent across
|
||||||
|
profile switches.
|
||||||
|
|
||||||
|
**Why now:** Sprint 22 added profile switching but five coherence bugs remained:
|
||||||
|
the model picker ignored the profile's default, workspaces were a global file,
|
||||||
|
DEFAULT_WORKSPACE was a startup singleton, the session list showed all profiles,
|
||||||
|
and switchToProfile() didn't refresh workspaces or sessions.
|
||||||
|
|
||||||
|
### Track A: Bugs
|
||||||
|
- **Model picker ignores profile on switch.** `populateModelDropdown()` skipped
|
||||||
|
the profile's default model if `localStorage` had a saved preference. Fixed:
|
||||||
|
`switchToProfile()` now clears `hermes-webui-model` from localStorage and
|
||||||
|
applies the profile's default model from the switch response.
|
||||||
|
- **Workspace list is a global file.** `workspaces.json` was process-global.
|
||||||
|
Fixed: workspace storage is now profile-local at `{profile_home}/webui_state/`.
|
||||||
|
Default profile uses global STATE_DIR for backward compatibility.
|
||||||
|
- **`DEFAULT_WORKSPACE` is a startup singleton.** Frozen at boot. Fixed:
|
||||||
|
`get_last_workspace()` and `_profile_default_workspace()` now resolve
|
||||||
|
dynamically through the active profile's config.
|
||||||
|
- **Session list shows all profiles.** Fixed: `renderSessionListFromCache()`
|
||||||
|
filters to `S.activeProfile` by default, with "Show N from other profiles"
|
||||||
|
toggle (modeled on the archived toggle).
|
||||||
|
- **`switchToProfile()` doesn't refresh workspace list or sessions.** Fixed:
|
||||||
|
now calls `loadWorkspaceList()`, `renderSessionList()`, resets profile filter.
|
||||||
|
|
||||||
|
### Track B: Features
|
||||||
|
- **Profile-local workspace storage.** Each named profile stores its own
|
||||||
|
`workspaces.json` and `last_workspace.txt` under `{profile_home}/webui_state/`.
|
||||||
|
Falls back to global STATE_DIR for the default profile (preserves test
|
||||||
|
isolation and backward compat).
|
||||||
|
- **Profile switch returns defaults.** `POST /api/profile/switch` response now
|
||||||
|
includes `default_model` and `default_workspace` so the frontend can apply
|
||||||
|
both in one round-trip.
|
||||||
|
- **Session profile filter.** Session sidebar filters to active profile by
|
||||||
|
default. "Show N from other profiles" toggle reveals sessions from all
|
||||||
|
profiles. Resets on profile switch.
|
||||||
|
|
||||||
|
### Track C: Architecture
|
||||||
|
- `api/workspace.py`: Rewritten with `_profile_state_dir()`, `_workspaces_file()`,
|
||||||
|
`_last_workspace_file()`, `_profile_default_workspace()`. All lazy imports to
|
||||||
|
avoid circular deps.
|
||||||
|
- `api/profiles.py`: `switch_profile()` returns `default_model` and
|
||||||
|
`default_workspace` from the new profile's config.yaml.
|
||||||
|
- `static/panels.js`: `switchToProfile()` clears localStorage model key,
|
||||||
|
refreshes workspace list and session list, resets profile filter.
|
||||||
|
- `static/sessions.js`: `_showAllProfiles` state variable, profile filter in
|
||||||
|
`renderSessionListFromCache()`, toggle UI.
|
||||||
|
|
||||||
|
**Tests:** 8 new (test_sprint23.py). Total: 423.
|
||||||
|
**Hermes CLI parity impact:** High (coherent profile behavior)
|
||||||
|
**Claude parity impact:** Low
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 24 -- Desktop Application (PLANNED)
|
||||||
|
|
||||||
**Theme:** Native desktop experience.
|
**Theme:** Native desktop experience.
|
||||||
|
|
||||||
@@ -607,5 +663,5 @@ single default profile, blocking multi-persona workflows.
|
|||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: April 3, 2026*
|
*Last updated: April 3, 2026*
|
||||||
*Current version: v0.24 | 415 tests*
|
*Current version: v0.25 | 423 tests*
|
||||||
*Next sprint: Sprint 23 (Desktop Application)*
|
*Next sprint: Sprint 24 (Desktop Application)*
|
||||||
|
|||||||
@@ -148,7 +148,23 @@ def switch_profile(name: str) -> dict:
|
|||||||
# Reload config.yaml from the new profile
|
# Reload config.yaml from the new profile
|
||||||
reload_config()
|
reload_config()
|
||||||
|
|
||||||
return {'profiles': list_profiles_api(), 'active': name}
|
# Return profile-specific defaults so frontend can apply them
|
||||||
|
from api.workspace import get_last_workspace, _profile_default_workspace
|
||||||
|
from api.config import get_config
|
||||||
|
cfg = get_config()
|
||||||
|
model_cfg = cfg.get('model', {})
|
||||||
|
default_model = None
|
||||||
|
if isinstance(model_cfg, str):
|
||||||
|
default_model = model_cfg
|
||||||
|
elif isinstance(model_cfg, dict):
|
||||||
|
default_model = model_cfg.get('default')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'profiles': list_profiles_api(),
|
||||||
|
'active': name,
|
||||||
|
'default_model': default_model,
|
||||||
|
'default_workspace': get_last_workspace(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_profiles_api() -> list:
|
def list_profiles_api() -> list:
|
||||||
|
|||||||
@@ -1,43 +1,123 @@
|
|||||||
"""
|
"""
|
||||||
Hermes Web UI -- Workspace and file system helpers.
|
Hermes Web UI -- Workspace and file system helpers.
|
||||||
|
|
||||||
|
Workspace lists and last-used workspace are stored per-profile so each
|
||||||
|
profile has its own workspace configuration. State files live at
|
||||||
|
``{profile_home}/webui_state/workspaces.json`` and
|
||||||
|
``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
|
||||||
|
paths are used as fallback when no profile module is available.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from api.config import (
|
from api.config import (
|
||||||
WORKSPACES_FILE, LAST_WORKSPACE_FILE, DEFAULT_WORKSPACE,
|
WORKSPACES_FILE as _GLOBAL_WS_FILE,
|
||||||
|
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
|
||||||
|
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
|
||||||
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
|
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Profile-aware path resolution ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _profile_state_dir() -> Path:
|
||||||
|
"""Return the webui_state directory for the active profile.
|
||||||
|
|
||||||
|
For the default profile, returns the global STATE_DIR (respects
|
||||||
|
HERMES_WEBUI_STATE_DIR env var for test isolation).
|
||||||
|
For named profiles, returns {profile_home}/webui_state/.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from api.profiles import get_active_profile_name, get_active_hermes_home
|
||||||
|
name = get_active_profile_name()
|
||||||
|
if name and name != 'default':
|
||||||
|
d = get_active_hermes_home() / 'webui_state'
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return _GLOBAL_WS_FILE.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _workspaces_file() -> Path:
|
||||||
|
"""Return the workspaces.json path for the active profile."""
|
||||||
|
return _profile_state_dir() / 'workspaces.json'
|
||||||
|
|
||||||
|
|
||||||
|
def _last_workspace_file() -> Path:
|
||||||
|
"""Return the last_workspace.txt path for the active profile."""
|
||||||
|
return _profile_state_dir() / 'last_workspace.txt'
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_default_workspace() -> str:
|
||||||
|
"""Read the profile's default workspace from its config.yaml.
|
||||||
|
|
||||||
|
Falls back to the boot-time DEFAULT_WORKSPACE constant.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from api.profiles import get_active_hermes_home
|
||||||
|
from api.config import get_config
|
||||||
|
cfg = get_config()
|
||||||
|
ws = cfg.get('default_workspace')
|
||||||
|
if ws:
|
||||||
|
p = Path(ws).expanduser().resolve()
|
||||||
|
if p.is_dir():
|
||||||
|
return str(p)
|
||||||
|
except (ImportError, Exception):
|
||||||
|
pass
|
||||||
|
return str(_BOOT_DEFAULT_WORKSPACE)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_workspaces() -> list:
|
def load_workspaces() -> list:
|
||||||
if WORKSPACES_FILE.exists():
|
ws_file = _workspaces_file()
|
||||||
|
if ws_file.exists():
|
||||||
try:
|
try:
|
||||||
return json.loads(WORKSPACES_FILE.read_text(encoding='utf-8'))
|
return json.loads(ws_file.read_text(encoding='utf-8'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return [{'path': str(DEFAULT_WORKSPACE), 'name': 'default'}]
|
# Fallback: try global file (migrates from pre-profile storage)
|
||||||
|
if _GLOBAL_WS_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [{'path': _profile_default_workspace(), 'name': 'default'}]
|
||||||
|
|
||||||
|
|
||||||
def save_workspaces(workspaces: list):
|
def save_workspaces(workspaces: list):
|
||||||
WORKSPACES_FILE.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
ws_file = _workspaces_file()
|
||||||
|
ws_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
|
||||||
def get_last_workspace() -> str:
|
def get_last_workspace() -> str:
|
||||||
if LAST_WORKSPACE_FILE.exists():
|
lw_file = _last_workspace_file()
|
||||||
|
if lw_file.exists():
|
||||||
try:
|
try:
|
||||||
p = LAST_WORKSPACE_FILE.read_text(encoding='utf-8').strip()
|
p = lw_file.read_text(encoding='utf-8').strip()
|
||||||
if p and Path(p).is_dir():
|
if p and Path(p).is_dir():
|
||||||
return p
|
return p
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return str(DEFAULT_WORKSPACE)
|
# Fallback: try global file
|
||||||
|
if _GLOBAL_LW_FILE.exists():
|
||||||
|
try:
|
||||||
|
p = _GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()
|
||||||
|
if p and Path(p).is_dir():
|
||||||
|
return p
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _profile_default_workspace()
|
||||||
|
|
||||||
|
|
||||||
def set_last_workspace(path: str):
|
def set_last_workspace(path: str):
|
||||||
try:
|
try:
|
||||||
LAST_WORKSPACE_FILE.write_text(str(path), encoding='utf-8')
|
lw_file = _last_workspace_file()
|
||||||
|
lw_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lw_file.write_text(str(path), encoding='utf-8')
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.24</div></div></div>
|
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.25</div></div></div>
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||||
|
|||||||
@@ -663,14 +663,36 @@ async function switchToProfile(name) {
|
|||||||
try {
|
try {
|
||||||
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
|
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
|
||||||
S.activeProfile = data.active || name;
|
S.activeProfile = data.active || name;
|
||||||
syncTopbar();
|
// Clear stale model pref so profile default applies
|
||||||
// Refresh dependent panels
|
localStorage.removeItem('hermes-webui-model');
|
||||||
|
// Refresh model dropdown (profile may have different provider/models)
|
||||||
_skillsData = null;
|
_skillsData = null;
|
||||||
await populateModelDropdown();
|
await populateModelDropdown();
|
||||||
|
// Apply profile's default model if provided
|
||||||
|
if (data.default_model && $('modelSelect')) {
|
||||||
|
$('modelSelect').value = data.default_model;
|
||||||
|
if ($('modelSelect').value !== data.default_model) {
|
||||||
|
// Model not in list — add it
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = data.default_model;
|
||||||
|
opt.textContent = data.default_model.split('/').pop();
|
||||||
|
$('modelSelect').insertBefore(opt, $('modelSelect').firstChild);
|
||||||
|
$('modelSelect').value = data.default_model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Refresh workspace list (now profile-local)
|
||||||
|
_workspaceList = null;
|
||||||
|
await loadWorkspaceList();
|
||||||
|
// Reset profile filter and refresh session list
|
||||||
|
_showAllProfiles = false;
|
||||||
|
await renderSessionList();
|
||||||
|
syncTopbar();
|
||||||
|
// Refresh visible sidebar panels
|
||||||
if (_currentPanel === 'skills') await loadSkills();
|
if (_currentPanel === 'skills') await loadSkills();
|
||||||
if (_currentPanel === 'memory') await loadMemory();
|
if (_currentPanel === 'memory') await loadMemory();
|
||||||
if (_currentPanel === 'tasks') await loadCrons();
|
if (_currentPanel === 'tasks') await loadCrons();
|
||||||
if (_currentPanel === 'profiles') await loadProfilesPanel();
|
if (_currentPanel === 'profiles') await loadProfilesPanel();
|
||||||
|
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
|
||||||
showToast('Switched to profile: ' + name);
|
showToast('Switched to profile: ' + name);
|
||||||
} catch (e) { showToast('Switch failed: ' + e.message); }
|
} catch (e) { showToast('Switch failed: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ let _renamingSid = null; // session_id currently being renamed (blocks list re-
|
|||||||
let _showArchived = false; // toggle to show archived sessions
|
let _showArchived = false; // toggle to show archived sessions
|
||||||
let _allProjects = []; // cached project list
|
let _allProjects = []; // cached project list
|
||||||
let _activeProject = null; // project_id filter (null = show all)
|
let _activeProject = null; // project_id filter (null = show all)
|
||||||
|
let _showAllProfiles = false; // false = filter to active profile only
|
||||||
|
|
||||||
async function renderSessionList(){
|
async function renderSessionList(){
|
||||||
try{
|
try{
|
||||||
@@ -111,8 +112,10 @@ function renderSessionListFromCache(){
|
|||||||
// Merge content matches (deduped): content matches appended after title matches
|
// Merge content matches (deduped): content matches appended after title matches
|
||||||
const titleIds=new Set(titleMatches.map(s=>s.session_id));
|
const titleIds=new Set(titleMatches.map(s=>s.session_id));
|
||||||
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
|
const allMatched=q?[...titleMatches,..._contentSearchResults.filter(s=>!titleIds.has(s.session_id))]:titleMatches;
|
||||||
|
// Filter by active profile (unless "All profiles" is toggled on)
|
||||||
|
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>!s.profile||s.profile===S.activeProfile);
|
||||||
// Filter by active project
|
// Filter by active project
|
||||||
const projectFiltered=_activeProject?allMatched.filter(s=>s.project_id===_activeProject):allMatched;
|
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
|
||||||
// Filter archived unless toggle is on
|
// Filter archived unless toggle is on
|
||||||
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
const sessions=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
||||||
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
||||||
@@ -154,6 +157,21 @@ function renderSessionListFromCache(){
|
|||||||
bar.appendChild(addBtn);
|
bar.appendChild(addBtn);
|
||||||
list.appendChild(bar);
|
list.appendChild(bar);
|
||||||
}
|
}
|
||||||
|
// Profile filter toggle (show sessions from other profiles)
|
||||||
|
const otherProfileCount=allMatched.filter(s=>s.profile&&s.profile!==S.activeProfile).length;
|
||||||
|
if(otherProfileCount>0&&!_showAllProfiles){
|
||||||
|
const pfToggle=document.createElement('div');
|
||||||
|
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||||||
|
pfToggle.textContent='Show '+otherProfileCount+' from other profiles';
|
||||||
|
pfToggle.onclick=()=>{_showAllProfiles=true;renderSessionListFromCache();};
|
||||||
|
list.appendChild(pfToggle);
|
||||||
|
} else if(_showAllProfiles&&otherProfileCount>0){
|
||||||
|
const pfToggle=document.createElement('div');
|
||||||
|
pfToggle.style.cssText='font-size:10px;padding:4px 10px;color:var(--muted);cursor:pointer;text-align:center;opacity:.7;';
|
||||||
|
pfToggle.textContent='Show active profile only';
|
||||||
|
pfToggle.onclick=()=>{_showAllProfiles=false;renderSessionListFromCache();};
|
||||||
|
list.appendChild(pfToggle);
|
||||||
|
}
|
||||||
// Show/hide archived toggle if there are archived sessions
|
// Show/hide archived toggle if there are archived sessions
|
||||||
if(archivedCount>0){
|
if(archivedCount>0){
|
||||||
const toggle=document.createElement('div');
|
const toggle=document.createElement('div');
|
||||||
|
|||||||
114
tests/test_sprint23.py
Normal file
114
tests/test_sprint23.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Sprint 23 tests: profile/workspace/model coherence."""
|
||||||
|
import json, pathlib, re, urllib.request, urllib.error
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8788"
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||||
|
return json.loads(r.read()), r.status
|
||||||
|
|
||||||
|
def post(path, body=None):
|
||||||
|
data = json.dumps(body or {}).encode()
|
||||||
|
req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return json.loads(r.read()), r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return json.loads(e.read()), e.code
|
||||||
|
|
||||||
|
|
||||||
|
# ── Workspace profile-locality ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_workspace_list_returns_data():
|
||||||
|
"""Workspace list endpoint works after profile-local refactor."""
|
||||||
|
data, status = get("/api/workspaces")
|
||||||
|
assert status == 200
|
||||||
|
assert "workspaces" in data
|
||||||
|
assert isinstance(data["workspaces"], list)
|
||||||
|
assert "last" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_add_remove_roundtrip():
|
||||||
|
"""Workspace add/remove still works with profile-local storage."""
|
||||||
|
import os
|
||||||
|
# Use a path that won't resolve differently (macOS /tmp -> /private/tmp)
|
||||||
|
resolved_tmp = str(pathlib.Path("/tmp").resolve())
|
||||||
|
# Clean slate
|
||||||
|
post("/api/workspaces/remove", {"path": resolved_tmp})
|
||||||
|
# Add
|
||||||
|
data, status = post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
|
||||||
|
assert status == 200
|
||||||
|
assert any(w["path"] == resolved_tmp for w in data.get("workspaces", []))
|
||||||
|
# Remove
|
||||||
|
data, status = post("/api/workspaces/remove", {"path": resolved_tmp})
|
||||||
|
assert status == 200
|
||||||
|
assert not any(w["path"] == resolved_tmp for w in data.get("workspaces", []))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Profile switch response fields ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_profile_switch_returns_default_model_and_workspace():
|
||||||
|
"""switch_profile() response includes default_model and default_workspace."""
|
||||||
|
data, status = post("/api/profile/switch", {"name": "default"})
|
||||||
|
assert status == 200
|
||||||
|
assert "active" in data
|
||||||
|
assert data["active"] == "default"
|
||||||
|
# default_workspace should always be present (may be null for model)
|
||||||
|
assert "default_workspace" in data
|
||||||
|
assert isinstance(data["default_workspace"], str)
|
||||||
|
assert "default_model" in data # can be None
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_active_endpoint():
|
||||||
|
"""GET /api/profile/active returns name and path."""
|
||||||
|
data, status = get("/api/profile/active")
|
||||||
|
assert status == 200
|
||||||
|
assert data["name"] == "default"
|
||||||
|
assert "path" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session profile tagging ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_new_session_has_profile_field():
|
||||||
|
"""Sessions created after Sprint 22 should have a profile field."""
|
||||||
|
data, status = post("/api/session/new", {})
|
||||||
|
assert status == 200
|
||||||
|
session = data["session"]
|
||||||
|
assert "profile" in session
|
||||||
|
# Clean up
|
||||||
|
post("/api/session/delete", {"session_id": session["session_id"]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_sessions_list_includes_profile():
|
||||||
|
"""Session list endpoint returns profile field for filtering."""
|
||||||
|
# Create a session
|
||||||
|
create_data, _ = post("/api/session/new", {})
|
||||||
|
sid = create_data["session"]["session_id"]
|
||||||
|
try:
|
||||||
|
data, status = get("/api/sessions")
|
||||||
|
assert status == 200
|
||||||
|
matching = [s for s in data["sessions"] if s["session_id"] == sid]
|
||||||
|
if matching:
|
||||||
|
assert "profile" in matching[0]
|
||||||
|
finally:
|
||||||
|
post("/api/session/delete", {"session_id": sid})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Static JS analysis ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
|
def test_sessions_js_has_profile_filter():
|
||||||
|
"""sessions.js should filter sessions by active profile."""
|
||||||
|
content = (REPO_ROOT / "static" / "sessions.js").read_text()
|
||||||
|
assert "_showAllProfiles" in content
|
||||||
|
assert "profileFiltered" in content
|
||||||
|
assert "S.activeProfile" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_panels_js_clears_model_on_switch():
|
||||||
|
"""switchToProfile() must clear localStorage model key."""
|
||||||
|
content = (REPO_ROOT / "static" / "panels.js").read_text()
|
||||||
|
assert "localStorage.removeItem('hermes-webui-model')" in content
|
||||||
|
assert "loadWorkspaceList" in content
|
||||||
|
assert "renderSessionList" in content
|
||||||
Reference in New Issue
Block a user