Merge remote-tracking branch 'origin/master' into feat/webui-notes-sources

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
AJV20
2026-05-22 21:38:31 -04:00
4 changed files with 172 additions and 6 deletions
+6
View File
@@ -7,6 +7,12 @@
- Add a default-off, read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki when explicitly enabled with `webui_external_notes_sources` or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`, while leaving automatic session recall unchanged.
## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored)
### Fixed
- **PR #2773** by @nesquena-hermes — fix(chat): rename `_inflightStateLimits()` in `static/ui.js` to `_getInflightStateLimits()` so it no longer collides with the `window._inflightStateLimits` config object set in `static/boot.js`. Closes #2771. The v0.51.117 in-flight-recovery quota fix (#2766) declared a top-level helper with the same name as a window-attached config object; because top-level `function foo(){…}` declarations in classic (non-module) scripts attach to `window`, boot.js's `window._inflightStateLimits = {…}` assignment overwrote the function reference before any session could send. Every new chat broke on first `send()` with `TypeError: _inflightStateLimits is not a function`, leaving v0.51.117 effectively unusable. Renamed the function only (the public-ish window key is unchanged) and updated all 4 call sites. **New regression test `tests/test_window_function_collision.py` scans every static JS file for top-level `function NAME()` declarations whose name is also the target of `window.NAME = {…}` / `= <number>`, the exact shape that broke #2715 (`_pinnedSessionsLimit` in v0.51.106) and #2771 (`_inflightStateLimits` in v0.51.117). The test fails loudly with a precise file:name diagnostic if the bug class returns. Verified end-to-end against the live browser before merge: `_getInflightStateLimits()` returns the limits object and `saveInflightState()` persists to localStorage without throwing.
## [v0.51.117] — 2026-05-22 — Release CO (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe)
### Fixed
+4 -4
View File
@@ -4081,7 +4081,7 @@ function _boundedInflightInt(value, fallback, min, max){
if(!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
function _inflightStateLimits(){
function _getInflightStateLimits(){
const configured=(typeof window!=='undefined'&&window._inflightStateLimits&&typeof window._inflightStateLimits==='object')?window._inflightStateLimits:{};
return {
maxSessions:_boundedInflightInt(configured.maxSessions, INFLIGHT_STATE_DEFAULT_LIMITS.maxSessions, 1, 25),
@@ -4110,7 +4110,7 @@ function _isStorageQuotaError(err){
);
}
function _truncateInflightValue(value, maxChars){
const limits=_inflightStateLimits();
const limits=_getInflightStateLimits();
const stringLimit=_boundedInflightInt(maxChars, limits.stringChars, 1000, 500000);
if(typeof value==='string'){
if(value.length<=stringLimit) return value;
@@ -4125,7 +4125,7 @@ function _truncateInflightValue(value, maxChars){
return value;
}
function _compactInflightState(state){
const limits=_inflightStateLimits();
const limits=_getInflightStateLimits();
const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[];
const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[];
return _truncateInflightValue({
@@ -4136,7 +4136,7 @@ function _compactInflightState(state){
}, limits.stringChars);
}
function _writeInflightStateMap(all){
const limits=_inflightStateLimits();
const limits=_getInflightStateLimits();
const entries=Object.entries(all||{})
.sort((a,b)=>Number(b[1]&&b[1].updated_at||0)-Number(a[1]&&a[1].updated_at||0))
.slice(0,limits.maxSessions);
+11 -2
View File
@@ -28,7 +28,7 @@ def test_inflight_state_is_compacted_before_localstorage_write():
compact_body = _function_body(UI_JS, "_compactInflightState")
assert "const entry={..._compactInflightState(state),updated_at:Date.now()};" in save_body
assert "const limits=_inflightStateLimits();" in compact_body
assert "const limits=_getInflightStateLimits();" in compact_body
assert ".slice(-limits.messages)" in compact_body
assert ".slice(-limits.toolCalls)" in compact_body
assert "limits.jsonChars" in UI_JS
@@ -49,7 +49,16 @@ def test_inflight_state_limits_are_configurable_from_settings():
assert "window._inflightStateLimits={" in BOOT_JS
assert "maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8" in BOOT_JS
assert "messages:parseInt(s.inflight_state_max_messages||24,10)||24" in BOOT_JS
assert "function _inflightStateLimits()" in UI_JS
# The reader function MUST use a different name than the window-attached
# config object — top-level `function foo(){}` in non-module scripts
# attaches to `window`, so a collision causes boot.js to overwrite the
# function with the config object and every later call throws
# `_inflightStateLimits is not a function`. See #2771.
assert "function _getInflightStateLimits()" in UI_JS
assert "function _inflightStateLimits()" not in UI_JS, (
"Function name must not collide with window._inflightStateLimits "
"config object (#2771)."
)
assert "window._inflightStateLimits" in UI_JS
assert "INFLIGHT_STATE_MAX_SESSIONS = 3" not in UI_JS
assert "INFLIGHT_STATE_MAX_MESSAGES = 8" not in UI_JS
+151
View File
@@ -0,0 +1,151 @@
"""Regression coverage for the function-name × window-attached-config collision class.
This test guards against a specific failure mode that has caused two
brick-class regressions (v0.51.106 #2715 `_pinnedSessionsLimit`, v0.51.117
#2771 `_inflightStateLimits`):
- Some module declares `function foo(){...}` at top level. Since the
WebUI ships classic (non-module) scripts via `<script defer>`, top-
level function declarations attach to `window` as `window.foo`.
- Another module later does `window.foo = {...}` (or `= 8`, etc).
- Boot order makes the assignment win, so by the time anyone tries
`foo()` they're calling an Object/Number → `TypeError: foo is not a
function`.
This is hard to spot in code review because the function and the config
object live in different files and the name choice is locally innocuous.
The test below scans static JS for any top-level `function foo()` decl
whose name also appears as the target of `window.foo = <non-function-
non-identifier>`. False-positive shape (which we deliberately exclude):
re-binding a function reference onto `window` (`window.foo = foo;` or
`window.foo = function(){...};`) — this is the normal "expose to global"
pattern.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
STATIC_JS = sorted(
p for p in (REPO_ROOT / "static").glob("*.js")
if not p.name.endswith(".min.js")
)
# Top-of-line: `function NAME(`
TOP_LEVEL_FN_RE = re.compile(r"^function\s+([A-Za-z_\$][A-Za-z_\$0-9]*)\s*\(", re.MULTILINE)
# `window.NAME = <rhs>` where rhs is the next non-space chunk.
# We only care about the *value shape* of rhs. Lookahead must be long enough
# to see `function(` (8 chars) even after some whitespace; we use 32 chars to
# be safe against newlines and odd indenting.
WINDOW_ASSIGN_RE = re.compile(
r"window\.([A-Za-z_\$][A-Za-z_\$0-9]*)\s*=\s*([^=].{0,32})",
re.DOTALL,
)
def _classify_rhs(rhs: str) -> str:
"""Classify the right-hand side of `window.X = rhs`.
Returns one of:
- 'function' — explicit `function(` literal (named or anonymous)
- 'identifier' — bare identifier (almost certainly a function reference)
- 'object' — `{...}` object literal (THE BUG SHAPE — function got
replaced by config object)
- 'number' — numeric literal (ALSO BUG SHAPE — #2715 was this)
- 'arrow' — `() =>` / `x =>` arrow function (benign re-bind)
- 'other' — anything else; treat as suspicious to be safe
"""
rhs = rhs.lstrip()
if rhs.startswith("function"):
return "function"
if rhs.startswith("{"):
return "object"
# Arrow function: `(args) =>` or `x =>`
if rhs.startswith("(") and "=>" in rhs:
return "arrow"
if re.match(r"[A-Za-z_\$][A-Za-z_\$0-9]*\s*=>", rhs):
return "arrow"
# Numeric literal: int or decimal
if re.match(r"-?\d", rhs):
return "number"
# Bare identifier reference: looks like `_foo;` or `_foo,` or `_foo ` etc.
# Allow ||, &&, ? chains too (e.g. `window.X = window.X || false;`).
if re.match(r"[A-Za-z_\$][A-Za-z_\$0-9]*[\s;,)|&?.]", rhs):
return "identifier"
return "other"
def test_no_top_level_function_shadowed_by_window_object_assignment():
"""Catch the v0.51.106 / v0.51.117 collision class before it ships.
See #2715 (`_pinnedSessionsLimit` function shadowed by `window._pinnedSessionsLimit = <int>`)
and #2771 (`_inflightStateLimits` function shadowed by
`window._inflightStateLimits = {...}`). Both broke entire user
workflows for everyone on the affected version.
"""
# Collect all top-level function names across every static JS file.
fn_names: dict[str, list[str]] = {}
for js_file in STATIC_JS:
src = js_file.read_text(encoding="utf-8")
for m in TOP_LEVEL_FN_RE.finditer(src):
fn_names.setdefault(m.group(1), []).append(js_file.name)
# Find every window.NAME = <rhs> assignment, classify the rhs.
collisions: list[str] = []
for js_file in STATIC_JS:
src = js_file.read_text(encoding="utf-8")
for m in WINDOW_ASSIGN_RE.finditer(src):
name, rhs_snippet = m.group(1), m.group(2)
if name not in fn_names:
continue
kind = _classify_rhs(rhs_snippet)
if kind in {"function", "identifier", "arrow"}:
continue # benign exposure of a function to global scope.
# 'object', 'number', and 'other' are the BUG shapes.
collisions.append(
f"In {js_file.name}: `window.{name} = ...` (rhs={kind!r}) "
f"shadows the top-level `function {name}()` declared in "
f"{', '.join(fn_names[name])}. This will cause "
f"`TypeError: {name} is not a function` once boot.js's "
f"assignment overwrites the function. See #2715, #2771."
)
assert not collisions, (
"Function-name × window-config collision detected — this is the "
"brick-class regression shape from #2715 and #2771:\n - "
+ "\n - ".join(collisions)
)
def test_inflight_state_limits_no_longer_collides_with_window_config():
"""Issue-pinned regression for #2771 specifically.
Confirms the function rename landed and the old colliding name is gone.
"""
ui_js = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
boot_js = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
# The window-attached config still exists (we deliberately kept this name).
assert "window._inflightStateLimits={" in boot_js, (
"boot.js should still expose the config under the documented name."
)
# The function must use the renamed identifier.
assert "function _getInflightStateLimits()" in ui_js, (
"ui.js should declare the limit-reader as `_getInflightStateLimits()` "
"to avoid the #2771 collision."
)
# The old colliding name must not appear as a function declaration anywhere.
assert "function _inflightStateLimits(" not in ui_js, (
"`function _inflightStateLimits()` is the colliding name from #2771 "
"and must not be reintroduced."
)
# Every call site uses the new name.
assert "_inflightStateLimits()" not in ui_js, (
"Stale call sites to the old function name `_inflightStateLimits()` "
"remain in ui.js (#2771). Update them to `_getInflightStateLimits()`."
)