Both files had drifted significantly from the actual current state of the
project:
ROADMAP.md previously contained:
- A ~75-row 'sprint history' table that overlapped with CHANGELOG.md
- A 'Wave 2 Core' section frozen at Sprint 7 progress
- A 'Wave 2: Full CRUD' nested section repeating the same Wave 7 items
- A 'User Requested Features' table that double-counted the same shipped issues
- A 'Feature Parity Checklist' with many unchecked boxes that were actually shipped
(branch/fork via #465, LLM-generated session titles via auto_title_refresh_every,
workspace git detection at api/workspace.py:719, code execution and TTS
reclassified, etc.)
SPRINTS.md previously contained:
- 1159 lines of historical sprint plans (Sprints 11-26)
- Inline planning detail more appropriate for the private workspace
- Stale 'as of v0.50.245' header with 'next sprint Sprint 24' reference
- Track-A/B/C breakdowns from sprints already long-merged
Rewrite:
ROADMAP.md (now 397 lines, was 363):
- Status snapshot table at the top
- Architecture table reflecting current layout (api/ ~20k LOC, static/*.js ~26k LOC)
- Feature parity checklist reorganized by surface (chat / sessions / workspace /
cron / skills / memory / profiles / config / security / visual / voice /
mobile / i18n / gateway / MCP / distribution) with every line currently in
master correctly checked
- 'Forward work' section split into confirmed candidates (with tracking issue
numbers) vs deferred backlog vs intentionally not planned
- 'Sprint history' compressed to a single chronological theme table — per-version
detail explicitly redirects to CHANGELOG.md
- Versioning conventions documented
SPRINTS.md (now 165 lines, was 1159):
- Forward-looking only — no historical sprint plans (those live in CHANGELOG.md)
- Active sprint candidates table sourced from the sprint-candidate label
- Planning principles section (phase-0 fit assessment, salvage over absorb,
independent-review gate, per-PR release velocity, no feature creep mid-PR,
pre-release gate)
- Sprint shape table (typical 3-7 day sprint with phases)
- Out-of-scope section centralized
- Template for new sprint plans
Also updates TESTING.md test count 3990 → 3995 to match actual pytest collect.
No private workspace info, agent infra references, or contributor stipend
content. References to the maintainer's private planning notes are
acknowledged as 'in a private workspace' without further specifics — same
disclosure pattern most open-source projects use.
Spliced from #1531 by @Asunfly: take Change-1 only (the actual bug fix +
cache signature inclusion) and skip Change-2 (auxiliary title-route
extra_body change) which is a separate scope concern.
## What
Two surgical fixes in api/streaming.py:
1. Line 1820 — `_cfg.cfg.get(...)` → `_cfg.get(...)`. `get_config()` returns
a plain dict (not a wrapper exposing `.cfg`). The buggy line raised
AttributeError that the surrounding try/except swallowed, so
`_reasoning_config` was always None regardless of what `/reasoning
<level>` had been set to. Verified locally — `api/streaming.py:1959`
already correctly used `_cfg.get(...)` in the same function, so the
same `_cfg` was being read two different ways in one file.
2. Line 1888 — added `_reasoning_config or {}` to `_sig_blob`. Without
this, switching effort mid-session would fail to take effect because
the per-session agent cache key would still match the old entry.
Mirrors how `resolved_provider` / `resolved_base_url` already
participate in the signature.
## Why splice instead of merge #1531 directly
@Asunfly force-pushed a Change-2 onto #1531 after the original review
that removes `extra_body={"reasoning": {"enabled": False}}` from
`generate_title_raw_via_aux` (the auxiliary title-generation route).
That intent is reasonable (let operator-configured `extra_body.reasoning`
flow through to the title route) but it touches a different surface and
deserves its own PR.
The narrow concern is operators who selected a reasoning-capable
auxiliary title model without explicitly setting
`reasoning.enabled=False` in the task config — pre-Change-2 the WebUI
defended against accidental reasoning on the title hot path; post-Change-2
those configs would reason on every new conversation`s title, with cost
and latency implications.
## What is NOT in this PR
- The `generate_title_raw_via_aux` extra_body refactor (Change-2 from #1531).
- The `test_does_not_override_configured_reasoning_extra_body` test (guards
Change-2). Asunfly can re-open that as its own focused PR.
## Tests
Two new R17b/R17c regression assertions in tests/test_regressions.py:
- `test_streaming_reads_reasoning_effort_from_config_dict` — static-source
guard: `_cfg.cfg` must not return to streaming.py
- `test_streaming_agent_cache_signature_includes_reasoning_config` —
catches removal of `_reasoning_config` from `_sig_blob`
## Closes
- Closes#1531 (the Change-1 portion ships here; Asunfly can re-open
Change-2 as a separate PR if desired)
Co-authored-by: Asunfly <[email protected]>
Opus advisor flagged that the conflict-marker resolution from PR #1525's
merge had not actually landed — static/sw.js still contained the literal
<<<<<<< HEAD / ======= / >>>>>>> pr-1525 markers, which made the file
fail to parse as JavaScript even though the substring-based source-string
tests still passed (the __WEBUI_VERSION__ token was present, just inside
the conflict block).
Concrete impact pre-fix when shipped:
- Service worker install handler would throw on script load
- SW would never reach activated state
- Old SW (from v0.50.278) would keep controlling the page indefinitely
- Frontend cache-bust pathway silently broken
- The INFLIGHT[sid] clear in static/sessions.js (the frontend half of
PR #1525's stale-stream cleanup) would never deliver to existing
browsers because the new SW would never activate
Fix:
- Resolve sw.js conflict to keep CACHE_NAME = 'hermes-shell-__WEBUI_VERSION__'
(the post-#1517 rename, with the manual -stale-stream-cleanup1 suffix
dropped as redundant — natural version-token bump invalidates old caches).
- Add tests/test_pwa_manifest_sw.py::test_sw_js_has_no_merge_conflict_markers
regression guard that scans for <<<<<<<, =======, >>>>>>> in sw.js source.
- Update tests/test_stale_stream_cleanup.py::test_service_worker_cache_
bumped_for_frontend_fix_delivery to assert the canonical version-token
CACHE_NAME pattern instead of the (now-removed) -stale-stream-cleanup1
manual suffix.
3945 → 3946 tests passing (+1 from the new conflict-marker guard).
This issue would have shipped a broken service worker if Opus hadn't
caught it. The new test_sw_js_has_no_merge_conflict_markers test would
have flagged it earlier in the pipeline.
Caught-by: Opus advisor pass on stage-279 brief
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Merge conflict resolution: kept HEAD's `CACHE_NAME = 'hermes-shell-__WEBUI_VERSION__'` (post-#1517 rename) over PR #1525's `'hermes-shell-__CACHE_VERSION__-stale-stream-cleanup1'` manual suffix. The renamed placeholder still auto-bumps with each release through the `quote(WEBUI_VERSION, safe="")` substitution, so the manual `-stale-stream-cleanup1` suffix is no longer needed to force-update existing service workers — the natural version bump (v0.50.278 → v0.50.279) already invalidates the old cache via `caches.delete(k)` for `k !== CACHE_NAME` in the SW activate handler. No behavioral regression: the SW cache still bumps on this release, just via the canonical version-token path.
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Read configured max_tokens from config.yaml, pass it into WebUI-created AIAgent instances when supported, and include it in the agent cache signature. Also classify OpenRouter quota phrasing such as more credits, can only afford, and fewer max_tokens.
Adds regression coverage for max_tokens propagation, cache signature isolation, and quota error classification.
Clear persisted active_stream_id and pending runtime fields when the server no longer has the referenced live stream. Also drop browser-side INFLIGHT state when the server reports a session idle and bump the service-worker cache so the frontend fix is delivered.
Adds regression coverage for backend stale-stream cleanup, frontend inflight invalidation, and cache busting.
CHANGELOG, ROADMAP, TESTING bumped (3929 \u2192 3936).
Pre-release Opus advisor pass: SHIP AS-IS. Sentinel collision impossible
(UUID hex \u2014 no underscores), stale-active-filter on project delete safe,
CSS specificity clean. One non-blocking edge case (stuck filter at zero
projects + zero unassigned) explicitly deferred per Opus advice
(recoverable via reload, too narrow to justify pre-merge work).
Both contributors (Thanatos-Z and AlexeyDsov) credited via Co-authored-by
trailers preserved from the synthesis commit.
Spliced from contributor PRs #1497 (Thanatos-Z) and #1513 (AlexeyDsov), which
both added the ability to filter the sidebar to sessions with no project_id
assigned. Lands here as a focused PR with the best of both:
## Synthesis decisions
- **Sentinel constant approach** (from #1497, Thanatos-Z): single state
variable (`_activeProject` set to `NO_PROJECT_FILTER` sentinel) instead
of a parallel `_showNoneProject` boolean. No two-state-machine ambiguity,
no risk of "All" + "Unassigned" both reading active. Clicking "All"
automatically clears the unassigned filter because there is only one
variable to reset.
- **Conditional rendering** (from #1497): the chip only appears when
there are actually unassigned sessions to filter to (`hasUnprojected`).
Common case where every session is organized → chip stays hidden,
uncluttered chip bar. The project-bar itself also renders when there
are unassigned sessions (was previously gated on `_allProjects.length`).
- **Dashed-border visual treatment** (from #1497): `.project-chip.no-project
{border-style:dashed;}` distinguishes the chip from real project chips
so it reads as a meta-filter ("things without a project") rather than
another project. Subtle but present.
- **"Unassigned" label** (new): clearer than #1497s "No project" (which
reads like a status filter) or #1513s "None" (which is ambiguous —
none of what?). Matches the conventional file-manager / task-tracker
mental model: "things not yet assigned to a category." Tooltip elaborates:
"Show conversations not yet assigned to a project."
- **Branched empty-state copy**: when the Unassigned filter is active
and the result is empty, show "No unassigned sessions." instead of
the generic "No sessions in this project yet."
## Tests
7 new tests in tests/test_sidebar_unassigned_filter.py pin every contract:
sentinel constant declared; filter logic uses !s.project_id when sentinel
is active; chip only renders when hasUnprojected; chip label and click
handler; visual treatment (dashed border + .no-project class); empty-state
copy branches on the active filter; All chip handler clears _activeProject
to null (would catch a regression if a parallel _showNoneProject boolean
is ever reintroduced).
Local full suite: 3929 → 3936 passing (+7).
Live verified at port 8789 with seeded data (5 projects + 73 unassigned
sessions in active profile): chip appears between "All" and project chips
when unassigned sessions exist; click cycles correctly; clicking a real
project hides the Unassigned chip from active state; clicking "All"
deactivates everything; dashed border present per getComputedStyle.
Co-authored-by: Thanatos-Z <thanatos-z@users.noreply.github.com>
Co-authored-by: Alexey Denisov <AlexeyDsov@users.noreply.github.com>
The onboarding wizard's API-key input calls _scheduleOnboardingProbe()
on every keystroke (oninput). When the 400ms-debounced probe completes,
_setOnboardingProbeState() calls _renderOnboardingBody() which rebuilds
the entire form — destroying and recreating the <input> element. The
user's focus and cursor position are lost.
On fast connections (localhost) the probe completes between keystrokes
so the bug window is narrow. On slow networks (VPN, corporate proxy,
cold-start vLLM) the re-render routinely lands mid-typing.
Fix: remove _scheduleOnboardingProbe() from the api-key input's
oninput handler. The probe still fires on:
- baseUrl input change (oninput + debounce, unchanged)
- api-key field blur (onblur, added)
- 'Test connection' button click (unchanged)
- nextOnboardingStep() before Continue (unchanged)
The baseUrl input retains the oninput probe because the UX trade-off
is acceptable there (text input preserves visible content on re-render).
When a user disables 'Hands-free voice mode' in Settings while voice
mode is active, the button hides but the SpeechRecognition keeps
running — the user can't stop it because the button is invisible.
Fix: _applyVoiceModePref() now checks if voice mode is active and
calls _deactivate() when the pref is toggled off. Move
_voiceModeActive declaration above the function to avoid TDZ.
Also removes a duplicate window._applyVoiceModePref assignment.
__CACHE_VERSION__ (sw.js) and __WEBUI_VERSION__ (index.html) are
functionally identical — both resolve to quote(WEBUI_VERSION, safe='')
at request time. Two names exist for historical reasons (different files
added at different times).
Rename __CACHE_VERSION__ → __WEBUI_VERSION__ in:
- static/sw.js (CACHE_NAME + VQ constant + comment)
- api/routes.py (substitution string)
- tests/test_pwa_manifest_sw.py (all assertions)
Single canonical name. No behavior change — same ?v=vX.Y.Z query strings
on the same URLs.
Prism's YAML grammar wraps tokens in <span> elements where white-space
defaults to normal, collapsing \n characters into spaces. The DOM
textContent is correct (confirmed by reporter's probe), so the bug is
purely CSS.
Force white-space:pre on .token elements inside language-yaml code
blocks for both .msg-body and .preview-md contexts.
CHANGELOG, ROADMAP, TESTING bumped (3925 → 3929 tests collected).
Opus SHOULD-FIX absorbed in-release: tests #1-3 documented the dedup
contract via direct construction but did not invoke get_models_grouped().
Test #4 (test_get_models_grouped_unconfigured_providers_get_independent_dicts)
inspects the live source for the literal copy.deepcopy(auto_detected_models)
call AND runs an end-to-end smoke of the fixed assignment loop.
A future refactor that removes the deepcopy at api/config.py:2078 will
fail this test immediately.
Supersedes contributor PR #1511 (lost9999), which removed the label-suffix
logic in _deduplicate_model_ids() but left the underlying shared-reference
bug intact — IDs would still be silently corrupted across provider groups,
just with cleaner-looking labels.
## Bug shape
When multiple unconfigured providers (Ollama / HuggingFace / custom
endpoints / Google Gemini CLI / Xiaomi / etc.) all fell through to the
'else' branch in api/config.py:get_models_grouped() that ends with:
groups.append({..., "models": auto_detected_models})
every group ended up sharing the SAME list reference AND the SAME dicts
inside. When _deduplicate_model_ids() then mutated those dicts to add
@provider_id: prefixes and provider-name parentheticals, the changes were
applied to every group that referenced the same dict.
Visible symptom: user 'vishnu' reported the dropdown showing
'Deepseek V4 Flash (Xiaomi) (Ollama) (HuggingFace) (Google-Gemini-Cli)'
on every group. Hidden symptom (worse): the 'id' field collapsed to
'@xiaomi:deepseek-v4-flash' on every group too, so clicking the entry
under any group routed the request to Xiaomi.
## Fix
api/config.py:2078 — wrap auto_detected_models in copy.deepcopy() at the
groups.append site so each group gets its own independent dicts. The
existing _deduplicate_model_ids() logic is correct and unchanged; the
bug was in the assignment site, not the dedup function.
The single-parenthetical disambiguation in labels is retained because
the composer chip (composer-model-label) shows the model label without
the optgroup header context — 'Deepseek V4 Flash (Ollama)' is more
useful than ambiguous 'Deepseek V4 Flash' there.
## Tests
tests/test_issue1511_dedup_shared_reference.py — 3 new tests:
- test_groups_have_independent_model_lists: structural invariant pin
- test_unconfigured_providers_no_shared_dedup_bleed: end-to-end against
the corrected code path; verifies each group gets its own @provider_id:
prefix and exactly ONE provider parenthetical per disambiguated label
- test_shared_reference_pre_fix_demonstrates_corruption: documents the
broken state that motivated the fix
Full suite: 3925 → 3928 passing (+3 new, 0 regressions).
Co-authored-by: lost9999 <56498264+lost9999@users.noreply.github.com>
CHANGELOG, ROADMAP, TESTING all updated.
3923 → 3925 tests collected (+2 regression tests).
Pre-release Opus advisor pass: SHIP AS-IS.
Independent review: nesquena APPROVED with end-to-end trace.
Migration note: existing v0.50.275 users will see one more round of
broken styling on first reload after upgrade (old SW serves old
index.html). Subsequent reloads clean. Future upgrades will not
recur because SW pre-cache is now keyed on versioned URL.
Filed follow-up #1509 for __CACHE_VERSION__/__WEBUI_VERSION__
placeholder consolidation (low-priority cleanup, no functional impact).
Container restart / in-place upgrade left the previous service worker still
controlling open tabs. Its fetch handler intercepted 'static/style.css',
matched the unversioned URL exactly against its old shell cache, and returned
the OLD CSS — while the JS files (which already carry ?v=__WEBUI_VERSION__)
hit the cache as misses and loaded fresh from network. New JS + old CSS
broke the layout until a force refresh bypassed the SW.
Fix is a 1-line attribute change plus aligning the SW pre-cache list:
* static/index.html: add ?v=__WEBUI_VERSION__ to the style.css link, matching
the pattern already in use for every JS file in the page.
* static/sw.js: add the same ?v=__CACHE_VERSION__ suffix to every versioned
entry in SHELL_ASSETS so that pre-cache URLs match what the page actually
requests. Unversioned entries (root, manifest, favicons) stay unversioned.
Tests:
* New regression test_index_versions_stylesheet (lock the href) and
test_sw_shell_assets_match_versioned_asset_urls in test_pwa_manifest_sw.py.
* test_workspace_panel_preload_marker_restored_in_head in test_sprint37.py
loosened to match the css link prefix (preserves the ordering invariant).
Verified live on port 8789: served HTML carries
'static/style.css?v=v0.50.275-dirty' and SW SHELL_ASSETS receive the
matching VQ at request time.
Closes#1507.
- Add tests/test_session_static_assets.py (5 tests):
* /session/static/style.css must return text/css (not text/html)
* /session/static/ui.js must return application/javascript
* /session/<id> still serves the HTML index (catch-all not weakened)
* Path-traversal still sandboxed after prefix strip
* /session/static/* matches /static/* auth-exemption policy
- Drop unused 'from urllib.parse import urlparse as _up' import from
PR #1505's added block (parsed._replace already gives a usable result).
Co-authored-by: Rick Chew <rickchew@users.noreply.github.com>
When the browser loads a session page at /session/<id>, it requests
static assets relative to that path — e.g. /session/static/style.css.
The /session/* catch-all in handle_get() intercepted those requests and
returned the HTML index page (text/html), causing browsers to refuse the
stylesheet with a MIME-type mismatch error.
Two-part fix:
- routes.py: add a guard before the /session/ catch-all that strips the
/session prefix from /session/static/* paths and delegates to
_serve_static(), so the correct Content-Type is returned.
- auth.py: whitelist /session/static/* in check_auth() alongside
/static/, so static assets on session pages are served without
requiring an authenticated session (same policy as /static/).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>