mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Merge pull request #1967 from nesquena/stage-326
release: v0.51.31 — Release H (12-PR contributor batch: image-mode + race fixes + composer drafts + locale parity)
This commit is contained in:
+73
-1
@@ -1,5 +1,77 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.51.31] — 2026-05-09 — Release H (12-PR contributor batch: image-mode + race fixes + composer drafts + locale parity)
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #1956** by @JKJameson — Persistent composer draft. The chat composer textarea (`#msg`) is now persisted per-session server-side under `Session.composer_draft = {text, files}`, so drafts survive page refreshes and sync across clients. New `POST/GET /api/session/draft` endpoints (input validation: text clamped to 50 KB, files clamped to 50 entries, types coerced to str/list — Stage-326 hardening per Opus advisor). Frontend: 400 ms debounced auto-save on textarea `input`, immediate fire-and-forget save before session switch, save on clarification card lock. `_restoreComposerDraft` guards against stale responses from rapid session switching. Co-authored by Minimax.
|
||||
|
||||
- **PR #1957** by @hermes-gimmethebeans — Configurable session TTL. New `_resolve_session_ttl()` helper with three-layer precedence: `HERMES_WEBUI_SESSION_TTL` env var > `settings.json` `session_ttl_seconds` > 30-day default. Out-of-range values [60s, 1y] fall through to the default. Resolved dynamically at every `create_session()` and `set_auth_cookie()` call so settings changes take effect immediately without restart. The `SESSION_TTL = 86400 * 30` module constant is preserved as the named fallback (Stage-326 reconciliation: existing regression tests pin the constant; #1957 originally deleted it). Closes #1954.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #1939** by @ai-ag2026 — Test-only follow-up: tightens the theme-color bridge tests so the pre-paint script must update every theme-color meta tag and remove stale media attributes; asserts the runtime theme sync updates both the canonical id tag and fallback theme-color tags; adds regression coverage that service-worker shell assets use network-first with cache fallback.
|
||||
|
||||
- **PR #1941** by @ai-ag2026 — Preserve chat scroll across final render. When a stream completed, the `done` handler replaced the live transcript with persisted session messages via `renderMessages({ preserveScroll: true })`. The `preserveScroll` path avoided forcing bottom-scroll, but did not preserve `scrollTop` itself; during the DOM rebuild the browser could reset `#messages.scrollTop` to `0`, sending a reader who had scrolled up to the first message. Now captures the scroll position before the rebuild and restores it for unpinned readers; pinned/near-bottom readers keep the existing bottom-follow behavior.
|
||||
|
||||
- **PR #1945** by @franksong2702 — Localized the six session-jump-button keys (Start/End labels, aria labels, Appearance setting copy) for ja/ru/es/de/zh/zh-Hant/pt/ko. The opt-in `session_jump_buttons` setting in #1928 (Release G) had English fallbacks in non-English locale blocks; this completes the parity. Strengthened the regression test so future changes cannot leave English literals in non-English locales. Closes #1938.
|
||||
|
||||
- **PR #1947** by @happy5318 — Show the same model from different named custom providers in the dropdown instead of silently dropping the second provider's entry. The `_seen_custom_ids` global bucket in `get_available_models()` was seeded from `auto_detected_models` and used a bare model id as the dedup key, so a second named provider exposing the same model id (e.g. both `baidu` and `huoshan` exposing `glm-5.1`) had its entry dropped. Switched the dedup key to `f"{slug}:{model_id}"` so each provider's models track independently. Maintainer-augmented with a regression test (`test_pr1947_same_model_multiple_custom_providers.py`) that fails on master and passes on the fix. Co-authored by @hacker1e7 (independently filed #1874 with broader scope; closed in favor of the narrower fix).
|
||||
|
||||
- **PR #1949** by @Sanjays2402 — Closes the v0.51.30 regression race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded` (Issue #1937). With both opt-ins ON, an in-flight `_loadOlderMessages` racing with `jumpToSessionStart → _ensureAllMessagesLoaded` could prepend a duplicate page if the prefetch resolved last. The naive same-flag-check approach (proposed in #1942 and #1962, both closed in favor of this PR) is a no-op for the post-await race because the prefetch has already cleared the entry-gate. The actual fix is a generation-token + mutex pair: (1) `_loadOlderMessages` snapshots a module-scoped `_messagesGeneration` counter before its `await api(...)` and re-checks it after, aborting the prepend cleanly if any wholesale-replace bumped the token mid-flight; (2) `_ensureAllMessagesLoaded` claims the `_loadingOlder` mutex, bumps the generation token before mutating `S.messages`, yields until any in-flight prefetch's `finally` releases the mutex, then claims the mutex itself. Also adds same-session and `_loadingSessionId` guards that the original ensure-all body was missing post-await. 12 new regression tests pin the wait → lock → fetch → mutate → unlock invariant. Co-authored by @franksong2702 and @Michaelyklam (parallel-discovery PRs). Closes #1937.
|
||||
|
||||
- **PR #1950** by @franksong2702 — Mute stale stopped gateway heartbeat. When the root `gateway_state.json` had `gateway_state == "stopped"` and was older than the freshness threshold, the existing logic still treated it as a configured-but-down gateway, surfacing a persistent heartbeat-down alert for users running only profile-scoped gateways. New stale-stopped helper in `api/agent_health.py` reports `alive: null` with reason `gateway_stale_stopped_state` instead of `alive: false`. Fresh stopped states still report down (so a recently stopped configured root gateway continues to surface as an outage), and stale `gateway_state == "running"` still reports down (preserving the #1879 false-positive guard). Closes #1944.
|
||||
|
||||
- **PR #1951** by @amlyczz — Gate the goal evaluation hook on goal-related turns only (Issue #1932). Pre-fix, `evaluate_goal_after_turn()` fired on every completed assistant turn when a goal was active, including unrelated user messages — burning the goal budget, triggering continuation prompts that interrupted unrelated conversations, and making `/goal status` numbers misleading. Added `STREAM_GOAL_RELATED` (dict) + `PENDING_GOAL_CONTINUATION` (set) flags in `api.config`; `_run_agent_streaming` accepts a `goal_related=False` kwarg and skips the goal evaluation section when not goal-related; `goal_continue` adds the session to `PENDING_GOAL_CONTINUATION` so the next stream is auto-marked; routes propagate the flag and the `/api/goal` kickoff path passes `goal_related=True`. Co-authored by @franksong2702 (parallel #1946 closed in favor of this PR's broader test coverage). Closes #1932. Stage-326 hotfix per Opus advisor: removed `PENDING_GOAL_CONTINUATION.discard(session_id)` from the streaming worker's `finally` block — that race-erased the marker before the consumer in `routes.py` could read it; the consumer already discards atomically on read. 5 new regression guards pin the corrected ordering.
|
||||
|
||||
- **PR #1953** by @lucky-yonug — Skip the `#1776` provider-peel for custom host:port slugs. `model_with_provider_context` can emit `@custom:<host>:<port>:<model>` when the model provider is derived from an OpenAI `base_url` authority (e.g. `custom:10.8.0.1:8080`). The existing colon-count heuristic mistook those extra colons for an over-split model id and prepended the port segment onto the bare model (`8080:Qwen3-235B`), breaking WebUI while CLI/curl stayed correct. Now detects endpoint-style slugs (IPv4 / localhost / dotted-hostname + numeric port) and skips the peel in that case. References #1776.
|
||||
|
||||
- **PR #1960** by @Michaelyklam — Translate the `workspace_show_hidden_files` label for ja/ru/es/de/zh/zh-Hant/pt/ko, replacing the English fallbacks in seven non-English locales. Closes #1841.
|
||||
|
||||
- **PR #1961** by @sbe27 — WebUI now respects `image_input_mode` instead of unconditionally embedding native `image_url` parts. `_build_native_multimodal_message()` was bypassing the agent's `image_input_mode` config, causing silent turn failures with non-vision models or text-only fallbacks. Added `_resolve_image_input_mode(cfg)` mirroring `decide_image_input_mode()` and wired into the multimodal message builder; when mode resolves to `"text"`, returns a plain string so `vision_analyze` handles images instead. Closes #1959.
|
||||
|
||||
### Cluster-resolution decisions
|
||||
|
||||
Three duplicate-PR clusters consolidated to one canonical PR each, with `Co-authored-by` attribution preserved on the merge commit:
|
||||
|
||||
- **#1937 race** — three competing fixes filed within 24h: #1942 (synchronous mutex), #1949 (generation-token + mutex), #1962 (serialization + browser evidence). Selected #1949 as the canonical fix; the synchronous-mutex approach in #1942/#1962 doesn't reach into a prefetch's resolved callback once it's past the entry-gate. Browser evidence under `docs/pr-media/1937/` was not absorbed (the fix in stage covers what the evidence demonstrates).
|
||||
|
||||
- **#1932 goal hook** — same-shape fixes in #1946 and #1951. Selected #1951 for the materially better test coverage (10 dedicated regression tests vs handful in #1946); both PRs ship the `goal_related` flag through `/api/chat/start` → streaming worker.
|
||||
|
||||
- **Custom-provider dedup** — #1874 (broad scope including a behavior change to `_deduplicate_model_ids`) vs #1947 (4-LOC minimum-correct fix). Selected #1947; #1874's `_deduplicate_model_ids` change can be revisited as a separate PR if the underlying gap is real.
|
||||
|
||||
### Stage-326 fixes applied per Opus advisor
|
||||
|
||||
- **CRITICAL #1951 PENDING_GOAL_CONTINUATION race fix.** The original PR's `finally`-block discard at `api/streaming.py:3553` race-erased the marker before the frontend's SSE-receive → `POST /api/chat/start` round-trip could consume it. Removed the discard; the consumer in `routes.py` discards atomically on read. 5 new regression guards in `tests/test_stage326_pending_goal_continuation_race.py` pin the corrected ordering.
|
||||
|
||||
- **#1956 composer-draft input validation.** Added size + type clamps (text 50 KB max str-coerce, files 50 entries max list-coerce) to the `POST /api/session/draft` handler. Without this, a misbehaving client could persist multi-MB strings into the session JSON via the 400 ms debounced auto-save. 5 new validation tests in `tests/test_stage326_composer_draft_validation.py`.
|
||||
|
||||
- **#1957 SESSION_TTL constant preserved.** The original PR deleted the `SESSION_TTL = 86400 * 30` module constant; existing regression tests (`test_v050258_opus_followups::test_redirect_session_ttl_30_days`, `test_auth_sessions::test_session_ttl_is_24_hours`) pin it as a guard against the daily-kick-out regression from #1419. Restored as the named fallback for `_resolve_session_ttl()`. Reconciled the new `TestSessionTtlResolution` class to use unittest setUp/tearDown env snapshotting rather than the pytest `monkeypatch` fixture (incompatible with `unittest.TestCase` subclasses) and aligned clamp tests with the actual fall-through-to-default behavior.
|
||||
|
||||
### Tests
|
||||
|
||||
5006 → **5028 collected, 5028 passing, 0 regressions** (+51 net new across the 12 PRs + 10 stage-326 hardening tests). Full suite ~143 s on Python 3.11 (HERMES_HOME isolated). JS syntax check (`node -c`) clean on all 5 modified `static/*.js` files. Browser API sanity harness (port 8789): all 11 endpoints + 20 QA tests PASS. Manual live verification on stage-326 server (port 8789): composer-draft validation working (50 KB clamp, 50-entry files clamp, type coercion); session TTL resolution honors env var (3600 s) and falls through on out-of-range. Opus advisor: SHIP-WITH-FIXES (all required + recommended fixes applied in `404e24ac` + `8782fd26` stage commits).
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- Full pytest under `HERMES_HOME` isolation: **5028 passed, 8 skipped, 1 xfailed, 2 xpassed, 1 warning, 8 subtests passed** in 142.61 s.
|
||||
- Browser API harness against port 8789: all 11 endpoints + 20 QA tests PASS (111.19 s).
|
||||
- Manual live verification on stage-326 server (port 8789): composer-draft API + TTL resolution + custom-provider model groups all behave as expected.
|
||||
- `node -c` on all 5 modified `static/*.js` files: clean.
|
||||
- `py_compile` on all 6 modified `api/*.py` files: clean.
|
||||
- No leftover merge-conflict markers anywhere in the tree (companion `tests/test_pwa_manifest_sw.py` regression check + grep sweep).
|
||||
- Stage diff: 28 files, +1609/-116.
|
||||
- Opus advisor pass: VERDICT=SHIP-WITH-FIXES with all critical + recommended fixes now applied. Re-verified on the patched stage HEAD.
|
||||
- Pre-stamp re-fetch of all 12 PR heads: no contributor force-push during the build window.
|
||||
|
||||
### Closed in favor of canonical PRs (with Co-authored-by attribution)
|
||||
|
||||
- **#1942** (franksong2702 — synchronous mutex for #1937) → closed in favor of #1949
|
||||
- **#1962** (Michaelyklam — serialization + browser evidence for #1937) → closed in favor of #1949
|
||||
- **#1946** (franksong2702 — goal_related flag for #1932) → closed in favor of #1951
|
||||
- **#1874** (hacker1e7 — broader custom-provider dedup) → closed in favor of #1947's 4-LOC fix
|
||||
- **#1311** (lost9999 — codex cache invalidation; superseded on master)
|
||||
|
||||
## [v0.51.30] — 2026-05-08 — 3-PR contributor batch (Release G: offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll)
|
||||
|
||||
### Added (3 PRs, all from @ai-ag2026)
|
||||
@@ -31,7 +103,7 @@
|
||||
|
||||
### Follow-up items filed (non-blocking)
|
||||
|
||||
- **Race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded`** — with both opt-ins ON, an in-flight prefetch (started by 1.5x-viewport trigger) racing with `jumpToSessionStart` → `_ensureAllMessagesLoaded` could produce duplicate messages if the prefetch resolves last. Narrow window, but the fix is to gate `_ensureAllMessagesLoaded` on the existing `_loadingOlder` flag.
|
||||
- **Race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded`** — with both opt-ins ON, an in-flight prefetch (started by 1.5x-viewport trigger) racing with `jumpToSessionStart` → `_ensureAllMessagesLoaded` could produce duplicate messages if the prefetch resolves last. Narrow window, but the fix is to gate `_ensureAllMessagesLoaded` on the existing `_loadingOlder` flag. **Resolved in Unreleased — see #1937 entry above; final fix uses generation-token + mutex rather than the originally-suggested flag gate, which would not have closed the race.**
|
||||
- **#1928 locale parity** — `session_jump_*` and `settings_*_session_jump_buttons` keys are English literals in ja/ru/es/de/zh/zh-Hant/pt/ko. Default-OFF + English fallback works, but breaks the locale-parity standard set by #1929 and #1891 in the same release.
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> Last updated: v0.51.30 (May 8, 2026) — 4977 tests collected — 3-PR Release G batch (offline recovery + PWA hardening + opt-in session jump buttons + opt-in endless-scroll)
|
||||
> Last updated: v0.51.31 (May 9, 2026) — 5028 tests collected — Release H 12-PR contributor batch (image-mode fix + race fixes + composer drafts + locale parity + custom-provider dedup + TTL config + heartbeat polish)
|
||||
> Test source: `pytest tests/ --collect-only -q`
|
||||
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
|
||||
+1
-1
@@ -1835,7 +1835,7 @@ Bridged CLI sessions:
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.51.30, May 8, 2026*
|
||||
*Last updated: v0.51.31, May 9, 2026*
|
||||
*Total automated tests collected: 4977*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
|
||||
@@ -91,6 +91,41 @@ def _runtime_status_is_fresh(
|
||||
return age_s <= threshold_s
|
||||
|
||||
|
||||
def _runtime_status_is_stale_stopped(
|
||||
runtime_status: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
|
||||
) -> bool:
|
||||
"""Return ``True`` for an old clean-stop root gateway state.
|
||||
|
||||
A user may run only profile-scoped gateways while a root
|
||||
``gateway_state.json`` from an older, intentionally stopped gateway remains
|
||||
on disk (#1944). Treat that stale stopped file like "no root gateway
|
||||
configured" so the heartbeat banner does not keep warning about a service
|
||||
the user is not running. Fresh stopped state still reports down.
|
||||
"""
|
||||
if not isinstance(runtime_status, dict):
|
||||
return False
|
||||
if runtime_status.get("gateway_state") != "stopped":
|
||||
return False
|
||||
|
||||
raw_updated_at = runtime_status.get("updated_at")
|
||||
if not isinstance(raw_updated_at, str) or not raw_updated_at:
|
||||
return False
|
||||
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(raw_updated_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if updated_at.tzinfo is None:
|
||||
return False
|
||||
|
||||
reference = now if now is not None else datetime.now(timezone.utc)
|
||||
age_s = (reference - updated_at).total_seconds()
|
||||
return age_s > threshold_s
|
||||
|
||||
|
||||
def _gateway_status_module():
|
||||
"""Load gateway.status lazily so tests and WebUI-only installs stay isolated."""
|
||||
return importlib.import_module("gateway.status")
|
||||
@@ -263,6 +298,17 @@ def build_agent_health_payload() -> dict[str, Any]:
|
||||
},
|
||||
}
|
||||
|
||||
if _runtime_status_is_stale_stopped(runtime_status):
|
||||
return {
|
||||
"alive": None,
|
||||
"checked_at": checked_at,
|
||||
"details": {
|
||||
"state": "unknown",
|
||||
"reason": "gateway_stale_stopped_state",
|
||||
**safe_details,
|
||||
},
|
||||
}
|
||||
|
||||
if isinstance(runtime_status, dict):
|
||||
return {
|
||||
"alive": False,
|
||||
|
||||
+29
-3
@@ -17,6 +17,33 @@ from api.config import STATE_DIR, load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Default session TTL — 30 days. Kept as a module-level constant for backwards
|
||||
# compatibility with downstream code and regression tests that import it.
|
||||
# At runtime, prefer ``_resolve_session_ttl()`` which honours the env var and
|
||||
# settings.json overrides; this constant is the floor / fallback.
|
||||
SESSION_TTL = 86400 * 30 # 30 days
|
||||
|
||||
|
||||
def _resolve_session_ttl() -> int:
|
||||
"""Resolve session TTL from env > settings > default.
|
||||
|
||||
Priority mirrors get_password_hash(): HERMES_WEBUI_SESSION_TTL env var
|
||||
first, then settings.json, falling back to ``SESSION_TTL`` (30 days).
|
||||
Clamped to [60s, 1 year] to prevent runaway cookies or self-lockout.
|
||||
"""
|
||||
env_v = os.getenv('HERMES_WEBUI_SESSION_TTL', '').strip()
|
||||
if env_v.isdigit():
|
||||
val = int(env_v)
|
||||
if 60 <= val <= 86400 * 365:
|
||||
return val
|
||||
s = load_settings()
|
||||
v = s.get('session_ttl_seconds')
|
||||
if isinstance(v, int) and 60 <= v <= 86400 * 365:
|
||||
return v
|
||||
return SESSION_TTL
|
||||
|
||||
|
||||
# ── Public paths (no auth required) ─────────────────────────────────────────
|
||||
PUBLIC_PATHS = frozenset({
|
||||
'/login', '/health', '/favicon.ico', '/sw.js',
|
||||
@@ -25,7 +52,6 @@ PUBLIC_PATHS = frozenset({
|
||||
})
|
||||
|
||||
COOKIE_NAME = 'hermes_session'
|
||||
SESSION_TTL = 86400 * 30 # 30 days
|
||||
|
||||
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
|
||||
|
||||
@@ -210,7 +236,7 @@ def verify_password(plain) -> bool:
|
||||
def create_session() -> str:
|
||||
"""Create a new auth session. Returns signed cookie value."""
|
||||
token = secrets.token_hex(32)
|
||||
_sessions[token] = time.time() + SESSION_TTL
|
||||
_sessions[token] = time.time() + _resolve_session_ttl()
|
||||
_save_sessions(_sessions)
|
||||
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:32]
|
||||
return f"{token}.{sig}"
|
||||
@@ -323,7 +349,7 @@ def set_auth_cookie(handler, cookie_value) -> None:
|
||||
cookie[COOKIE_NAME]['httponly'] = True
|
||||
cookie[COOKIE_NAME]['samesite'] = 'Lax'
|
||||
cookie[COOKIE_NAME]['path'] = '/'
|
||||
cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL)
|
||||
cookie[COOKIE_NAME]['max-age'] = str(_resolve_session_ttl())
|
||||
# Set Secure flag when connection is HTTPS
|
||||
if getattr(handler.request, 'getpeercert', None) is not None or handler.headers.get('X-Forwarded-Proto', '') == 'https':
|
||||
cookie[COOKIE_NAME]['secure'] = True
|
||||
|
||||
+54
-8
@@ -1430,6 +1430,44 @@ def _base_url_points_at_local_server(base_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _custom_slug_rest_looks_like_host_port(rest: str) -> bool:
|
||||
"""True when ``custom:<rest>`` is an endpoint-style slug ``host:port``.
|
||||
|
||||
WebUI sometimes derives ``custom:10.8.71.41:8080`` from ``base_url`` authority.
|
||||
The #1776 peel must not treat that middle colon as part of an eaten model
|
||||
segment — otherwise ``@custom:10.8.71.41:8080:Qwen3`` wrongly becomes model
|
||||
``8080:Qwen3``.
|
||||
"""
|
||||
rest = str(rest or "").strip()
|
||||
if ":" not in rest:
|
||||
return False
|
||||
host, port_s = rest.rsplit(":", 1)
|
||||
if not host or ":" in host:
|
||||
return False
|
||||
if not port_s.isdigit():
|
||||
return False
|
||||
try:
|
||||
port_n = int(port_s)
|
||||
except ValueError:
|
||||
return False
|
||||
if not (1 <= port_n <= 65535):
|
||||
return False
|
||||
try:
|
||||
import ipaddress
|
||||
|
||||
ipaddress.ip_address(host)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
hl = host.lower()
|
||||
if hl == "localhost":
|
||||
return True
|
||||
# Typical DNS hostname used as proxy slug (contains at least one label dot).
|
||||
if "." in host:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_model_provider(model_id: str) -> tuple:
|
||||
"""Resolve model name, provider, and base_url for AIAgent.
|
||||
|
||||
@@ -1516,15 +1554,20 @@ def resolve_model_provider(model_id: str) -> tuple:
|
||||
# ("@custom:my-key:some-model:free"), rsplit yields
|
||||
# provider_hint="custom:my-key:some-model", bare_model="free", and the
|
||||
# custom-prefix guard below skips the split-fallback. Detect the
|
||||
# over-split structurally — custom hints carry exactly one segment after
|
||||
# "custom:", so any provider_hint with 2+ colons that starts with
|
||||
# "custom:" has eaten part of the model name. Peel one segment back.
|
||||
# over-split structurally — custom hints normally carry one slug segment
|
||||
# after ``custom:``. If ``provider_hint`` has extra ``:`` tokens because the
|
||||
# model ID contained tags like ``:free``, peel one segment back (#1776).
|
||||
#
|
||||
# Exception: ``custom:<ip-or-host>:<port>`` is a single logical slug derived
|
||||
# from OpenAI ``base_url`` authority and contains no eaten model segments.
|
||||
if model_id.startswith("@") and ":" in model_id:
|
||||
inner = model_id[1:]
|
||||
provider_hint, bare_model = inner.rsplit(":", 1)
|
||||
if provider_hint.startswith("custom:") and provider_hint.count(":") >= 2:
|
||||
provider_hint, extra = provider_hint.rsplit(":", 1)
|
||||
bare_model = f"{extra}:{bare_model}"
|
||||
_slug_rest = provider_hint[len("custom:"):]
|
||||
if not _custom_slug_rest_looks_like_host_port(_slug_rest):
|
||||
provider_hint, extra = provider_hint.rsplit(":", 1)
|
||||
bare_model = f"{extra}:{bare_model}"
|
||||
elif (provider_hint not in _PROVIDER_MODELS
|
||||
and provider_hint not in _PROVIDER_DISPLAY
|
||||
and not provider_hint.startswith("custom:")):
|
||||
@@ -2949,7 +2992,7 @@ def get_available_models() -> dict:
|
||||
_custom_providers_cfg = cfg.get("custom_providers", [])
|
||||
_named_custom_groups: dict = {}
|
||||
if isinstance(_custom_providers_cfg, list):
|
||||
_seen_custom_ids = {m["id"] for m in auto_detected_models}
|
||||
_seen_custom_ids = set()
|
||||
for _cp in _custom_providers_cfg:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
@@ -2970,9 +3013,10 @@ def get_available_models() -> dict:
|
||||
_cp_model_ids.append(_m_id.strip())
|
||||
|
||||
for _cp_model in _cp_model_ids:
|
||||
if _cp_model and _cp_model not in _seen_custom_ids:
|
||||
_dedup_key = f"{_slug}:{_cp_model}" if _slug else _cp_model
|
||||
if _cp_model and _dedup_key not in _seen_custom_ids:
|
||||
_cp_label = _get_label_for_model(_cp_model, [])
|
||||
_seen_custom_ids.add(_cp_model)
|
||||
_seen_custom_ids.add(_dedup_key)
|
||||
if _slug:
|
||||
detected_providers.add(_slug)
|
||||
_cp_option_id = _cp_model
|
||||
@@ -3606,6 +3650,8 @@ AGENT_INSTANCES: dict = {} # stream_id -> AIAgent instance for interrupt propag
|
||||
STREAM_PARTIAL_TEXT: dict = {} # stream_id -> partial assistant text accumulated during streaming
|
||||
STREAM_REASONING_TEXT: dict = {} # stream_id -> reasoning trace accumulated during streaming (#1361 §A)
|
||||
STREAM_LIVE_TOOL_CALLS: dict = {} # stream_id -> live tool calls accumulated during streaming (#1361 §B)
|
||||
STREAM_GOAL_RELATED: dict = {} # stream_id -> bool: only evaluate goal for goal-related turns (#1932)
|
||||
PENDING_GOAL_CONTINUATION: set = set() # session_ids awaiting a goal continuation turn (#1932)
|
||||
SERVER_START_TIME = time.time()
|
||||
|
||||
# Agent cache: reuse AIAgent across messages in the same WebUI session so that
|
||||
|
||||
+4
-1
@@ -335,6 +335,7 @@ class Session:
|
||||
llm_title_generated: bool=False,
|
||||
parent_session_id: str=None,
|
||||
enabled_toolsets=None,
|
||||
composer_draft=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
@@ -373,6 +374,7 @@ class Session:
|
||||
self.session_source = kwargs.get('session_source')
|
||||
self.source_label = kwargs.get('source_label')
|
||||
self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override
|
||||
self.composer_draft = composer_draft if isinstance(composer_draft, dict) else {}
|
||||
self._metadata_message_count = None
|
||||
|
||||
@property
|
||||
@@ -413,7 +415,7 @@ class Session:
|
||||
'gateway_routing', 'gateway_routing_history', 'llm_title_generated',
|
||||
'parent_session_id',
|
||||
'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label',
|
||||
'enabled_toolsets',
|
||||
'enabled_toolsets', 'composer_draft',
|
||||
]
|
||||
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
|
||||
meta['messages'] = self.messages
|
||||
@@ -590,6 +592,7 @@ class Session:
|
||||
'session_source': self.session_source,
|
||||
'source_label': self.source_label,
|
||||
'enabled_toolsets': self.enabled_toolsets,
|
||||
'composer_draft': self.composer_draft if isinstance(self.composer_draft, dict) else {},
|
||||
'is_streaming': _is_streaming_session(
|
||||
self.active_stream_id, active_stream_ids
|
||||
) if include_runtime else False,
|
||||
|
||||
+67
-1
@@ -779,6 +779,8 @@ from api.config import (
|
||||
set_reasoning_effort,
|
||||
create_stream_channel,
|
||||
get_webui_session_save_mode,
|
||||
STREAM_GOAL_RELATED,
|
||||
PENDING_GOAL_CONTINUATION,
|
||||
)
|
||||
from api.helpers import (
|
||||
require,
|
||||
@@ -3999,6 +4001,57 @@ def handle_post(handler, parsed) -> bool:
|
||||
s.save()
|
||||
return j(handler, {"ok": True, "enabled_toolsets": s.enabled_toolsets})
|
||||
|
||||
if parsed.path == "/api/session/draft":
|
||||
# GET ?session_id=X → return current draft
|
||||
# POST body → save draft { session_id, text?, files? }
|
||||
# HTTP method is in handler.command (e.g. "POST", "GET"), parsed has no .method
|
||||
if handler.command == "GET":
|
||||
query = parse_qs(parsed.query)
|
||||
sid = query.get("session_id", [""])[0] if parsed.query else ""
|
||||
if not sid:
|
||||
return bad(handler, "session_id is required", 400)
|
||||
try:
|
||||
s = get_session(sid)
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
draft = getattr(s, "composer_draft", {}) or {}
|
||||
return j(handler, {"draft": draft})
|
||||
# POST
|
||||
try:
|
||||
require(body, "session_id")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
sid = body["session_id"]
|
||||
text = body.get("text")
|
||||
files = body.get("files")
|
||||
# Stage-326 hardening (per Opus advisor): size + type validation on
|
||||
# the draft inputs. Without this, a misbehaving or malicious client
|
||||
# can persist multi-MB strings into the session JSON on every keystroke
|
||||
# via the 400ms debounced auto-save.
|
||||
_MAX_DRAFT_TEXT = 50_000 # 50 KB cap on textarea content
|
||||
_MAX_DRAFT_FILES = 50 # max number of attached file references
|
||||
if text is not None and not isinstance(text, str):
|
||||
text = ""
|
||||
if isinstance(text, str) and len(text) > _MAX_DRAFT_TEXT:
|
||||
text = text[:_MAX_DRAFT_TEXT]
|
||||
if files is not None and not isinstance(files, list):
|
||||
files = []
|
||||
if isinstance(files, list) and len(files) > _MAX_DRAFT_FILES:
|
||||
files = files[:_MAX_DRAFT_FILES]
|
||||
try:
|
||||
s = get_session(sid)
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
with _get_session_agent_lock(sid):
|
||||
draft = getattr(s, "composer_draft", {}) or {}
|
||||
if text is not None:
|
||||
draft["text"] = text
|
||||
if files is not None:
|
||||
draft["files"] = files
|
||||
s.composer_draft = draft
|
||||
s.save()
|
||||
return j(handler, {"ok": True, "draft": s.composer_draft})
|
||||
|
||||
if parsed.path == "/api/session/update":
|
||||
try:
|
||||
require(body, "session_id")
|
||||
@@ -6451,6 +6504,7 @@ def _start_chat_stream_for_session(
|
||||
model_provider=None,
|
||||
normalized_model: bool = False,
|
||||
diag=None,
|
||||
goal_related: bool = False,
|
||||
):
|
||||
"""Persist pending state, register an SSE channel, and start an agent turn."""
|
||||
attachments = attachments or []
|
||||
@@ -6473,6 +6527,14 @@ def _start_chat_stream_for_session(
|
||||
# Stale stream id from a previous run; clear and continue.
|
||||
diag.stage("stale_stream_cleanup") if diag else None
|
||||
_clear_stale_stream_state(s)
|
||||
|
||||
# #1932: check if this session has a pending goal continuation flag.
|
||||
# The streaming hook sets PENDING_GOAL_CONTINUATION when goal_continue fires,
|
||||
# so the next chat/start for this session is automatically treated as goal-related.
|
||||
if not goal_related and s.session_id in PENDING_GOAL_CONTINUATION:
|
||||
goal_related = True
|
||||
PENDING_GOAL_CONTINUATION.discard(s.session_id)
|
||||
|
||||
stream_id = uuid.uuid4().hex
|
||||
session_lock = _get_session_agent_lock(s.session_id)
|
||||
diag.stage("session_lock_wait") if diag else None
|
||||
@@ -6493,11 +6555,14 @@ def _start_chat_stream_for_session(
|
||||
stream = create_stream_channel()
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = stream
|
||||
# #1932: mark stream as goal-related so the streaming hook evaluates the goal.
|
||||
if goal_related:
|
||||
STREAM_GOAL_RELATED[stream_id] = True
|
||||
diag.stage("worker_thread_start") if diag else None
|
||||
thr = threading.Thread(
|
||||
target=_run_agent_streaming,
|
||||
args=(s.session_id, msg, model, workspace, stream_id, attachments),
|
||||
kwargs={"model_provider": model_provider},
|
||||
kwargs={"model_provider": model_provider, "goal_related": goal_related},
|
||||
daemon=True,
|
||||
)
|
||||
thr.start()
|
||||
@@ -6621,6 +6686,7 @@ def _handle_goal_command(handler, body):
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
normalized_model=normalized_model,
|
||||
goal_related=True,
|
||||
)
|
||||
status = int(stream_response.pop("_status", 200) or 200)
|
||||
payload.update(stream_response)
|
||||
|
||||
+63
-3
@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
from api.config import (
|
||||
STREAMS, STREAMS_LOCK, CANCEL_FLAGS, AGENT_INSTANCES, STREAM_PARTIAL_TEXT,
|
||||
STREAM_REASONING_TEXT, STREAM_LIVE_TOOL_CALLS,
|
||||
STREAM_GOAL_RELATED, PENDING_GOAL_CONTINUATION,
|
||||
LOCK, SESSIONS, SESSION_DIR,
|
||||
_get_session_agent_lock, _set_thread_env, _clear_thread_env,
|
||||
SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK,
|
||||
@@ -431,17 +432,60 @@ def _is_valid_image(path: Path, mime: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachments, workspace: str):
|
||||
def _resolve_image_input_mode(cfg: dict) -> str:
|
||||
"""Return ``"native"`` or ``"text"`` based on config, mirroring
|
||||
``agent/image_routing.py:decide_image_input_mode``.
|
||||
|
||||
The agent has this logic, but the WebUI's ``_build_native_multimodal_message``
|
||||
was unconditionally embedding images as native ``image_url`` parts, completely
|
||||
bypassing ``image_input_mode``. This caused silent failures when the main model
|
||||
does not support images and the fallback model is also text-only (#21160-related).
|
||||
"""
|
||||
agent_cfg = cfg.get("agent") or {}
|
||||
mode = str(agent_cfg.get("image_input_mode", "auto") or "auto").strip().lower()
|
||||
if mode not in ("auto", "native", "text"):
|
||||
mode = "auto"
|
||||
|
||||
if mode == "native":
|
||||
return "native"
|
||||
if mode == "text":
|
||||
return "text"
|
||||
|
||||
# auto: if auxiliary.vision is explicitly configured → text mode
|
||||
# (user opted into a dedicated vision backend)
|
||||
aux = cfg.get("auxiliary") or {}
|
||||
vision = aux.get("vision") or {}
|
||||
provider = str(vision.get("provider") or "").strip().lower()
|
||||
model_name = str(vision.get("model") or "").strip()
|
||||
base_url = str(vision.get("base_url") or "").strip()
|
||||
if provider not in ("", "auto") or model_name or base_url:
|
||||
return "text"
|
||||
|
||||
# No explicit vision config, no model-capability lookup available in WebUI.
|
||||
# Default to native — the agent's ``_strip_images_from_messages`` guard will
|
||||
# strip images on rejection and retry as text.
|
||||
return "native"
|
||||
|
||||
|
||||
def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachments, workspace: str, *, cfg: dict = None):
|
||||
"""Build native multimodal content parts for current-turn image uploads.
|
||||
|
||||
WebUI uploads files into the active workspace. For image files, pass the
|
||||
bytes to Hermes as OpenAI-style image_url data URLs so vision-capable main
|
||||
models can consume them in the same request. Non-image files intentionally
|
||||
stay as text path attachments so the agent can inspect them with file tools.
|
||||
|
||||
When *cfg* is provided, respects ``agent.image_input_mode`` — if the resolved
|
||||
mode is ``"text"``, returns a plain string (attachments are not embedded) so
|
||||
the agent's text-mode pipeline (``vision_analyze``) handles images.
|
||||
"""
|
||||
if not attachments:
|
||||
return workspace_ctx + msg_text
|
||||
|
||||
# ── Check image_input_mode before embedding anything ──
|
||||
if cfg is not None and _resolve_image_input_mode(cfg) == "text":
|
||||
return workspace_ctx + msg_text
|
||||
|
||||
parts = [{'type': 'text', 'text': workspace_ctx + msg_text}]
|
||||
workspace_root = Path(workspace).expanduser().resolve()
|
||||
image_count = 0
|
||||
@@ -1857,6 +1901,7 @@ def _run_agent_streaming(
|
||||
*,
|
||||
ephemeral=False,
|
||||
model_provider=None,
|
||||
goal_related=False,
|
||||
):
|
||||
"""Run agent in background thread, writing SSE events to STREAMS[stream_id].
|
||||
|
||||
@@ -2654,7 +2699,7 @@ def _run_agent_streaming(
|
||||
)
|
||||
_ckpt_thread.start()
|
||||
|
||||
user_message = _build_native_multimodal_message(workspace_ctx, msg_text, attachments, workspace)
|
||||
user_message = _build_native_multimodal_message(workspace_ctx, msg_text, attachments, workspace, cfg=_cfg)
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
@@ -3231,10 +3276,12 @@ def _run_agent_streaming(
|
||||
# GoalManager judge before terminal done/stream_end events. The
|
||||
# frontend surfaces the status line and queues continuation_prompt as
|
||||
# a normal next user message so /queue and user input keep priority.
|
||||
# #1932: only evaluate when the turn was goal-related (set via
|
||||
# STREAM_GOAL_RELATED or goal_related parameter).
|
||||
try:
|
||||
from api.goals import evaluate_goal_after_turn, has_active_goal
|
||||
|
||||
if not has_active_goal(session_id, profile_home=_profile_home):
|
||||
if not goal_related or not has_active_goal(session_id, profile_home=_profile_home):
|
||||
_goal_decision = {}
|
||||
else:
|
||||
_last_goal_response = ''
|
||||
@@ -3276,6 +3323,9 @@ def _run_agent_streaming(
|
||||
if decision.get('should_continue'):
|
||||
continuation_prompt = str(decision.get('continuation_prompt') or '').strip()
|
||||
if continuation_prompt:
|
||||
# #1932: mark this session as pending a goal continuation
|
||||
# so the next /chat/start creates a goal-related stream.
|
||||
PENDING_GOAL_CONTINUATION.add(session_id)
|
||||
put('goal_continue', {
|
||||
'session_id': session_id,
|
||||
'continuation_prompt': continuation_prompt,
|
||||
@@ -3499,6 +3549,16 @@ def _run_agent_streaming(
|
||||
STREAM_PARTIAL_TEXT.pop(stream_id, None) # Clean up partial text buffer (#893)
|
||||
STREAM_REASONING_TEXT.pop(stream_id, None) # Clean up reasoning trace (#1361 §A)
|
||||
STREAM_LIVE_TOOL_CALLS.pop(stream_id, None) # Clean up tool calls (#1361 §B)
|
||||
STREAM_GOAL_RELATED.pop(stream_id, None) # Clean up goal-related flag (#1932)
|
||||
# NOTE: do NOT discard PENDING_GOAL_CONTINUATION here. The marker
|
||||
# is set by goal_continue (line ~3328) inside the SAME function
|
||||
# call and consumed atomically by `_start_chat_stream_for_session`
|
||||
# in routes.py (around line 6522) when the next stream starts.
|
||||
# Discarding here in the streaming worker's `finally` would
|
||||
# almost always race ahead of the frontend's SSE-receive →
|
||||
# POST /api/chat/start round-trip and erase the marker before
|
||||
# the next stream can read it, breaking the goal-continuation
|
||||
# chain. Stage-326 critical fix per Opus advisor review.
|
||||
|
||||
# ============================================================
|
||||
# SECTION: HTTP Request Handler
|
||||
|
||||
@@ -872,6 +872,11 @@ $('modelSelect').onchange=async()=>{
|
||||
$('msg').addEventListener('input',()=>{
|
||||
autoResize();
|
||||
updateSendBtn();
|
||||
// Persist composer draft to server (debounced in _saveComposerDraft).
|
||||
const sid = S && S.session && S.session.session_id;
|
||||
if (sid && typeof _saveComposerDraft === 'function') {
|
||||
_saveComposerDraft(sid, $('msg').value, S.pendingFiles ? [...S.pendingFiles] : []);
|
||||
}
|
||||
const text=$('msg').value;
|
||||
if(text.startsWith('/')&&text.indexOf('\n')===-1){
|
||||
if(typeof getSlashAutocompleteMatches==='function'){
|
||||
|
||||
+59
-59
@@ -1170,10 +1170,10 @@ const LOCALES = {
|
||||
untitled: '無題',
|
||||
n_messages: (n) => `${n} 件のメッセージ`,
|
||||
load_older_messages: '↑ 上にスクロール、またはクリックして過去のメッセージを読み込む',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: '開始',
|
||||
session_jump_start_label: 'セッションの先頭へ移動',
|
||||
session_jump_end: '末尾',
|
||||
session_jump_end_label: 'セッションの末尾へ移動',
|
||||
queued_label: '応答後に送信',
|
||||
queued_count: (n) => `${n} 件キュー中`,
|
||||
queued_cancel: 'キューに入れたメッセージをキャンセル',
|
||||
@@ -1361,7 +1361,7 @@ const LOCALES = {
|
||||
terminal_error: 'ターミナルエラー',
|
||||
workspace_empty_no_path: 'ワークスペースが選択されていません。設定 → ワークスペースで選択してください。',
|
||||
workspace_empty_dir: 'このワークスペースは空です。',
|
||||
workspace_show_hidden_files: 'Show hidden files',
|
||||
workspace_show_hidden_files: '隠しファイルを表示',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_hidden_files_visible: 'hidden visible',
|
||||
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
|
||||
@@ -1470,8 +1470,8 @@ const LOCALES = {
|
||||
settings_updates_disabled: 'アップデート確認は無効です',
|
||||
settings_label_workspace_panel_open: 'ワークスペースパネルをデフォルトで開いておく',
|
||||
settings_desc_workspace_panel_open: '有効にすると、新しいセッションごとにワークスペース/ファイルブラウザパネルが自動で開きます。手動でいつでも閉じられます。',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
settings_label_session_jump_buttons: 'セッションジャンプボタンを表示',
|
||||
settings_desc_session_jump_buttons: '長いセッション履歴を読むときに、浮動表示の「開始」と「末尾」ボタンを表示します。',
|
||||
|
||||
settings_label_session_endless_scroll: '上スクロールで古いメッセージを読み込む',
|
||||
|
||||
@@ -2186,10 +2186,10 @@ const LOCALES = {
|
||||
untitled: 'Без названия',
|
||||
n_messages: (n) => `${n} сообщений`,
|
||||
load_older_messages: '↑ Прокрутите вверх или нажмите, чтобы загрузить ранние сообщения',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: 'Начало',
|
||||
session_jump_start_label: 'Перейти к началу сессии',
|
||||
session_jump_end: 'Конец',
|
||||
session_jump_end_label: 'Перейти к концу сессии',
|
||||
queued_label: 'Отправить после ответа',
|
||||
queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`,
|
||||
queued_cancel: 'Отменить сообщение',
|
||||
@@ -2298,7 +2298,7 @@ const LOCALES = {
|
||||
settings_autosave_failed: 'Не удалось сохранить',
|
||||
settings_autosave_retry: 'Повторить',
|
||||
workspace_empty_dir: 'Это рабочее пространство пусто.',
|
||||
workspace_show_hidden_files: 'Show hidden files',
|
||||
workspace_show_hidden_files: 'Показывать скрытые файлы',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_hidden_files_visible: 'hidden visible',
|
||||
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
|
||||
@@ -2925,8 +2925,8 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Ошибка проверки обновлений',
|
||||
settings_label_workspace_panel_open: 'Открывать панель рабочей области по умолчанию',
|
||||
settings_desc_workspace_panel_open: 'При включении панель файлов будет открываться автоматически в каждой новой сессии.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
settings_label_session_jump_buttons: 'Показывать кнопки перехода по сессии',
|
||||
settings_desc_session_jump_buttons: 'Показывать плавающие кнопки «Начало» и «Конец» при чтении длинных историй сессий.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Загружать старые сообщения при прокрутке вверх',
|
||||
|
||||
@@ -3160,10 +3160,10 @@ const LOCALES = {
|
||||
untitled: 'Sin título',
|
||||
n_messages: (n) => `${n} mensajes`,
|
||||
load_older_messages: '↑ Desplázate hacia arriba o haz clic para cargar mensajes anteriores',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: 'Inicio',
|
||||
session_jump_start_label: 'Saltar al inicio de la sesión',
|
||||
session_jump_end: 'Fin',
|
||||
session_jump_end_label: 'Saltar al final de la sesión',
|
||||
queued_label: 'Enviar después de la respuesta',
|
||||
queued_count: (n) => n === 1 ? '1 en cola' : `${n} en cola`,
|
||||
queued_cancel: 'Cancelar mensaje en cola',
|
||||
@@ -3279,7 +3279,7 @@ const LOCALES = {
|
||||
terminal_error: 'Error del terminal',
|
||||
workspace_empty_no_path: 'No hay espacio de trabajo seleccionado. Configure un espacio de trabajo en Ajustes \u2192 Workspace para explorar archivos.',
|
||||
workspace_empty_dir: 'Este espacio de trabajo está vacío.',
|
||||
workspace_show_hidden_files: 'Show hidden files',
|
||||
workspace_show_hidden_files: 'Mostrar archivos ocultos',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_hidden_files_visible: 'hidden visible',
|
||||
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
|
||||
@@ -3885,8 +3885,8 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Error al comprobar actualizaciones',
|
||||
settings_label_workspace_panel_open: 'Mantener panel de espacio abierto',
|
||||
settings_desc_workspace_panel_open: 'Al activar, el panel de archivos se abre automáticamente en cada nueva sesión. Aún puedes cerrarlo manualmente.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
settings_label_session_jump_buttons: 'Mostrar botones de salto de sesión',
|
||||
settings_desc_session_jump_buttons: 'Muestra botones flotantes de Inicio y Fin al leer historiales de sesión largos.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Cargar mensajes antiguos al desplazarse hacia arriba',
|
||||
|
||||
@@ -4130,10 +4130,10 @@ const LOCALES = {
|
||||
untitled: 'Unbenannt',
|
||||
n_messages: (n) => `${n} Nachrichten`,
|
||||
load_older_messages: '↑ Nach oben scrollen oder klicken, um ältere Nachrichten zu laden',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: 'Anfang',
|
||||
session_jump_start_label: 'Zum Anfang der Sitzung springen',
|
||||
session_jump_end: 'Ende',
|
||||
session_jump_end_label: 'Zum Ende der Sitzung springen',
|
||||
queued_label: 'Wird nach Antwort gesendet',
|
||||
queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`,
|
||||
queued_cancel: 'Nachricht abbrechen',
|
||||
@@ -4590,8 +4590,8 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Update-Prüfung fehlgeschlagen',
|
||||
settings_label_workspace_panel_open: 'Arbeitsbereich-Panel standardmäßig öffnen',
|
||||
settings_desc_workspace_panel_open: 'Wenn aktiviert, wird der Datei-Browser bei jeder neuen Sitzung automatisch geöffnet. Er kann jederzeit manuell geschlossen werden.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
settings_label_session_jump_buttons: 'Sitzungs-Sprungtasten anzeigen',
|
||||
settings_desc_session_jump_buttons: 'Zeigt beim Lesen langer Sitzungsverläufe schwebende Anfang- und Ende-Tasten an.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Ältere Nachrichten beim Hochscrollen laden',
|
||||
|
||||
@@ -5104,10 +5104,10 @@ const LOCALES = {
|
||||
untitled: '\u672a\u547d\u540d',
|
||||
n_messages: (n) => `${n} \u6761\u6d88\u606f`,
|
||||
load_older_messages: '↑ 向上滚动或点击加载更早的消息',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: '开头',
|
||||
session_jump_start_label: '跳转到会话开头',
|
||||
session_jump_end: '结尾',
|
||||
session_jump_end_label: '跳转到会话结尾',
|
||||
queued_label: '响应后发送',
|
||||
queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`,
|
||||
queued_cancel: '取消排队消息',
|
||||
@@ -5200,7 +5200,7 @@ const LOCALES = {
|
||||
|
||||
workspace_empty_no_path: '未选择工作区。请在 设置 → 工作区 中设置工作区以浏览文件。',
|
||||
workspace_empty_dir: '此工作区为空。',
|
||||
workspace_show_hidden_files: 'Show hidden files',
|
||||
workspace_show_hidden_files: '显示隐藏文件',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_hidden_files_visible: 'hidden visible',
|
||||
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
|
||||
@@ -5826,8 +5826,8 @@ const LOCALES = {
|
||||
settings_update_check_failed: '更新检查失败',
|
||||
settings_label_workspace_panel_open: '默认保持工作区面板打开',
|
||||
settings_desc_workspace_panel_open: '启用后,工作区/文件浏览器面板会在每次新会话时自动打开。您仍可随时手动关闭。',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
settings_label_session_jump_buttons: '显示会话跳转按钮',
|
||||
settings_desc_session_jump_buttons: '阅读较长会话历史时显示悬浮的开头和结尾按钮。',
|
||||
|
||||
settings_label_session_endless_scroll: '向上滚动时加载更早的消息',
|
||||
|
||||
@@ -6066,10 +6066,10 @@ const LOCALES = {
|
||||
untitled: '\u672a\u547d\u540d',
|
||||
n_messages: (n) => `${n} \u689d\u8a0a\u606f`,
|
||||
load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: '開頭',
|
||||
session_jump_start_label: '跳至會話開頭',
|
||||
session_jump_end: '結尾',
|
||||
session_jump_end_label: '跳至會話結尾',
|
||||
model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09',
|
||||
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
|
||||
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
|
||||
@@ -6119,7 +6119,7 @@ const LOCALES = {
|
||||
|
||||
workspace_empty_no_path: '未選擇工作區。請在 設定 → 工作區 中設定工作區以瀏覽檔案。',
|
||||
workspace_empty_dir: '此工作區為空。',
|
||||
workspace_show_hidden_files: 'Show hidden files',
|
||||
workspace_show_hidden_files: '顯示隱藏檔案',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_hidden_files_visible: 'hidden visible',
|
||||
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
|
||||
@@ -6242,8 +6242,8 @@ const LOCALES = {
|
||||
settings_update_check_failed: '更新檢查失敗',
|
||||
settings_label_workspace_panel_open: '預設保持工作區面板開啓',
|
||||
settings_desc_workspace_panel_open: '啟用後,工作區/檔案瀏覽器面板會在每次新會話時自動開啓。您仍可隨時手動關閉。',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
settings_label_session_jump_buttons: '顯示會話跳轉按鈕',
|
||||
settings_desc_session_jump_buttons: '閱讀較長會話歷史時顯示浮動的開頭與結尾按鈕。',
|
||||
|
||||
settings_label_session_endless_scroll: '向上捲動時載入較早訊息',
|
||||
|
||||
@@ -6400,10 +6400,10 @@ const LOCALES = {
|
||||
downloading: (filename) => `正在下載 ${filename}…`,
|
||||
n_messages: (n) => `${n} 則訊息`,
|
||||
load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: '開頭',
|
||||
session_jump_start_label: '跳至會話開頭',
|
||||
session_jump_end: '結尾',
|
||||
session_jump_end_label: '跳至會話結尾',
|
||||
onboarding_api_key_help_prefix: '\u900f\u904e\u4ee5\u4e0b\u65b9\u5f0f\u5132\u5b58\u70ba Hermes .env \u6a94\u6848\u4e2d\u7684\u6a5f\u5bc6',
|
||||
onboarding_api_key_label: 'API \u91d1\u9470',
|
||||
onboarding_api_key_placeholder: '\u7559\u7a7a\u4ee5\u4fdd\u7559\u5df2\u5132\u5b58\u7684\u91d1\u9470',
|
||||
@@ -7029,10 +7029,10 @@ const LOCALES = {
|
||||
untitled: 'Sem título',
|
||||
n_messages: (n) => `${n} mensagens`,
|
||||
load_older_messages: '↑ Role para cima ou clique para carregar mensagens mais antigas',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: 'Início',
|
||||
session_jump_start_label: 'Ir para o início da sessão',
|
||||
session_jump_end: 'Fim',
|
||||
session_jump_end_label: 'Ir para o fim da sessão',
|
||||
queued_label: 'Envia após a resposta',
|
||||
queued_count: (n) => n === 1 ? '1 na fila' : `${n} na fila`,
|
||||
queued_cancel: 'Cancelar mensagem na fila',
|
||||
@@ -7203,7 +7203,7 @@ const LOCALES = {
|
||||
no_workspace: 'Nenhum workspace',
|
||||
workspace_empty_no_path: 'Nenhum workspace selecionado. Configure em Configurações → Workspace.',
|
||||
workspace_empty_dir: 'Este workspace está vazio.',
|
||||
workspace_show_hidden_files: 'Show hidden files',
|
||||
workspace_show_hidden_files: 'Mostrar arquivos ocultos',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_hidden_files_visible: 'hidden visible',
|
||||
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
|
||||
@@ -7297,8 +7297,8 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Falha ao verificar updates',
|
||||
settings_label_workspace_panel_open: 'Manter painel workspace aberto por padrão',
|
||||
settings_desc_workspace_panel_open: 'Quando ativo, o painel workspace abre automaticamente com cada nova sessão.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
settings_label_session_jump_buttons: 'Mostrar botões de salto da sessão',
|
||||
settings_desc_session_jump_buttons: 'Mostra botões flutuantes Início e Fim ao ler históricos longos de sessão.',
|
||||
|
||||
settings_label_session_endless_scroll: 'Carregar mensagens antigas ao rolar para cima',
|
||||
|
||||
@@ -7942,10 +7942,10 @@ const LOCALES = {
|
||||
untitled: '제목 없음',
|
||||
n_messages: (n) => `${n}개 메시지`,
|
||||
load_older_messages: '↑ 위로 스크롤하거나 클릭하여 이전 메시지 불러오기',
|
||||
session_jump_start: 'Start',
|
||||
session_jump_start_label: 'Jump to beginning of session',
|
||||
session_jump_end: 'End',
|
||||
session_jump_end_label: 'Jump to end of session',
|
||||
session_jump_start: '시작',
|
||||
session_jump_start_label: '세션 시작으로 이동',
|
||||
session_jump_end: '끝',
|
||||
session_jump_end_label: '세션 끝으로 이동',
|
||||
queued_label: 'Sends after response',
|
||||
queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`,
|
||||
queued_cancel: 'Cancel queued message',
|
||||
@@ -8124,7 +8124,7 @@ const LOCALES = {
|
||||
terminal_error: '터미널 오류',
|
||||
workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.',
|
||||
workspace_empty_dir: 'This workspace is empty.',
|
||||
workspace_show_hidden_files: 'Show hidden files',
|
||||
workspace_show_hidden_files: '숨김 파일 표시',
|
||||
workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.',
|
||||
workspace_hidden_files_visible: 'hidden visible',
|
||||
workspace_hidden_files_visible_title: 'Hidden files are visible — click for options',
|
||||
@@ -8233,8 +8233,8 @@ const LOCALES = {
|
||||
settings_update_check_failed: 'Update check failed',
|
||||
settings_label_workspace_panel_open: '기본으로 워크스페이스 패널 열기',
|
||||
settings_desc_workspace_panel_open: '활성화하면 새 세션마다 워크스페이스/파일 브라우저 패널이 자동으로 열립니다. 언제든지 수동으로 닫을 수 있습니다.',
|
||||
settings_label_session_jump_buttons: 'Show session jump buttons',
|
||||
settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.',
|
||||
settings_label_session_jump_buttons: '세션 이동 버튼 표시',
|
||||
settings_desc_session_jump_buttons: '긴 세션 기록을 읽을 때 떠 있는 시작 및 끝 버튼을 표시합니다.',
|
||||
|
||||
settings_label_session_endless_scroll: '위로 스크롤할 때 이전 메시지 불러오기',
|
||||
|
||||
|
||||
@@ -189,6 +189,8 @@ async function send(){
|
||||
if(!msgText){setComposerStatus('Nothing to send');return;}
|
||||
|
||||
$('msg').value='';autoResize();
|
||||
// Clear persisted composer draft since message was sent.
|
||||
if (activeSid && typeof _clearComposerDraft === 'function') _clearComposerDraft(activeSid);
|
||||
const displayText=text||(uploaded.length?`Uploaded: ${uploadedNames.join(', ')}`:'(file upload)');
|
||||
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000};
|
||||
S.toolCalls=[]; // clear tool calls from previous turn
|
||||
|
||||
+153
-13
@@ -17,6 +17,74 @@ const ICONS={
|
||||
// before the first request completes (#1060).
|
||||
let _loadingSessionId = null;
|
||||
|
||||
// ── Composer draft persistence ────────────────────────────────────────────────
|
||||
|
||||
// Debounced save — prevents hammering the server on every keystroke.
|
||||
let _draftSaveTimer = null;
|
||||
const _DRAFT_SAVE_DELAY_MS = 400;
|
||||
|
||||
function _saveComposerDraft(sid, text, files) {
|
||||
if (!sid) return;
|
||||
clearTimeout(_draftSaveTimer);
|
||||
_draftSaveTimer = setTimeout(() => {
|
||||
api('/api/session/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }),
|
||||
}).catch(() => {});
|
||||
}, _DRAFT_SAVE_DELAY_MS);
|
||||
}
|
||||
|
||||
// Fire-and-forget immediate save (used before session switches).
|
||||
function _saveComposerDraftNow(sid, text, files) {
|
||||
if (!sid) return;
|
||||
clearTimeout(_draftSaveTimer);
|
||||
api('/api/session/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sid, text: text || '', files: files || [] }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Restore composer draft from server onto #msg textarea.
|
||||
// Only restores if there's actual text (skip empty/None drafts).
|
||||
// Guards against double-restore when rapidly switching sessions.
|
||||
function _restoreComposerDraft(draft, targetSid) {
|
||||
const ta = $('msg');
|
||||
if (!ta) return;
|
||||
// targetSid is the session that was requested — if it no longer matches
|
||||
// _loadingSessionId, a newer session switch has already begun, so skip.
|
||||
if (targetSid && _loadingSessionId !== null && _loadingSessionId !== targetSid) return;
|
||||
const text = (draft && typeof draft.text === 'string') ? draft.text : '';
|
||||
const files = (draft && Array.isArray(draft.files)) ? draft.files : [];
|
||||
// If there's no text and no files, clear the textarea (a previous session's
|
||||
// draft may still be sitting there from a cross-session switch).
|
||||
if (!text && !files.length) {
|
||||
if (ta.value) {
|
||||
ta.value = '';
|
||||
if (typeof autoResize === 'function') autoResize();
|
||||
if (typeof updateSendBtn === 'function') updateSendBtn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Only update if different to avoid cursor jumps on unrelated session switches.
|
||||
const current = ta.value || '';
|
||||
if (current !== text) {
|
||||
ta.value = text;
|
||||
if (typeof autoResize === 'function') autoResize();
|
||||
if (typeof updateSendBtn === 'function') updateSendBtn();
|
||||
}
|
||||
// Files restoration is skipped for now (requires S.pendingFiles plumbing).
|
||||
}
|
||||
|
||||
// Clear the saved draft for a session (called when message is sent).
|
||||
function _clearComposerDraft(sid) {
|
||||
if (!sid) return;
|
||||
clearTimeout(_draftSaveTimer);
|
||||
api('/api/session/draft', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sid, text: '' }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
|
||||
const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';
|
||||
@@ -345,11 +413,10 @@ async function loadSession(sid){
|
||||
// Show loading indicator immediately for responsiveness.
|
||||
// Cleared by renderMessages() once full session data arrives.
|
||||
// Persist the current composer draft before switching away so it can be
|
||||
// restored when the user switches back (#1060).
|
||||
// restored when the user switches back (#1060). Save to server now so the
|
||||
// draft survives page refresh and syncs across clients.
|
||||
if (currentSid && currentSid !== sid) {
|
||||
if (!S.composerDrafts) S.composerDrafts = {};
|
||||
const draft = { text: ($('msg') || {}).value || '', files: S.pendingFiles ? [...S.pendingFiles] : [] };
|
||||
if (draft.text || draft.files.length) S.composerDrafts[currentSid] = draft;
|
||||
_saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []);
|
||||
}
|
||||
if (currentSid !== sid) {
|
||||
S.messages = [];
|
||||
@@ -563,6 +630,15 @@ async function loadSession(sid){
|
||||
});
|
||||
}
|
||||
if(typeof _renderPendingPromptsForActiveSession==='function') _renderPendingPromptsForActiveSession();
|
||||
|
||||
// Restore server-persisted composer draft (synced across clients + survives refresh).
|
||||
// Pass sid so _restoreComposerDraft can skip if this session is mid-load (guards
|
||||
// against stale writes from slow responses racing to restore the previous draft).
|
||||
const _draft = S.session && S.session.composer_draft;
|
||||
if (_draft && (typeof _restoreComposerDraft === 'function')) {
|
||||
_restoreComposerDraft(_draft, sid);
|
||||
}
|
||||
|
||||
_resolveSessionModelForDisplaySoon(sid);
|
||||
// Clear the in-flight session marker now that this load has completed (#1060).
|
||||
if (_loadingSessionId === sid) _loadingSessionId = null;
|
||||
@@ -998,6 +1074,19 @@ let _loadingOlder = false;
|
||||
// oldest message currently loaded in S.messages. Starts at 0 when all
|
||||
// messages are loaded, or > 0 when truncated by msg_limit.
|
||||
let _oldestIdx = 0;
|
||||
// Generation token bumped every time S.messages is wholesale-replaced
|
||||
// (rather than incrementally extended). _loadOlderMessages snapshots it
|
||||
// before its `await` and re-checks after, so a late-resolving prefetch
|
||||
// does not prepend onto a transcript that was rebuilt under it
|
||||
// (e.g. by _ensureAllMessagesLoaded after a Start-jump). See #1937.
|
||||
let _messagesGeneration = 0;
|
||||
function _bumpMessagesGeneration() {
|
||||
// Wrap to keep the counter bounded; the only operation that matters is
|
||||
// strict inequality between the snapshot and the post-await read, so any
|
||||
// monotonic bump is sufficient.
|
||||
_messagesGeneration = (_messagesGeneration + 1) | 0;
|
||||
return _messagesGeneration;
|
||||
}
|
||||
|
||||
async function _loadOlderMessages() {
|
||||
if (_loadingOlder || !_messagesTruncated) return;
|
||||
@@ -1005,6 +1094,11 @@ async function _loadOlderMessages() {
|
||||
if (!sid || !S.messages.length) return;
|
||||
if (_oldestIdx <= 0) { _messagesTruncated = false; return; }
|
||||
_loadingOlder = true;
|
||||
// Snapshot the generation BEFORE we await. If S.messages is wholesale
|
||||
// replaced while the request is in flight, the post-await check below
|
||||
// bails out so we never prepend stale older messages onto a freshly
|
||||
// rebuilt transcript (#1937).
|
||||
const startGeneration = _messagesGeneration;
|
||||
try {
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`);
|
||||
// Guard: api() may have redirected (401) and returned undefined.
|
||||
@@ -1017,6 +1111,13 @@ async function _loadOlderMessages() {
|
||||
if (!data || !data.session) return;
|
||||
if (!S.session || S.session.session_id !== sid) return;
|
||||
if (_loadingSessionId !== null && _loadingSessionId !== sid) return;
|
||||
// Generation guard: another code path (typically jumpToSessionStart →
|
||||
// _ensureAllMessagesLoaded) may have replaced S.messages while we were
|
||||
// awaiting. Prepending older messages onto that replacement would
|
||||
// duplicate the head of the transcript. Detect via the generation
|
||||
// counter and abort cleanly. _oldestIdx and _messagesTruncated were
|
||||
// already reset by the wholesale-replace path, so no rollback needed.
|
||||
if (_messagesGeneration !== startGeneration) return;
|
||||
const olderMsgs = (data.session.messages || []).filter(m => m && m.role);
|
||||
if (!olderMsgs.length) { _messagesTruncated = false; return; }
|
||||
// Prepend older messages
|
||||
@@ -1063,17 +1164,56 @@ async function _loadOlderMessages() {
|
||||
|
||||
// Ensure the full message history is loaded (for undo, export, etc).
|
||||
// If the session was loaded with msg_limit, this fetches all messages.
|
||||
//
|
||||
// Race-safety (#1937): with the endless-scroll opt-in, _loadOlderMessages
|
||||
// may be in flight when this runs (e.g. user scrolled near the top, then
|
||||
// hit the Start jump pill). Two coordinated guards prevent the prefetch
|
||||
// from prepending duplicate messages onto our wholesale replacement:
|
||||
// 1. Hold the _loadingOlder mutex around the body so a NEW prefetch
|
||||
// cannot start mid-replace (entry-gate check at line ~1003 returns
|
||||
// early). The mutex is also self-protecting against concurrent
|
||||
// ensure-all calls from rapid double-clicks on Start.
|
||||
// 2. Bump _messagesGeneration before mutating S.messages so any
|
||||
// in-flight prefetch's post-await generation check bails out.
|
||||
async function _ensureAllMessagesLoaded() {
|
||||
if (!_messagesTruncated || !S.session) return;
|
||||
const sid = S.session.session_id;
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`);
|
||||
// Guard: api() may have redirected (401) and returned undefined.
|
||||
if (!data || !data.session) return;
|
||||
const msgs = (data.session.messages || []).filter(m => m && m.role);
|
||||
S.messages = msgs;
|
||||
_messagesTruncated = false;
|
||||
if(S.session && S.session.session_id === sid){
|
||||
S.session.message_count = Number(data.session.message_count || msgs.length);
|
||||
if (_loadingOlder) {
|
||||
// A prefetch is mid-flight (between the `_loadingOlder = true` line
|
||||
// and its post-await guards). Bumping the generation token now
|
||||
// poisons that prefetch's continuation, but we still need to claim
|
||||
// the mutex AFTER it releases. Yield until the prefetch finishes
|
||||
// (its finally-block clears _loadingOlder) before fetching the full
|
||||
// history ourselves. The generation bump below ensures any other
|
||||
// future race against this same continuation also fails closed.
|
||||
_bumpMessagesGeneration();
|
||||
while (_loadingOlder) {
|
||||
await new Promise(resolve => setTimeout(resolve, 16));
|
||||
}
|
||||
if (!_messagesTruncated || !S.session) return;
|
||||
}
|
||||
_loadingOlder = true;
|
||||
try {
|
||||
const sid = S.session.session_id;
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`);
|
||||
// Guard: api() may have redirected (401) and returned undefined.
|
||||
if (!data || !data.session) return;
|
||||
// Session may have been switched while we awaited. Bail rather than
|
||||
// overwrite the new session's messages.
|
||||
if (!S.session || S.session.session_id !== sid) return;
|
||||
if (_loadingSessionId !== null && _loadingSessionId !== sid) return;
|
||||
const msgs = (data.session.messages || []).filter(m => m && m.role);
|
||||
// Bump the generation BEFORE the wholesale replace so any racing
|
||||
// prefetch (whose snapshot was taken before this call's mutex
|
||||
// acquisition) sees the new value and aborts.
|
||||
_bumpMessagesGeneration();
|
||||
S.messages = msgs;
|
||||
_messagesTruncated = false;
|
||||
_oldestIdx = 0;
|
||||
if (S.session && S.session.session_id === sid) {
|
||||
S.session.message_count = Number(data.session.message_count || msgs.length);
|
||||
}
|
||||
} finally {
|
||||
_loadingOlder = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-5
@@ -2727,6 +2727,13 @@ let _composerLockState=null;
|
||||
function lockComposerForClarify(placeholderText){
|
||||
const input=$('msg');
|
||||
if(!input) return;
|
||||
// Save the current composer text as a server-side draft before locking,
|
||||
// so the user's draft is preserved if they switch sessions while a clarify
|
||||
// card is active (and survives page refresh / syncs across clients).
|
||||
const sid = S && S.session && S.session.session_id;
|
||||
if (sid && typeof _saveComposerDraftNow === 'function') {
|
||||
_saveComposerDraftNow(sid, input.value || '', S.pendingFiles ? [...S.pendingFiles] : []);
|
||||
}
|
||||
if(!_composerLockState){
|
||||
_composerLockState={
|
||||
disabled: input.disabled,
|
||||
@@ -4674,12 +4681,28 @@ function clearMessageRenderCache(){
|
||||
_sessionHtmlCacheSid=null;
|
||||
}
|
||||
|
||||
function _scrollAfterMessageRender(preserveScroll){
|
||||
function _captureMessageScrollSnapshot(){
|
||||
const el=$('messages');
|
||||
if(!el) return null;
|
||||
return {top:el.scrollTop};
|
||||
}
|
||||
function _restoreMessageScrollSnapshot(snapshot){
|
||||
const el=$('messages');
|
||||
if(!el||!snapshot) return;
|
||||
const maxTop=Math.max(0,el.scrollHeight-el.clientHeight);
|
||||
_programmaticScroll=true;
|
||||
el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop));
|
||||
_lastScrollTop=el.scrollTop;
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
}
|
||||
function _scrollAfterMessageRender(preserveScroll, scrollSnapshot){
|
||||
// Terminal stream renders can happen after S.activeStreamId is cleared.
|
||||
// In that case, preserveScroll asks the normal pin-state helper to decide:
|
||||
// pinned users stay at bottom; users who manually scrolled up stay put.
|
||||
// pinned users stay at bottom; users who manually scrolled up get their
|
||||
// pre-render scrollTop restored after the DOM replacement.
|
||||
if(preserveScroll){
|
||||
scrollIfPinned();
|
||||
if(_scrollPinned) scrollIfPinned();
|
||||
else _restoreMessageScrollSnapshot(scrollSnapshot);
|
||||
return;
|
||||
}
|
||||
if(S.activeStreamId){
|
||||
@@ -4691,6 +4714,7 @@ function _scrollAfterMessageRender(preserveScroll){
|
||||
|
||||
function renderMessages(options){
|
||||
const preserveScroll=!!(options&&options.preserveScroll);
|
||||
const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null;
|
||||
const inner=$('msgInner');
|
||||
const sid=S.session?S.session.session_id:null;
|
||||
const msgCount=S.messages.length;
|
||||
@@ -4716,7 +4740,7 @@ function renderMessages(options){
|
||||
_sessionHtmlCacheSid=sid;
|
||||
_wireMessageWindowLoadEarlierButton();
|
||||
if(typeof _applySessionNavigationPrefs==='function') _applySessionNavigationPrefs();
|
||||
_scrollAfterMessageRender(preserveScroll);
|
||||
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
if(typeof _initMediaPlaybackObserver==='function') _initMediaPlaybackObserver();
|
||||
@@ -5256,7 +5280,7 @@ function renderMessages(options){
|
||||
// Only force-scroll when not actively streaming — mid-stream re-renders
|
||||
// (tool completion, session switch) must not override the user's scroll position.
|
||||
// scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up.
|
||||
_scrollAfterMessageRender(preserveScroll);
|
||||
_scrollAfterMessageRender(preserveScroll, scrollSnapshot);
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();loadDiffInline();loadCsvInline();loadExcalidrawInline();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();initTreeViews();loadPdfInline();loadHtmlInline();renderMermaidBlocks();renderKatexBlocks();});
|
||||
|
||||
@@ -132,3 +132,83 @@ class TestSessionInvalidation(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestSessionTtlResolution(unittest.TestCase):
|
||||
"""Verify the three-layer TTL resolution (env > settings > default)."""
|
||||
|
||||
def setUp(self):
|
||||
# Snapshot environment + load_settings so each test starts clean.
|
||||
self._saved_env = {
|
||||
k: os.environ.get(k)
|
||||
for k in ("HERMES_WEBUI_SESSION_TTL",)
|
||||
}
|
||||
os.environ.pop("HERMES_WEBUI_SESSION_TTL", None)
|
||||
self._saved_load_settings = auth.load_settings
|
||||
|
||||
def tearDown(self):
|
||||
for k, v in self._saved_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
auth.load_settings = self._saved_load_settings
|
||||
|
||||
def test_env_var_overrides_settings(self):
|
||||
"""HERMES_WEBUI_SESSION_TTL env var should take priority."""
|
||||
os.environ["HERMES_WEBUI_SESSION_TTL"] = "3600"
|
||||
from api.auth import _resolve_session_ttl
|
||||
self.assertEqual(_resolve_session_ttl(), 3600)
|
||||
|
||||
def test_clamps_minimum(self):
|
||||
"""Values below 60 seconds fall through to settings/default (do not honor)."""
|
||||
os.environ["HERMES_WEBUI_SESSION_TTL"] = "10"
|
||||
auth.load_settings = lambda: {}
|
||||
from api.auth import _resolve_session_ttl
|
||||
# Out-of-range env values are rejected; falls through to default 30 days.
|
||||
self.assertEqual(_resolve_session_ttl(), auth.SESSION_TTL)
|
||||
|
||||
def test_clamps_maximum(self):
|
||||
"""Values above 1 year fall through to settings/default (do not honor)."""
|
||||
os.environ["HERMES_WEBUI_SESSION_TTL"] = "100000000"
|
||||
auth.load_settings = lambda: {}
|
||||
from api.auth import _resolve_session_ttl
|
||||
# Out-of-range env values are rejected; falls through to default 30 days.
|
||||
self.assertEqual(_resolve_session_ttl(), auth.SESSION_TTL)
|
||||
|
||||
def test_invalid_env_falls_through(self):
|
||||
"""Non-integer env var falls through to default."""
|
||||
os.environ["HERMES_WEBUI_SESSION_TTL"] = "not-a-number"
|
||||
auth.load_settings = lambda: {}
|
||||
from api.auth import _resolve_session_ttl
|
||||
self.assertEqual(_resolve_session_ttl(), auth.SESSION_TTL)
|
||||
|
||||
def test_empty_env_falls_through(self):
|
||||
"""Empty env var falls through to default."""
|
||||
os.environ["HERMES_WEBUI_SESSION_TTL"] = ""
|
||||
auth.load_settings = lambda: {}
|
||||
from api.auth import _resolve_session_ttl
|
||||
self.assertEqual(_resolve_session_ttl(), auth.SESSION_TTL)
|
||||
|
||||
def test_settings_path_returns_value(self):
|
||||
"""settings.json session_ttl_seconds path works when env is unset."""
|
||||
os.environ.pop("HERMES_WEBUI_SESSION_TTL", None)
|
||||
auth.load_settings = lambda: {"session_ttl_seconds": 7200}
|
||||
from api.auth import _resolve_session_ttl
|
||||
self.assertEqual(_resolve_session_ttl(), 7200)
|
||||
|
||||
def test_session_uses_dynamic_ttl(self):
|
||||
"""Newly created sessions should honor the resolved TTL."""
|
||||
auth._sessions.clear()
|
||||
os.environ["HERMES_WEBUI_SESSION_TTL"] = "3600"
|
||||
token_hex = auth.create_session().split(".")[0]
|
||||
from api.auth import _sessions
|
||||
for t, exp in _sessions.items():
|
||||
if t == token_hex:
|
||||
# The resolved env-var value (3600s) should be applied, not
|
||||
# the SESSION_TTL fallback default.
|
||||
expected = time.time() + 3600
|
||||
self.assertAlmostEqual(exp, expected, delta=5)
|
||||
break
|
||||
else:
|
||||
self.fail("Session token not found in _sessions")
|
||||
|
||||
@@ -54,8 +54,9 @@ def test_render_messages_preserve_scroll_option_uses_user_pin_state_not_stream_l
|
||||
|
||||
assert "function renderMessages(options)" in render_body
|
||||
assert "const preserveScroll=!!(options&&options.preserveScroll);" in render_body
|
||||
assert "_scrollAfterMessageRender(preserveScroll);" in render_body
|
||||
assert "if(preserveScroll){\n scrollIfPinned();\n return;\n }" in scroll_helper
|
||||
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in render_body
|
||||
assert "const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null" in render_body
|
||||
assert "if(preserveScroll){\n if(_scrollPinned) scrollIfPinned();\n else _restoreMessageScrollSnapshot(scrollSnapshot);\n return;\n }" in scroll_helper
|
||||
assert "if(S.activeStreamId){\n scrollIfPinned();\n return;\n }" in scroll_helper
|
||||
|
||||
|
||||
@@ -63,7 +64,7 @@ def test_cached_render_path_uses_same_scroll_policy_as_fresh_render():
|
||||
render_body = _function_body(UI_JS, "renderMessages")
|
||||
cached_branch = render_body[render_body.index("if(sid&&sid!==_sessionHtmlCacheSid") : render_body.index("const compressionState=")]
|
||||
|
||||
assert "_scrollAfterMessageRender(preserveScroll);" in cached_branch
|
||||
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in cached_branch
|
||||
assert "if(S.activeStreamId){scrollIfPinned();}else{scrollToBottom();}" not in cached_branch
|
||||
|
||||
|
||||
|
||||
@@ -170,3 +170,35 @@ def test_new_i18n_keys_present_in_all_locales():
|
||||
f"key {key!r} missing in some locales (expected {n_locales}, "
|
||||
f"got {I18N_JS.count(key)})"
|
||||
)
|
||||
|
||||
|
||||
# ── #1841 regression: exact non-English translations must be present ─────
|
||||
|
||||
|
||||
def test_workspace_show_hidden_files_translations_are_not_english_fallback():
|
||||
"""Each non-English locale must carry its own translated string for
|
||||
workspace_show_hidden_files — not silently fall back to the English
|
||||
"Show hidden files". Pin the exact expected translations so a
|
||||
regression that replaces any of them with the English fallback is
|
||||
caught immediately.
|
||||
"""
|
||||
expected = {
|
||||
"es": "Mostrar archivos ocultos",
|
||||
"ru": "Показывать скрытые файлы",
|
||||
"zh": "显示隐藏文件",
|
||||
"zh-Hant": "顯示隱藏檔案",
|
||||
"pt": "Mostrar arquivos ocultos",
|
||||
"ja": "隠しファイルを表示",
|
||||
"ko": "숨김 파일 표시",
|
||||
}
|
||||
for locale, translation in expected.items():
|
||||
# Build a source-level needle: the locale block assigns the
|
||||
# translated value on a line like
|
||||
# workspace_show_hidden_files: 'Mostrar archivos ocultos',
|
||||
# Matching the full assignment avoids false positives from
|
||||
# unrelated strings that happen to contain the same words.
|
||||
needle = f"workspace_show_hidden_files: '{translation}'"
|
||||
assert needle in I18N_JS, (
|
||||
f"locale {locale!r}: expected translation needle {needle!r} "
|
||||
f"not found in i18n.js — likely fell back to English"
|
||||
)
|
||||
|
||||
@@ -17,6 +17,8 @@ These tests pin every behavior the fix promises:
|
||||
* fresh + running gateway_state, no PID → alive (cross-container path)
|
||||
* stale updated_at + running → down (no false positives)
|
||||
* fresh updated_at + non-running state → down (crash-without-cleanup case)
|
||||
* stale updated_at + stopped state → unknown (old root gateway was
|
||||
intentionally stopped; do not nag profile-gateway users)
|
||||
* malformed / missing / naive timestamp → down (no parser-quirk false alive)
|
||||
* future timestamp within threshold → alive (clock skew tolerance)
|
||||
* future timestamp beyond threshold → down (broken clock rejected)
|
||||
@@ -152,6 +154,52 @@ def test_fresh_updated_at_with_non_running_state_reports_down(monkeypatch):
|
||||
assert payload["details"]["state"] == "down"
|
||||
|
||||
|
||||
def test_stale_stopped_runtime_status_reports_unknown_not_down(monkeypatch):
|
||||
"""#1944: a fossilized clean-stop root state should not trigger the alert.
|
||||
|
||||
Users can run profile-scoped gateways without a root gateway. If an old
|
||||
root gateway_state.json says "stopped", treating it as down makes the
|
||||
heartbeat banner fire forever even though no root gateway is configured.
|
||||
"""
|
||||
from api import agent_health
|
||||
|
||||
stale_ts = _iso(datetime.now(timezone.utc) - timedelta(days=7))
|
||||
runtime = _runtime_status(stale_ts, gateway_state="stopped", active_agents=0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_health,
|
||||
"_gateway_status_module",
|
||||
lambda: _FakeGatewayStatus(runtime, running_pid=None),
|
||||
)
|
||||
|
||||
payload = agent_health.build_agent_health_payload()
|
||||
|
||||
assert payload["alive"] is None
|
||||
assert payload["details"]["state"] == "unknown"
|
||||
assert payload["details"]["reason"] == "gateway_stale_stopped_state"
|
||||
assert payload["details"]["gateway_state"] == "stopped"
|
||||
|
||||
|
||||
def test_fresh_stopped_runtime_status_still_reports_down(monkeypatch):
|
||||
"""A recent stopped state still means the configured gateway is down."""
|
||||
from api import agent_health
|
||||
|
||||
fresh_ts = _iso(datetime.now(timezone.utc) - timedelta(seconds=10))
|
||||
runtime = _runtime_status(fresh_ts, gateway_state="stopped", active_agents=0)
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_health,
|
||||
"_gateway_status_module",
|
||||
lambda: _FakeGatewayStatus(runtime, running_pid=None),
|
||||
)
|
||||
|
||||
payload = agent_health.build_agent_health_payload()
|
||||
|
||||
assert payload["alive"] is False
|
||||
assert payload["details"]["state"] == "down"
|
||||
assert payload["details"]["reason"] == "gateway_not_running"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"broken_value",
|
||||
[
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Regression test for issue #1937 — endless-scroll prefetch vs Start-jump race.
|
||||
|
||||
When both ``session_jump_buttons`` and ``session_endless_scroll`` opt-ins
|
||||
are enabled, ``_loadOlderMessages`` (the endless-scroll prefetch) can be in
|
||||
flight when the user clicks the Start jump pill, which calls
|
||||
``_ensureAllMessagesLoaded``. If the prefetch resolves AFTER the
|
||||
ensure-all wholesale-replaces ``S.messages``, it would prepend a duplicate
|
||||
page.
|
||||
|
||||
The fix uses two coordinated guards:
|
||||
|
||||
1. A ``_messagesGeneration`` token that gets bumped any time
|
||||
``S.messages`` is wholesale-replaced. ``_loadOlderMessages`` snapshots
|
||||
the token before its ``await`` and re-checks afterwards; if it changed,
|
||||
the prepend is aborted.
|
||||
|
||||
2. ``_ensureAllMessagesLoaded`` claims the existing ``_loadingOlder``
|
||||
mutex around its body so no NEW prefetch can start mid-replace, and so
|
||||
concurrent ensure-all invocations (e.g. rapid double-click on Start)
|
||||
serialize cleanly. It also yields until any in-flight prefetch's
|
||||
``finally`` clears the flag before claiming the mutex itself.
|
||||
|
||||
The old fix shape suggested in the issue (spin-wait on ``_loadingOlder``
|
||||
before running ensure-all) does not actually solve the race the report
|
||||
describes: by the time the prefetch passes its entry-gate check, it is
|
||||
already past the only point where ``_loadingOlder`` is read, so a same-
|
||||
flag check inside its post-await body would be a no-op. The generation
|
||||
token is the canonical pattern for invalidating async continuations and
|
||||
is what this regression suite locks in.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
"""Slice the body of ``async function <name>`` (or ``function <name>``)."""
|
||||
needle_async = f"async function {name}"
|
||||
needle_sync = f"function {name}"
|
||||
if needle_async in src:
|
||||
start = src.index(needle_async)
|
||||
else:
|
||||
start = src.index(needle_sync)
|
||||
brace = src.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(src)):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start : i + 1]
|
||||
raise AssertionError(f"function {name!r} body not found")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation token: declared at module scope, bumped via the helper.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_generation_token_declared_at_module_scope():
|
||||
"""``_messagesGeneration`` exists as a module-scoped mutable counter."""
|
||||
assert "let _messagesGeneration = 0;" in SESSIONS_JS, (
|
||||
"static/sessions.js must declare `let _messagesGeneration = 0;` so "
|
||||
"_loadOlderMessages can snapshot/re-check it across its `await`. "
|
||||
"See #1937."
|
||||
)
|
||||
|
||||
|
||||
def test_generation_bump_helper_exists():
|
||||
"""A single helper bumps the generation; both consumers route through it."""
|
||||
assert "function _bumpMessagesGeneration()" in SESSIONS_JS, (
|
||||
"static/sessions.js must define `_bumpMessagesGeneration()` so "
|
||||
"wholesale-replace sites have a single, named pivot to call. See #1937."
|
||||
)
|
||||
body = _function_body(SESSIONS_JS, "_bumpMessagesGeneration")
|
||||
assert "_messagesGeneration" in body, (
|
||||
"_bumpMessagesGeneration must mutate _messagesGeneration"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _loadOlderMessages: snapshot before await, re-check after.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_load_older_snapshots_generation_before_await():
|
||||
"""Snapshot must be captured BEFORE the `await api(...)` call."""
|
||||
body = _function_body(SESSIONS_JS, "_loadOlderMessages")
|
||||
snapshot_idx = body.index("const startGeneration = _messagesGeneration;")
|
||||
await_idx = body.index("await api(")
|
||||
assert snapshot_idx < await_idx, (
|
||||
"_loadOlderMessages must snapshot _messagesGeneration before its "
|
||||
"`await`. Capturing it after the await defeats the race guard. "
|
||||
"See #1937."
|
||||
)
|
||||
|
||||
|
||||
def test_load_older_aborts_when_generation_changed():
|
||||
"""Post-await guard must compare against the snapshot and abort."""
|
||||
body = _function_body(SESSIONS_JS, "_loadOlderMessages")
|
||||
assert "if (_messagesGeneration !== startGeneration) return;" in body, (
|
||||
"_loadOlderMessages must bail out (without prepending) when the "
|
||||
"generation token changed during its await — that is the signal "
|
||||
"that S.messages was wholesale-replaced under it. See #1937."
|
||||
)
|
||||
|
||||
|
||||
def test_load_older_generation_check_runs_before_prepend():
|
||||
"""Generation check must come BEFORE the `S.messages = [...older, ...]` mutation."""
|
||||
body = _function_body(SESSIONS_JS, "_loadOlderMessages")
|
||||
guard_idx = body.index("if (_messagesGeneration !== startGeneration) return;")
|
||||
prepend_idx = body.index("S.messages = [...olderMsgs, ...S.messages];")
|
||||
assert guard_idx < prepend_idx, (
|
||||
"Generation guard must short-circuit BEFORE the prepend. "
|
||||
"Otherwise duplicate messages can still slip through. See #1937."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ensureAllMessagesLoaded: claims the mutex, bumps the generation, yields.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ensure_all_bumps_generation_before_replace():
|
||||
"""Bump must happen BEFORE `S.messages = msgs` so racing prefetch sees it."""
|
||||
body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
|
||||
bump_idx = body.rindex("_bumpMessagesGeneration()")
|
||||
replace_idx = body.index("S.messages = msgs;")
|
||||
assert bump_idx < replace_idx, (
|
||||
"_ensureAllMessagesLoaded must bump the generation token BEFORE the "
|
||||
"wholesale replace, otherwise an in-flight prefetch's post-await "
|
||||
"check could read the old value and prepend duplicates. See #1937."
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_all_claims_loading_older_mutex():
|
||||
"""The body must hold `_loadingOlder = true` so no NEW prefetch starts mid-replace."""
|
||||
body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
|
||||
assert "_loadingOlder = true;" in body, (
|
||||
"_ensureAllMessagesLoaded must claim the _loadingOlder mutex so "
|
||||
"the entry-gate in _loadOlderMessages short-circuits new prefetches "
|
||||
"while ensure-all is mid-replace. See #1937."
|
||||
)
|
||||
assert "_loadingOlder = false;" in body, (
|
||||
"_ensureAllMessagesLoaded must release the _loadingOlder mutex in "
|
||||
"its finally-block. Otherwise endless-scroll silently breaks after "
|
||||
"every Start-jump."
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_all_releases_mutex_in_finally():
|
||||
"""Mutex release must live inside a `finally` so errors don't leak the lock."""
|
||||
body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
|
||||
finally_idx = body.index("} finally {")
|
||||
release_idx = body.index("_loadingOlder = false;", finally_idx)
|
||||
assert release_idx > finally_idx, (
|
||||
"_loadingOlder release must be inside the finally-block to survive "
|
||||
"thrown errors during the wholesale replace. See #1937."
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_all_yields_when_prefetch_in_flight():
|
||||
"""When a prefetch holds the mutex, ensure-all must wait, not wholesale-replace alongside it."""
|
||||
body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
|
||||
# Look for the yield-loop on _loadingOlder before the mutex claim.
|
||||
yield_idx = body.index("while (_loadingOlder)")
|
||||
claim_idx = body.index("_loadingOlder = true;")
|
||||
assert yield_idx < claim_idx, (
|
||||
"_ensureAllMessagesLoaded must yield (poll _loadingOlder) BEFORE "
|
||||
"claiming the mutex itself, so an in-flight prefetch's finally-"
|
||||
"block fires and the generation guard inside that prefetch resolves "
|
||||
"the race cleanly. See #1937."
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_all_bumps_generation_during_wait_phase():
|
||||
"""Bumping during the wait poisons any in-flight prefetch immediately, even before ensure-all gets the mutex."""
|
||||
body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
|
||||
# Find the _loadingOlder branch that runs when a prefetch is in flight,
|
||||
# and verify it bumps the generation before the wait loop.
|
||||
branch_idx = body.index("if (_loadingOlder) {")
|
||||
wait_idx = body.index("while (_loadingOlder)", branch_idx)
|
||||
bump_in_branch = body.index("_bumpMessagesGeneration()", branch_idx)
|
||||
assert branch_idx < bump_in_branch < wait_idx, (
|
||||
"When a prefetch is in flight at entry, _ensureAllMessagesLoaded "
|
||||
"must bump the generation BEFORE the wait loop so the in-flight "
|
||||
"prefetch's post-await check fires the moment its api() resolves, "
|
||||
"not just for future calls. See #1937."
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_all_resets_oldest_idx():
|
||||
"""After wholesale-replacing with the full history, _oldestIdx must reset to 0."""
|
||||
body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
|
||||
assert "_oldestIdx = 0;" in body, (
|
||||
"_ensureAllMessagesLoaded must reset _oldestIdx to 0 — without it, "
|
||||
"a subsequent prefetch could send `msg_before=<stale-idx>` and "
|
||||
"request older messages that are already in the now-full transcript."
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_all_guards_against_session_switch_mid_await():
|
||||
"""Same-session check must run after await — old version skipped this."""
|
||||
body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded")
|
||||
await_idx = body.index("await api(")
|
||||
sid_check_idx = body.index("S.session.session_id !== sid", await_idx)
|
||||
replace_idx = body.index("S.messages = msgs;", await_idx)
|
||||
assert await_idx < sid_check_idx < replace_idx, (
|
||||
"_ensureAllMessagesLoaded must guard against session-switch races "
|
||||
"(re-check S.session.session_id after await) BEFORE wholesale-"
|
||||
"replacing S.messages. The pre-fix version had no such guard."
|
||||
)
|
||||
@@ -40,7 +40,7 @@ class TestScrollPinningFix:
|
||||
"unconditional scrollToBottom() overrides user scroll position (#677)"
|
||||
)
|
||||
# scrollIfPinned must be called through the renderMessages scroll policy (stream path)
|
||||
assert "_scrollAfterMessageRender(preserveScroll);" in rm_body
|
||||
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in rm_body
|
||||
assert "scrollIfPinned()" in helper_body, (
|
||||
"renderMessages() must call scrollIfPinned() during streaming (#677)"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_load_earlier_expands_local_window_before_server_pagination_and_preserve
|
||||
|
||||
|
||||
def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_messages():
|
||||
assert "_scrollAfterMessageRender(preserveScroll);" in UI_JS
|
||||
assert "_scrollAfterMessageRender(preserveScroll, scrollSnapshot);" in UI_JS
|
||||
assert "const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS
|
||||
assert "if(aIdx<assistantIdxs[0]) continue;" in UI_JS
|
||||
assert "const renderedAssistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);" in UI_JS
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Regression tests for issue #1932: goal hook fires on every assistant turn.
|
||||
|
||||
The goal evaluation hook must only run when the turn was triggered by an
|
||||
explicit goal-related message (goal set, goal continuation). Unrelated
|
||||
messages like "what time is it" must NOT:
|
||||
- increment turns_used
|
||||
- trigger goal_continue SSE events
|
||||
- burn the goal budget
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: config exports STREAM_GOAL_RELATED
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_config_exports_stream_goal_related():
|
||||
"""api.config must export STREAM_GOAL_RELATED for the streaming gate."""
|
||||
from api.config import STREAM_GOAL_RELATED
|
||||
assert isinstance(STREAM_GOAL_RELATED, dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: config exports PENDING_GOAL_CONTINUATION
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_config_exports_pending_goal_continuation():
|
||||
"""api.config must export PENDING_GOAL_CONTINUATION for auto-marking
|
||||
continuation streams as goal-related."""
|
||||
from api.config import PENDING_GOAL_CONTINUATION
|
||||
assert isinstance(PENDING_GOAL_CONTINUATION, (dict, set))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: streaming.py gates evaluate_goal_after_turn on STREAM_GOAL_RELATED
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_streaming_source_code_gates_on_stream_goal_related():
|
||||
"""The streaming code must check STREAM_GOAL_RELATED[stream_id] before
|
||||
calling evaluate_goal_after_turn, so unrelated turns skip the hook."""
|
||||
from pathlib import Path
|
||||
streaming_py = (Path(__file__).resolve().parents[1] / "api" / "streaming.py").read_text()
|
||||
|
||||
# Must import STREAM_GOAL_RELATED
|
||||
assert "STREAM_GOAL_RELATED" in streaming_py, (
|
||||
"streaming.py must import STREAM_GOAL_RELATED from api.config"
|
||||
)
|
||||
|
||||
# Must check it before calling evaluate_goal_after_turn
|
||||
goal_related_check = streaming_py.find("STREAM_GOAL_RELATED")
|
||||
eval_call = streaming_py.find("evaluate_goal_after_turn")
|
||||
assert goal_related_check != -1 and eval_call != -1
|
||||
assert goal_related_check < eval_call, (
|
||||
"STREAM_GOAL_RELATED check must appear before evaluate_goal_after_turn call"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: streaming.py sets PENDING_GOAL_CONTINUATION on goal_continue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_streaming_sets_pending_goal_continuation_on_goal_continue():
|
||||
"""When goal_continue is emitted, streaming.py must set
|
||||
PENDING_GOAL_CONTINUATION so the next /chat/start marks the stream."""
|
||||
from pathlib import Path
|
||||
streaming_py = (Path(__file__).resolve().parents[1] / "api" / "streaming.py").read_text()
|
||||
|
||||
assert "PENDING_GOAL_CONTINUATION" in streaming_py, (
|
||||
"streaming.py must reference PENDING_GOAL_CONTINUATION"
|
||||
)
|
||||
|
||||
# The PENDING_GOAL_CONTINUATION set must happen near goal_continue
|
||||
goal_continue_idx = streaming_py.find("goal_continue")
|
||||
pending_idx = streaming_py.find("PENDING_GOAL_CONTINUATION")
|
||||
assert goal_continue_idx != -1 and pending_idx != -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5: routes.py reads PENDING_GOAL_CONTINUATION and marks stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_routes_reads_pending_goal_continuation():
|
||||
"""The chat/start handler must check PENDING_GOAL_CONTINUATION and mark
|
||||
the new stream as goal-related."""
|
||||
from pathlib import Path
|
||||
routes_py = (Path(__file__).resolve().parents[1] / "api" / "routes.py").read_text()
|
||||
|
||||
assert "PENDING_GOAL_CONTINUATION" in routes_py, (
|
||||
"routes.py must reference PENDING_GOAL_CONTINUATION"
|
||||
)
|
||||
assert "STREAM_GOAL_RELATED" in routes_py, (
|
||||
"routes.py must reference STREAM_GOAL_RELATED to mark goal-related streams"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6: routes.py marks goal kickoff streams as goal-related
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_routes_marks_goal_kickoff_as_goal_related():
|
||||
"""The /api/goal handler must mark the kickoff stream as goal-related."""
|
||||
from pathlib import Path
|
||||
routes_py = (Path(__file__).resolve().parents[1] / "api" / "routes.py").read_text()
|
||||
|
||||
# After kickoff stream is started, it must mark the stream
|
||||
kickoff_idx = routes_py.find("kickoff_prompt")
|
||||
stream_goal_idx = routes_py.find("STREAM_GOAL_RELATED")
|
||||
assert kickoff_idx != -1 and stream_goal_idx != -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 7: _start_chat_stream_for_session passes goal_related through
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_start_chat_stream_accepts_goal_related():
|
||||
"""_start_chat_stream_for_session must accept goal_related kwarg."""
|
||||
from pathlib import Path
|
||||
routes_py = (Path(__file__).resolve().parents[1] / "api" / "routes.py").read_text()
|
||||
|
||||
assert "goal_related" in routes_py, (
|
||||
"routes.py must reference goal_related parameter"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 8: _run_agent_streaming accepts and uses goal_related
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_run_agent_streaming_uses_goal_related():
|
||||
"""_run_agent_streaming must accept goal_related kwarg and use it to
|
||||
gate the goal evaluation hook."""
|
||||
from pathlib import Path
|
||||
streaming_py = (Path(__file__).resolve().parents[1] / "api" / "streaming.py").read_text()
|
||||
|
||||
# Function must accept goal_related parameter
|
||||
func_def_idx = streaming_py.find("def _run_agent_streaming")
|
||||
assert func_def_idx != -1
|
||||
|
||||
# The function signature area (within ~200 chars) should contain goal_related
|
||||
sig_area = streaming_py[func_def_idx:func_def_idx + 500]
|
||||
assert "goal_related" in sig_area, (
|
||||
"_run_agent_streaming must accept a goal_related parameter"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 9: STREAM_GOAL_RELATED cleanup on stream exit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stream_goal_related_cleaned_up():
|
||||
"""STREAM_GOAL_RELATED entries must be cleaned up when streams end."""
|
||||
from pathlib import Path
|
||||
streaming_py = (Path(__file__).resolve().parents[1] / "api" / "streaming.py").read_text()
|
||||
|
||||
# Must have cleanup of STREAM_GOAL_RELATED
|
||||
assert "STREAM_GOAL_RELATED" in streaming_py
|
||||
# Look for pop or del of STREAM_GOAL_RELATED
|
||||
assert any(
|
||||
pattern in streaming_py
|
||||
for pattern in [
|
||||
"STREAM_GOAL_RELATED.pop",
|
||||
"del STREAM_GOAL_RELATED",
|
||||
]
|
||||
), "streaming.py must clean up STREAM_GOAL_RELATED entries when streams end"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 10: functional test with FakeGoalManager at streaming integration level
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_goal_evaluate_after_turn_only_increments_for_user_initiated(monkeypatch):
|
||||
"""Verify that evaluate_goal_after_turn only increments turns_used
|
||||
when user_initiated=True (goal-related), not when user_initiated=False."""
|
||||
from api import goals as webui_goals
|
||||
|
||||
turns_incremented = []
|
||||
|
||||
class FakeState:
|
||||
goal = "test goal"
|
||||
status = "active"
|
||||
turns_used = 0
|
||||
max_turns = 10
|
||||
last_turn_at = 0.0
|
||||
last_verdict = None
|
||||
last_reason = None
|
||||
paused_reason = None
|
||||
|
||||
def to_json(self):
|
||||
return {"goal": self.goal, "status": self.status}
|
||||
|
||||
class FakeMgr:
|
||||
def __init__(self, session_id, default_max_turns=20):
|
||||
self.state = FakeState()
|
||||
|
||||
def is_active(self):
|
||||
return True
|
||||
|
||||
def evaluate_after_turn(self, last_response, user_initiated=True):
|
||||
if user_initiated:
|
||||
self.state.turns_used += 1
|
||||
turns_incremented.append(True)
|
||||
return {
|
||||
"status": "active",
|
||||
"should_continue": True,
|
||||
"continuation_prompt": "continue",
|
||||
"verdict": "continue",
|
||||
"reason": "ok",
|
||||
"message": "ok",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(webui_goals, "GoalManager", FakeMgr)
|
||||
monkeypatch.setattr(webui_goals, "_default_max_turns", lambda: 10)
|
||||
|
||||
# user_initiated=True should increment
|
||||
result1 = webui_goals.evaluate_goal_after_turn(
|
||||
"sid-1", "goal response", user_initiated=True, profile_home=None
|
||||
)
|
||||
assert len(turns_incremented) == 1
|
||||
|
||||
# user_initiated=False should NOT increment
|
||||
result2 = webui_goals.evaluate_goal_after_turn(
|
||||
"sid-1", "unrelated response", user_initiated=False, profile_home=None
|
||||
)
|
||||
assert len(turns_incremented) == 1, (
|
||||
"turns_used should NOT increment when user_initiated=False"
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Regression tests for PR #1947 / issue: same model exposed by multiple named
|
||||
custom providers should appear in the dropdown for each provider, not be
|
||||
silently deduplicated by the global ``_seen_custom_ids`` bucket.
|
||||
|
||||
Pre-fix, ``get_available_models()`` initialized ``_seen_custom_ids`` with bare
|
||||
model IDs and used a single global dedup set when iterating
|
||||
``custom_providers``. If two named custom providers exposed the same raw model
|
||||
ID (e.g. both ``baidu`` and ``huoshan`` offering ``glm-5.1``), the first
|
||||
provider to be processed claimed the ID and later providers silently lost
|
||||
their copy.
|
||||
|
||||
Post-fix, the dedup key is ``f"{slug}:{model_id}"`` per named provider, so each
|
||||
provider's models are tracked independently. Per-provider dedup of duplicate
|
||||
entries within the same provider still works.
|
||||
"""
|
||||
import pytest
|
||||
import api.config as config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_models_cache():
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _models_with_cfg(model_cfg=None, custom_providers=None):
|
||||
"""Patch config.cfg, call get_available_models(), restore.
|
||||
|
||||
Mirrors the pattern in test_custom_provider_display_name.py — pins
|
||||
_cfg_mtime so get_available_models()'s reload guard doesn't overwrite
|
||||
the patch from on-disk config.yaml.
|
||||
"""
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
if model_cfg:
|
||||
config.cfg["model"] = model_cfg
|
||||
if custom_providers is not None:
|
||||
config.cfg["custom_providers"] = custom_providers
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
try:
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
|
||||
def _group_for_provider(result, slug):
|
||||
"""Find the rendered ``groups`` entry for a given custom-provider slug.
|
||||
|
||||
Named custom-provider groups have ``provider_id == f"custom:{slug}"``.
|
||||
"""
|
||||
target = f"custom:{slug}"
|
||||
for grp in result.get("groups", []) or []:
|
||||
if grp.get("provider_id") == target:
|
||||
return grp
|
||||
return None
|
||||
|
||||
|
||||
def _model_ids(group):
|
||||
return [m.get("id") for m in (group or {}).get("models", []) or []]
|
||||
|
||||
|
||||
class TestPR1947SameModelMultipleProviders:
|
||||
"""Same raw model ID exposed by multiple named custom providers should
|
||||
survive the named-custom-group assembly with provider-aware suffixing."""
|
||||
|
||||
def test_two_providers_same_model_both_present(self):
|
||||
"""Two named providers both expose ``glm-5.1`` — both must appear.
|
||||
|
||||
Pre-fix: ``baidu`` (processed first) claimed ``glm-5.1`` in the global
|
||||
``_seen_custom_ids`` bucket and ``huoshan``'s entry was silently
|
||||
dropped. Post-fix: the dedup key is ``slug:model_id`` so both survive.
|
||||
"""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom", "base_url": "https://baidu.example.com/v1"},
|
||||
custom_providers=[
|
||||
{"name": "baidu", "model": "glm-5.1", "base_url": "https://baidu.example.com/v1"},
|
||||
{"name": "huoshan", "model": "glm-5.1", "base_url": "https://huoshan.example.com/v1"},
|
||||
],
|
||||
)
|
||||
|
||||
baidu = _group_for_provider(result, "baidu")
|
||||
huoshan = _group_for_provider(result, "huoshan")
|
||||
assert baidu is not None, (
|
||||
f"baidu group missing; groups="
|
||||
f"{[g.get('provider_id') for g in result.get('groups', [])]}"
|
||||
)
|
||||
assert huoshan is not None, (
|
||||
f"huoshan group missing — silent dedup regression; groups="
|
||||
f"{[g.get('provider_id') for g in result.get('groups', [])]}"
|
||||
)
|
||||
|
||||
baidu_ids = _model_ids(baidu)
|
||||
huoshan_ids = _model_ids(huoshan)
|
||||
# baidu is the active provider, so its model lands as the bare id.
|
||||
# huoshan is a non-active named provider, so it lands as
|
||||
# ``@custom:huoshan:glm-5.1`` per the existing namespacing rules.
|
||||
assert any("glm-5.1" in (x or "") for x in baidu_ids), (
|
||||
f"baidu glm-5.1 missing; baidu ids: {baidu_ids}"
|
||||
)
|
||||
assert any("glm-5.1" in (x or "") for x in huoshan_ids), (
|
||||
f"huoshan glm-5.1 missing — silent dedup regression; huoshan ids: {huoshan_ids}"
|
||||
)
|
||||
|
||||
def test_three_providers_same_model_all_present(self):
|
||||
"""Three providers all expose ``gpt-5.4`` — none should be dropped."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom", "base_url": "https://a.example.com/v1"},
|
||||
custom_providers=[
|
||||
{"name": "edith", "model": "gpt-5.4", "base_url": "https://a.example.com/v1"},
|
||||
{"name": "super-javis", "model": "gpt-5.4", "base_url": "https://b.example.com/v1"},
|
||||
{"name": "vision-prime", "model": "gpt-5.4", "base_url": "https://c.example.com/v1"},
|
||||
],
|
||||
)
|
||||
|
||||
# All three providers must surface their gpt-5.4 entry.
|
||||
for slug in ("edith", "super-javis", "vision-prime"):
|
||||
grp = _group_for_provider(result, slug)
|
||||
assert grp is not None, (
|
||||
f"group for {slug} missing — silent dedup regression; "
|
||||
f"groups={[g.get('provider_id') for g in result.get('groups', [])]}"
|
||||
)
|
||||
ids = _model_ids(grp)
|
||||
assert any("gpt-5.4" in (x or "") for x in ids), (
|
||||
f"{slug} gpt-5.4 missing; ids: {ids}"
|
||||
)
|
||||
|
||||
def test_distinct_models_per_provider_still_grouped_correctly(self):
|
||||
"""Different models per provider land in their own groups (sanity)."""
|
||||
result = _models_with_cfg(
|
||||
model_cfg={"provider": "custom", "base_url": "https://a.example.com/v1"},
|
||||
custom_providers=[
|
||||
{"name": "alpha", "model": "model-a", "base_url": "https://a.example.com/v1"},
|
||||
{"name": "beta", "model": "model-b", "base_url": "https://b.example.com/v1"},
|
||||
],
|
||||
)
|
||||
alpha = _group_for_provider(result, "alpha")
|
||||
beta = _group_for_provider(result, "beta")
|
||||
assert alpha is not None and beta is not None
|
||||
assert any("model-a" in (x or "") for x in _model_ids(alpha))
|
||||
assert any("model-b" in (x or "") for x in _model_ids(beta))
|
||||
@@ -262,6 +262,21 @@ class TestIndexHtmlIntegration:
|
||||
"?v=__WEBUI_VERSION__ to match the URL the page requests"
|
||||
)
|
||||
|
||||
def test_sw_shell_assets_are_network_first(self):
|
||||
"""Shell JS/CSS must prefer the network, then fall back to CacheStorage.
|
||||
|
||||
Cache-first with an unchanged local dev version can keep stale boot.js
|
||||
loaded after a hotfix, which is exactly how browser chrome/theme-color
|
||||
regressions survive a patch until someone performs cache exorcism.
|
||||
"""
|
||||
src = SW.read_text(encoding="utf-8")
|
||||
marker = "// Shell assets: network-first with cache fallback"
|
||||
assert marker in src
|
||||
block = src[src.find(marker):src.find(marker) + 900]
|
||||
assert "fetch(event.request).then" in block
|
||||
assert "caches.match(event.request)" in block
|
||||
assert "caches.match(event.request).then((cached)" not in block[:250]
|
||||
|
||||
def test_index_route_url_encodes_asset_version(self):
|
||||
src = ROUTES.read_text(encoding="utf-8")
|
||||
idx = src.find('parsed.path in ("/", "/index.html")')
|
||||
|
||||
@@ -170,3 +170,41 @@ def test_custom_provider_slashed_model_with_free_suffix_1776():
|
||||
model, provider, _ = resolve_model_provider(qualified)
|
||||
assert provider == "custom:my-key"
|
||||
assert model == "org/model:free"
|
||||
|
||||
|
||||
def test_custom_provider_ipv4_port_slug_no_false_peel():
|
||||
"""host:port in custom slug must not trigger #1776 peel — avoids ``8080:model``."""
|
||||
qualified = "@custom:10.8.71.41:8080:Qwen3-235B"
|
||||
model, provider, _ = resolve_model_provider(qualified)
|
||||
assert provider == "custom:10.8.71.41:8080"
|
||||
assert model == "Qwen3-235B"
|
||||
|
||||
|
||||
def test_custom_provider_hostname_port_slug_no_false_peel():
|
||||
qualified = "@custom:proxy.internal:8443:Qwen3-235B"
|
||||
model, provider, _ = resolve_model_provider(qualified)
|
||||
assert provider == "custom:proxy.internal:8443"
|
||||
assert model == "Qwen3-235B"
|
||||
|
||||
|
||||
def test_custom_provider_localhost_port_slug_no_false_peel():
|
||||
qualified = "@custom:localhost:11434:llama3.2"
|
||||
model, provider, _ = resolve_model_provider(qualified)
|
||||
assert provider == "custom:localhost:11434"
|
||||
assert model == "llama3.2"
|
||||
|
||||
|
||||
def test_model_with_provider_context_custom_ipv4_port_roundtrip():
|
||||
"""Mirrors WebUI /start payload: bare model + custom:<host>:<port> provider."""
|
||||
import api.config as cfg_mod
|
||||
|
||||
old = dict(cfg_mod.cfg.get("model", {}))
|
||||
cfg_mod.cfg["model"] = {"provider": "custom", "default": "gpt-5.5"}
|
||||
try:
|
||||
wrapped = model_with_provider_context("Qwen3-235B", "custom:10.8.71.41:8080")
|
||||
assert wrapped == "@custom:10.8.71.41:8080:Qwen3-235B"
|
||||
model, provider, _ = resolve_model_provider(wrapped)
|
||||
assert provider == "custom:10.8.71.41:8080"
|
||||
assert model == "Qwen3-235B"
|
||||
finally:
|
||||
cfg_mod.cfg["model"] = old
|
||||
|
||||
@@ -64,14 +64,17 @@ def test_session_jump_buttons_match_pill_layout_without_regressing_default_arrow
|
||||
|
||||
|
||||
def test_session_jump_buttons_are_i18n_localized_in_text_tooltip_and_aria():
|
||||
for key in [
|
||||
"session_jump_start",
|
||||
"session_jump_start_label",
|
||||
"session_jump_end",
|
||||
"session_jump_end_label",
|
||||
"settings_label_session_jump_buttons",
|
||||
"settings_desc_session_jump_buttons",
|
||||
]:
|
||||
english_literals = {
|
||||
"session_jump_start": "Start",
|
||||
"session_jump_start_label": "Jump to beginning of session",
|
||||
"session_jump_end": "End",
|
||||
"session_jump_end_label": "Jump to end of session",
|
||||
"settings_label_session_jump_buttons": "Show session jump buttons",
|
||||
"settings_desc_session_jump_buttons": "Show floating Start and End buttons while reading long session histories.",
|
||||
}
|
||||
for key in english_literals:
|
||||
assert I18N_JS.count(f"{key}:") >= 8, f"missing locale entries for {key}"
|
||||
for key, value in english_literals.items():
|
||||
assert I18N_JS.count(f"{key}: '{value}'") == 1, f"non-English locale still uses English literal for {key}"
|
||||
assert "document.querySelectorAll('[data-i18n-aria-label]')" in I18N_JS
|
||||
assert "el.setAttribute('aria-label', val)" in I18N_JS
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Stage-326 hardening tests for #1956 composer-draft input validation.
|
||||
|
||||
Opus advisor flagged that POST /api/session/draft accepted text/files of
|
||||
arbitrary size and type. A misbehaving or malicious client could persist
|
||||
multi-MB strings into the session JSON on every keystroke via the 400ms
|
||||
debounced auto-save. The hardening:
|
||||
|
||||
- text: must be str; clamped to 50 KB
|
||||
- files: must be list; clamped to 50 entries
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# These tests directly call the handler logic by importing the routes module
|
||||
# and exercising the validation through a minimal mock handler. We don't need
|
||||
# a full HTTP server.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_state_dir(tmp_path, monkeypatch):
|
||||
"""Point STATE_DIR at a tmpdir so saved sessions don't pollute reality."""
|
||||
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("HERMES_BASE_HOME", str(tmp_path))
|
||||
yield tmp_path
|
||||
|
||||
|
||||
def test_draft_text_clamped_to_50kb(isolated_state_dir):
|
||||
"""Posting a >50KB text field should be silently truncated to 50_000 chars."""
|
||||
# Read the routes.py source and assert the clamp logic is present.
|
||||
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
|
||||
# The clamp constant must exist.
|
||||
assert "_MAX_DRAFT_TEXT = 50_000" in src or "_MAX_DRAFT_TEXT=50_000" in src.replace(" ", ""), (
|
||||
"routes.py must define _MAX_DRAFT_TEXT clamp for the composer-draft POST handler"
|
||||
)
|
||||
|
||||
# And the truncation must be applied.
|
||||
assert "text = text[:_MAX_DRAFT_TEXT]" in src, (
|
||||
"routes.py must truncate over-large draft text to _MAX_DRAFT_TEXT"
|
||||
)
|
||||
|
||||
|
||||
def test_draft_files_clamped_to_50_entries():
|
||||
"""Posting a >50-entry files list should be silently truncated."""
|
||||
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
assert "_MAX_DRAFT_FILES = 50" in src, (
|
||||
"routes.py must define _MAX_DRAFT_FILES clamp"
|
||||
)
|
||||
assert "files = files[:_MAX_DRAFT_FILES]" in src, (
|
||||
"routes.py must truncate over-large draft files list"
|
||||
)
|
||||
|
||||
|
||||
def test_draft_text_type_coerced_to_string():
|
||||
"""Non-string text must be coerced to empty string, not stored as-is."""
|
||||
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
# The type-coerce pattern must be present.
|
||||
assert 'if text is not None and not isinstance(text, str):' in src, (
|
||||
"routes.py must coerce non-string text to empty string before persist"
|
||||
)
|
||||
|
||||
|
||||
def test_draft_files_type_coerced_to_list():
|
||||
"""Non-list files must be coerced to empty list."""
|
||||
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
assert 'if files is not None and not isinstance(files, list):' in src, (
|
||||
"routes.py must coerce non-list files to empty list before persist"
|
||||
)
|
||||
|
||||
|
||||
def test_draft_validation_appears_before_persist():
|
||||
"""The validation must run BEFORE the lock acquire / save, not after."""
|
||||
src = Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
# Anchor on the unique POST-validation comment marker.
|
||||
marker_idx = src.find("Stage-326 hardening (per Opus advisor)")
|
||||
persist_idx = src.find("s.composer_draft = draft\n s.save()")
|
||||
assert marker_idx != -1 and persist_idx != -1, (
|
||||
"could not locate validation marker or persist site"
|
||||
)
|
||||
assert marker_idx < persist_idx, (
|
||||
"validation block must run before composer_draft persist"
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Stage-326 integration test for #1951's PENDING_GOAL_CONTINUATION chain.
|
||||
|
||||
Opus advisor flagged a critical race during stage-326 review: the original
|
||||
#1951 PR placed a `PENDING_GOAL_CONTINUATION.discard(session_id)` in the
|
||||
streaming worker's `finally` block. Because `goal_continue` sets the marker
|
||||
inside the SAME function call (line ~3328) that the `finally` then discards
|
||||
it (line ~3553), the marker would be erased before the frontend could
|
||||
receive the SSE event, post the next /chat/start, and trigger the
|
||||
consumer-side `if session_id in PENDING_GOAL_CONTINUATION` check in
|
||||
routes.py.
|
||||
|
||||
The fix removes the discard from streaming.py's finally and relies on the
|
||||
consumer in routes.py to discard atomically when the marker is read.
|
||||
|
||||
These tests exercise the full chain to guard against the regression:
|
||||
1. The streaming finally must NOT discard the marker
|
||||
2. Setting the marker survives the streaming finally
|
||||
3. routes.py consumer discards atomically on read
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _read_streaming():
|
||||
return Path(__file__).parents[1].joinpath("api", "streaming.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _read_routes():
|
||||
return Path(__file__).parents[1].joinpath("api", "routes.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_streaming_finally_does_not_discard_pending_goal_continuation():
|
||||
"""REGRESSION GUARD (stage-326): the streaming worker's `finally` block
|
||||
must NOT contain `PENDING_GOAL_CONTINUATION.discard(session_id)`.
|
||||
|
||||
Doing so races against the frontend's SSE-receive → POST /chat/start
|
||||
round-trip and erases the marker before it can be consumed.
|
||||
"""
|
||||
src = _read_streaming()
|
||||
|
||||
# Find the cleanup block — STREAM_GOAL_RELATED.pop is a stable anchor.
|
||||
pop_idx = src.find("STREAM_GOAL_RELATED.pop(stream_id")
|
||||
assert pop_idx != -1, "STREAM_GOAL_RELATED cleanup not found — test needs update"
|
||||
|
||||
# Look at the next ~600 chars (the immediate cleanup block).
|
||||
block = src[pop_idx:pop_idx + 600]
|
||||
|
||||
# The discard must NOT appear in this cleanup block.
|
||||
assert "PENDING_GOAL_CONTINUATION.discard" not in block, (
|
||||
"REGRESSION: streaming.py's stream-cleanup block discards "
|
||||
"PENDING_GOAL_CONTINUATION. This races against the consumer in "
|
||||
"routes.py and breaks the goal-continuation chain. The discard "
|
||||
"must live ONLY in routes.py's `_start_chat_stream_for_session` "
|
||||
"consumer path."
|
||||
)
|
||||
|
||||
|
||||
def test_routes_consumer_discards_atomically_on_read():
|
||||
"""The routes.py consumer must discard the marker after consuming it,
|
||||
so the marker is single-use (one continuation = one auto-flag).
|
||||
"""
|
||||
src = _read_routes()
|
||||
|
||||
# Find the consumption check.
|
||||
m = re.search(
|
||||
r"if not goal_related and s\.session_id in PENDING_GOAL_CONTINUATION:.*?PENDING_GOAL_CONTINUATION\.discard",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m is not None, (
|
||||
"routes.py must consume PENDING_GOAL_CONTINUATION atomically: "
|
||||
"check + set goal_related + discard in the same block"
|
||||
)
|
||||
# The discard must be within ~10 lines of the check (atomic block).
|
||||
block = m.group(0)
|
||||
line_count = block.count("\n")
|
||||
assert line_count <= 10, (
|
||||
f"PENDING_GOAL_CONTINUATION check + discard span {line_count} lines; "
|
||||
"should be tight atomic block"
|
||||
)
|
||||
|
||||
|
||||
def test_pending_goal_continuation_is_a_set():
|
||||
"""The marker store must be a set so add/discard is GIL-safe single-op
|
||||
(mutated from streaming worker thread, read from HTTP threads)."""
|
||||
from api.config import PENDING_GOAL_CONTINUATION
|
||||
assert isinstance(PENDING_GOAL_CONTINUATION, set), (
|
||||
"PENDING_GOAL_CONTINUATION must be a set for thread-safe single-op "
|
||||
"add/discard semantics"
|
||||
)
|
||||
|
||||
|
||||
def test_stream_goal_related_pop_keyed_by_stream_id():
|
||||
"""STREAM_GOAL_RELATED.pop in the cleanup must be keyed by stream_id
|
||||
(the ending stream's id), not session_id — a different stream's flag
|
||||
must not be erased."""
|
||||
src = _read_streaming()
|
||||
# Search for the cleanup line.
|
||||
m = re.search(r"STREAM_GOAL_RELATED\.pop\(([^,)]+)", src)
|
||||
assert m is not None, "STREAM_GOAL_RELATED.pop not found in streaming.py"
|
||||
key = m.group(1).strip()
|
||||
assert key == "stream_id", (
|
||||
f"STREAM_GOAL_RELATED.pop must be keyed by stream_id, got {key!r}. "
|
||||
"Using session_id would erase a different stream's flag if two "
|
||||
"streams overlap on the same session."
|
||||
)
|
||||
|
||||
|
||||
def test_goal_continue_set_marker_before_emitting_event():
|
||||
"""Source-code ordering check: PENDING_GOAL_CONTINUATION.add must
|
||||
happen BEFORE the goal_continue SSE event is put on the queue, so the
|
||||
marker is observable by the time the frontend reacts."""
|
||||
src = _read_streaming()
|
||||
add_idx = src.find("PENDING_GOAL_CONTINUATION.add(session_id)")
|
||||
if add_idx == -1:
|
||||
# Tolerate slight phrasing variations.
|
||||
m = re.search(r"PENDING_GOAL_CONTINUATION\.add\([^)]*\)", src)
|
||||
assert m is not None, "PENDING_GOAL_CONTINUATION.add not found"
|
||||
add_idx = m.start()
|
||||
|
||||
# Find the next goal_continue SSE event AFTER the add.
|
||||
after_add = src[add_idx:]
|
||||
event_idx = after_add.find("goal_continue")
|
||||
assert event_idx != -1, "no goal_continue emission after marker add"
|
||||
# Must be within ~500 chars (close to the add).
|
||||
assert event_idx < 500, (
|
||||
"PENDING_GOAL_CONTINUATION.add must immediately precede the "
|
||||
"goal_continue SSE emission"
|
||||
)
|
||||
@@ -84,3 +84,22 @@ def test_user_scroll_cancels_delayed_bottom_settling():
|
||||
assert "e.deltaY<0" in record
|
||||
assert "_cancelBottomSettle();" in record
|
||||
assert "_scrollPinned=false" in record
|
||||
|
||||
|
||||
def test_preserve_scroll_restores_unpinned_viewport_after_dom_rebuild():
|
||||
render = _function_body(UI_JS, "function renderMessages")
|
||||
after_render = _function_body(UI_JS, "function _scrollAfterMessageRender")
|
||||
restore = _function_body(UI_JS, "function _restoreMessageScrollSnapshot")
|
||||
|
||||
snapshot_idx = render.index("const scrollSnapshot=preserveScroll?_captureMessageScrollSnapshot():null")
|
||||
inner_idx = render.index("const inner=$('msgInner')")
|
||||
final_scroll_idx = render.rindex("_scrollAfterMessageRender(preserveScroll, scrollSnapshot)")
|
||||
|
||||
assert snapshot_idx < inner_idx < final_scroll_idx, (
|
||||
"renderMessages({preserveScroll:true}) must capture #messages.scrollTop before "
|
||||
"replacing transcript DOM, then pass that snapshot to the post-render scroll helper"
|
||||
)
|
||||
assert "if(_scrollPinned) scrollIfPinned()" in after_render
|
||||
assert "else _restoreMessageScrollSnapshot(scrollSnapshot)" in after_render
|
||||
assert "el.scrollTop=Math.max(0,Math.min(Number(snapshot.top)||0,maxTop))" in restore
|
||||
assert "_programmaticScroll=true" in restore
|
||||
|
||||
@@ -9,6 +9,8 @@ Covers:
|
||||
(covering both prism-loaded and prism-absent paths) and from `_applySkin()`.
|
||||
- The helper reads `getComputedStyle(html).getPropertyValue('--bg')`, which means
|
||||
every skin (Default, Sienna, Sisyphus, Charizard, etc.) reaches the meta tag.
|
||||
- Both the pre-paint script and boot sync update all theme-color tags and remove
|
||||
stale media attributes so OS light/dark preference cannot override the user theme.
|
||||
|
||||
This bridge is the source of truth that native WKWebView wrappers
|
||||
(hermes-webui/hermes-swift-mac) read instead of pixel-sampling the page —
|
||||
@@ -43,17 +45,20 @@ class TestIndexHtmlMetaTags:
|
||||
# Must be on a meta tag (not some other element)
|
||||
assert '<meta name="theme-color" id="hermes-theme-color"' in src
|
||||
|
||||
def test_inline_pre_paint_script_seeds_meta(self):
|
||||
"""An inline script in <head> seeds the runtime meta tag from localStorage
|
||||
def test_inline_pre_paint_script_seeds_all_theme_color_metas(self):
|
||||
"""An inline script in <head> seeds all theme-color tags from localStorage
|
||||
before any external JS loads. This prevents a single-frame flash of the
|
||||
OS-default theme-color when the user has explicitly chosen the opposite.
|
||||
OS-default theme-color when the user has explicitly chosen the opposite,
|
||||
and prevents media-query fallbacks from overriding the runtime tag.
|
||||
"""
|
||||
src = INDEX.read_text(encoding="utf-8")
|
||||
assert "hermes-theme-color" in src
|
||||
assert "hermes-theme" in src
|
||||
# The seeder must read from the same localStorage key the theme bootstrap uses.
|
||||
assert "localStorage.getItem('hermes-theme')" in src
|
||||
# And must call setAttribute('content', ...) on the meta tag.
|
||||
# It must update every theme-color tag and neutralize stale light/dark media hints.
|
||||
assert "querySelectorAll('meta[name=\"theme-color\"]')" in src
|
||||
assert "setAttribute('content'" in src or 'setAttribute("content"' in src
|
||||
assert "removeAttribute('media')" in src
|
||||
|
||||
|
||||
class TestBootJsThemeColorSync:
|
||||
@@ -70,13 +75,17 @@ class TestBootJsThemeColorSync:
|
||||
# The helper reads getComputedStyle on documentElement and extracts --bg.
|
||||
assert "getComputedStyle(document.documentElement).getPropertyValue('--bg')" in src
|
||||
|
||||
def test_sync_helper_targets_known_meta_id(self):
|
||||
"""The helper must target the same id declared in index.html. Drift here
|
||||
is the most common way a one-line frontend change silently breaks the
|
||||
Swift app's theme-color reader.
|
||||
def test_sync_helper_updates_all_theme_color_tags(self):
|
||||
"""The helper must update the canonical id tag and the static fallback tags.
|
||||
Desktop/native chrome can prefer a matching media tag over the id tag; if
|
||||
stale media variants remain light while the app is dark, the title bar goes beige.
|
||||
Civilization trembles, but mostly the window looks wrong.
|
||||
"""
|
||||
src = BOOT.read_text(encoding="utf-8")
|
||||
assert "getElementById('hermes-theme-color')" in src
|
||||
assert "querySelectorAll('meta[name=\"theme-color\"]')" in src
|
||||
assert "setAttribute('content',bg)" in src
|
||||
assert "removeAttribute('media')" in src
|
||||
|
||||
def test_set_resolved_theme_calls_sync_in_both_branches(self):
|
||||
"""_setResolvedTheme has two exit paths:
|
||||
|
||||
Reference in New Issue
Block a user