diff --git a/CHANGELOG.md b/CHANGELOG.md index b708fb8..5548aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ --- +## [v0.26] Profile System Polish -- 10 Post-Sprint-23 Fixes +*April 3, 2026 | 426 tests* + +### Bug Fixes +- **Profile switch base dir bug.** When `HERMES_HOME` was mutated to a + `profiles/` subdir at startup, `switch_profile()` doubled the path + (e.g. `~/.hermes/profiles/X/profiles/X`). New `_resolve_base_hermes_home()` + detects profile subdirs and walks up to the actual base. +- **Cross-provider model routing.** Picking a model from a different provider + than the config's default now routes through OpenRouter instead of trying + a direct API call to a provider whose key may not exist. +- **Legacy sessions missing profile tag.** `all_sessions()` now backfills + `profile='default'` for pre-Sprint-22 sessions so the profile filter works. +- **Workspace list cleanup.** Stale paths, test artifacts, and cross-profile + entries are now cleaned on load. Legacy global workspace file migrated + once for the default profile. +- **API error messages.** `api()` helper now parses JSON error bodies and + surfaces the human-readable message instead of raw JSON. +- **Workspace dropdown moved to sidebar.** The workspace picker now opens + upward from the sidebar bottom instead of clipping behind the topbar. + +### Features +- **Rate limit error display.** Rate limit errors (429) now show a distinct + card with a rate limit icon and hint, instead of the generic error message. +- **SSE `apperror`/`warning` events.** Server can send typed error events + that the frontend handles with appropriate UX (rate limit card, fallback + notice, etc.). +- **Smart model resolver.** `_findModelInDropdown()` handles name mismatches + between config model IDs and dropdown values (e.g. `claude-sonnet-4-6` vs + `anthropic/claude-sonnet-4.6`). +- **Profile switch starts new session.** When the current session has messages, + switching profiles automatically starts a fresh session to prevent + cross-profile tagging. +- **Per-profile toolsets.** Agent now reads `platform_toolsets.cli` from the + active profile's config at call time, not the boot-time snapshot. +- **Per-profile fallback model.** `fallback_model` config is read from the + active profile and passed to AIAgent. + +### Architecture +- `api/profiles.py`: `_resolve_base_hermes_home()` replaces naive env var read. +- `api/workspace.py`: `_clean_workspace_list()`, `_migrate_global_workspaces()`. +- `api/streaming.py`: Per-profile toolsets and fallback model at call time. +- `api/models.py`: `all_sessions()` backfills `profile='default'`. +- `static/ui.js`: `_findModelInDropdown()`, `_applyModelToDropdown()`. +- `static/messages.js`: `apperror` and `warning` SSE event handlers. + +--- + ## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence *April 3, 2026 | 423 tests* @@ -829,4 +877,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.25, April 3, 2026 | Tests: 423* +*Last updated: v0.26, April 3, 2026 | Tests: 426* diff --git a/SPRINTS.md b/SPRINTS.md index d3894a0..6d104c2 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.25 | 423 tests | Daily driver ready +> Current state: v0.26 | 426 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -663,5 +663,5 @@ and switchToProfile() didn't refresh workspaces or sessions. --- *Last updated: April 3, 2026* -*Current version: v0.25 | 423 tests* +*Current version: v0.26 | 426 tests* *Next sprint: Sprint 24 (Desktop Application)* diff --git a/SPRINT_23_PLAN.md b/SPRINT_23_PLAN.md deleted file mode 100644 index 4611cf6..0000000 --- a/SPRINT_23_PLAN.md +++ /dev/null @@ -1,478 +0,0 @@ -# Sprint 23 — Profile/Workspace/Model Coherence - -**Goal:** Make the three systems (Profiles, Workspaces, Model picker) behave as a coherent -hierarchy. Profile is the identity layer. Workspace and model are per-profile defaults that -flow into per-session overrides. Switching profiles updates defaults immediately; it never -retroactively changes existing sessions. - -**Repo:** `nesquena/hermes-webui` -**Branch to create:** `feat/profile-workspace-model-coherence` -**Base:** current `master` (f21b088, v0.24) -**Test baseline:** 415 passing tests - ---- - -## The Invariant (Do Not Violate) - -``` -Profile switch → sets new DEFAULTS for future sessions - refreshes dependent UI (models list, workspace list, session list) - NEVER mutates existing sessions - -Session create → inherits active profile's default model + default workspace - tagged with active profile name - -Session override (mid-convo model change, workspace chip change) - → affects ONLY that session - does not touch profile defaults -``` - ---- - -## What Is Broken Today (Root Cause Analysis) - -### Problem 1 — Model picker ignores profile default on switch - -`switchToProfile()` in `static/panels.js` calls `populateModelDropdown()`, which rebuilds -the dropdown and restores the model from `localStorage.getItem('hermes-webui-model')` — a -single global browser key. So switching from Profile A (GPT-4) to Profile B (Claude) leaves -the picker still showing GPT-4 because localStorage trumps the server default. - -**Root cause:** `populateModelDropdown()` has this guard: -```js -if (data.default_model && !localStorage.getItem('hermes-webui-model')) { - sel.value = data.default_model; -} -``` -The localStorage key is never cleared on profile switch, so the profile's default model -never applies after the first session. - -### Problem 2 — Workspace list is global, not per-profile - -`WORKSPACES_FILE = STATE_DIR / 'workspaces.json'` — a single file in the global state dir. -`api/workspace.py:load_workspaces()` reads this file unconditionally. Switching profiles -does NOT reload the workspace list. Profile A's workspaces remain visible under Profile B. - -`LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt'` — also global. New sessions on -Profile B inherit Profile A's last-used workspace. - -### Problem 3 — `DEFAULT_WORKSPACE` is a process-level singleton - -`api/config.py` line 193: `DEFAULT_WORKSPACE = _discover_default_workspace()` — evaluated -at server startup, frozen forever. `new_session()` in `api/models.py` line 71 calls -`get_last_workspace()` which reads `LAST_WORKSPACE_FILE` — also global. So new sessions -never get the active profile's configured default workspace. - -### Problem 4 — Session list is not filtered by active profile - -`_allSessions` in `sessions.js` contains sessions from all profiles. Session objects have -a `profile` field (added in Sprint 22) but the sidebar never filters on it. Users see -other profiles' sessions mixed in. - -### Problem 5 — `switchToProfile()` doesn't refresh the workspace list or session list - -After a switch, the workspace dropdown shows stale data. The session list still shows all -profiles' sessions. Neither is refreshed. - ---- - -## Changes Required - -### 1. `api/workspace.py` — Make workspace storage profile-aware - -The workspace list and last-workspace pointer need to live inside the active profile's -state, not in a global `STATE_DIR` file. - -**New helper — `_profile_workspaces_file()` and `_profile_last_workspace_file()`:** -```python -def _profile_state_dir() -> Path: - """Return the state dir for the active profile. - Falls back to global STATE_DIR when profiles module is unavailable.""" - try: - from api.profiles import get_active_hermes_home - home = get_active_hermes_home() - # Per-profile state lives inside the profile's HERMES_HOME - d = home / 'webui_state' - d.mkdir(parents=True, exist_ok=True) - return d - except ImportError: - from api.config import STATE_DIR - return STATE_DIR - -def _workspaces_file() -> Path: - return _profile_state_dir() / 'workspaces.json' - -def _last_workspace_file() -> Path: - return _profile_state_dir() / 'last_workspace.txt' -``` - -**Update `load_workspaces()`** to call `_workspaces_file()` instead of `WORKSPACES_FILE`. -The fallback default when the file doesn't exist should be the profile's configured -default workspace, not the global `DEFAULT_WORKSPACE`: -```python -def load_workspaces() -> list: - f = _workspaces_file() - if f.exists(): - try: - return json.loads(f.read_text(encoding='utf-8')) - except Exception: - pass - # Fallback: build a single-entry list from the profile's default workspace - default = _get_profile_default_workspace() - return [{'path': str(default), 'name': 'default'}] -``` - -**New helper — `_get_profile_default_workspace()`:** -```python -def _get_profile_default_workspace() -> Path: - """Return the default workspace for the active profile. - Priority: profile config.yaml 'workspace' key > env var > STATE_DIR/workspace.""" - from api.config import get_config, DEFAULT_WORKSPACE - cfg_ws = get_config().get('workspace') or get_config().get('default_workspace') - if cfg_ws: - p = Path(cfg_ws).expanduser() - if p.is_dir(): - return p - return DEFAULT_WORKSPACE -``` - -**Update `save_workspaces()`, `get_last_workspace()`, `set_last_workspace()`** to use -`_workspaces_file()` and `_last_workspace_file()` respectively. These are already -small single-liners — just swap the path source. - -**Important:** The global `WORKSPACES_FILE` in `api/config.py` can stay as-is for -backward compatibility. `api/workspace.py` simply stops importing and using it directly. - -**Migration:** On first call to `load_workspaces()` for a profile, if the profile-local -file doesn't exist but the global `WORKSPACES_FILE` does, copy the global file's contents -as the starting point for the default profile only. Non-default profiles start fresh. - -### 2. `api/routes.py` — New endpoint: `GET /api/profile/default-workspace` - -```python -if parsed.path == '/api/profile/default-workspace': - from api.workspace import _get_profile_default_workspace - return j(handler, {'workspace': str(_get_profile_default_workspace())}) -``` - -This lets the frontend ask "what workspace should I start a new session with?" using the -currently-active profile's config, not a stale server-startup value. - -### 3. `api/profiles.py` — Return `default_model` and `default_workspace` on switch - -`switch_profile()` currently returns `{'profiles': [...], 'active': name}`. - -Extend the return value: -```python -return { - 'profiles': list_profiles_api(), - 'active': name, - 'default_model': _cfg.DEFAULT_MODEL, # freshly read after reload_config() - 'default_workspace': str(_get_profile_default_workspace()), -} -``` - -This gives the frontend everything it needs to update the picker and workspace chip -atomically in a single round-trip — no second fetch required. - -`_get_profile_default_workspace()` should be imported from `api.workspace` (or duplicated -as a small helper here to avoid circular imports — check carefully). - -### 4. `static/panels.js` — `switchToProfile()` uses returned defaults - -```js -async function switchToProfile(name) { - if (S.busy) { showToast('Cannot switch profiles while agent is running'); return; } - try { - const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) }); - S.activeProfile = data.active || name; - - // ── Model: apply profile default, bypassing localStorage ────────────── - // Profile switch is an explicit user intent to adopt this profile's model. - // We clear the localStorage preference so the profile default wins. - if (data.default_model) { - localStorage.removeItem('hermes-webui-model'); - } - await populateModelDropdown(); // now respects data.default_model via server - // If the session has a model set, keep it. If no active session, update the picker. - const sel = $('modelSelect'); - if (sel && data.default_model && !S.session) { - sel.value = data.default_model; - } - - // ── Workspace: update active workspace to profile default ───────────── - if (data.default_workspace) { - S._profileDefaultWorkspace = data.default_workspace; - } - - syncTopbar(); - - // ── Refresh all dependent panels ────────────────────────────────────── - _skillsData = null; - await Promise.all([ - loadWorkspaceList(), // refresh workspace list from new profile's storage - ]); - renderSessionListFromCache(); // re-render to show/hide by profile filter - await loadSessions(); // fetch sessions tagged to the new profile - - if (_currentPanel === 'skills') await loadSkills(); - if (_currentPanel === 'memory') await loadMemory(); - if (_currentPanel === 'tasks') await loadCrons(); - if (_currentPanel === 'profiles') await loadProfilesPanel(); - - showToast('Switched to profile: ' + name); - } catch (e) { showToast('Switch failed: ' + e.message); } -} -``` - -### 5. `static/sessions.js` — Filter session list by active profile - -The session list should default to showing only the current profile's sessions. -Add a toggle to show all profiles. - -**State variable** (add near `_activeProject`): -```js -let _profileFilter = true; // true = show only active profile's sessions -``` - -**Filter application** (inside `renderSessionListFromCache()`, after project filter): -```js -// Profile filter — show only sessions tagged to the active profile -// (sessions with profile=null are legacy pre-Sprint22, always shown) -const profileFiltered = _profileFilter - ? projectFiltered.filter(s => !s.profile || s.profile === S.activeProfile) - : projectFiltered; -``` - -**Toggle button** — add a small "All profiles" / "This profile" toggle in the session -list header, similar to the existing archived toggle. When clicked, flips `_profileFilter` -and calls `renderSessionListFromCache()`. No server round-trip needed. - -**On profile switch** — reset `_profileFilter = true` and call `renderSessionListFromCache()` -so you immediately see only the new profile's sessions. - -### 6. `static/panels.js` — `renderWorkspaceDropdown()` refresh on profile switch - -`loadWorkspaceList()` already calls `GET /api/workspaces`. Since `api/workspace.py` will -now read from the profile-local file, calling `loadWorkspaceList()` after a profile switch -is sufficient — no other changes needed in the dropdown renderer. - -However, the workspace chip in the topbar shows the current session's workspace, which -should not change on profile switch. Only the *dropdown list* (available options) should -update. This is already the correct behavior — the chip reads `S.session.workspace`, not -the list. - -### 7. `api/models.py` — `new_session()` uses profile default workspace - -```python -def new_session(workspace=None, model=None): - try: - from api.profiles import get_active_profile_name - _profile = get_active_profile_name() - except ImportError: - _profile = None - - # Use profile's default workspace, not the global last_workspace - if workspace is None: - try: - from api.workspace import _get_profile_default_workspace, get_last_workspace - # last_workspace is now profile-local too, so this is correct - workspace = get_last_workspace() - except Exception: - from api.config import DEFAULT_WORKSPACE - workspace = str(DEFAULT_WORKSPACE) - - s = Session( - workspace=workspace, - model=model or _cfg.DEFAULT_MODEL, - profile=_profile, - ) - ... -``` - -Note: since `get_last_workspace()` will be profile-local after change #1, this -effectively already does the right thing. The explicit comment is just for clarity. - ---- - -## What NOT to Change - -- **The workspace chip on the topbar** — it shows the current session's workspace, not - a profile default. This is correct. Don't change it. -- **Per-session model overrides** — the session's `model` field should not be touched on - profile switch. Already correct. -- **The `WORKSPACES_FILE` import in `api/config.py`** — leave it. It's used by the - settings serialization and possibly conftest.py. `api/workspace.py` simply stops - importing it directly. -- **Auth, streaming, profiles.py lock logic** — untouched. -- **The `profile` field on Session** — already exists from Sprint 22. Just start using it - in the filter. - ---- - -## New Tests Required - -File: `tests/test_sprint23.py` - -``` -test_workspace_file_is_profile_local - — GET /api/workspaces before and after profile switch return different lists - (after saving different workspaces to each profile's state dir) - -test_new_session_inherits_profile_default_workspace - — Create session with no workspace arg; verify session.workspace matches - the active profile's configured workspace - -test_switch_profile_response_includes_default_model - — POST /api/profile/switch returns default_model field - -test_switch_profile_response_includes_default_workspace - — POST /api/profile/switch returns default_workspace field - -test_session_list_profile_field - — Sessions created under different profiles have correct profile field - -test_profile_filter_excludes_other_profiles - — Static analysis: sessions.js renderSessionListFromCache contains - '_profileFilter' and 's.profile === S.activeProfile' - -test_workspace_list_reload_on_switch - — Static analysis: switchToProfile() in panels.js calls loadWorkspaceList() - -test_model_localstorage_cleared_on_switch - — Static analysis: switchToProfile() in panels.js calls - localStorage.removeItem('hermes-webui-model') -``` - ---- - -## File Change Summary - -| File | Change | -|------|--------| -| `api/workspace.py` | Make `load_workspaces`, `save_workspaces`, `get_last_workspace`, `set_last_workspace` read/write from profile-local paths. Add `_get_profile_default_workspace()`. Add migration for default profile. | -| `api/profiles.py` | `switch_profile()` returns `default_model` and `default_workspace` in response. | -| `api/routes.py` | Add `GET /api/profile/default-workspace` endpoint. | -| `api/models.py` | `new_session()` comment clarification only (behavior already correct after workspace.py fix). | -| `static/panels.js` | `switchToProfile()`: clear localStorage model key, call `loadWorkspaceList()`, call `loadSessions()`, reset profile filter. | -| `static/sessions.js` | Add `_profileFilter` state, filter `renderSessionListFromCache()` by active profile, add "All profiles" toggle button, reset filter on profile switch. | -| `tests/test_sprint23.py` | New test file with 8 tests. | - ---- - -## Explicit Non-Goals (Out of Scope for Sprint 23) - -- Migrating existing sessions from "no profile tag" to "default profile" — they stay - untagged and are shown under all profiles (the `!s.profile` guard handles this). -- Per-profile session storage directories — sessions stay in the global `SESSION_DIR`. - The profile tag on the session object is sufficient for filtering. -- UI for setting a profile's default workspace (that's a settings panel feature, Sprint 24). -- Disabling workspaces by default — rejected. The workspace is the agent's `cwd`; - hiding it doesn't simplify things, it makes the default silently wrong. - ---- - -## Circular Import Warning - -`api/workspace.py` will import from `api.profiles`. `api/profiles.py` imports from -`api.config`. `api/config.py` imports from `api.profiles` (already, for `get_config()`). - -To avoid a new circular: `api/workspace.py` should use a **deferred import** inside the -helper functions, not a top-level import. The pattern already exists in `api/profiles.py` -and `api/models.py`. Example: - -```python -def _profile_state_dir() -> Path: - try: - from api.profiles import get_active_hermes_home # deferred — avoid circular - ... -``` - -Do NOT add `from api.profiles import ...` at the top level of `workspace.py`. - ---- - -## How to Verify End-to-End (Manual Checklist) - -After implementation, verify these flows in the browser: - -1. **Profile switch updates model picker** - - Set Profile A's config.yaml: `model: anthropic/claude-opus-4-5` - - Set Profile B's config.yaml: `model: openai/gpt-5.4-mini` - - Load the UI. Switch to Profile A. Picker should show Claude. - - Switch to Profile B. Picker should show GPT-4o-mini. - - Verify: changing the picker manually doesn't affect the other profile. - -2. **Profile switch updates workspace list** - - Add workspace `/tmp/work-a` to Profile A via the workspace dropdown. - - Switch to Profile B. Open workspace dropdown. `/tmp/work-a` should NOT appear. - - Add `/tmp/work-b` to Profile B. Switch back to Profile A. Only A's workspaces appear. - -3. **New session inherits profile's default workspace** - - While on Profile B (default workspace: `/tmp/work-b`), create a new session. - - Session workspace chip should show `/tmp/work-b`, not a stale Profile A path. - -4. **Session list filters by profile** - - Create 2 sessions on Profile A, 2 sessions on Profile B. - - While on Profile B, sidebar shows only Profile B's 2 sessions. - - Toggle "All profiles" — all 4 appear. - -5. **Existing sessions survive profile switch unmodified** - - Open Session X on Profile A (model: Claude, workspace: `/tmp/work-a`). - - Switch to Profile B. - - Switch back to Profile A and reopen Session X. - - Model and workspace should be unchanged. - ---- - -## Commit Message Template - -``` -feat: Sprint 23 — profile/workspace/model coherence - -- Workspaces are now profile-local: each profile's workspace list and - last-workspace pointer live in {profile_home}/webui_state/ instead of - the global STATE_DIR. Switching profiles reloads the correct workspace list. - -- Profile switch response now includes default_model and default_workspace, - so the frontend can update the model picker and session defaults in one - round-trip. - -- Model picker: switching profiles clears the localStorage preference and - applies the new profile's default model. Per-session overrides are unaffected. - -- Session list: filtered to active profile by default with an "All profiles" - toggle. Sessions from before Sprint 22 (no profile tag) always shown. - -- new_session() inherits the active profile's default workspace via the - now-profile-local get_last_workspace(). - -Tests: N passed, 0 failed (+8 new tests in test_sprint23.py). -Co-Authored-By: -``` - ---- - -## Key Existing Code Locations (for the implementing agent) - -``` -api/workspace.py — load_workspaces, save_workspaces, get/set_last_workspace -api/config.py:38 — WORKSPACES_FILE = STATE_DIR / 'workspaces.json' -api/config.py:41 — LAST_WORKSPACE_FILE = STATE_DIR / 'last_workspace.txt' -api/config.py:194 — DEFAULT_MODEL = os.getenv(...) -api/config.py:193 — DEFAULT_WORKSPACE = _discover_default_workspace() -api/config.py:677-690 — save_settings() updates DEFAULT_MODEL / DEFAULT_WORKSPACE globals -api/models.py:64-71 — new_session() — uses get_last_workspace() and _cfg.DEFAULT_MODEL -api/models.py:37 — Session.__init__ — has profile=None field -api/profiles.py:100-135 — switch_profile() — returns {'profiles':[], 'active': name} -api/profiles.py:155-180 — list_profiles_api() — has p.model per profile -api/routes.py:169-170 — GET /api/workspaces -api/routes.py:360-367 — workspace add/remove/rename endpoints -api/routes.py:385-421 — profile switch/create/delete endpoints -static/panels.js:659-680 — switchToProfile() — currently missing ws/session refresh -static/panels.js:474-492 — toggleWsDropdown() / closeWsDropdown() -static/sessions.js:67 — _allSessions cache -static/sessions.js:89-120 — filterSessions() / renderSessionListFromCache() -static/sessions.js:71 — _activeProject filter (model for _profileFilter) -static/ui.js:10-44 — populateModelDropdown() — has the localStorage guard -``` diff --git a/static/index.html b/static/index.html index c9bd642..1bd22f9 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,7 @@