diff --git a/CHANGELOG.md b/CHANGELOG.md index eeaa0e04..478a3cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:::` 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. diff --git a/ROADMAP.md b/ROADMAP.md index 6bfe7c39..2f07e779 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/TESTING.md b/TESTING.md index cbdc0faf..ee35af45 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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* diff --git a/api/agent_health.py b/api/agent_health.py index c68375b8..ea3bc572 100644 --- a/api/agent_health.py +++ b/api/agent_health.py @@ -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, diff --git a/api/auth.py b/api/auth.py index b6187455..73303f01 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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 diff --git a/api/config.py b/api/config.py index 27de96d2..d3df1cef 100644 --- a/api/config.py +++ b/api/config.py @@ -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:`` 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::`` 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 diff --git a/api/models.py b/api/models.py index ed300617..1aac37a5 100644 --- a/api/models.py +++ b/api/models.py @@ -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, diff --git a/api/routes.py b/api/routes.py index 26bcd782..cdf9e12a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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) diff --git a/api/streaming.py b/api/streaming.py index d9daeb33..5fcd1ae9 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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 diff --git a/static/boot.js b/static/boot.js index 2902718c..66f6dd0b 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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'){ diff --git a/static/i18n.js b/static/i18n.js index 8b86a209..7e367291 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1170,10 +1170,10 @@ const LOCALES = { untitled: '無題', n_messages: (n) => `${n} 件のメッセージ`, load_older_messages: '↑ 上にスクロール、またはクリックして過去のメッセージを読み込む', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: '開始', + session_jump_start_label: 'セッションの先頭へ移動', + session_jump_end: '末尾', + session_jump_end_label: 'セッションの末尾へ移動', queued_label: '応答後に送信', queued_count: (n) => `${n} 件キュー中`, queued_cancel: 'キューに入れたメッセージをキャンセル', @@ -1361,7 +1361,7 @@ const LOCALES = { terminal_error: 'ターミナルエラー', workspace_empty_no_path: 'ワークスペースが選択されていません。設定 → ワークスペースで選択してください。', workspace_empty_dir: 'このワークスペースは空です。', - workspace_show_hidden_files: 'Show hidden files', + workspace_show_hidden_files: '隠しファイルを表示', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', @@ -1470,8 +1470,8 @@ const LOCALES = { settings_updates_disabled: 'アップデート確認は無効です', settings_label_workspace_panel_open: 'ワークスペースパネルをデフォルトで開いておく', settings_desc_workspace_panel_open: '有効にすると、新しいセッションごとにワークスペース/ファイルブラウザパネルが自動で開きます。手動でいつでも閉じられます。', - settings_label_session_jump_buttons: 'Show session jump buttons', - settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + settings_label_session_jump_buttons: 'セッションジャンプボタンを表示', + settings_desc_session_jump_buttons: '長いセッション履歴を読むときに、浮動表示の「開始」と「末尾」ボタンを表示します。', settings_label_session_endless_scroll: '上スクロールで古いメッセージを読み込む', @@ -2186,10 +2186,10 @@ const LOCALES = { untitled: 'Без названия', n_messages: (n) => `${n} сообщений`, load_older_messages: '↑ Прокрутите вверх или нажмите, чтобы загрузить ранние сообщения', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: 'Начало', + session_jump_start_label: 'Перейти к началу сессии', + session_jump_end: 'Конец', + session_jump_end_label: 'Перейти к концу сессии', queued_label: 'Отправить после ответа', queued_count: (n) => n === 1 ? '1 в очереди' : `${n} в очереди`, queued_cancel: 'Отменить сообщение', @@ -2298,7 +2298,7 @@ const LOCALES = { settings_autosave_failed: 'Не удалось сохранить', settings_autosave_retry: 'Повторить', workspace_empty_dir: 'Это рабочее пространство пусто.', - workspace_show_hidden_files: 'Show hidden files', + workspace_show_hidden_files: 'Показывать скрытые файлы', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', @@ -2925,8 +2925,8 @@ const LOCALES = { settings_update_check_failed: 'Ошибка проверки обновлений', settings_label_workspace_panel_open: 'Открывать панель рабочей области по умолчанию', settings_desc_workspace_panel_open: 'При включении панель файлов будет открываться автоматически в каждой новой сессии.', - settings_label_session_jump_buttons: 'Show session jump buttons', - settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + settings_label_session_jump_buttons: 'Показывать кнопки перехода по сессии', + settings_desc_session_jump_buttons: 'Показывать плавающие кнопки «Начало» и «Конец» при чтении длинных историй сессий.', settings_label_session_endless_scroll: 'Загружать старые сообщения при прокрутке вверх', @@ -3160,10 +3160,10 @@ const LOCALES = { untitled: 'Sin título', n_messages: (n) => `${n} mensajes`, load_older_messages: '↑ Desplázate hacia arriba o haz clic para cargar mensajes anteriores', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: 'Inicio', + session_jump_start_label: 'Saltar al inicio de la sesión', + session_jump_end: 'Fin', + session_jump_end_label: 'Saltar al final de la sesión', queued_label: 'Enviar después de la respuesta', queued_count: (n) => n === 1 ? '1 en cola' : `${n} en cola`, queued_cancel: 'Cancelar mensaje en cola', @@ -3279,7 +3279,7 @@ const LOCALES = { terminal_error: 'Error del terminal', workspace_empty_no_path: 'No hay espacio de trabajo seleccionado. Configure un espacio de trabajo en Ajustes \u2192 Workspace para explorar archivos.', workspace_empty_dir: 'Este espacio de trabajo está vacío.', - workspace_show_hidden_files: 'Show hidden files', + workspace_show_hidden_files: 'Mostrar archivos ocultos', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', @@ -3885,8 +3885,8 @@ const LOCALES = { settings_update_check_failed: 'Error al comprobar actualizaciones', settings_label_workspace_panel_open: 'Mantener panel de espacio abierto', settings_desc_workspace_panel_open: 'Al activar, el panel de archivos se abre automáticamente en cada nueva sesión. Aún puedes cerrarlo manualmente.', - settings_label_session_jump_buttons: 'Show session jump buttons', - settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + settings_label_session_jump_buttons: 'Mostrar botones de salto de sesión', + settings_desc_session_jump_buttons: 'Muestra botones flotantes de Inicio y Fin al leer historiales de sesión largos.', settings_label_session_endless_scroll: 'Cargar mensajes antiguos al desplazarse hacia arriba', @@ -4130,10 +4130,10 @@ const LOCALES = { untitled: 'Unbenannt', n_messages: (n) => `${n} Nachrichten`, load_older_messages: '↑ Nach oben scrollen oder klicken, um ältere Nachrichten zu laden', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: 'Anfang', + session_jump_start_label: 'Zum Anfang der Sitzung springen', + session_jump_end: 'Ende', + session_jump_end_label: 'Zum Ende der Sitzung springen', queued_label: 'Wird nach Antwort gesendet', queued_count: (n) => n === 1 ? '1 in Warteschlange' : `${n} in Warteschlange`, queued_cancel: 'Nachricht abbrechen', @@ -4590,8 +4590,8 @@ const LOCALES = { settings_update_check_failed: 'Update-Prüfung fehlgeschlagen', settings_label_workspace_panel_open: 'Arbeitsbereich-Panel standardmäßig öffnen', settings_desc_workspace_panel_open: 'Wenn aktiviert, wird der Datei-Browser bei jeder neuen Sitzung automatisch geöffnet. Er kann jederzeit manuell geschlossen werden.', - settings_label_session_jump_buttons: 'Show session jump buttons', - settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + settings_label_session_jump_buttons: 'Sitzungs-Sprungtasten anzeigen', + settings_desc_session_jump_buttons: 'Zeigt beim Lesen langer Sitzungsverläufe schwebende Anfang- und Ende-Tasten an.', settings_label_session_endless_scroll: 'Ältere Nachrichten beim Hochscrollen laden', @@ -5104,10 +5104,10 @@ const LOCALES = { untitled: '\u672a\u547d\u540d', n_messages: (n) => `${n} \u6761\u6d88\u606f`, load_older_messages: '↑ 向上滚动或点击加载更早的消息', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: '开头', + session_jump_start_label: '跳转到会话开头', + session_jump_end: '结尾', + session_jump_end_label: '跳转到会话结尾', queued_label: '响应后发送', queued_count: (n) => n === 1 ? '1 条排队' : `${n} 条排队`, queued_cancel: '取消排队消息', @@ -5200,7 +5200,7 @@ const LOCALES = { workspace_empty_no_path: '未选择工作区。请在 设置 → 工作区 中设置工作区以浏览文件。', workspace_empty_dir: '此工作区为空。', - workspace_show_hidden_files: 'Show hidden files', + workspace_show_hidden_files: '显示隐藏文件', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', @@ -5826,8 +5826,8 @@ const LOCALES = { settings_update_check_failed: '更新检查失败', settings_label_workspace_panel_open: '默认保持工作区面板打开', settings_desc_workspace_panel_open: '启用后,工作区/文件浏览器面板会在每次新会话时自动打开。您仍可随时手动关闭。', - settings_label_session_jump_buttons: 'Show session jump buttons', - settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + settings_label_session_jump_buttons: '显示会话跳转按钮', + settings_desc_session_jump_buttons: '阅读较长会话历史时显示悬浮的开头和结尾按钮。', settings_label_session_endless_scroll: '向上滚动时加载更早的消息', @@ -6066,10 +6066,10 @@ const LOCALES = { untitled: '\u672a\u547d\u540d', n_messages: (n) => `${n} \u689d\u8a0a\u606f`, load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: '開頭', + session_jump_start_label: '跳至會話開頭', + session_jump_end: '結尾', + session_jump_end_label: '跳至會話結尾', model_unavailable: '\uff08\u4e0d\u53ef\u7528\uff09', model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d', provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`, @@ -6119,7 +6119,7 @@ const LOCALES = { workspace_empty_no_path: '未選擇工作區。請在 設定 → 工作區 中設定工作區以瀏覽檔案。', workspace_empty_dir: '此工作區為空。', - workspace_show_hidden_files: 'Show hidden files', + workspace_show_hidden_files: '顯示隱藏檔案', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', @@ -6242,8 +6242,8 @@ const LOCALES = { settings_update_check_failed: '更新檢查失敗', settings_label_workspace_panel_open: '預設保持工作區面板開啓', settings_desc_workspace_panel_open: '啟用後,工作區/檔案瀏覽器面板會在每次新會話時自動開啓。您仍可隨時手動關閉。', - settings_label_session_jump_buttons: 'Show session jump buttons', - settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + settings_label_session_jump_buttons: '顯示會話跳轉按鈕', + settings_desc_session_jump_buttons: '閱讀較長會話歷史時顯示浮動的開頭與結尾按鈕。', settings_label_session_endless_scroll: '向上捲動時載入較早訊息', @@ -6400,10 +6400,10 @@ const LOCALES = { downloading: (filename) => `正在下載 ${filename}…`, n_messages: (n) => `${n} 則訊息`, load_older_messages: '↑ 向上捲動或點擊以載入較早的訊息', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: '開頭', + session_jump_start_label: '跳至會話開頭', + session_jump_end: '結尾', + session_jump_end_label: '跳至會話結尾', onboarding_api_key_help_prefix: '\u900f\u904e\u4ee5\u4e0b\u65b9\u5f0f\u5132\u5b58\u70ba Hermes .env \u6a94\u6848\u4e2d\u7684\u6a5f\u5bc6', onboarding_api_key_label: 'API \u91d1\u9470', onboarding_api_key_placeholder: '\u7559\u7a7a\u4ee5\u4fdd\u7559\u5df2\u5132\u5b58\u7684\u91d1\u9470', @@ -7029,10 +7029,10 @@ const LOCALES = { untitled: 'Sem título', n_messages: (n) => `${n} mensagens`, load_older_messages: '↑ Role para cima ou clique para carregar mensagens mais antigas', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: 'Início', + session_jump_start_label: 'Ir para o início da sessão', + session_jump_end: 'Fim', + session_jump_end_label: 'Ir para o fim da sessão', queued_label: 'Envia após a resposta', queued_count: (n) => n === 1 ? '1 na fila' : `${n} na fila`, queued_cancel: 'Cancelar mensagem na fila', @@ -7203,7 +7203,7 @@ const LOCALES = { no_workspace: 'Nenhum workspace', workspace_empty_no_path: 'Nenhum workspace selecionado. Configure em Configurações → Workspace.', workspace_empty_dir: 'Este workspace está vazio.', - workspace_show_hidden_files: 'Show hidden files', + workspace_show_hidden_files: 'Mostrar arquivos ocultos', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', @@ -7297,8 +7297,8 @@ const LOCALES = { settings_update_check_failed: 'Falha ao verificar updates', settings_label_workspace_panel_open: 'Manter painel workspace aberto por padrão', settings_desc_workspace_panel_open: 'Quando ativo, o painel workspace abre automaticamente com cada nova sessão.', - settings_label_session_jump_buttons: 'Show session jump buttons', - settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + settings_label_session_jump_buttons: 'Mostrar botões de salto da sessão', + settings_desc_session_jump_buttons: 'Mostra botões flutuantes Início e Fim ao ler históricos longos de sessão.', settings_label_session_endless_scroll: 'Carregar mensagens antigas ao rolar para cima', @@ -7942,10 +7942,10 @@ const LOCALES = { untitled: '제목 없음', n_messages: (n) => `${n}개 메시지`, load_older_messages: '↑ 위로 스크롤하거나 클릭하여 이전 메시지 불러오기', - session_jump_start: 'Start', - session_jump_start_label: 'Jump to beginning of session', - session_jump_end: 'End', - session_jump_end_label: 'Jump to end of session', + session_jump_start: '시작', + session_jump_start_label: '세션 시작으로 이동', + session_jump_end: '끝', + session_jump_end_label: '세션 끝으로 이동', queued_label: 'Sends after response', queued_count: (n) => n === 1 ? '1 queued' : `${n} queued`, queued_cancel: 'Cancel queued message', @@ -8124,7 +8124,7 @@ const LOCALES = { terminal_error: '터미널 오류', workspace_empty_no_path: 'No workspace selected. Set a workspace in Settings \u2192 Workspace to browse files.', workspace_empty_dir: 'This workspace is empty.', - workspace_show_hidden_files: 'Show hidden files', + workspace_show_hidden_files: '숨김 파일 표시', workspace_show_hidden_files_desc: 'Include .DS_Store, .git, node_modules, and other hidden / system files in the file tree.', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', @@ -8233,8 +8233,8 @@ const LOCALES = { settings_update_check_failed: 'Update check failed', settings_label_workspace_panel_open: '기본으로 워크스페이스 패널 열기', settings_desc_workspace_panel_open: '활성화하면 새 세션마다 워크스페이스/파일 브라우저 패널이 자동으로 열립니다. 언제든지 수동으로 닫을 수 있습니다.', - settings_label_session_jump_buttons: 'Show session jump buttons', - settings_desc_session_jump_buttons: 'Show floating Start and End buttons while reading long session histories.', + settings_label_session_jump_buttons: '세션 이동 버튼 표시', + settings_desc_session_jump_buttons: '긴 세션 기록을 읽을 때 떠 있는 시작 및 끝 버튼을 표시합니다.', settings_label_session_endless_scroll: '위로 스크롤할 때 이전 메시지 불러오기', diff --git a/static/messages.js b/static/messages.js index d7122b9f..75758f7c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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 diff --git a/static/sessions.js b/static/sessions.js index 8a88217a..b243e3e8 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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; } } diff --git a/static/ui.js b/static/ui.js index 3827d926..abbf2fc7 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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();}); diff --git a/tests/test_auth_sessions.py b/tests/test_auth_sessions.py index ce685fbd..9e95dedf 100644 --- a/tests/test_auth_sessions.py +++ b/tests/test_auth_sessions.py @@ -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") diff --git a/tests/test_issue1690_scroll_completion.py b/tests/test_issue1690_scroll_completion.py index 21c202f9..66233b65 100644 --- a/tests/test_issue1690_scroll_completion.py +++ b/tests/test_issue1690_scroll_completion.py @@ -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 diff --git a/tests/test_issue1793_file_tree_cruft_filter.py b/tests/test_issue1793_file_tree_cruft_filter.py index 8cc12f68..c920ba20 100644 --- a/tests/test_issue1793_file_tree_cruft_filter.py +++ b/tests/test_issue1793_file_tree_cruft_filter.py @@ -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" + ) diff --git a/tests/test_issue1879_cross_container_gateway_liveness.py b/tests/test_issue1879_cross_container_gateway_liveness.py index 262b8f23..2eeaf38e 100644 --- a/tests/test_issue1879_cross_container_gateway_liveness.py +++ b/tests/test_issue1879_cross_container_gateway_liveness.py @@ -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", [ diff --git a/tests/test_issue1937_endless_scroll_jumpstart_race.py b/tests/test_issue1937_endless_scroll_jumpstart_race.py new file mode 100644 index 00000000..7f3db384 --- /dev/null +++ b/tests/test_issue1937_endless_scroll_jumpstart_race.py @@ -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 `` (or ``function ``).""" + 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=` 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." + ) diff --git a/tests/test_issue677.py b/tests/test_issue677.py index 09554e12..ff5dfbf6 100644 --- a/tests/test_issue677.py +++ b/tests/test_issue677.py @@ -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)" ) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index f93e2f6b..992c5046 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -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(aIdxa-b);" in UI_JS diff --git a/tests/test_issue_1932_goal_hook_unrelated_turns.py b/tests/test_issue_1932_goal_hook_unrelated_turns.py new file mode 100644 index 00000000..b0860232 --- /dev/null +++ b/tests/test_issue_1932_goal_hook_unrelated_turns.py @@ -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" + ) diff --git a/tests/test_pr1947_same_model_multiple_custom_providers.py b/tests/test_pr1947_same_model_multiple_custom_providers.py new file mode 100644 index 00000000..b181e5da --- /dev/null +++ b/tests/test_pr1947_same_model_multiple_custom_providers.py @@ -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)) diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index 8d1769f3..e1f84b3c 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -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")') diff --git a/tests/test_resolve_model_provider_free_suffix.py b/tests/test_resolve_model_provider_free_suffix.py index 9d9d0760..8798b71e 100644 --- a/tests/test_resolve_model_provider_free_suffix.py +++ b/tests/test_resolve_model_provider_free_suffix.py @@ -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:: 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 diff --git a/tests/test_session_jump_buttons.py b/tests/test_session_jump_buttons.py index 48461eea..a0dd85d4 100644 --- a/tests/test_session_jump_buttons.py +++ b/tests/test_session_jump_buttons.py @@ -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 diff --git a/tests/test_stage326_composer_draft_validation.py b/tests/test_stage326_composer_draft_validation.py new file mode 100644 index 00000000..71e3ecec --- /dev/null +++ b/tests/test_stage326_composer_draft_validation.py @@ -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" + ) diff --git a/tests/test_stage326_pending_goal_continuation_race.py b/tests/test_stage326_pending_goal_continuation_race.py new file mode 100644 index 00000000..412c742a --- /dev/null +++ b/tests/test_stage326_pending_goal_continuation_race.py @@ -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" + ) diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index b05fa4e8..a37abf2e 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -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 diff --git a/tests/test_theme_color_meta_bridge.py b/tests/test_theme_color_meta_bridge.py index 4faaeed8..29041cbe 100644 --- a/tests/test_theme_color_meta_bridge.py +++ b/tests/test_theme_color_meta_bridge.py @@ -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 ' seeds the runtime meta tag from localStorage + def test_inline_pre_paint_script_seeds_all_theme_color_metas(self): + """An inline script in 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: