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:
nesquena-hermes
2026-05-09 11:55:08 -07:00
committed by GitHub
30 changed files with 1663 additions and 118 deletions
+73 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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*
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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 sesn',
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: '위로 스크롤할 때 이전 메시지 불러오기',
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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();});
+80
View File
@@ -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")
+4 -3
View File
@@ -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."
)
+1 -1
View File
@@ -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)"
)
+1 -1
View File
@@ -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))
+15
View File
@@ -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
+11 -8
View File
@@ -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
+18 -9
View File
@@ -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: