Merge origin/master into fix/7535-provider-base-url-ownership

This commit is contained in:
webtecnica committed 2026-09-23 09:20:41 -03:00
commit a44e329997
60 files changed
+5010 -384

No files matched your search

+18 -1
View File
@@ -12,7 +12,9 @@ on:
- 'api/**.py'
- 'server.py'
- 'tests/browser_conversation_lifecycle.py'
- 'tests/sidebar_terminal_barrier_server.py'
- 'tests/browser_historical_transcript_hydration.py'
- 'tests/browser_reconnect_scene_redraw.py'
- 'requirements*.txt'
- 'pyproject.toml'
- '.github/workflows/conversation-lifecycle.yml'
@@ -23,7 +25,9 @@ on:
- 'api/**.py'
- 'server.py'
- 'tests/browser_conversation_lifecycle.py'
- 'tests/sidebar_terminal_barrier_server.py'
- 'tests/browser_historical_transcript_hydration.py'
- 'tests/browser_reconnect_scene_redraw.py'
- 'requirements*.txt'
- 'pyproject.toml'
- '.github/workflows/conversation-lifecycle.yml'
@@ -32,7 +36,8 @@ jobs:
live-to-final:
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
# Keep existing proof rows informational; reconnect regressions fail CI.
continue-on-error: ${{ matrix.name != 'reconnect-scene-redraw' }}
strategy:
fail-fast: false
matrix:
@@ -45,6 +50,9 @@ jobs:
scenario: terminal-error
- name: historical-transcript-hydration
script: tests/browser_historical_transcript_hydration.py
- name: reconnect-scene-redraw
script: tests/browser_reconnect_scene_redraw.py
browsers: chromium
steps:
- uses: actions/checkout@v4
@@ -69,6 +77,7 @@ jobs:
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts
LIFECYCLE_SCENARIO: ${{ matrix.scenario }}
BROWSERS: ${{ matrix.browsers }}
run: python ${{ matrix.script }}
- name: Run settlement frame proof
@@ -79,6 +88,14 @@ jobs:
LIFECYCLE_TEST_BITE: settle-worklog-frame-proof
run: python tests/browser_conversation_lifecycle.py
- name: Recover a missing terminal frame
if: matrix.scenario == 'normal'
env:
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts/missing-terminal
LIFECYCLE_SCENARIO: normal
LIFECYCLE_TEST_BITE: sidebar-idle-missing-terminal
run: python tests/browser_conversation_lifecycle.py
- name: Upload failure artifacts
if: failure()
uses: actions/upload-artifact@v4
+53
View File
@@ -5,6 +5,10 @@
### Added
- **Full-session resolve concurrency is configurable.** `HERMES_WEBUI_MAX_SESSION_RESOLVE` sets how
many full-transcript session resolves may run at once (default 2, a positive integer up to 64;
zero, negative, non-numeric or out-of-range values fall back to 2). It is process-wide, so a
profile's `.env` cannot override it. (#7421, #7656 by @happy5318)
- **The sidebar's recent-session window is configurable.** `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` sets
how many recent sessions the sidebar lists (default 20). It also bounds how many delegated subagent
children can nest at once, so raise it for wide fan-outs. Invalid or non-positive values fall back to
@@ -24,6 +28,54 @@
### Fixed
- **A first visit now uses the browser's language.** The server stores "no preference" as `null`
instead of defaulting to `"en"`, so a first-time visitor gets `navigator.language` while an
explicitly saved language (including English) still wins, and legacy `settings.json` files that
already hold `"en"` keep English. Reading the browser language is guarded, so an environment where
`navigator` throws falls back cleanly. (#7622, #7730 by @happy5318)
- **The live model list for a custom provider respects its `models:` allowlist, without treating
per-model metadata as one.** `/api/models/live` filters a custom provider's live catalog to its
configured `models:` when that is a list, or when it is a mapping with `discover_models: false`.
A mapping of per-model settings (the shape `hermes setup` writes) keeps the full live catalog, as
the Agent does. (#7165 by @happy5318)
- **`MEDIA:` links wrapped in inline code no longer 404.** Every `MEDIA:` capture site (renderer,
streaming parser, TTS stripper, session-media authorization and snapshot capture, seven in all)
swallowed the closing backtick of `` `MEDIA:/path` `` into the path, so the file lookup and the
session allowlist both missed. Backtick-wrapped refs are now normalized first, while bare paths that
genuinely contain a backtick keep their full name. (#7359, #7708 by @happy5318)
- **A turn's Worklog no longer vanishes when the sidebar reports idle before the final frame.**
`/api/sessions` could say a session was idle before the chat stream's terminal frame reached the
page, and three sidebar paths (idle reconciliation, the INFLIGHT purge and optimistic-row
retirement) then erased the pane's Worklog and stream id, so the late `done` was rejected as stale
and the settled scene was never saved. The cleanup now waits briefly for the pane's own open
stream, then checks `/api/chat/stream/status` once, and falls back to the existing
interrupted-stream recovery if that check fails. (#7749 by @franksong2702)
- **A session's run journal can no longer be written out of order.** The journal writer reserved a
sequence number under the per-path lock, released it, and then appended, so two concurrent writers
could land sequence N+1 on disk before N and the replay reader would stop at the gap
(`replay_noncontiguous`). Sequence allocation and the physical append now happen under the same
existing lock. (#7751 by @franksong2702)
- **A stopped chat can no longer publish or reuse its agent after Stop.** A worker that was
cancelled could still publish its cached Agent or invoke it after Stop landed, and its late
cleanup could close or evict the same Agent object a successor turn had just picked up. The initial
path and both credential self-heal paths now take one Stop admission, the cancellation event, live
stream and exact Agent are rechecked immediately before invocation (no registry lock is held across
provider or tool execution), and cache/lifecycle handles are retired only while the surviving owner
still matches. (#7748 by @franksong2702)
- **Reconnecting to a running session no longer redraws every tool card over and over.** After a
reconnect restored the live activity scene from the run journal, the client also replayed its older
cached in-flight tool list on top, so a turn with N tool cards redrew them N times. When the
journal-backed scene restores successfully the replay is now skipped (newer rows still arrive
through the reattached stream); the replay stays for legacy restores and for a failed or
unavailable scene. (#7436, @atchisonbrent)
- **A settled assistant answer is no longer shown twice (#2051).** Two client-side paths could put a
second copy of a finished answer on screen after a turn settled: a stale live-turn node that was
still treated as live, and a settled rebuild branch that appended a turn instead of replacing it.
The same fix stops an interrupted turn's notice from appearing twice, and stops the composer's
model picker from listing one configured custom-provider model twice. The stored transcript was
always correct; only the rendering duplicated it. (#7374, @Thireus)
- **A hidden browser tab stops polling a session that was deleted.** When a tab is in the
background, its stream poll kept asking for a session every few seconds after it was deleted or
archived away, forever. A `404`/`410` for the polled session now stops that poll; `503` and network
@@ -220,6 +272,7 @@
### Changed
- **Two flaky tests are stable again.** Three `get_available_models()` cache-metadata tests raced the 4-second live-rebuild budget on a loaded CI host and could time out onto the stale-cache path. They now pin the budget to `0`, which is the documented setting for the legacy unbounded synchronous rebuild, so they still exercise the real rebuild (#7735). A source-text oracle that string-matched `delete_cli_session`'s source to prove it opens a writable connection is removed. The behavioral test in `test_issue1494_state_db_fd_leak.py` still guards that contract: it fails with `attempt to write a readonly database` if the delete path is ever switched to a read-only connection (#7726). Test-only; no runtime change. Thanks @webtecnica. (#7744, #7742)
- **The chat composer grows natively instead of being resized by JavaScript on every keystroke.** `autoResize()` measured `scrollHeight` and wrote `style.height` on each input event — a forced synchronous reflow on the most-typed-in surface in the app. Browsers that support CSS `field-sizing: content` (Chromium today; also Firefox 152 and Safari 26.2) now own the geometry directly, gated on `CSS.supports()`, and the existing JavaScript path is untouched for every other engine. Measured behaviour is identical across both paths: 44px resting height, no jump when the first character is typed or the last deleted, growth to the 200px ceiling, then internal scrolling. Because `field-sizing` deliberately includes placeholder text in content sizing, `:placeholder-shown` pins fixed sizing while the composer is empty so a long placeholder can't inflate it. Thanks @starship-s. (#6760, #5514)
### Fixed
+29 -6
View File
@@ -154,12 +154,35 @@ HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \
python tests/browser_historical_transcript_hydration.py
```
The dedicated `Conversation lifecycle (informational)` workflow runs the current
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) and
stays non-blocking while the public
matrix expands to additional behavior rows. The maintainer's private QA harness
remains broader; later public slices will add session switching, reconnect/replay,
cancellation, compression, and recovery.
The dedicated `Conversation lifecycle (informational)` workflow keeps the existing
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`)
non-blocking while the public matrix expands. The Chromium
`reconnect-scene-redraw` row does **not** allow failures: it exercises the real
`loadSession` reconnect path and fails the workflow on a regression. Required
merge checks remain a maintainer-controlled repository setting.
The maintainer's private QA harness remains broader; later public slices will
add cancellation, compression, and recovery coverage.
### Active-session reconnect redraw gate
`tests/browser_reconnect_scene_redraw.py` is an opt-in deterministic browser
gate for reconnecting to a running session with a large Anchor activity scene.
It uses an isolated temporary server/home and fixture SSE events—no agent,
provider credentials, or production state. By default it checks Chromium and
WebKit, desktop and 390px viewports, and all three activity display modes:
```bash
pip install playwright
python -m playwright install chromium webkit
python tests/browser_reconnect_scene_redraw.py
```
`TOOL_COUNT`, `BROWSERS`, and `MODES` narrow the matrix. `MEASURE_BASELINE=1`
records an unoptimized baseline without enforcing the redraw ceiling;
`METRICS_FILE` saves JSON metrics and `SCREENSHOT_DIR` saves generated-fixture
screenshots. This gate tests page reconstruction, journal-cursor resume, and
subsequent fixture SSE updates. It does not test a real provider/network or PWA
service-worker cache behavior.
### Streaming reader intent
+26 -1
View File
@@ -11483,7 +11483,13 @@ _SETTINGS_DEFAULTS = {
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
"tab_order": [], # user-defined sidebar/rail tab order for reorderable tabs; chat/settings stay fixed
"composer_control_order": [], # user-defined composer footer control order; invalid/duplicate keys are ignored
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
# #7622 (round 3): language is intentionally absent from the defaults so
# `load_settings()` reports `None` for a fresh install. This lets the
# client distinguish "no preference" from an explicit saved choice,
# so a user who genuinely picked English (and has `language: "en"`
# persisted on disk) is no longer overridden by the browser hint on
# their first hydration. When a user picks a locale in the Settings
# modal the value is written here and the field is set explicitly.
"bot_name": os.getenv(
"HERMES_WEBUI_BOT_NAME", "Hermes"
), # display name for the assistant
@@ -11723,6 +11729,16 @@ def load_settings() -> dict:
settings["default_model_provider"] = str(model_cfg.get("provider"))
except Exception:
logger.debug("Failed to resolve default model provider for settings")
# #7622/#7730 (round 4): keep the tri-state signal explicit. `language`
# is intentionally absent from `_SETTINGS_DEFAULTS` (see above), so
# without this line a fresh install's returned dict would OMIT the key
# entirely and the API payload would carry no `language` field. Emit an
# explicit `None` (serialized as JSON `null`) so the client receives the
# three-way signal it trusts: `null` = no preference, "en" = explicitly
# saved English, any other code = explicitly chosen locale. Stored
# values (including a legacy English pick written before this change)
# win via the merge above and are never touched here.
settings.setdefault("language", None)
return settings
@@ -11730,6 +11746,15 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
"password_hash",
"default_model",
"simplified_tool_calling",
} | {
# #7622 (round 3): `language` is intentionally absent from
# `_SETTINGS_DEFAULTS` so a fresh install returns `None` for
# `settings["language"]` and the client can distinguish "no
# preference" from an explicit saved choice. But the user
# MUST still be able to pick a locale in the Settings modal,
# so we add it back to the explicit allow-list here. The
# existing BCP-47 validation at save-time still applies.
"language",
}
_SETTINGS_ENUM_VALUES = {
"send_key": {"enter", "ctrl+enter", "shift+enter"},
+8
View File
@@ -505,6 +505,13 @@ def annotate_media_snapshots(
if allowed_predicate is None:
allowed_predicate = media_capture_allowed
media_re = _re.compile(r"MEDIA:([^\s\)\]]+)")
# #7680 re-gate (9/22): two-pass scan. First strip backtick
# wrappers (`` `MEDIA:path` `` → ``MEDIA:path``) so the bare
# class below does not consume the closing backtick as part of
# the path. Then the bare class (no backtick in the exclusion
# set) captures the full filename even when the path itself
# contains a backtick (e.g. ``report`final.png``).
backtick_re = _re.compile(r"`MEDIA:([^`\s]+)`")
captured = 0
for msg in messages or []:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
@@ -530,6 +537,7 @@ def annotate_media_snapshots(
if not text_parts:
continue
text = "\n".join(text_parts)
text = backtick_re.sub(lambda m: f"MEDIA:{m.group(1)}", text)
refs = media_re.findall(text)
if not refs:
continue
+35 -1
View File
@@ -4788,7 +4788,41 @@ class _FullSessionResolveRequired(RuntimeError):
"""Internal signal that a full sidecar load must enter the bounded path."""
_FULL_SESSION_RESOLVE_MAX_CONCURRENT = 2
def _read_max_session_resolve_concurrent() -> int:
"""#7421: env override for the heavy session-resolve cap. The
hardcoded literal 2 is too low for high-concurrency
deployments where several parallel active sessions all need
a full-transcript resolve; operators can set
``HERMES_WEBUI_MAX_SESSION_RESOLVE`` to a positive int to
raise the cap without code changes. A bad or missing value
falls back to the previous default of 2, so an operator who
sets ``HERMES_WEBUI_MAX_SESSION_RESOLVE=0`` or a non-numeric
value does not regress to a blocked or unbounded state.
The cap is a *safety bound* (per-resolve cost tracked in
#7310), not a user preference — a typo like ``=999999``
must fall back to the safe default, never to the permissive
extreme. Every other bad input in this function falls back
to ``2``; out-of-range values do too. The previously
accepted upper bound (64) is reachable only via an explicit
in-range operator value; a profile's ``.env`` cannot reach
it at all (see ``_PROTECTED_ENV_KEYS``).
"""
raw = (os.getenv("HERMES_WEBUI_MAX_SESSION_RESOLVE") or "").strip()
if not raw:
return 2
try:
n = int(raw)
except (TypeError, ValueError):
return 2
if n < 1:
return 2
if n > 64:
return 2 # #7656 round-3: typo-safe fallback, not clamp-up-to-64
return n
_FULL_SESSION_RESOLVE_MAX_CONCURRENT = _read_max_session_resolve_concurrent()
_FULL_SESSION_RESOLVE_SLOTS = threading.BoundedSemaphore(
_FULL_SESSION_RESOLVE_MAX_CONCURRENT
)
+14
View File
@@ -202,6 +202,14 @@ _PROTECTED_ENV_KEYS = frozenset({
'HERMES_WEBUI_ISOLATED_PROFILE',
# Server-wide sidebar window; a profile .env must not widen the per-request query.
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
# #7656 round-3: HERMES_WEBUI_MAX_SESSION_RESOLVE is a process-wide resource
# cap (controls the BoundedSemaphore that gates full-transcript resolves).
# A profile's .env is per-profile by design; allowing it to set a
# server-wide limit (and a first-load-wins asymmetry) would let a
# single profile drag the rest into a tighter or looser posture than
# the operator intended. Same shape as the isolated-profile key: only
# the operator/launcher env at startup can set it.
'HERMES_WEBUI_MAX_SESSION_RESOLVE',
})
@@ -939,6 +947,12 @@ _BLOCKED_RUNTIME_ENV_KEYS = {
# profile's own env on any runtime/gateway-parity path.
'HERMES_WEBUI_ISOLATED_PROFILE',
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
# #7656 round-3: process-wide resolve cap. Filter out a profile's
# .env override the same way we filter the isolated-profile key, so
# the cap stays at the operator/launcher value across profile
# switches rather than re-sizing to whatever the first-loaded
# profile env set.
'HERMES_WEBUI_MAX_SESSION_RESOLVE',
}
+140 -7
View File
@@ -20641,6 +20641,16 @@ def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp
_MEDIA_TOKEN_RE = re.compile(r"MEDIA:([^\s\)\]]+)")
# #7680 re-gate (9/22): two-pass scan.
# 1. `` `MEDIA:path` `` (backtick-wrapped, inline-code form) → strip
# the wrapping backticks so the bare-token pass below sees a
# plain ``MEDIA:path`` and the closing backtick is not consumed
# as part of the path.
# 2. ``MEDIA:[^\s\)\]]+`` (bare, no backtick in the exclusion
# class) so a filename that legally contains a backtick
# (``report`final.png``) is captured in full instead of being
# truncated at the first backtick.
_BACKTICK_MEDIA_RE = re.compile(r"`MEDIA:([^`\s]+)`")
def _message_content_text(content) -> str:
@@ -20700,6 +20710,10 @@ def _session_media_token_allows_path(sid: str, target: Path, allowed_mimes: set[
)
if "MEDIA:" not in text:
continue
# #7680 re-gate: strip backtick wrappers first so the bare
# class below captures the full path even when the filename
# itself contains a backtick.
text = _BACKTICK_MEDIA_RE.sub(lambda m: f"MEDIA:{m.group(1)}", text)
for ref in _MEDIA_TOKEN_RE.findall(text):
if "://" in ref:
continue
@@ -21892,6 +21906,99 @@ def _handle_live_models(handler, parsed):
_env = str(_cp.get("key_env") or "").strip()
return os.getenv(_env, "").strip() if _env else ""
def _custom_provider_models_discover_is_false(_cp):
"""True when ``discover_models`` is an explicit ``false`` opt-out.
Mirrors ``api.config._provider_discover_allowed`` / Hermes
Agent ``model_switch_providers._discover_flag``: ``discover_models``
defaults to True, and the string forms ``"false"``/``"no"``/``"0"``
(case-insensitive) mean False. Used to decide whether a
dict-shaped ``models`` mapping is a hand-pinned allowlist (only
when discovery is off) rather than per-model metadata.
"""
_discover = _cp.get("discover_models", True) if isinstance(_cp, dict) else True
if isinstance(_discover, str):
return _discover.strip().lower() in {"false", "no", "0"}
return not bool(_discover)
def _custom_provider_allowlist_ids(_cp):
"""Plural ``models`` list only — the explicit allowlist signal.
The singular ``model`` field is sticky/default metadata, NOT an
allowlist: it must not gate live-catalog filtering, otherwise a
provider configured with only ``model: assistant`` (no ``models``
list) would be collapsed to a single model. Only an explicit
``models`` allowlist expresses "show exactly these models".
An auto-discovered catalog is NOT an allowlist: when Hermes
persisted discovery results back into config (``models: {...}``
plus ``models_discovered: true``), that mapping is a snapshot
of what the gateway exposed at discovery time. Gating on it
would permanently pin the live catalog to the first-discovery
set, silently dropping any model the user pulls in later
(LM Studio / Ollama). ``_provider_models_are_discovered_catalog()``
is the shared predicate the ``/api/models`` path already uses;
when it says "discovered", return no allowlist and let the
live probe win. An explicit ``discover_models: false`` opt-out
re-pins the catalog (the predicate accounts for it), so a
hand-pinned discovered catalog still filters.
A dict-shaped ``models`` mapping that is NOT marked discovered is
*per-model metadata* written by the Hermes Agent setup flow
(``hermes_cli/model_switch.py::_save_custom_provider`` and the
setup wizard) — e.g. ``{chat-a: {context_length: 128000}}``. It is
not a catalog narrow: treating its keys as an allowlist would
collapse the live picker to the single saved default (keyless
Ollama) while the CLI live-probe shows the full catalog. Only an
explicit ``discover_models: false`` opts into treating the dict
keys as a pinned allowlist. List/JSON-array-string/Python-literal
shapes remain plain allowlists.
The plural value is decoded through ``_parse_config_string_list()``
because ``hermes config set`` / JSON-mode editor saves persist
lists as quoted JSON-array strings (``'["chat-a","chat-b"]'``) or
Python literals (``"['chat-a']"``). Handling only ``dict``/``list``
made a serialized allowlist fall through to ``[]``, which the
caller reads as "no allowlist configured" and floods the picker
with the full upstream catalog — the same class of bug as the
``skills.disabled`` regression (#7120 / #7134).
"""
from api.config import _provider_models_are_discovered_catalog
if _provider_models_are_discovered_catalog(_cp):
return []
_ids = []
_models = _cp.get("models")
# Serialized shapes only: ``hermes config set`` / JSON-mode
# editor saves persist lists as quoted JSON-array strings
# (``'["chat-a","chat-b"]'``) or Python literals
# (``"['chat-a']"``). Decode those through the shared helper so
# they are honored; native dict/list values are walked below so
# dict *entries* keep their id|model|name metadata (the decoder
# str()-ifies list members, which would mangle them).
if isinstance(_models, str):
_models = _parse_config_string_list(_models)
if isinstance(_models, dict):
# A dict-shaped ``models`` is per-model metadata written by
# the Hermes Agent setup flow, NOT a catalog narrow. Only a
# ``discover_models: false`` opt-out treats the dict keys as a
# pinned allowlist; otherwise return the empty allowlist so the
# live probe returns the full catalog.
if not _custom_provider_models_discover_is_false(_cp):
return []
for _mid in _models:
if isinstance(_mid, str) and _mid.strip():
_ids.append(_mid.strip())
elif isinstance(_models, (list, tuple)):
for _item in _models:
if isinstance(_item, str) and _item.strip():
_ids.append(_item.strip())
elif isinstance(_item, dict):
_mid = _item.get("id") or _item.get("model") or _item.get("name")
if _mid and str(_mid).strip():
_ids.append(str(_mid).strip())
return _ids
# For 'custom' and 'custom:*' providers, provider_model_ids()
# returns [] because they aren't real hermes_cli endpoints.
# Fall back to the custom_providers entries from config.yaml so
@@ -21900,11 +22007,13 @@ def _handle_live_models(handler, parsed):
# Collect config-specified model IDs separately so they don't
# prevent the live fetch below from running (#3718).
_config_ids = []
_allowlist_ids = []
if provider == "custom" or provider.startswith("custom:"):
for _cp in _custom_provider_entries_for_request():
if custom_provider_entry is None:
custom_provider_entry = _cp
_config_ids.extend(_custom_provider_model_ids(_cp))
_allowlist_ids.extend(_custom_provider_allowlist_ids(_cp))
# Always try live fetch for custom providers — config entries are a
# fallback, not a replacement. The live endpoint should return ALL
@@ -21975,15 +22084,39 @@ def _handle_live_models(handler, parsed):
except Exception as _fetch_err:
logger.debug("Live fetch from custom provider failed: %s", _fetch_err)
# If live fetch succeeded, merge with config entries (live takes
# priority). If live fetch failed, fall back to config-only list.
# If live fetch succeeded, filter the live catalog down to the
# config-declared models allowlist first, then append any
# allowlisted models the live endpoint didn't return. Custom
# providers (esp. New-API-style gateways) expose their ENTIRE
# catalog via /v1/models — including image/audio models that
# are not chat models. The picker must not surface models the
# user never declared in config.yaml. Only a NON-EMPTY explicit
# ``models`` allowlist gates the filter — a provider configured
# with just a singular ``model`` (no ``models`` list) keeps the
# unfiltered live catalog. When no allowlist is configured,
# return the live list as-is (preserves the pre-filter
# discovery behaviour).
#
# An empty allowlist (``models: []``, ``models: "[]"``, or a
# value that decodes to no usable ids) is deliberately treated
# as "not configured", NOT as "allow nothing". Gating on
# declared-ness instead would emit an EMPTY picker and make the
# provider unselectable — a harder failure than surfacing a few
# extra models, and unrecoverable from the UI because
# ``custom_providers`` is hand-edited in config.yaml (the WebUI
# has no write path for it). ``[]`` in practice means a
# leftover/placeholder key, not an intentional deny-all, and no
# deny-all use case exists: a provider the user wants hidden is
# removed from ``custom_providers`` outright.
if ids:
_live_set = set(ids)
for _cid in _config_ids:
if _cid not in _live_set:
ids.append(_cid)
if _allowlist_ids:
_allowlist_set = set(_allowlist_ids)
ids = [m for m in ids if m in _allowlist_set] or []
for _aid in _allowlist_ids:
if _aid not in ids:
ids.append(_aid)
else:
ids = list(_config_ids)
ids = list(_allowlist_ids or _config_ids)
# ── OpenAI-compat live fetch fallback ──────────────────────────────────
# When provider_model_ids() is unavailable or returns [] for a provider
+3 -8
View File
@@ -451,8 +451,6 @@ class RunJournalWriter:
self.session_id = _validate_id(session_id, "session_id")
self.run_id = _validate_id(run_id, "run_id")
self.session_dir = Path(session_dir) if session_dir is not None else None
self._path = _run_path(self.session_id, self.run_id, session_dir=self.session_dir)
self._lock = _lock_for(self._path)
def append_sse_event(self, event_name: str, payload=None) -> dict | None:
# Live-UI-only telemetry (metering) has no recovery value in the journal:
@@ -464,18 +462,15 @@ class RunJournalWriter:
# the offline-gap coverage and replay-cursor contiguity checks rely on.
if str(event_name or "").strip() in REPLAY_SKIPPED_SSE_EVENTS:
return None
# Draw from the shared module-level seq cache under the per-path lock so
# this writer and any direct append_run_event() call on the same path
# agree on one monotonic, gapless sequence.
with self._lock:
seq = _reserve_next_seq(self._path)
# Allocate the sequence inside the same per-path transaction that writes
# the row. Reserving here, then releasing the lock before append, lets a
# concurrent writer put a higher sequence on disk first.
return append_run_event(
self.session_id,
self.run_id,
event_name,
payload or {},
session_dir=self.session_dir,
seq=seq,
)
+157 -90
View File
@@ -9791,6 +9791,84 @@ def _run_agent_streaming(
_streaming_skill_home_snapshot = None
_restore_streaming_skill_home_modules = False
_acquired_streaming_skill_home_patch_lock = False
def _register_agent_if_current(candidate, cache_signature=None):
nonlocal agent
# Every constructor, including both credential self-heal branches, must
# share the Stop publication edge. CANCEL_FLAGS may already be detached;
# the worker-retained event and stream membership remain authoritative.
with STREAMS_LOCK:
cancelled = cancel_event.is_set() or stream_id not in STREAMS
if not cancelled:
AGENT_INSTANCES[stream_id] = candidate
# Check and publication share one admission, not a check followed
# by an unlocked cache/lifecycle write that could undo Stop.
if cache_signature is not None:
from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK
with SESSION_AGENT_CACHE_LOCK:
SESSION_AGENT_CACHE[session_id] = (candidate, cache_signature)
SESSION_AGENT_CACHE.move_to_end(session_id)
if not ephemeral:
try:
# Pure in-memory bookkeeping; no Agent/provider call.
from api.session_lifecycle import register_agent
register_agent(session_id, candidate)
except Exception:
logger.debug("Lifecycle register_agent failed for session %s", session_id, exc_info=True)
if not cancelled:
return True
# A cache hit is a borrowed reusable object, not this worker's private
# candidate. Stop may already have let a successor reuse it. Rejected
# cache-hit admission must not interrupt that other turn.
if cache_signature is None and not ephemeral:
if agent is candidate:
agent = None
return False
# Newly constructed candidates were never published on this failed
# admission; they cannot have been borrowed by a successor from us.
# No Agent call, session persistence or SSE write under registry locks.
try:
candidate.interrupt("Cancelled before start")
except Exception:
logger.debug("Failed to interrupt cancelled candidate agent")
return False
def _agent_can_invoke(candidate):
nonlocal agent
# Prompt preparation and LRU cleanup can yield after registration. Admit
# each invocation again at its point of use; never hold registry locks
# across run_conversation or interrupt. A later Stop interrupts the
# already-admitted invocation through the registered Agent as usual.
from api.config import (
SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK,
SESSION_WRITEBACK_OWNERS, SESSION_WRITEBACK_OWNERS_LOCK,
)
with STREAMS_LOCK:
current = (not cancel_event.is_set() and stream_id in STREAMS
and AGENT_INSTANCES.get(stream_id) is candidate)
if not current:
if AGENT_INSTANCES.get(stream_id) is candidate:
AGENT_INSTANCES.pop(stream_id, None)
# Agent identity is not turn identity: a successor can reuse the
# same cached object. Use the existing cancellation-surviving
# ownership record, holding its lock through cache retirement.
# Missing ownership also fails closed (successor may have ended).
with SESSION_WRITEBACK_OWNERS_LOCK:
if SESSION_WRITEBACK_OWNERS.get(session_id) == stream_id:
with SESSION_AGENT_CACHE_LOCK:
entry = SESSION_AGENT_CACHE.get(session_id)
if entry and entry[0] is candidate:
SESSION_AGENT_CACHE.pop(session_id, None)
# Dirty memory segments keep their original owner.
from api.session_lifecycle import unregister_agent
unregister_agent(session_id)
# This invocation never started. Stop owns any required interrupt of the
# running Agent; a late duplicate interrupt could hit its new borrower.
# Drop our local borrowed handle too: final Steer drain must not reach
# the successor through the old worker's fallback `agent` reference.
if not current and agent is candidate:
agent = None
return current
# Initialised here (before any code that may raise) so the outer `finally`
# block can safely check `if _checkpoint_stop is not None` even when an
# exception fires before the checkpoint thread is created (Issue #765).
@@ -11013,6 +11091,7 @@ def _run_agent_streaming(
# ── Agent cache: reuse across messages in the same session ──
# Mirrors gateway _agent_cache. Keeps _user_turn_count alive so
# injectionFrequency: "first-turn" actually suppresses after turn 1.
_cache_new_agent = False
if ephemeral:
agent = _AIAgent(**_agent_kwargs)
logger.debug('[webui] Created ephemeral agent for session %s', session_id)
@@ -11055,14 +11134,6 @@ def _run_agent_streaming(
session_id,
_cached_agent_session_identity(_cached_agent),
)
if agent is not None:
# Reopened/cache-hit sessions must register the agent
# so later lifecycle commits can find it.
try:
from api.session_lifecycle import register_agent
register_agent(session_id, agent)
except Exception:
logger.debug("Lifecycle register_agent failed for cached session %s", session_id, exc_info=True)
if _identity_mismatch_entry is not None:
try:
@@ -11132,82 +11203,65 @@ def _run_agent_streaming(
agent._interrupt_message = None
else:
agent = _AIAgent(**_agent_kwargs)
# Register the new agent with the memory lifecycle so
# its commit_memory_session() can be found later.
try:
from api.session_lifecycle import register_agent
register_agent(session_id, agent)
except Exception:
logger.debug("Lifecycle register_agent failed for new session %s", session_id, exc_info=True)
_evicted_items = []
# Snapshot the set of session_ids with a LIVE agent worker
# BEFORE taking SESSION_AGENT_CACHE_LOCK, so LRU eviction never
# closes an agent mid-run AND we never nest ACTIVE_RUNS_LOCK
# inside SESSION_AGENT_CACHE_LOCK (avoids any lock-ordering
# deadlock). A cancel/reconnect can drop STREAMS while the
# worker is still unwinding or blocked in a provider call, so
# ACTIVE_RUNS (worker lifecycle) is the authoritative liveness
# signal, not STREAMS. (#3536 review round 2)
_active_sids = set()
try:
from api.config import ACTIVE_RUNS, ACTIVE_RUNS_LOCK
with ACTIVE_RUNS_LOCK:
for _entry in (ACTIVE_RUNS or {}).values():
_sid = (_entry or {}).get("session_id")
if _sid:
_active_sids.add(_sid)
except Exception:
_active_sids = set()
with SESSION_AGENT_CACHE_LOCK:
SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)
SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used
from api.config import SESSION_AGENT_CACHE_MAX
# Evict the oldest INACTIVE entries first. Walk LRU order
# (front = oldest); skip any session with a live run. If
# every over-cap entry is active, leave the cache
# temporarily above cap rather than close a live worker's
# agent — a later insertion/finalization trims it once the
# run ends.
while len(SESSION_AGENT_CACHE) > SESSION_AGENT_CACHE_MAX:
_evictable_sid = None
for _sid in list(SESSION_AGENT_CACHE.keys()):
if _sid not in _active_sids:
_evictable_sid = _sid
break
if _evictable_sid is None:
break # all over-cap entries are active; defer
evicted_entry = SESSION_AGENT_CACHE.pop(_evictable_sid)
_evicted_items.append((_evictable_sid, evicted_entry))
# Commit and close evicted agents outside the cache lock so
# concurrent cache users are not blocked by provider I/O.
for _evicted_sid, _evicted_entry in _evicted_items:
try:
_evicted_agent = _evicted_entry[0] if isinstance(_evicted_entry, tuple) else None
_close_evicted_agent_at_session_boundary(_evicted_sid, _evicted_agent)
except Exception:
logger.debug("Failed to close evicted agent for session %s", _evicted_sid, exc_info=True)
logger.debug('[webui] Evicted LRU agent from cache: %s', _evicted_sid)
logger.debug('[webui] Created new agent for session %s', session_id)
_cache_new_agent = True
# Stop may already have detached CANCEL_FLAGS while initialization
# was in flight. The worker-owned event and stream membership, not
# the removable registry entry, fence registration with cancellation.
with STREAMS_LOCK:
_cancelled_before_start = cancel_event.is_set() or stream_id not in STREAMS
if not _cancelled_before_start:
AGENT_INSTANCES[stream_id] = agent
if _cancelled_before_start:
# Interrupt, persistence and event writes must not hold the
# registry lock (the session lock can be held by another caller).
try:
agent.interrupt("Cancelled before start")
except Exception:
logger.debug("Failed to interrupt agent before start")
if not _register_agent_if_current(agent, _agent_sig if _cache_new_agent else None):
with _agent_lock:
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
put('cancel', _cancel_event_payload('Cancelled by user'))
return
# Cache publication is already fenced with Stop above. Keep LRU
# eviction and any memory-provider close outside the stream lock.
if _cache_new_agent:
_evicted_items = []
# Snapshot the set of session_ids with a LIVE agent worker
# BEFORE taking SESSION_AGENT_CACHE_LOCK, so LRU eviction never
# closes an agent mid-run AND we never nest ACTIVE_RUNS_LOCK
# inside SESSION_AGENT_CACHE_LOCK (avoids any lock-ordering
# deadlock). A cancel/reconnect can drop STREAMS while the
# worker is still unwinding or blocked in a provider call, so
# ACTIVE_RUNS (worker lifecycle) is the authoritative liveness
# signal, not STREAMS. (#3536 review round 2)
_active_sids = set()
try:
from api.config import ACTIVE_RUNS, ACTIVE_RUNS_LOCK
with ACTIVE_RUNS_LOCK:
for _entry in (ACTIVE_RUNS or {}).values():
_sid = (_entry or {}).get("session_id")
if _sid:
_active_sids.add(_sid)
except Exception:
_active_sids = set()
with SESSION_AGENT_CACHE_LOCK:
from api.config import SESSION_AGENT_CACHE_MAX
# Evict the oldest INACTIVE entries first. Walk LRU order
# (front = oldest); skip any session with a live run. If
# every over-cap entry is active, leave the cache
# temporarily above cap rather than close a live worker's
# agent — a later insertion/finalization trims it once the
# run ends.
while len(SESSION_AGENT_CACHE) > SESSION_AGENT_CACHE_MAX:
_evictable_sid = None
for _sid in list(SESSION_AGENT_CACHE.keys()):
if _sid not in _active_sids:
_evictable_sid = _sid
break
if _evictable_sid is None:
break # all over-cap entries are active; defer
evicted_entry = SESSION_AGENT_CACHE.pop(_evictable_sid)
_evicted_items.append((_evictable_sid, evicted_entry))
# Commit and close evicted agents outside the cache lock so
# concurrent cache users are not blocked by provider I/O.
for _evicted_sid, _evicted_entry in _evicted_items:
try:
_evicted_agent = _evicted_entry[0] if isinstance(_evicted_entry, tuple) else None
_close_evicted_agent_at_session_boundary(_evicted_sid, _evicted_agent)
except Exception:
logger.debug("Failed to close evicted agent for session %s", _evicted_sid, exc_info=True)
logger.debug('[webui] Evicted LRU agent from cache: %s', _evicted_sid)
logger.debug('[webui] Created new agent for session %s', session_id)
# Prepend workspace context so the agent always knows which directory
# to use for file operations, regardless of session age or AGENTS.md defaults.
workspace_ctx = _workspace_context_prefix(str(s.workspace))
@@ -11467,6 +11521,11 @@ def _run_agent_streaming(
)
_run_conversation_kwargs["user_message"] = user_message
_result_partial_pre_call_context = list(_previous_context_messages)
if not _agent_can_invoke(agent):
with _agent_lock:
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
put('cancel', _cancel_event_payload('Cancelled by user'))
return
result = agent.run_conversation(**_run_conversation_kwargs)
_remember_pending_steer_result(result)
_active_turn_identity = _resolve_active_turn_authority(
@@ -11949,8 +12008,6 @@ def _run_agent_streaming(
if 'credential_pool' in _agent_params:
_agent_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
agent = _AIAgent(**_agent_kwargs)
with STREAMS_LOCK:
AGENT_INSTANCES[stream_id] = agent
_agent_sig = _compute_agent_cache_signature(
resolved_model,
resolved_api_key,
@@ -11967,10 +12024,11 @@ def _run_agent_streaming(
profile_home=_profile_home,
safe_profile_runtime_env=_safe_profile_runtime_env,
)
from api.config import SESSION_AGENT_CACHE as _SAC, SESSION_AGENT_CACHE_LOCK as _SAC_L
with _SAC_L:
_SAC[session_id] = (agent, _agent_sig)
_SAC.move_to_end(session_id)
if not _register_agent_if_current(agent, _agent_sig):
# Returned-error settlement already owns _agent_lock.
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
put('cancel', _cancel_event_payload('Cancelled by user'))
return
# Retry the conversation once with fresh credentials
_self_healed = True
_token_sent = False
@@ -12003,6 +12061,11 @@ def _run_agent_streaming(
_result_partial_pre_call_context = list(
_heal_context_messages
)
if not _agent_can_invoke(agent):
# Returned-error settlement already owns the lock.
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
put('cancel', _cancel_event_payload('Cancelled by user'))
return
_heal_result = agent.run_conversation(**_heal_kwargs)
_remember_pending_steer_result(_heal_result)
_active_turn_identity = _resolve_active_turn_authority(
@@ -13301,8 +13364,6 @@ def _run_agent_streaming(
if 'credential_pool' in _agent_params:
_heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
_heal_agent = _AIAgent(**_heal_kwargs)
with STREAMS_LOCK:
AGENT_INSTANCES[stream_id] = _heal_agent
_agent_sig = _compute_agent_cache_signature(
resolved_model,
resolved_api_key,
@@ -13319,10 +13380,11 @@ def _run_agent_streaming(
profile_home=_profile_home,
safe_profile_runtime_env=_safe_profile_runtime_env,
)
from api.config import SESSION_AGENT_CACHE as _SAC2, SESSION_AGENT_CACHE_LOCK as _SAC2_L
with _SAC2_L:
_SAC2[session_id] = (_heal_agent, _agent_sig)
_SAC2.move_to_end(session_id)
if not _register_agent_if_current(_heal_agent, _agent_sig):
with _agent_lock:
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
put('cancel', _cancel_event_payload('Cancelled by user'))
return
# Retry the conversation
_token_sent = False
try:
@@ -13354,6 +13416,11 @@ def _run_agent_streaming(
_result_partial_pre_call_context = list(
_heal_context_messages
)
if not _agent_can_invoke(_heal_agent):
with _agent_lock:
_finalize_cancelled_turn(s, ephemeral=ephemeral, message='Task cancelled before start.', stream_id=stream_id)
put('cancel', _cancel_event_payload('Cancelled by user'))
return
_heal_result = _heal_agent.run_conversation(**_heal_kwargs2)
_remember_pending_steer_result(_heal_result)
_active_turn_identity = _resolve_active_turn_authority(
+9
View File
@@ -89,6 +89,15 @@ contributor guidance; it does not change runtime behavior or CI gates.
`models_cache.json` refresh timestamps), the stat-identity fallbacks, and the
schema/version stamps. Start here before changing model-catalog caching, the
`/api/models` cache keys, or the Codex catalog dependency (#2443, #7540).
- [`docs/architecture/live-models-allowlist.md`](architecture/live-models-allowlist.md):
current contract for how `/api/models/live` filters a custom provider's
upstream catalog: the four signals in evaluation order (discovered catalog
defers to the live probe, explicit plural `models:` allowlist filters,
singular `model:` never gates, no allowlist shows the full catalog), the
serialized-list shapes `hermes config set` persists, the deliberate
empty-allowlist-is-not-configured rule, and the probe-failure fallback.
Start here before changing custom-provider model filtering or
discovery-vs-allowlist semantics (#7165, #7404).
- [`docs/architecture/profile-home-resolve-cache.md`](architecture/profile-home-resolve-cache.md):
current contract for the call-scoped memoization around
`_resolve_profile_home_param()` in `api/workspace.py`: what it caches, the
+123
View File
@@ -0,0 +1,123 @@
# Live model picker allowlist contract (`/api/models/live`)
This document records the runtime contract for how `/api/models/live` decides
which models a `custom` / `custom:*` provider contributes to the chat model
picker. It describes shipped behavior and changes no runtime behavior. It was
added because the #7165 review flagged the contract as undocumented: the
filtering rules live in `_handle_live_models` (`api/routes.py`) and had only
code comments backing them.
Start here before changing how the live catalog of a custom provider is
filtered, how discovery snapshots are distinguished from allowlists, or what
the endpoint returns when the upstream probe fails.
## Why the endpoint filters at all
OpenAI-compatible gateways (New-API-style aggregators, LM Studio, Ollama
proxies) expose their **entire** catalog from `/v1/models`, including
image-generation, audio, TTS, and embedding models that are not chat models.
The background enrichment path must not surface models the user never opted
into — but it also must not hide models the gateway gained since config was
last edited. The contract below is how those two goals are reconciled.
## The four signals, in evaluation order
For a custom provider the handler consults these signals:
1. **Discovered catalog → no allowlist (live probe wins).**
When the provider entry carries `models_discovered: true` and discovery is
allowed (`discover_models` is not an explicit opt-out), the `models:`
mapping is a *snapshot Hermes persisted from a past discovery*, not a
hand-curated list. It must not gate the live probe: a model the user pulls
into LM Studio / Ollama after discovery has to appear in the picker.
Detected by the shared predicate
`api.config._provider_models_are_discovered_catalog()` — the same
predicate the `/api/models` catalog path uses, so both endpoints agree.
With this signal, no allowlist is derived and the full live catalog is
returned.
2. **Explicit plural `models:` allowlist → filter.**
A non-empty plural `models:` value on an entry that is *not* a discovered
catalog expresses "show exactly these models". The live catalog is
intersected with it, then any allowlisted model missing from the live
response is appended (so an allowlisted-but-offline model stays
selectable). This is the #7165 filter. Only **list** and **string**
(serialized) shapes are plain allowlists — see "Dict-shaped `models:`"
below.
3. **Singular `model:` → never gates.**
The singular field is sticky/default metadata (the model to preselect),
not an allowlist. A provider configured only with `model: assistant` keeps
the full unfiltered live catalog; gating on it would collapse discovery to
a single model.
4. **No allowlist → full live catalog.**
Preserves the pre-#7165 discovery behavior for setups that rely on it.
### Serialized shapes
`hermes config set` and the JSON-mode editor persist lists as quoted
JSON-array strings (`'["chat-a","chat-b"]'`) or Python literals
(`"['chat-a']"`). The plural value is decoded through the shared
`_parse_config_string_list()` before id extraction — but only when the value
is a `str`; native `dict` / `list` values are walked directly so dict *entries*
keep their `id|model|name` metadata (the decoder stringifies members). A
dict-only parser drops serialized allowlists to "no allowlist" and floods the
picker — the same bug class as `skills.disabled` (#7120 / #7134).
### Dict-shaped `models:`
A dict-shaped `models:` mapping (e.g. `{chat-a: {context_length: 128000}}`)
is *per-model metadata* written by the Hermes Agent setup flow —
`hermes_cli/model_switch.py::_save_custom_provider` and the setup wizard —
**not** a catalog narrow. Treating its keys as an allowlist would collapse the
live picker to the single saved default (keyless Ollama) while the CLI
live-probe shows the full catalog. So a dict is **never** a plain allowlist:
- without an explicit `discover_models: false`, a dict-shaped `models:`
contributes **no allowlist** and the full live catalog is returned;
- with `discover_models: false` (bool `False` or the string forms
`"false"`/`"no"`/`"0"`, case-insensitive — mirroring
`model_switch_providers.py:557`), the dict **keys** are honored as a pinned
allowlist.
This complements the discovered-catalog rule: `models_discovered: true`
without the opt-out is already ignored (signal 1). The dict guard covers the
Agent-setup shape that is *not* flagged discovered.
If someone later "fixes" the dict shape back into an always-allowlist, the
regression test `test_dict_models_without_discover_false_does_not_gate` fails
and forces the trade-off to be re-argued.
### Empty allowlist semantics
`models: []`, `models: "[]"`, an empty mapping, or a blank scalar are
deliberately treated as **not configured**, never as "allow nothing". The
filter runs only on a non-empty allowlist because gating on declared-ness
would emit an empty picker and make the provider unselectable — a harder
failure than showing a few extra models, and unrecoverable from the UI
(`custom_providers` has no WebUI write path; it is hand-edited in
config.yaml). There is no deny-all use case: a provider the user wants hidden
is removed from `custom_providers` outright.
### Probe failure / empty probe
When the upstream probe fails or returns no models, the endpoint falls back
to `allowlist or config ids` — the non-empty plural allowlist first, so a
singular `model:` outside the allowlist cannot leak in precisely when the
probe is unavailable; with no allowlist, the configured ids (singular +
plural) are served. A discovered catalog therefore still serves its saved
models on a transient probe failure instead of emptying the picker.
## Tests
- `tests/test_issue3718_live_models_custom_probe.py` — real-handler
(`_handle_live_models`) coverage of every signal above: plural list /
JSON-array-string / Python-literal / list-of-dicts filtering, dict-shaped
`models:` (not gated) and dict + `discover_models: false` (pinned),
singular passthrough, malformed safety, discovered-catalog passthrough,
`discover_models: false` pinning, and both fallback branches.
- `tests/test_issue7404_models_discovered_not_allowlist.py` — the
`/api/models` side of the discovered-catalog contract.
- `tests/test_byok_model_dropdown.py` — picker rendering, profile-scoped
caching, and current-session model preservation around this endpoint.
@@ -0,0 +1,91 @@
# Sidebar idle / terminal handoff evidence
## Failure and scope
The independent `/api/sessions` response can report idle before the chat SSE's
terminal frame reaches the page. Three sidebar paths were retiring chat state:
idle reconciliation, INFLIGHT purging, and optimistic-row retirement. The idle
path clears the Worklog and active stream ID; the subsequent `done` is rejected
as stale, so no settled Anchor scene is persisted.
A real-HTTP barrier reproduces this on both published PR head
`75c8ff8d2a991f22389e3e4263ee03fb8d8f67a0` and frozen upstream
`b09fded7b8c98b549a49aaaece3721015337d307`. Applying only the `sessions.js`
product fix to the frozen upstream also passes the identical oracle: this is a
shared lifecycle defect, not evidence that artifact replay introduced it. The
historical hosted run lacks this instrumentation, so its exact interleaving
cannot be proved retrospectively; its empty-surface/no-persistence signature
matches the controlled reproduction.
## Reproduction
```sh
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=settle-worklog-frame-proof \
.venv/bin/python tests/browser_conversation_lifecycle.py
./scripts/test.sh tests/test_sidebar_terminal_handoff.py -q
```
The existing frame proof now uses a test-only server wrapper to hold the actual
`done` HTTP write. The real worker finishes; the real browser receives an idle
sidebar response through `renderSessionList()` while its native EventSource
remains open. The test verifies pane, INFLIGHT and registry ownership, then
releases `done` and retains all existing painted-frame, final-answer, semantic
activity and hard-reload assertions. The fixture always releases/cleans up and
has a bounded wait. Normal production server startup is unchanged.
No renderer, terminal payload or persistence response is fabricated. Runtime
events come from the existing deterministic localhost Gateway fixture, not a
live model/provider. All state and files are disposable. These are browser
viewport checks, not physical-device acceptance.
## Before / after
Each screenshot is taken after the idle sidebar response, before the terminal
HTTP frame is released. The exact published source fails at both widths. The
patched source preserves three live activity rows and then passes completion
and hard reload. `results.json` records the predicate results without machine
paths or credentials.
| Viewport | Before | After |
| --- | --- | --- |
| 1440 x 900 | [Empty surface](before-1440.png) | [Owned handoff retained](after-1440.png) |
| 390 x 844 | [Empty surface](before-390.png) | [Owned handoff retained](after-390.png) |
The fix only defers these sidebar cleanups for the current pane's exact OPEN
chat transport. Missing, CONNECTING, CLOSED and mismatched sources still take
the existing stale recovery path.
## Missing terminal frame: bounded recovery
The OPEN-only guard at `85f1c2d34b62b0ade85f51ed2cf8c1cf56f5ab9e` could wait
forever when the terminal frame never arrived. The follow-up keeps the live
handoff for 1.5 seconds, then probes the exact stream through
`/api/chat/stream/status` with an eight-second timeout and no retries.
Repeated sidebar hints cannot restart the deadline or create concurrent
requests. `active:true` keeps the OPEN owner intact even when the persisted
session fields already look idle; only explicit runtime inactivity permits the
canonical session restore. A failed or malformed runtime probe uses the
existing interrupted-stream path rather than claiming successful settlement.
Normal terminal delivery, transport replacement/close, navigation, and a newer
optimistic turn invalidate the old request. Already-running stream-end recovery
remains the sole recovery owner.
```sh
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=sidebar-idle-missing-terminal \
.venv/bin/python tests/browser_conversation_lifecycle.py
./scripts/test.sh tests/test_sidebar_idle_stream_recovery.py -q
```
The new browser row keeps the real terminal HTTP barrier closed until cleanup;
the client must recover without receiving `done` or `stream_end`. Published
`85f1c2d34b` fails the 11-second completion bound with `S.busy` still true.
The patched implementation restores the final answer and passes the unchanged
painted-frame, semantic activity, persistence and hard-reload checks before the
barrier is released. The cloud workflow runs both delayed- and missing-terminal
rows. No live provider or production state is used.
A production Gateway regression also blocks post-turn goal evaluation after
the success transcript has already cleared `active_stream_id` and pending
fields. During that interval the worker and exact entry in `STREAMS` remain
live and no `done` event has been emitted, proving why persisted session flags
cannot authorize sidebar settlement on their own.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,66 @@
[
{
"mode": "before",
"viewport": [
1440,
900
],
"exit": 1,
"observation": {
"serverIdle": true,
"paneRetained": false,
"inflightRetained": false,
"sourceRetained": true,
"registryRetained": true,
"liveRows": 0
}
},
{
"mode": "before",
"viewport": [
390,
844
],
"exit": 1,
"observation": {
"serverIdle": true,
"paneRetained": false,
"inflightRetained": false,
"sourceRetained": true,
"registryRetained": true,
"liveRows": 0
}
},
{
"mode": "after",
"viewport": [
1440,
900
],
"exit": 0,
"observation": {
"serverIdle": true,
"paneRetained": true,
"inflightRetained": true,
"sourceRetained": true,
"registryRetained": true,
"liveRows": 3
}
},
{
"mode": "after",
"viewport": [
390,
844
],
"exit": 0,
"observation": {
"serverIdle": true,
"paneRetained": true,
"inflightRetained": true,
"sourceRetained": true,
"registryRetained": true,
"liveRows": 3
}
}
]
@@ -470,6 +470,19 @@ Expected behavior:
- A session row's running indicator should reflect a real active run or a
clearly restorable state, not stale persisted metadata alone.
- An idle sidebar response does not prove that the browser has consumed the
terminal chat frame. While the current pane still owns its exact OPEN chat
transport, list reconciliation and cache purging must preserve that handoff.
Missing, connecting, closed, or mismatched transports keep the normal stale
state recovery path; a cached stream ID or busy flag is insufficient.
OPEN is not proof that a terminal frame will arrive: an idle hint must also
schedule bounded recovery without erasing the live scene. Before reading a
settled session snapshot, that recovery must check the exact stream's runtime
status. A runtime-active stream retains ownership even when persisted
`active_stream_id` and pending fields are already clear; only explicit
runtime inactivity permits canonical snapshot settlement. Probe failure is a
degraded/interrupted state, not evidence of successful completion. A newer
turn, replacement source, or received terminal event invalidates the request.
- Background completion, cancellation, or failure should be represented without
stealing the visible pane from the user.
- Session switching should not erase pending live context, in-flight snapshots,
@@ -574,6 +574,38 @@ early renderers can avoid chasing `S.messages` during every paint. If settlement
later rewrites the transcript message, the anchor must be refreshed from that
message instead of allowing the two copies to drift silently.
### Settled ownership of a restored live turn
A preserved or snapshotted live-turn node is not evidence of a running turn. The
settled assistant message is persisted a few milliseconds before the stream's
terminal event clears the active stream id, so a live node whose stream has
already ended can survive a transcript rebuild or a session switch and be
appended under the settled answer, putting the same answer on screen twice
(#6948 follow-up; upstream symptom report #2051).
A live-turn node is therefore discarded only where the settled transcript is
proved to own it: the transcript ends with a settled assistant message, no
message still carries a live-projection marker, that settled message's
persisted stream identity equals the id of the stream that built the live node
— or, only where no scene identity was persisted, the markdown the stream
produced equals the persisted message source — and the node carries nothing the
settled rebuild could not have produced, meaning no unpersisted tool card,
reasoning row or transparent-stream row and no second live segment.
Ownership is a state proof, never a comparison of two rendered DOM trees: the
live body comes from the streaming parser and the settled body from the
markdown renderer, and an answer that reads the same as the previous one must
not delete a genuinely live turn. An unrecognised node shape keeps the turn.
Only the two branches that ADD a turn are gated; a branch that replaces a node
cannot duplicate one.
A settled activity scene projects one answer as well. A `process_prose` row
whose text duplicates the scene's final answer — the same text, or a prefix of
it within ten percent of its length — is dropped where both settled renderers
take their rows, so the answer is not rebuilt as a second assistant segment
above itself. Live rendering is unchanged: while the turn streams, the inline
live segment is hidden and the prose row is the visible answer.
## Replay, Reload, And Reconstruction
Replay/reload should reconstruct the same Assistant Turn Anchor from durable
@@ -30,6 +30,50 @@ This RFC defines a consistency contract for those layers. It complements the
larger run adapter direction in #1925 by documenting what must remain coherent
while WebUI still has multiple overlapping state stores.
## Agent registration after cancellation
Initial and credential self-heal Agent construction use the same registration
boundary. Under `STREAMS_LOCK`, both the worker-retained cancellation event and
live stream membership must permit registration. A removed `CANCEL_FLAGS` entry
is not permission to restart. If Stop won during initial or self-heal
construction, the candidate must not enter the reusable cache or call
`run_conversation`. Stream registration, reusable-cache publication and the
in-memory lifecycle handle share one atomic Stop admission, using lock order
`STREAMS_LOCK` then `SESSION_AGENT_CACHE_LOCK`. Merely moving a cache write
after a cancellation check does not protect that check-to-publication gap.
After prompt preparation and immediately before each initial/self-heal invocation,
revalidate the retained cancel event, stream membership and exact registered
Agent. If Stop already won, retire a matching reusable entry only while the
existing `SESSION_WRITEBACK_OWNERS` record still equals this exact stream.
Hold that ownership lock through cache/lifecycle retirement. Object identity is
insufficient because successors can reuse the same Agent; absent ownership is
not permission either, since a completed successor clears its record. Never
clear a successor's cache or lifecycle handle, and do not issue another Agent
interrupt for an invocation that never started. Rejected cache-hit registration
likewise must not interrupt the borrowed Agent; only a never-published newly
constructed candidate may receive construction-cancellation cleanup. Drop the
old worker's local borrowed handle as well, so final pending-Steer drain cannot
reach a successor through an object-reference fallback.
Stop after invocation admission uses the existing Agent interrupt mechanism;
registry locks must not span provider or tool execution. LRU eviction/close stays
outside the stream lock and retains the existing active-worker policy.
Interrupt and cancellation finalization occur outside the stream registry lock.
Session finalization still owns the session lock: the returned-error path already
holds it, while initial registration and the exception path acquire it. Do not
reacquire this non-reentrant lock from a branch that already owns it.
## Run-journal sequence publication
Within one WebUI process, auto-numbered appends to the same journal allocate
sequence numbers and write their rows under the same per-path lock.
`RunJournalWriter` delegates both operations to `append_run_event`; it must not
reserve a sequence and release the lock before the physical append. Otherwise
individually valid rows can reach disk out of order and the session replay
reader must reject them as noncontiguous. This does not change caller-supplied
sequence semantics, cross-process ownership, or failed-write recovery.
## Inactive compression continuation recovery
The Agent profile's SQLite compression lineage owns the canonical continuation,
+10 -1
View File
@@ -3431,8 +3431,17 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
localStorage.setItem('hermes-font-size',fontSize);
_applyFontSize(fontSize);
if(typeof setLocale==='function'){
// #7622 (round 3): the settings payload's `s.language` is
// absent (None) for a fresh install, so an explicit non-empty
// value is the user's genuine saved choice. The browser
// navigator hint is now read via the guarded
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
// a throwing `navigator` accessor in some embedded webviews
// can no longer abort this branch and reset loaded preferences.
// The fallback ternary preserves the pre-#7622 boot behaviour
// when neither helper is in scope (defence in depth).
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
: (s.language || localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
+76 -8
View File
@@ -26983,15 +26983,68 @@ function resolveLocale(lang) {
/**
* Resolve locale with precedence:
* 1) primary (typically server setting)
* 2) fallback (typically localStorage)
* 3) English
* @param {string} primary
* @param {string} fallback
* 1) primary (typically server setting)
* 2) fallback (typically localStorage)
* 3) fallback2 (typically browser navigator hint — first-visit only)
* 4) 'en' (final fallback)
*
* Resolve a language preference to a known LOCALES key, applying a strict
* `primary -> fallback -> fallback2 -> 'en'` precedence chain.
*
* #7622 (round 3): the server no longer injects `"language": "en"` as a
* schema default — `load_settings()` reports `None` (the key is absent
* from the response) until the user explicitly picks a locale in the
* Settings modal. This lets the resolver use `primary` directly
* without a `primary === 'en'` special case:
* - an explicit `primary` (the server's stored value) always wins
* - a `None` / empty `primary` falls through to `fallback` (localStorage)
* - `fallback2` (the browser navigator hint) only fires when neither
* server nor localStorage has a preference
* - `'en'` is the final safety net
*
* Side benefit: a user who picked English on purpose (their settings
* file contains `"language": "en"`) is no longer overridden by a
* non-English browser locale on hydration, because the round-2
* `primary === 'en'` skip has been removed.
*
* @param {string|null|undefined} primary server-stored language
* @param {string|null|undefined} fallback localStorage `hermes-lang`
* @param {string|null|undefined} fallback2 browser navigator hint
* @returns {string}
*/
function resolvePreferredLocale(primary, fallback) {
return resolveLocale(primary) || resolveLocale(fallback) || 'en';
function resolvePreferredLocale(primary, fallback, fallback2) {
return resolveLocale(primary)
|| resolveLocale(fallback)
|| resolveLocale(fallback2)
|| 'en';
}
/**
* Detect the browser's preferred language for first-visit locale
* selection. Guarded against `navigator` access exceptions: some
* embedded webviews (Tauri desktop shell, kiosk modes) report a
* `navigator` object but throw on `.languages` / `.language` reads.
* Callers (loadLocale, boot.js, panels.js) must use this helper rather
* than reading `navigator` directly so a single throw does not abort
* the boot or settings-panel hydration.
*
* @returns {string|null} the first entry of `navigator.languages`, or
* `navigator.language`, or `null` when neither is reachable.
*/
function _detectBrowserLanguageHint() {
try {
const nav = (typeof navigator !== 'undefined') ? navigator : null;
if (!nav) return null;
if (Array.isArray(nav.languages) && nav.languages.length) {
return nav.languages[0] || null;
}
if (typeof nav.language === 'string' && nav.language) {
return nav.language;
}
return null;
} catch (_) {
return null;
}
}
/**
@@ -27029,11 +27082,26 @@ function setLocale(lang) {
/**
* Load locale from localStorage (called once at boot, before DOMContentLoaded).
* Server-persisted preference is applied later in loadSettingsPanel().
*
* #7622: on the FIRST visit (no stored preference), fall back to the
* browser's preferred language (`navigator.languages[0]` /
* `navigator.language`) so non-English speakers don't have to dig into
* Settings after every fresh install. This is strictly a first-visit
* hint — the moment the user picks a language in Settings (or the
* server-side preference is applied via `loadSettingsPanel`), the
* stored value takes over and the browser hint is never consulted
* again for that browser profile.
*/
function loadLocale() {
let stored = null;
try { stored = localStorage.getItem('hermes-lang'); } catch (_) {}
setLocale(resolvePreferredLocale(null, stored));
// Only consult the browser hint when no stored preference exists.
// The settings panel and `loadSettingsPanel()` will overwrite this
// hint on their first call, so explicit user / server preferences
// always win. #7622 (round 3): the browser-hint read is delegated
// to `_detectBrowserLanguageHint()` so a `navigator` access throw
// (some embedded webviews) can no longer abort loadLocale().
setLocale(resolvePreferredLocale(stored, _detectBrowserLanguageHint()));
}
/**
+93 -8
View File
@@ -2089,6 +2089,7 @@ function closeLiveStream(sessionId, streamId, source){
if(!live) return;
if(streamId&&live.streamId!==streamId) return;
if(source&&live.source!==source) return;
if(typeof live.cancelIdleRecovery==='function') live.cancelIdleRecovery();
// Snapshot the current live-turn DOM BEFORE tearing the stream down. The
// per-event snapshot (snapshotLiveTurn) only fires on content/tool_complete
// SSE events, so switching away during a quiet window (mid tool-exec, silent
@@ -4845,7 +4846,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
function _smdMediaTailFlushEntry(entry){
const chunk=_smdMediaTailEntryChunk(entry);
if(!chunk) return;
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
// #7680 re-gate (9/22): strip backtick wrappers so the bare-token
// match below sees a plain ``MEDIA:path`` and the bare class
// (no backtick in the exclusion set) captures the full filename
// even when the path itself contains a backtick.
const normalized = String(chunk).replace(/`MEDIA:([^`\s]+)`/g, 'MEDIA:$1');
const m=/^MEDIA:([^\s\)\]]+)$/.exec(normalized);
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk);
}
@@ -4893,23 +4899,29 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// Prose runs go through the owning text writer. MEDIA tokens go through
// the single-token DOMParser helper only after a delimiter or
// reliable filename suffix proves the ref is complete.
// #7680 re-gate (9/22): strip backtick wrappers first so the bare
// class (no backtick in the exclusion set) captures the full
// filename even when the path itself contains a backtick.
// The pre-pass replaces `` `MEDIA:path` `` with ``MEDIA:path``
// so the wrapped form is consumed before the bare scan.
const normalized = combined.replace(/`MEDIA:([^`\s]+)`/g, 'MEDIA:$1');
const re=/MEDIA:([^\s\)\]]+)/g;
let last=0, m;
let unmatchedTail=null;
while((m=re.exec(combined))){
while((m=re.exec(normalized))){
const matchEnd = m.index + m[0].length;
if(m.index>last){
const slice = combined.slice(last, m.index);
const slice = normalized.slice(last, m.index);
writeCurrent(slice);
}
if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){
const candidate = combined.slice(m.index);
if(matchEnd===normalized.length && !_smdMediaRefHasReliableBoundary(m[1])){
const candidate = normalized.slice(m.index);
if(candidate.length < _MEDIA_TAIL_MAX){
unmatchedTail = candidate;
} else {
writeCurrent(candidate);
}
last = combined.length;
last = normalized.length;
break;
}
if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]);
@@ -4917,7 +4929,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
// Tail buffer — hold trailing bytes that look like an unterminated
// MEDIA prefix; flush any prose before the partial MEDIA suffix.
const rest = combined.slice(last);
const rest = normalized.slice(last);
if(rest){
const tailMatch = /MEDIA:[^\s\)\]]*$/.exec(rest);
const prefixTail = tailMatch ? '' : _smdMediaPrefixTail(rest);
@@ -5745,12 +5757,82 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return true;
}
function _bindSidebarIdleRecovery(live){
let pending=null;
live.cancelIdleRecovery=()=>{
if(pending&&pending.timer) clearTimeout(pending.timer);
pending=null;
};
live.recoverFromSidebarIdle=()=>{
if(pending||_streamFinalized||_terminalStateReached||_pendingStreamEndRecovery) return;
const request={inflight:INFLIGHT[activeSid],timer:null};
const isCurrent=()=>pending===request&&!_streamFinalized&&!_terminalStateReached&&
LIVE_STREAMS[activeSid]===live&&live.source.readyState===1&&
S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId&&
INFLIGHT[activeSid]===request.inflight&&
!(typeof _sendInProgress!=='undefined'&&_sendInProgress&&activeSid===_sendInProgressSid);
pending=request;
// Give the independently delivered terminal frame a short handoff window,
// then use canonical session recovery even if the transport stays OPEN.
// One ticket spans both timer and request; list refreshes cannot extend it.
request.timer=setTimeout(async()=>{
request.timer=null;
if(!isCurrent()){
if(pending===request) live.cancelIdleRecovery();
return;
}
try{
let runtimeStatus=null;
try{
runtimeStatus=await api(
`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`,
{timeoutMs:8000,retries:0,timeoutToast:false}
);
}catch(_){
// Runtime ownership is authoritative here. If the probe itself
// fails, do not infer completion from already-cleared session
// fields; surface the existing interrupted-stream path instead.
if(isCurrent()) _handleStreamError(live.source);
return;
}
if(!isCurrent()) return;
if(!runtimeStatus||typeof runtimeStatus.active!=='boolean'){
_handleStreamError(live.source);
return;
}
// Gateway success writeback clears persisted active/pending fields
// before post-turn goal evaluation and terminal event emission.
// STREAMS-backed status therefore owns this decision: an exact active
// runtime must keep its OPEN browser handoff even if the sidebar row
// already looks idle.
if(runtimeStatus.active) return;
const status=await _restoreSettledSession(live.source,{
status:true,
isCurrent,
requestOptions:{timeoutMs:8000,retries:0,timeoutToast:false},
preserveVisibleOnShorterTerminalSnapshot:true,
});
// A stale sidebar response is not permission to terminate a worker
// which the authoritative session snapshot still reports as active.
if(isCurrent()&&status!=='restored'&&status!=='active') _handleStreamError(live.source);
}finally{
if(pending===request) live.cancelIdleRecovery();
}
},1500);
};
for(const event of ['done','cancel','apperror','stream_end','error']){
live.source.addEventListener(event,live.cancelIdleRecovery);
}
}
function _wireSSE(source){
const existingLive=LIVE_STREAMS[activeSid];
if(existingLive&&existingLive.source&&existingLive.source!==source){
if(typeof existingLive.cancelIdleRecovery==='function') existingLive.cancelIdleRecovery();
try{if(existingLive.source.readyState!==2)existingLive.source.close();}catch(_){ }
}
LIVE_STREAMS[activeSid]={streamId,source};
_bindSidebarIdleRecovery(LIVE_STREAMS[activeSid]);
// Note on #631 Bug B: the original PR description stated the server
// "replays buffered token events" on reconnect, and proposed resetting
@@ -7024,13 +7106,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
async function _restoreSettledSession(source, options=null){
const returnStatus=!!(options&&options.status);
const isCurrent=options&&typeof options.isCurrent==='function'?options.isCurrent:null;
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
const preserveVisibleOnShorterTerminalSnapshot=!!(options&&options.preserveVisibleOnShorterTerminalSnapshot);
if(_isActiveSession() && S.activeStreamId!==streamId){
_closeSource(source);
return returnStatus?'stale':false;
}
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`,options&&options.requestOptions||{});
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
// we were awaiting the network roundtrip, bail out — done already settled.
if(_streamFinalized) return returnStatus?'restored':true;
+10 -1
View File
@@ -9300,8 +9300,17 @@ async function loadSettingsPanel(){
_setHiddenTabs(hiddenTabs);
_applyTabVisibility(hiddenTabs);
_renderTabVisibilityChips();
// #7622 (round 3): the settings payload's `settings.language` is
// absent (None) for a fresh install, so an explicit non-empty
// value is the user's genuine saved choice. The browser
// navigator hint is now read via the guarded
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
// a throwing `navigator` accessor can no longer abort settings
// hydration before model, provider, plugin and extension sections
// are populated. The fallback ternary preserves the pre-#7622
// settings-modal behaviour when neither helper is in scope.
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
// Keep settings modal and current page strings in sync with the resolved locale.
if(typeof setLocale==='function'){
+26 -1
View File
@@ -852,6 +852,14 @@ function _reconcileActiveSessionIdleStateFromList(serverRows) {
const serverRow=serverRows.find(s=>s&&s.session_id===sid);
if (!serverRow) return false;
if (!_isServerIdleSessionRow(serverRow)) return false;
// Sidebar idle metadata can beat the terminal frame on the independent chat
// SSE. Let its exact OPEN transport finish the Anchor handoff; orphaned or
// disconnected streams still use the existing idle recovery below.
if (_hasOwnedOpenLiveStream(sid)) {
const live=LIVE_STREAMS[sid];
if(typeof live.recoverFromSidebarIdle==='function') live.recoverFromSidebarIdle();
return false;
}
let changed=false;
if (S.busy) { S.busy=false; changed=true; }
if (S.activeStreamId) { S.activeStreamId=null; changed=true; }
@@ -940,6 +948,8 @@ function _purgeStaleInflightEntries() {
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) {
continue;
}
// The sidebar render must not purge what the idle reconciler preserved.
if (_hasOwnedOpenLiveStream(sid)) continue;
if (!sessionsById.has(sid)) {
const knownSource = sourceById ? sourceById.get(sid) : null;
if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) {
@@ -961,6 +971,14 @@ function _purgeStaleInflightEntries() {
}
}
function _hasOwnedOpenLiveStream(sid) {
if (typeof S === 'undefined' || !S || !S.session || S.session.session_id !== sid || !S.activeStreamId) return false;
const live = typeof LIVE_STREAMS === 'object' && LIVE_STREAMS ? LIVE_STREAMS[sid] : null;
// readyState 1 is EventSource.OPEN. A cached stream ID or busy flag alone
// is not ownership and must never disable recovery for a stuck indicator.
return Boolean(live && live.streamId === S.activeStreamId && live.source && live.source.readyState === 1);
}
function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) {
const resolvedSid = sid || (s && s.session_id);
if (!resolvedSid) return;
@@ -2169,6 +2187,12 @@ async function loadSession(sid){
S.activeStreamId=activeStreamId;
const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
const replayPersistedLiveToolCards=(opts)=>{
// The journal-backed Anchor scene is authoritative through its resume
// cursor; newer rows arrive through the reattached SSE stream. Replaying
// the older INFLIGHT tool cache after a successful scene restore would
// redraw all N rows N times. Keep the #3707 replay only for legacy HTML
// restoration or a failed/unavailable scene render.
if(restoredAnchorScene) return;
const liveToolCalls=Array.isArray(S.toolCalls)
? S.toolCalls
: (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]);
@@ -5272,7 +5296,8 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){
function _dropStaleOptimisticSessionRow(sid){
if(!sid) return;
if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false);
if(INFLIGHT&&INFLIGHT[sid]){
// Retiring sidebar optimism must not retire the independent chat owner.
if(INFLIGHT&&INFLIGHT[sid]&&!_hasOwnedOpenLiveStream(sid)){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
}
+237 -6
View File
@@ -3949,6 +3949,27 @@ function _isEquivalentConfiguredModelEntry(modelId,badge,entries){
// providers (@custom:name:model) without collapsing matching model IDs from
// different providers.
const rawId=String(modelId||'');
// `<provider>/<model>` is another routing spelling of `<model>`, so its badge
// key must not become a second picker row. configured_model_badges holds every
// spelling of a configured model and renderModelDropdown() synthesises a row
// for each key this predicate does not recognise. The provider-qualified
// spelling was missed because _normalizeConfiguredModelKey() strips only one
// leading slash segment (#3360 keeps `vendor_a/x` and `vendor_b/y/x` distinct),
// so `acme/example-model` and `custom/acme/example-model` normalise to
// different keys and the picker lists one model twice.
// Match it the way the `@provider:` rule below does: the badge declares a
// provider, the key starts with that provider's `<provider>/` prefix, and an
// existing row from the same provider normalises equal to the remainder. Two
// different models never satisfy the last clause, so this can only drop a
// duplicate of a row the catalog already produced.
const slashPrefix=provider?`${provider}/`:'';
if(slashPrefix&&rawId.toLowerCase().startsWith(slashPrefix)){
const slashRoutedId=rawId.slice(slashPrefix.length);
if(slashRoutedId&&(entries||[]).some(entry=>
String(entry.providerId||'').toLowerCase()===provider
&&_normalizeConfiguredModelKey(entry.value)===_normalizeConfiguredModelKey(slashRoutedId)
)) return true;
}
const prefix=provider?`@${provider}:`:'';
if(!prefix||!rawId.toLowerCase().startsWith(prefix)) return false;
const routedId=rawId.slice(prefix.length);
@@ -7702,6 +7723,16 @@ function renderMd(raw){
// generated images) and replace them with inline <img> or download links.
// Stashed so the path/URL is never processed as markdown.
const media_stash=[];
// #7680 re-gate (9/22): two-pass scan.
// 1. `` `MEDIA:path` `` (backtick-wrapped, inline-code form) → strip
// the wrapping backticks so the bare-token pass below sees a
// plain ``MEDIA:path`` and the closing backtick is not consumed
// as part of the path.
// 2. ``MEDIA:[^\s\)\]]+`` (bare, no backtick in the exclusion
// class) so a filename that legally contains a backtick
// (``report`final.png``) is captured in full instead of being
// truncated at the first backtick.
s=s.replace(/`MEDIA:([^`\s]+)`/g,'MEDIA:$1');
s=s.replace(/MEDIA:([^\s\)\]]+)/g,(_,raw_ref)=>{
media_stash.push(raw_ref);
return '\x00D'+(media_stash.length-1)+'\x00';
@@ -9149,6 +9180,12 @@ function _stripForTTS(text){
// Strip links, keep text
text=text.replace(/\[([^\]]+)\]\([^)]+\)/g,'$1');
// Replace MEDIA: paths with a simple label
// #7680 re-gate (9/22): TTS does not need the backtick-aware
// terminator. Inline code was already stripped at the top of this
// function (`:9137`), so a bare ``MEDIA:[^\s]+`` is correct and
// round-trips the original filename including a backtick in the
// path — the only thing the TTS ever does with the captured
// substring is throw it away.
text=text.replace(/MEDIA:[^\s]+/g,'a file');
// Strip emoji and emoticons
text=text.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}]/gu,'');
@@ -9928,6 +9965,99 @@ function _liveAssistantSegmentTextLength(seg){
return String(body.textContent||'').trim().length;
}
// ── #6948 follow-up: settled-transcript ownership of a live-turn node ──────
// (duplicate assistant answer; upstream symptom report #2051)
//
// Both live-turn insertion paths — the renderMessages() re-attach and
// restoreLiveTurnHtmlForSession() — have one branch that ADDS a turn the
// settled rebuild did not produce. The assistant row is persisted a few ms
// BEFORE the stream's terminal event clears S.activeStreamId, so a dead live
// node left behind by an INFLIGHT entry that outlived its stream is re-attached
// on top of the settled transcript: the same answer twice, self-sustaining
// across later renders (the node keeps id=liveAssistantTurn and is re-preserved
// by the #6948 guard), healed only by a reload.
//
// Dropping a live node must be an OWNERSHIP proof, never "the rendered text
// looks the same":
// * the live body is built by the streaming smd parser and the settled body
// by renderMd + post-processing, so their textContent differs for anything
// past single-paragraph plain text (lists, fenced code with its language
// label, tables, `_underscore_` emphasis, the injected copy button) — a
// text comparison would miss most real answers; and
// * an identical answer earlier in the history (a repeated "Done.", a
// greeting, a same-prompt resend) would delete a GENUINELY LIVE turn: while
// the current turn is unpersisted, the last settled assistant message IS the
// previous turn's answer.
// So ownership is proved from state: the transcript must END with a settled
// assistant message that THIS stream produced, and the node must carry nothing
// the settled rebuild could not have produced.
// Stream identity persisted on a settled assistant message. The server stamps
// _anchor_stream_id from the anchor-scene sidecar record (api/routes.py); the
// client stamps _anchor_stream_id and scene.identity.stream_id at stream end
// (_attachProjectedAnchorSceneToLastAssistant, static/messages.js).
function _settledAssistantStreamId(message){
if(!message) return '';
const scene=message._anchor_activity_scene||null;
const identity=(scene&&scene.identity)||null;
return String(message._anchor_stream_id||(scene&&scene.stream_id)||(identity&&identity.stream_id)||'');
}
// The live-projection markers the #6948 preserve guard already treats as proof
// of a live owner. A message the client still considers live is never evidence
// that the turn has settled.
function _messageHasLiveAssistantProjection(m){
return !!(m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined));
}
// True when the live turn holds something the settled rebuild cannot have
// produced from S.messages — an unpersisted tool card, reasoning/thinking row,
// transparent-stream row, or a second live segment. Such a node is never
// dropped: #3714's whole premise is that the live DOM can be AHEAD of
// S.messages, and a tail-segment match must not discard the tool card or the
// earlier segment above it. An unknown node shape fails closed (keep the turn).
function _liveTurnCarriesUnsettledContent(turn){
if(!turn||typeof turn.querySelectorAll!=='function') return true;
if(turn.querySelectorAll(
'.tool-card-row,.wl-reason,.agent-activity-thinking,.thinking-card-row,.transparent-event-row'
).length) return true;
return turn.querySelectorAll('[data-live-assistant="1"]').length>1;
}
function _settledTranscriptOwnsLiveTurn(sid, turn){
if(!turn) return false;
const msgs=(typeof S!=='undefined'&&S&&Array.isArray(S.messages))?S.messages:null;
if(!msgs||!msgs.length) return false;
// The transcript must END with a settled assistant answer. A trailing user
// turn means the live turn IS the current turn — nothing of it is persisted
// yet, so it can never be a leftover (this is the case a text comparison gets
// wrong when the previous answer happens to read the same), and a live
// projection anywhere means the rebuild still owns a live turn of its own.
const last=msgs[msgs.length-1];
if(!last||last.role!=='assistant') return false;
if(msgs.some(_messageHasLiveAssistantProjection)) return false;
const inflight=(typeof INFLIGHT!=='undefined'&&INFLIGHT)?INFLIGHT[sid]:null;
// Owner of the live DOM: INFLIGHT carries the id of the stream that built it
// (attachLiveStream), and S.activeStreamId is that same id until the terminal
// event clears it.
const liveStreamId=String((inflight&&inflight.streamId)||(typeof S!=='undefined'&&S&&S.activeStreamId)||'');
const settledStreamId=_settledAssistantStreamId(last);
if(settledStreamId){
// Identity recorded on both sides decides — renderer- and text-independent,
// so code blocks, lists and tables are handled like plain prose.
if(!liveStreamId||settledStreamId!==liveStreamId) return false;
}else{
// No persisted identity on the tail (a turn whose scene was not worklog
// worthy). Compare SOURCE with SOURCE — the markdown this stream produced
// (INFLIGHT.lastAssistantText) against the persisted message content —
// never two independently rendered DOM trees. Regeneration cannot reach
// this branch: startRegeneration() truncates S.messages at the user turn,
// so the tail is a user message while a regenerated answer streams.
const streamed=String((inflight&&inflight.lastAssistantText)||'').replace(/\s+/g,' ').trim();
if(!streamed) return false;
const settled=String((typeof msgContent==='function'?msgContent(last):last.content)||'').replace(/\s+/g,' ').trim();
if(!settled||streamed!==settled) return false;
}
return !_liveTurnCarriesUnsettledContent(turn);
}
function _mergeRestoredLiveAssistantSegment(restored, existing){
if(!restored||!existing) return;
const existingLive=existing.querySelector('[data-live-assistant="1"]');
@@ -9964,6 +10094,18 @@ function restoreLiveTurnHtmlForSession(sid){
const existing=$('liveAssistantTurn');
_mergeRestoredLiveAssistantSegment(restored, existing);
if(existing) existing.replaceWith(restored);
// #6948 follow-up (duplicate assistant answer; #2051): only this branch ADDS a
// turn — replacing an existing live turn cannot duplicate. When the settled
// transcript already owns the stream this snapshot belongs to, appending it
// pins a SECOND copy of the same answer under the settled one. Release the
// stale snapshot (no other consumer can use it — it would be re-refused on
// every later restore) and report "nothing restored", so loadSession takes the
// same path it already takes for an INFLIGHT entry that never carried one.
else if(typeof _settledTranscriptOwnsLiveTurn==='function'
&&_settledTranscriptOwnsLiveTurn(sid, restored)){
inflight.liveTurnHtml=null;
return false;
}
else inner.appendChild(restored);
// Transparent Stream: liveTurnHtml is restored via template.innerHTML, which
// drops the property-bound onclick/onkeydown handlers wired by
@@ -11469,6 +11611,15 @@ function _assistantMessageBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs, vis
const isTurnFinalAssistant=!!(opts&&opts.isTurnFinalAssistant);
const visibleText=String(visibleContent!==undefined?visibleContent:msgContent(m)||'').trim();
const hasVisibleText=!!visibleText&&!_isAssistantEmptyPlaceholderContent(m, visibleText);
// The caller only consults this predicate once the turn has settled (it gates
// on `!S.busy`), so an `_live` marker seen here is a leftover of the
// live-snapshot projection, not an ongoing stream. Folding on it hid the turn's
// final answer inline and echoed it into the Worklog, leaving the turn with no
// visible content until the #3875 blank-turn fail-safe revealed both copies. A
// turn-final assistant message with visible text is the answer, so keep it out
// of the fold; every other `_live` message folds as before. Kept as a separate
// guard so the live rule below keeps its position.
if(m._live&&hasVisibleText&&isTurnFinalAssistant) return false;
if(m._live) return true;
if(hasVisibleText&&m._anchor_activity_scene) return false;
if(hasVisibleText&&isTurnFinalAssistant) return false;
@@ -13151,6 +13302,17 @@ function _anchorSceneRowsForRendering(scene, opts){
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
const settled=!!(opts&&opts.settled);
const live=!settled;
// #6948 follow-up (duplicate assistant answer; #2051): on a SETTLED turn the
// assistant segment owns the final answer, so a process_prose row carrying
// that same text must not also be rebuilt into the activity scene. The
// transparent-stream renderer suppresses it per row
// (_anchorSceneTransparentNodeForRow), but the compact-worklog row builder
// (_anchorSceneNodeForRow) has no such guard and renders the row as a second
// .assistant-segment above the settled one. Drop it here — the single place
// BOTH settled renderers (and the #5839 deferred-rows path) get their rows.
// LIVE rendering is untouched: while streaming, the inline live segment is
// hidden and the prose row IS the visible answer.
const settledFinalAnswer=settled?String((scene&&scene.final_answer)||'').trim():'';
const out=[];
const byKey=new Map();
const liveProseTextKeys=new Map();
@@ -13173,6 +13335,9 @@ function _anchorSceneRowsForRendering(scene, opts){
if(_anchorSceneIsSettledSuccessfulCompression(row,settled)) continue;
const text=String(row.text||'').trim();
if((row.role==='prose'||row.role==='thinking')&&!text) continue;
if(settledFinalAnswer&&row.role==='prose'
&&typeof _anchorSceneProseDuplicatesFinalAnswer==='function'
&&_anchorSceneProseDuplicatesFinalAnswer(text,settledFinalAnswer)) continue; // #6948 follow-up
const key=keyFor(row);
if(byKey.has(key)){
const index=byKey.get(key);
@@ -13447,6 +13612,26 @@ function _anchorSceneProseMatchesFinalAnswer(proseText, finalAnswer){
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
return shorter>=80 && (shorter/longer)>=0.9;
}
// #6948 follow-up (duplicate assistant answer; #2051): the same near-equality
// test as _anchorSceneProseMatchesFinalAnswer, minus its absolute `shorter>=80`
// floor. That floor makes the matcher a no-op for every SHORT final answer
// (greetings, "Done.", one-liners), so a prose row that is the answer minus its
// last streamed token — e.g. "Hey! What can I help you with today" vs
// "...today?" — was rebuilt as a SECOND assistant-segment beside the settled
// one. The >=0.9 length ratio is what actually keeps a distinct short
// intermediate sentence from being swallowed by a long final answer (Codex
// #4568), so it alone is kept. Deliberately a separate helper: the per-row
// render-time matcher above, and the tests pinning its exact predicate, stay
// untouched.
function _anchorSceneProseDuplicatesFinalAnswer(proseText, finalAnswer){
if(_anchorSceneProseMatchesFinalAnswer(proseText,finalAnswer)) return true;
const norm=(s)=>String(s||'').replace(/\s+/g,' ').trim();
const a=norm(proseText), b=norm(finalAnswer);
if(!a||!b) return false;
if(!(a.startsWith(b)||b.startsWith(a))) return false;
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
return (shorter/longer)>=0.9;
}
function _anchorSceneWorklogGroup(blocks, opts){
if(!blocks) return null;
const live=!!(opts&&opts.live);
@@ -16922,6 +17107,23 @@ function _processWakeupCardHtml(info, rawText, extras){
return `<details class="process-wakeup-card"><summary class="process-wakeup-summary"><span class="process-wakeup-toggle">${li('chevron-right',12)}</span><span class="process-wakeup-label">${li('terminal',13)}<span>${esc(t('process_wakeup_label'))}</span></span>${cmdHtml}${chip}${extras.timeHtml||''}</summary><div class="process-wakeup-detail">${extras.filesHtml||''}${patternRow}${cmdRow}<div class="msg-body process-wakeup-body">${outHtml}</div>${extras.footHtml||''}</div></details>`;
}
// #2051: parse into a <template> and move the nodes instead of insertAdjacentHTML —
// every step is idempotent, so a DOM-API wrapper (e.g. an anti-fingerprinting
// extension) that executes the call twice cannot duplicate the block.
function _insertSegmentBlock(seg, html){
if(!seg) return;
if(typeof document!=='undefined'&&typeof document.createElement==='function'){
try{
const tpl=document.createElement('template');
if('content' in tpl){
tpl.innerHTML=html;
seg.appendChild(tpl.content);
return;
}
}catch(_){ /* fall through to the string path below */ }
}
seg.insertAdjacentHTML('beforeend', html);
}
function renderMessages(options){
_lastMessageRenderAt=performance.now();
const preserveScroll=!!(options&&options.preserveScroll);
@@ -17686,7 +17888,7 @@ function renderMessages(options){
if(isLastTextPart&&statusHtml){
orderedSeg.insertAdjacentHTML('beforeend', statusHtml);
}
orderedSeg.insertAdjacentHTML('beforeend', `${isLastTextPart?filesHtml:''}<div class="msg-body">${(typeof m!=='undefined'&&m&&m._media_snapshots&&typeof m._media_snapshots==='object')?_stampMediaSnapshots(partBodyHtml,m._media_snapshots):partBodyHtml}</div>${isLastTextPart?footHtml:''}`);
_insertSegmentBlock(orderedSeg, `${isLastTextPart?filesHtml:''}<div class="msg-body">${(typeof m!=='undefined'&&m&&m._media_snapshots&&typeof m._media_snapshots==='object')?_stampMediaSnapshots(partBodyHtml,m._media_snapshots):partBodyHtml}</div>${isLastTextPart?footHtml:''}`);
blocks.appendChild(orderedSeg);
if(!firstSeg) firstSeg=orderedSeg;
});
@@ -17746,9 +17948,9 @@ function renderMessages(options){
const hasVisibleBody=!!(String(content||'').trim()||filesHtml||recoveryHtml);
if(statusHtml){
seg.insertAdjacentHTML('beforeend', statusHtml);
if(hasVisibleBody) seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
if(hasVisibleBody) _insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
}else if(hasVisibleBody){
seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
_insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
}else if(!(thinkingText&&window._showThinking!==false&&!isSimplifiedToolCalling())){
seg.classList.add('assistant-segment-anchor');
}
@@ -18171,12 +18373,15 @@ function renderMessages(options){
if(!cards.length&&!anchorReasonHtml&&!thinkingText) continue;
const anchorTurn=anchorRow.closest('.assistant-turn');
if(!anchorTurn) continue;
// Hoisted out of the `if(!state)` block below (same expression, same
// value) so the append path can use the ownership fact the group
// construction already uses.
const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');
let state=activityByTurn.get(anchorTurn);
if(!state){
const includeTurnDuration=!durationAssignedTurns.has(anchorTurn);
if(includeTurnDuration) durationAssignedTurns.add(anchorTurn);
const activityKey=`assistant:${aIdx}`;
const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');
const group=ensureActivityGroup(anchorParent,{
collapsed:true,
anchor:anchorRow,
@@ -18196,7 +18401,14 @@ function renderMessages(options){
state.cards.push(...cards);
_appendWorklogStep(state.group, anchorRow, cards, thinkingText, {
live:false,
includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml,
// Echo an anchor's prose as a `.wl-reason` row only when that anchor was
// folded into this Worklog. `assistant-segment-worklog-source` is the
// proof, and its `display:none` is the only reason the echo is not a
// second visible copy. The group construction above already reasons
// that way (`syncAnchorReason`); the append path did not, so an anchor
// that escapes the fold (the turn-final answer, an `_error` message)
// had its text rendered both inline and inside the Worklog.
includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml&&!!anchorIsWorklogSource,
thinkingKey:thinkingText?`thinking:${_normalizeThinkingEchoCompare(thinkingText)}`:'',
thinkingDisclosureKey:thinkingText?`thinking:${entry.key}`:'',
seenReasons:state.seenReasons,
@@ -18485,6 +18697,15 @@ function renderMessages(options){
const groups=turn.querySelectorAll('.tool-worklog-group,.tool-call-group');
let revealed=false;
for(const group of groups){
// A settled Worklog whose rows are still deferred (#5839) has an empty
// textContent but is not empty in substance. Judging it empty here drops
// through to the last-resort un-hide below, and the deferred rows then
// materialize the same prose beside the segments it just un-hid.
// Materialize first, then judge.
if(group.getAttribute&&group.getAttribute('data-worklog-rows-deferred')==='1'
&&typeof _materializeDeferredWorklogRows==='function'){
_materializeDeferredWorklogRows(group);
}
if(!(group.textContent||'').trim()) continue; // empty group can't help
if(group.classList.contains('tool-call-group-collapsed')){
group.classList.remove('tool-call-group-collapsed');
@@ -18603,7 +18824,17 @@ function renderMessages(options){
// restore the whole preserved turn so nothing the user saw vanishes.
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
_rebuilt.replaceWith(_preservedLiveTurn);
}else{
}else if(!(typeof _settledTranscriptOwnsLiveTurn==='function'
&&_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn))){
// #6948 follow-up (duplicate assistant answer; #2051): this is the
// only branch that ADDS a turn — the rebuild produced no live turn of
// its own. When the settled transcript already ends with THIS stream's
// own answer and the preserved node carries nothing unpersisted, the
// node is a dead leftover (the row persisted and the turn settled while
// INFLIGHT[sid] was not yet cleaned) and appending pins a SECOND copy
// that re-preserves itself on every later render until a reload.
// Mid-stream the transcript ends with the user turn (or still carries a
// live projection), so #3877 preservation is untouched.
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
inner.appendChild(_preservedLiveTurn);
}
+73 -5
View File
@@ -39,6 +39,7 @@ SCENARIO = os.environ.get("LIFECYCLE_SCENARIO", "normal").strip() or "normal"
TOOL_NAME = "read_file"
TOOL_ID = "lifecycle-tool-1"
TEST_BITE = os.environ.get("LIFECYCLE_TEST_BITE", "").strip()
_FRAME_PROOF_BITES = {"settle-worklog-frame-proof", "sidebar-idle-missing-terminal"}
GATEWAY_ACTIVITY_TIMEOUT = 60.0
ANCHOR_SCENE_PERSIST_TIMEOUT = 60.0
ANCHOR_SCENE_PROJECTION_TIMEOUT = 10_000
@@ -267,8 +268,17 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
suffix = "" if attempts == 1 else f"-attempt-{attempt + 1}"
log_path = artifact_dir / f"server{suffix}.log"
log = log_path.open("w", encoding="utf-8")
command = [sys.executable, str(repo_root / "server.py")]
barrier_dir = env.get("HERMES_TEST_TERMINAL_BARRIER_DIR")
if barrier_dir:
command = [
sys.executable,
str(repo_root / "tests" / "sidebar_terminal_barrier_server.py"),
str(repo_root),
barrier_dir,
]
proc = subprocess.Popen(
[sys.executable, str(repo_root / "server.py")],
command,
cwd=repo_root,
env=run_env,
stdout=log,
@@ -286,6 +296,50 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
raise RuntimeError(f"WebUI server did not become healthy{detail}")
def _exercise_sidebar_terminal_handoff(page, barrier_dir: Path, artifact_dir: Path):
"""Order the real idle-list response before the real terminal SSE write."""
deadline = time.monotonic() + 10
while not (barrier_dir / "ready").exists():
if time.monotonic() >= deadline:
raise AssertionError("terminal HTTP barrier was not reached")
page.wait_for_timeout(10)
observation = page.evaluate(
"""async () => {
const sid = S.session.session_id;
const stream = S.activeStreamId;
const inflight = INFLIGHT[sid];
const source = LIVE_STREAMS[sid] && LIVE_STREAMS[sid].source;
const registries = window._liveAnchorRegistries;
const registry = registries && registries.get(stream);
await renderSessionList();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const live = document.querySelector('#liveAssistantTurn');
return {
serverIdle: _allSessions.some(row => row.session_id === sid && _isServerIdleSessionRow(row)),
paneRetained: S.busy === true && S.activeStreamId === stream,
inflightRetained: !!inflight && INFLIGHT[sid] === inflight,
sourceRetained: !!source && source.readyState === EventSource.OPEN &&
LIVE_STREAMS[sid] && LIVE_STREAMS[sid].source === source,
registryRetained: !!registry && registries.get(stream) === registry,
liveRows: live ? live.querySelectorAll('[data-anchor-scene-row="1"]').length : 0,
};
}"""
)
(artifact_dir / "sidebar-before-terminal.json").write_text(
json.dumps(observation, indent=2), encoding="utf-8"
)
page.screenshot(path=str(artifact_dir / "sidebar-before-terminal.png"), full_page=True)
for invariant in ("serverIdle", "paneRetained", "inflightRetained", "sourceRetained", "registryRetained"):
assert observation[invariant] is True, {
"message": "idle sidebar response revoked the live terminal handoff",
"invariant": invariant,
"observation": observation,
}
assert observation["liveRows"] > 0, observation
if TEST_BITE != "sidebar-idle-missing-terminal":
(barrier_dir / "release").touch()
class DeterministicGateway:
"""A localhost-only Gateway Runs server with test-controlled phase gates."""
@@ -733,11 +787,12 @@ def main() -> int:
"drop-anchor-persistence",
"drop-terminal-anchor-row",
"settle-worklog-frame-proof",
"sidebar-idle-missing-terminal",
}:
raise ValueError(
f"Unsupported LIFECYCLE_TEST_BITE {TEST_BITE!r}; "
"expected one of '', 'drop-anchor-persistence', "
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof'"
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof', 'sidebar-idle-missing-terminal'"
)
if TEST_BITE == "drop-terminal-anchor-row" and scenario != "terminal-error":
raise ValueError(
@@ -782,6 +837,12 @@ def main() -> int:
"NO_PROXY": "127.0.0.1,localhost",
"no_proxy": "127.0.0.1,localhost",
})
env.pop("HERMES_TEST_TERMINAL_BARRIER_DIR", None)
terminal_barrier_dir = None
if TEST_BITE in _FRAME_PROOF_BITES and scenario == "normal":
terminal_barrier_dir = state_dir / "terminal-barrier"
terminal_barrier_dir.mkdir()
env["HERMES_TEST_TERMINAL_BARRIER_DIR"] = str(terminal_barrier_dir)
proc = None
log = None
log_path = None
@@ -873,7 +934,7 @@ def main() -> int:
# must never expose an expanded empty Worklog shell or an activity-less
# gap. Sample after rAF callbacks for a frame have drained; DOM mutations
# within one task or earlier rAF callbacks are not user-visible frames.
if TEST_BITE == "settle-worklog-frame-proof":
if TEST_BITE in _FRAME_PROOF_BITES:
page.evaluate(
"""() => {
const samples = [];
@@ -941,12 +1002,17 @@ def main() -> int:
timeout=10000,
)
gateway.release_terminal.set()
if terminal_barrier_dir is not None:
_exercise_sidebar_terminal_handoff(page, terminal_barrier_dir, artifact_dir)
page.wait_for_function(
"""text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId &&
((document.querySelector('#msgInner') || {}).innerText || '').includes(text)""",
arg=FINAL_TEXT,
timeout=15000,
timeout=11000 if TEST_BITE == "sidebar-idle-missing-terminal" else 15000,
)
if TEST_BITE == "sidebar-idle-missing-terminal":
assert not (terminal_barrier_dir / "release").exists(), "terminal frame was unexpectedly released"
print("OK missing terminal: canonical snapshot settled the still-OPEN transport before terminal delivery")
else:
gateway.release_terminal.set()
page.wait_for_function(
@@ -958,7 +1024,7 @@ def main() -> int:
)
session_id = page.evaluate("S.session && S.session.session_id")
assert session_id, "active session id missing after settlement"
if TEST_BITE == "settle-worklog-frame-proof":
if TEST_BITE in _FRAME_PROOF_BITES:
page.wait_for_timeout(100)
settle_proof = page.evaluate(
"window.__lifecycleSettleFrameProof && window.__lifecycleSettleFrameProof.stop()"
@@ -1176,6 +1242,8 @@ def main() -> int:
exit_code = 1
return 1
finally:
if terminal_barrier_dir is not None:
(terminal_barrier_dir / "release").touch()
gateway.close()
if browser is not None:
browser.close()
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Real page/loadSession/renderers with deterministic session and SSE fixtures.
No agent, provider, production state, or credentials. Tests DOM reconstruction,
not a real provider/network reconnect.
"""
import json
import os
import tempfile
import time
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from playwright.sync_api import sync_playwright
from browser_conversation_lifecycle import _start_webui_server, _terminate_process
ROOT = Path(os.environ.get('WEBUI_TEST_ROOT', Path(__file__).resolve().parent.parent))
def fixture(count):
tools = [dict(name='terminal', tid=f'tool-{i}', args={'command': f'printf result-{i}'},
preview=f'result-{i}', snippet=f'result-{i}', done=True) for i in range(count)]
rows = [dict(row_id=f'tool:{t["tid"]}', local_id=t['tid'], role='tool', kind='tool_call',
source_event_type='tool_complete', status='completed', order_index=i,
tool=t, payload=t) for i, t in enumerate(tools)]
return dict(stream_id='run-fixture', last_seq=1000, last_event_id='run-fixture:1000',
messages=[], tool_calls=tools, last_assistant_text='', last_reasoning_text='',
anchor_activity_scene=dict(version='activity_scene_v1',
identity=dict(session_id='fixture', stream_id='run-fixture', run_id='run-fixture'),
activity_rows=rows))
INIT = """
// A controlling service worker bypasses Playwright's page route fixtures in
// WebKit. This test targets reconnect rendering, not PWA cache behavior.
if(window===window.top && 'serviceWorker' in navigator){
navigator.serviceWorker.register=()=>Promise.reject(new Error('Disabled in reconnect harness'));
}
window.fixtureSources=[];
class FixtureEventSource {
static OPEN=1; static CONNECTING=0; static CLOSED=2;
constructor(url){this.url=String(url);this.readyState=1;this.listeners={};window.fixtureSources.push(this);}
addEventListener(name,fn){(this.listeners[name]||=[]).push(fn);}
removeEventListener(){}
close(){this.readyState=2;}
emit(name,data,id){for(const fn of this.listeners[name]||[])fn({data:JSON.stringify(data),lastEventId:id||''});}
}
window.EventSource=FixtureEventSource;
"""
def session_route(session, sid, workspace):
def handle(route):
query = parse_qs(urlsplit(route.request.url).query)
requested = query.get('session_id', [''])[0]
data = session if requested == sid else dict(
session_id='idle-fixture', messages=[], message_count=0,
tool_calls=[], workspace=workspace)
route.fulfill(json={'session': data})
return handle
def main():
tool_count = int(os.environ.get('TOOL_COUNT', '470'))
snapshot = fixture(tool_count)
stream_id = snapshot['stream_id']
sid = 'fixture'
with tempfile.TemporaryDirectory(prefix='webui-reconnect-') as temp:
state = Path(temp)
env = {k:os.environ[k] for k in ('PATH','SYSTEMROOT','TMPDIR') if k in os.environ}
env.update(HOME=temp, HERMES_HOME=temp, HERMES_BASE_HOME=temp,
HERMES_WEBUI_STATE_DIR=str(state/'webui'), HERMES_CONFIG_PATH=str(state/'config.yaml'),
HERMES_WEBUI_HOST='127.0.0.1', HERMES_WEBUI_SKIP_ONBOARDING='1',
HERMES_WEBUI_AGENT_DIR=str(state/'no-agent'))
proc, log, _, base = _start_webui_server(ROOT, env, state)
results = []
try:
with sync_playwright() as pw:
for engine in os.environ.get('BROWSERS','chromium,webkit').split(','):
browser = getattr(pw,engine).launch(headless=True)
try:
for mode in os.environ.get('MODES','compact_worklog,transparent_stream,hide_all_activity').split(','):
for width in [1280,390]:
context = browser.new_context(viewport={'width':width,'height':844},bypass_csp=True)
context.add_init_script(INIT)
page = context.new_page()
errors=[]
page.on('pageerror',lambda e, errors=errors:errors.append(str(e)))
session = dict(session_id=sid,title='Reconnect performance fixture',model='',
workspace=temp,messages=[],message_count=0,tool_calls=[],
active_stream_id=stream_id,pending_user_message='Inspect the fixture',
pending_started_at=1,runtime_journal_snapshot=snapshot)
page.route('**/api/session?*',session_route(session,sid,temp))
page.route('**/api/chat/stream/status?*',lambda r:r.fulfill(json={'active':True}))
page.goto(base,wait_until='load')
# WebKit's wait_for_function uses eval, blocked by
# the app CSP even with bypass_csp. Inspector
# evaluation still works; wait on actual boot state.
deadline=time.monotonic()+30
while not page.evaluate("typeof loadSession==='function' && S._bootReady === true"):
assert time.monotonic()<deadline, ('boot timeout', errors)
page.wait_for_timeout(50)
page.evaluate("""mode=>{
window._chatActivityDisplayMode=mode;window._showThinking=true;
window._simplifiedToolCalling=true;
window.redrawCount=0;
const render=window._renderLiveAnchorActivitySceneForStream;
window._renderLiveAnchorActivitySceneForStream=function(...args){window.redrawCount++;return render(...args);};
}""",mode)
measured=page.evaluate("""async sid=>{
const t=performance.now();await loadSession(sid);
const loadMs=performance.now()-t;
const deadline=performance.now()+5000;
while(!fixtureSources.some(s=>s.url.includes('api/chat/stream?'))&&performance.now()<deadline){
await new Promise(r=>setTimeout(r,10));
}
return {ms:loadMs,redraws:window.redrawCount,
tools:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
busy:S.busy,
sources:fixtureSources.filter(s=>s.url.includes('api/chat/stream?')).map(s=>s.url)};
}""",sid)
result=dict(engine=engine,mode=mode,width=width,
**{k:v for k,v in measured.items() if k!='sources'})
print(json.dumps(result),flush=True)
results.append(result)
if not os.environ.get('MEASURE_BASELINE'):
assert measured['redraws']<=2,result
expected=0 if mode=='hide_all_activity' else tool_count
assert measured['tools']==expected,result
assert measured['busy'],result
assert measured['sources'], (result, errors)
cursor=parse_qs(urlsplit(measured['sources'][-1]).query)
assert cursor['after_seq']==[str(snapshot['last_seq'])], (result,errors)
if mode!='hide_all_activity':
# Subsequent real SSE handler updates still paint the existing owner.
updated=page.evaluate("""({sid,stream})=>{
const source=fixtureSources.findLast(s=>s.url.includes('api/chat/stream?')&&s.readyState===1);
if(!source)throw new Error('missing chat stream');
source.emit('tool',{name:'terminal',tid:'after-reconnect',args:{command:'printf later'},preview:'later'},stream+':1001');
source.emit('tool_complete',{name:'terminal',tid:'after-reconnect',preview:'LATER RESULT',duration:1},stream+':1002');
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
}""",{'sid':sid,'stream':stream_id})
assert updated=={'rows':expected+1,'result':True},updated
switched=page.evaluate("""async sid=>{
await loadSession('idle-fixture');
await loadSession(sid);
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
}""",sid)
assert switched==updated,switched
artifact=os.environ.get('SCREENSHOT_DIR')
if artifact:
Path(artifact).mkdir(parents=True,exist_ok=True)
page.screenshot(path=str(Path(artifact)/f'{engine}-{mode}-{width}.png'))
assert not errors,errors
context.close()
finally:
browser.close()
output=os.environ.get('METRICS_FILE')
if output:
Path(output).write_text(json.dumps(results,indent=2))
finally:
_terminate_process(proc)
log.close()
if __name__=='__main__':
main()
+40
View File
@@ -0,0 +1,40 @@
"""Test-only server for the public settle-frame gate's cross-channel race.
Hold the actual HTTP terminal frame after the worker has finished. The browser
keeps its native, open EventSource while /api/sessions independently reports idle.
No browser state, renderer, persistence result, or terminal payload is mocked.
"""
from pathlib import Path
import sys
import time
def main():
if len(sys.argv) != 3:
raise SystemExit("expected repository root and isolated barrier directory")
repo_root, barrier_dir = (Path(value) for value in sys.argv[1:])
sys.path.insert(0, str(repo_root))
import server
from api import routes
ready = barrier_dir / "ready"
release = barrier_dir / "release"
original = routes._sse_with_id
def terminal_barrier(handler, event, data, event_id=None):
if event == "done" and handler.path.startswith("/api/chat/stream?"):
ready.touch()
deadline = time.monotonic() + 15
while not release.exists():
if time.monotonic() >= deadline:
raise TimeoutError("test did not release the terminal HTTP frame")
time.sleep(0.005)
return original(handler, event, data, event_id)
routes._sse_with_id = terminal_barrier
server.main()
if __name__ == "__main__":
main()
+8 -9
View File
@@ -79,20 +79,19 @@ RAISED_EXCEPTION_SELF_HEAL_MARKER = (
"retrying stream after credential refresh')"
)
# Each region ends at the cache write that stores the signature it just
# computed. Bounding the region at the write -- rather than at the next
# lifecycle anchor or at EOF -- is what proves the signature is recomputed
# *before* it is cached: a call moved below its own write lands outside the
# region and fails, instead of passing while the write consumes a stale
# `_agent_sig`.
# Each region ends at the atomic registration/publication call that consumes
# the signature it just computed. The shared helper now writes the cache under
# the Stop lock; the call boundary must still follow fresh signature computation.
# A signature call moved below its publication lands outside the region and
# fails, rather than silently reusing a stale `_agent_sig` on a retry.
INITIAL_SEND_CACHE_WRITE_MARKER = (
"SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)"
"if not _register_agent_if_current(agent, _agent_sig if _cache_new_agent else None):"
)
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC[session_id] = (agent, _agent_sig)"
"if not _register_agent_if_current(agent, _agent_sig):"
)
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC2[session_id] = (_heal_agent, _agent_sig)"
"if not _register_agent_if_current(_heal_agent, _agent_sig):"
)
@@ -0,0 +1,245 @@
"""Stop must not lose to cache publication after an Agent was registered.
Run the real worker and Stop primitive. Pause immediately after the registration
lock is released, not inside the constructor covered by the older tests. Only
external Agent/provider calls are synthetic; no live credentials are used.
"""
import threading
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor
import pytest
from api import config, session_lifecycle, streaming
from tests.test_steer_worker_boundaries import worker_scene as worker_scene
@pytest.mark.parametrize("path", ["initial", "cached", "returned", "exception"])
@pytest.mark.parametrize("cancellation", ["stop", "event-only", "detached-only", "successor", "same-agent-successor", "same-agent-completed", "none"])
def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkeypatch, path, cancellation):
scene = worker_scene
reached, release = threading.Event(), threading.Event()
created, publications, finalized = [], [], []
worker_id = [None]
paused = [False]
successor = object()
stop_interrupt_seen = threading.Event()
successor_interrupt_count = None
monkeypatch.setattr(session_lifecycle, "_sessions", {})
monkeypatch.setattr(config, "SESSION_WRITEBACK_OWNERS", {})
config.register_session_writeback_owner("original", "run")
def on_init():
created.append(scene.agent)
interrupt = scene.agent.interrupt
def observed_interrupt(reason):
result = interrupt(reason)
stop_interrupt_seen.set()
return result
scene.agent.interrupt = observed_interrupt
def on_run():
assert scene.lock.owner != threading.get_ident(), "provider invocation under stream lock"
if path in ("returned", "exception") and len(created) == 1:
if path == "exception":
raise RuntimeError("401 unauthorized")
scene.result = {"error": "401 unauthorized", "messages": []}
else:
scene.result = None
scene.on_init, scene.on_run = on_init, on_run
if path == "cached":
scene.run()
assert scene.calls.count("run") == 1
assert config.SESSION_AGENT_CACHE["original"][0] is created[0]
scene.calls.clear()
config.STREAMS["run"] = scene.events
config.STREAM_SESSION_OWNERS["run"] = "original"
scene.session.active_stream_id = "run"
scene.session.pending_user_message = "Do the task."
scene.session.pending_started_at = 2.0
scene.session.save()
config.register_session_writeback_owner("original", "run")
stop_interrupt_seen.clear()
class ObservedCache(OrderedDict):
def __setitem__(self, key, value):
if key == "original":
publications.append((value[0], scene.lock.owner == threading.get_ident(),
"run" in config.STREAMS))
super().__setitem__(key, value)
cache = ObservedCache()
# Preserve an existing cache hit without counting fixture setup as publication.
for key, value in config.SESSION_AGENT_CACHE.items():
OrderedDict.__setitem__(cache, key, value)
monkeypatch.setattr(config, "SESSION_AGENT_CACHE", cache)
if hasattr(streaming, "SESSION_AGENT_CACHE"):
monkeypatch.setattr(streaming, "SESSION_AGENT_CACHE", cache)
monkeypatch.setattr(streaming, "_attempt_credential_self_heal", lambda *a, **kw: {
"provider": "openai", "api_key": "synthetic-publication-fixture",
"base_url": "http://127.0.0.1:1/v1",
})
target_count = 2 if path in ("returned", "exception") else 1
def after_registration_unlock():
if (worker_id[0] != threading.get_ident() or paused[0]
or len(created) != target_count
or config.AGENT_INSTANCES.get("run") is not created[-1]):
return
paused[0] = True
reached.set()
assert release.wait(8), "post-registration barrier timed out"
scene.lock.after_release = after_registration_unlock
finalize = streaming._finalize_cancelled_turn
def observed_finalize(*args, **kwargs):
assert scene.lock.owner != threading.get_ident(), "finalize under stream lock"
finalized.append(True)
return finalize(*args, **kwargs)
monkeypatch.setattr(streaming, "_finalize_cancelled_turn", observed_finalize)
def run():
worker_id[0] = threading.get_ident()
scene.run()
with ThreadPoolExecutor(max_workers=2) as pool:
stop_future = None
future = pool.submit(run)
try:
assert reached.wait(8), "worker never released target registration lock"
retained = config.CANCEL_FLAGS["run"]
if cancellation in ("stop", "successor", "same-agent-successor", "same-agent-completed"):
stop_future = pool.submit(streaming.cancel_stream, "run")
assert retained.wait(5), "Stop did not publish cancellation"
assert stop_interrupt_seen.wait(5), "Stop has not finished its own interrupt"
with config.STREAMS_LOCK:
assert "run" not in config.STREAMS
if cancellation in ("successor", "same-agent-successor", "same-agent-completed"):
if cancellation.startswith("same-agent"):
successor = created[-1]
config.register_session_writeback_owner("original", "successor")
with config.SESSION_AGENT_CACHE_LOCK:
OrderedDict.__setitem__(cache, "original", (successor, "new-signature"))
session_lifecycle.register_agent("original", successor)
scene.session.active_stream_id = "successor"
scene.session.pending_user_message = "Successor request"
if cancellation == "same-agent-completed":
config.clear_session_writeback_owner_if_owned("original", "successor")
scene.session.active_stream_id = None
scene.session.pending_user_message = None
else:
config.AGENT_INSTANCES["successor"] = successor
config.register_active_run("successor", session_id="original", backend="legacy")
if cancellation == "same-agent-successor":
with successor.pending_lock:
successor.pending.append("Successor-only guidance")
successor_interrupt_count = scene.calls.count("interrupt")
elif cancellation == "event-only":
with config.STREAMS_LOCK:
retained.set()
config.CANCEL_FLAGS.pop("run")
elif cancellation == "detached-only":
with config.STREAMS_LOCK:
config.STREAMS.pop("run")
assert not retained.is_set()
finally:
release.set()
future.result(timeout=12)
if stop_future is not None:
assert stop_future.result(timeout=12) is True
prior_runs = 1 if path in ("returned", "exception") else 0
if cancellation == "none":
assert scene.calls.count("run") == prior_runs + 1
assert config.SESSION_AGENT_CACHE["original"][0] is created[-1]
else:
assert scene.calls.count("run") == prior_runs, "cancelled candidate was invoked"
cached = config.SESSION_AGENT_CACHE.get("original")
if not cancellation.startswith("same-agent"):
assert not cached or cached[0] is not created[-1], "cancelled candidate stayed reusable"
assert finalized, "cancellation did not reach terminal settlement"
if successor_interrupt_count is not None:
assert scene.calls.count("interrupt") == successor_interrupt_count, "stale worker interrupted its successor"
assert config.SESSION_AGENT_CACHE["original"][0] is successor
assert session_lifecycle._sessions["original"]["agent"] is successor
if cancellation == "same-agent-successor":
assert successor.pending == ["Successor-only guidance"], "stale terminal drain stole successor guidance"
if cancellation == "same-agent-completed":
assert scene.session.active_stream_id is None
else:
assert config.AGENT_INSTANCES["successor"] is successor
assert scene.session.active_stream_id == "successor"
assert scene.session.pending_user_message == "Successor request"
assert all(held and live for _, held, live in publications), (
"Agent cache publication escaped the stream/Stop admission boundary", publications)
assert "run" not in config.AGENT_INSTANCES
assert "run" not in config.ACTIVE_RUNS
def test_cache_publication_and_registration_exclude_competing_stop(worker_scene, monkeypatch):
"""Block the actual cache write; Stop cannot observe a half-published Agent."""
scene = worker_scene
entered, release = threading.Event(), threading.Event()
interrupted_state = []
interrupt_observed = threading.Event()
monkeypatch.setattr(session_lifecycle, "_sessions", {})
monkeypatch.setattr(config, "SESSION_WRITEBACK_OWNERS", {})
config.register_session_writeback_owner("original", "run")
class PublicationBarrier(OrderedDict):
def __setitem__(self, key, value):
if key == "original":
entered.set()
assert release.wait(8), "cache publication was not released"
super().__setitem__(key, value)
cache = PublicationBarrier()
monkeypatch.setattr(config, "SESSION_AGENT_CACHE", cache)
if hasattr(streaming, "SESSION_AGENT_CACHE"):
monkeypatch.setattr(streaming, "SESSION_AGENT_CACHE", cache)
def on_init():
candidate = scene.agent
interrupt = candidate.interrupt
def observed_interrupt(reason):
interrupted_state.append((
cache.get("original", (None,))[0] is candidate,
session_lifecycle._sessions.get("original", {}).get("agent") is candidate,
))
result = interrupt(reason)
interrupt_observed.set()
return result
candidate.interrupt = observed_interrupt
scene.on_init = on_init
with ThreadPoolExecutor(max_workers=2) as pool:
worker = pool.submit(scene.run)
stop = None
try:
assert entered.wait(8), "worker never reached cache publication"
retained = config.CANCEL_FLAGS["run"]
# Wait at the first publication unlock until Stop has won admission,
# so the provider cannot outrun the controlled schedule.
worker_ident = scene.lock.owner
def wait_for_stop():
if threading.get_ident() == worker_ident:
scene.lock.after_release = None
assert interrupt_observed.wait(5), "Stop did not interrupt the published Agent"
scene.lock.after_release = wait_for_stop
stop = pool.submit(streaming.cancel_stream, "run")
assert scene.lock.contender.wait(5), "Stop bypassed publication's stream lock"
assert not retained.is_set(), "Stop observed half-published Agent state"
finally:
release.set()
worker.result(timeout=12)
if stop is not None:
assert stop.result(timeout=12) is True
assert interrupted_state[0] == (True, True), "Stop did not see coherent cache/lifecycle publication"
assert "run" not in scene.calls
assert "original" not in cache
+5
View File
@@ -416,6 +416,10 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
legacy_metadata_source = _function_source(
_ui_js(), "_legacySettledFallbackHasToolMetadata"
)
# #2051: renderMessages() inserts a message block through this helper, so it is
# evaluated with it. The shim's createElement() returns no template `content`, so the
# helper takes its insertAdjacentHTML fallback here, exactly as before.
insert_block_source = _function_source(_ui_js(), "_insertSegmentBlock")
script = textwrap.dedent(
f"""
class FakeClassList {{
@@ -702,6 +706,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
eval({json.dumps(transparent_source)});
eval({json.dumps(legacy_metadata_source)});
eval({json.dumps(insert_block_source)});
eval({json.dumps(render_source)});
const toolResult = {{ role: 'tool', tool_call_id: 'toolu_1', content: 'tool result' }};
+8 -1
View File
@@ -291,6 +291,13 @@ class TestLiveModelsCustomProviderFallback:
"model": "gpt-5.5",
"models": {"gpt-5.5-mini": {}},
"base_url": "https://right.codes/codex/v1",
# dict-shaped ``models`` is per-model metadata (Agent setup
# flow) and only narrows the live catalog when discovery is
# explicitly off — the new #7165 contract. This test still
# wants provider-scoping (no sibling leak), so pin the
# catalog via ``discover_models: false`` instead of relying
# on the removed dict-as-allowlist behavior.
"discover_models": False,
},
{
"name": "infini-ai",
@@ -308,7 +315,7 @@ class TestLiveModelsCustomProviderFallback:
resp = self._call_live_models(monkeypatch, cfg, "custom:rightcode-codex")
assert resp["provider"] == "custom:rightcode-codex"
assert [m["id"] for m in resp["models"]] == ["gpt-5.5", "gpt-5.5-mini"]
assert [m["id"] for m in resp["models"]] == ["gpt-5.5-mini"]
def test_bare_custom_fallback_ignores_named_custom_provider_models(self, monkeypatch):
"""Bare custom only represents unnamed custom entries, not named siblings."""
@@ -50,6 +50,10 @@ _REAL_FUNCTIONS = (
"_insertPreservedCompressionTaskFallback",
"_pinCompactionCardAtTop",
"_pinSettledCompressionReferenceAtTop",
# #2051: renderMessages() inserts a message block through this helper, so it has to be
# evaluated with it. The shim's createElement() returns no template `content`, so the
# helper takes its insertAdjacentHTML fallback here, exactly as before.
"_insertSegmentBlock",
"renderMessages",
)
@@ -79,20 +79,19 @@ RAISED_EXCEPTION_SELF_HEAL_MARKER = (
"retrying stream after credential refresh')"
)
# Each region ends at the cache write that stores the signature it just
# computed. Bounding the region at the write -- rather than at the next
# lifecycle anchor or at EOF -- is what proves the signature is recomputed
# *before* it is cached: a call moved below its own write lands outside the
# region and fails, instead of passing while the write consumes a stale
# `_agent_sig`.
# Each region ends at the atomic registration/publication call that consumes
# the signature it just computed. The shared helper now writes the cache under
# the Stop lock; the call boundary must still follow fresh signature computation.
# A signature call moved below its publication lands outside the region and
# fails, rather than silently reusing a stale `_agent_sig` on a retry.
INITIAL_SEND_CACHE_WRITE_MARKER = (
"SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)"
"if not _register_agent_if_current(agent, _agent_sig if _cache_new_agent else None):"
)
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC[session_id] = (agent, _agent_sig)"
"if not _register_agent_if_current(agent, _agent_sig):"
)
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC2[session_id] = (_heal_agent, _agent_sig)"
"if not _register_agent_if_current(_heal_agent, _agent_sig):"
)
@@ -112,7 +111,7 @@ def _signature_calls_by_region(
The regions are the initial send, the returned-error self-heal retry and
the raised-exception self-heal retry; each runs from its lifecycle anchor
to the cache write that consumes `_agent_sig`. Requiring exactly one call
to the atomic publication call that consumes `_agent_sig`. Requiring one call
inside those bounds means neither a dropped retry call (masked by its
surviving siblings) nor a call recomputed after its own cache write (which
would leave the write storing a stale signature) can read as correct.
@@ -0,0 +1,370 @@
"""Regression: the assistant answer is displayed TWICE (#6948 follow-up;
upstream symptom report #2051).
Two independent renderers can put a second copy of the same answer on screen
after a turn settles. Both are covered here.
1. The live-turn node (static/ui.js)
------------------------------------
`renderMessages()` re-attaches the preserved `#liveAssistantTurn` node after the
transcript wipe, and `restoreLiveTurnHtmlForSession()` re-inserts the
`INFLIGHT[sid].liveTurnHtml` snapshot on a session switch / reconnect. Each has
ONE branch that *appends* — i.e. adds a turn the settled rebuild did not
produce. The assistant row is persisted a few ms BEFORE the stream's terminal
event clears `S.activeStreamId`, so a dead live node left behind by an INFLIGHT
entry that outlived its stream is appended under the settled copy: the same
answer twice, self-sustaining across later renders (the node keeps
id=liveAssistantTurn and is re-preserved by the #6948 guard), healed only by a
reload.
The contract (`_settledTranscriptOwnsLiveTurn`) is an OWNERSHIP proof, never a
rendered-text comparison:
* the live body comes from the streaming smd parser and the settled body from
renderMd + post-processing, so comparing textContent would only ever match
single-paragraph plain text — lists, fenced code, tables and `_emphasis_`
render differently in the two pipelines;
* comparing text would DELETE a genuinely live turn whenever the previous
answer reads the same ("Done.", a greeting, a same-prompt resend), because
while the current turn is unpersisted the last settled assistant message is
the PREVIOUS turn's answer.
So the append is skipped only when the transcript ENDS with a settled assistant
message that the SAME stream produced (persisted anchor identity, or a
source-vs-source text match when the turn has no persisted scene), and the
preserved node carries nothing the settled rebuild could not have produced (no
unpersisted tool card / reasoning row, no second live segment — #3714).
2. The settled activity scene (static/ui.js)
--------------------------------------------
`_anchorSceneRowsForRendering(scene,{settled:true})` feeds both settled
renderers. A `process_prose` row whose text IS the final answer is suppressed
per-row by the transparent-stream renderer but NOT by the compact-worklog row
builder, which rebuilds it as a second `.assistant-segment` above the settled
answer. The row is now dropped in the shared filter, using a near-equality test
without the absolute `shorter>=80` floor that made the existing matcher a no-op
for short answers (the reported case: the row is the answer minus its last
streamed token, 35 vs 36 chars). LIVE rendering is untouched — while streaming,
the inline live segment is hidden and the prose row IS the visible answer.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def js(*names):
"""Real source of the named ui.js functions — never a re-implementation."""
return "\n".join(extract_function(UI_JS, name) for name in names)
def run_node(script):
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
assert "OK" in out.stdout, out.stdout
return out.stdout
# Minimal DOM stand-in: class/attribute selector matching over a node tree.
DOM_STUB = """
const assert=require('assert');
class El{
constructor(spec){
spec=spec||{};
this.classes=new Set(spec.classes||[]);
this.attrs=Object.assign({},spec.attrs||{});
this.children=spec.children||[];
}
_matchesToken(tok){
tok=String(tok).trim();
if(tok.startsWith('.')) return this.classes.has(tok.slice(1));
const m=/^\\[([a-zA-Z0-9-]+)="([^"]*)"\\]$/.exec(tok);
if(m) return String(this.attrs[m[1]]===undefined?'':this.attrs[m[1]])===m[2];
return false;
}
querySelectorAll(sel){
const toks=String(sel).split(',');
const out=[];
const walk=(node)=>{
for(const child of node.children){
if(toks.some(t=>child._matchesToken(t))) out.push(child);
walk(child);
}
};
walk(this);
return out;
}
}
const seg=(text)=>new El({attrs:{'data-live-assistant':'1'},classes:['assistant-segment'],
children:[new El({classes:['msg-body'],children:[]})]});
const turnWith=(...kids)=>new El({classes:['assistant-turn'],children:kids});
const toolCard=()=>new El({classes:['tool-card-row']});
const emptyWorklogShell=()=>new El({classes:['live-worklog'],attrs:{'data-live-worklog-shell':'1'}});
var S={messages:[],activeStreamId:null,session:{session_id:'s1'}};
var INFLIGHT={};
function setState(messages,inflight,activeStreamId){
S.messages=messages;
S.activeStreamId=activeStreamId===undefined?null:activeStreamId;
for(const k of Object.keys(INFLIGHT)) delete INFLIGHT[k];
if(inflight) INFLIGHT['s1']=inflight;
}
const user=(content)=>({role:'user',content});
const settledAnswer=(content,streamId)=>{
const m={role:'assistant',content};
if(streamId) m._anchor_stream_id=streamId;
return m;
};
const sceneIdentityAnswer=(content,streamId)=>({
role:'assistant',content,
_anchor_activity_scene:{identity:{stream_id:streamId},final_answer:content},
});
"""
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_dead_live_turn_is_dropped_only_when_the_stream_is_provably_settled():
"""The #2051 duplicate is suppressed; a live turn is never deleted."""
script = DOM_STUB + js(
"msgContent",
"_settledAssistantStreamId",
"_messageHasLiveAssistantProjection",
"_liveTurnCarriesUnsettledContent",
"_settledTranscriptOwnsLiveTurn",
) + """
const ANSWER='Hey! What can I help you with today?';
// ── the bug: stream settled, INFLIGHT not yet cleaned → dead leftover ──
setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER),emptyWorklogShell())),true,
'dead live node whose stream the settled transcript owns must be dropped (#2051)');
// Identity may live only on the persisted scene.
setState([user('Hi'),sceneIdentityAnswer(ANSWER,'st1')],{streamId:'st1'},null);
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true,
'scene identity.stream_id is accepted as ownership proof');
// Shape independence: the decision must not depend on rendered text at all, so
// a multi-block answer (fenced code / list / table), which the smd parser and
// renderMd render to DIFFERENT textContent, is handled like plain prose.
setState([user('Hi'),settledAnswer('Here you go:\\n\\n```python\\nprint(1)\\n```','st1')],
{streamId:'st1'},'st1');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Here you go:print(1)'))),true,
'a code-block answer must be de-duplicated too (renderer-independent)');
// ── false positives that a text comparison would cause ──
// The current turn is unpersisted: the transcript ends with the USER turn while
// the live turn streams text identical to the PREVIOUS answer.
setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{streamId:'st2'},'st2');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'a genuinely live turn must never be dropped because an earlier answer matches');
// markInflight() shape at send time (no snapshot yet, stream just started).
setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{sid:'s1',streamId:'st2',ts:1},'st2');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Do'))),false,
'partial live text under a fresh stream must never be dropped');
// A settled tail from a DIFFERENT stream never owns this live turn.
setState([user('Hi'),settledAnswer('Done.','st1')],{streamId:'st2'},'st2');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'a tail from another stream must not authorize dropping the live turn');
// Reconnect/terminal projection: S.messages still carries a live marker.
setState([user('Hi'),Object.assign(settledAnswer('Done.','st1'),{_live:true})],
{streamId:'st1'},null);
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'a live projection in S.messages means the turn has not settled');
// Ownership unknown (no INFLIGHT stream id, no active stream) → fail closed.
setState([user('Hi'),settledAnswer('Done.','st1')],{},null);
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'unknown live owner must fail closed');
setState([],{streamId:'st1'},'st1');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
'an empty transcript owns nothing');
// ── #3714: live-only structure is never discarded ──
setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(toolCard(),seg(ANSWER))),false,
'a preserved turn carrying an unpersisted tool card must be kept');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('first'),seg(ANSWER))),false,
'a multi-segment live turn must not be dropped on a tail-segment match');
assert.strictEqual(
_settledTranscriptOwnsLiveTurn('s1',turnWith(new El({classes:['wl-reason']}),seg(ANSWER))),false,
'a preserved turn carrying an unpersisted reasoning row must be kept');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',{}),false,
'an unknown node shape must fail closed');
// ── no persisted scene identity: SOURCE text vs SOURCE text ──
setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1',lastAssistantText:ANSWER},'st1');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true,
'streamed markdown identical to the persisted answer proves ownership');
setState([user('Hi'),settledAnswer(ANSWER,null)],
{streamId:'st1',lastAssistantText:'Hey! What can I help'},'st1');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Hey! What can I help'))),false,
'a still-growing stream must not be dropped against a longer settled answer');
setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1'},'st1');
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),false,
'no identity and no streamed source text → fail closed');
console.log('OK');
"""
run_node(script)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_append_decision_renders_the_answer_exactly_once():
"""Observable outcome of the two append branches, driven by the real
predicate: one copy for a settled leftover, and the live turn is still
attached in every case where the turn may still be running."""
script = DOM_STUB + js(
"msgContent",
"_settledAssistantStreamId",
"_messageHasLiveAssistantProjection",
"_liveTurnCarriesUnsettledContent",
"_settledTranscriptOwnsLiveTurn",
) + """
// Mirrors the two call sites: the branch appends unless the settled transcript
// provably owns the node (static/ui.js renderMessages re-attach and
// restoreLiveTurnHtmlForSession).
function visibleAnswers(messages,inflight,activeStreamId,turn){
setState(messages,inflight,activeStreamId);
const settledCopies=messages.filter(m=>m.role==='assistant'&&!m._live).length;
const appended=!_settledTranscriptOwnsLiveTurn('s1',turn)?1:0;
return {settledCopies,appended,total:settledCopies+appended};
}
const ANSWER='Hey! What can I help you with today?';
// #2051: exactly one copy of the answer.
assert.deepStrictEqual(
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1',
turnWith(seg(ANSWER),emptyWorklogShell())),
{settledCopies:1,appended:0,total:1},
'the settled answer must render exactly once');
// A live turn is still attached while the turn is running (#3877).
assert.deepStrictEqual(
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1'),user('Hi again')],
{streamId:'st2'},'st2',turnWith(seg(ANSWER))),
{settledCopies:1,appended:1,total:2},
'a running turn must stay on screen even when it repeats an earlier answer');
// A leftover carrying unpersisted structure is kept whole (#3714).
assert.deepStrictEqual(
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1',
turnWith(toolCard(),seg(ANSWER))).appended,1,
'never discard live-only blocks the rebuild did not produce');
console.log('OK');
"""
run_node(script)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_settled_scene_drops_the_prose_row_that_is_the_final_answer():
script = DOM_STUB + js(
"_anchorSceneIsSettledSuccessfulCompression",
"_anchorSceneProseMatchesFinalAnswer",
"_anchorSceneProseDuplicatesFinalAnswer",
"_anchorSceneRowsForRendering",
) + """
function _anchorSceneToolRowLogicalKey(row){ return (row&&row.row_id)||''; }
function _anchorSceneMergeToolRows(_prev,row){ return row; }
const FINAL='Hey! What can I help you with today?';
const scene={
final_answer:FINAL,
activity_rows:[
{role:'thinking',row_id:'t1',text:'thinking out loud'},
{role:'prose',row_id:'p1',text:'Let me check the config first.'},
// the reported shape: the answer minus its last streamed token (35 vs 36)
{role:'prose',row_id:'p2',text:'Hey! What can I help you with today'},
{role:'terminal',row_id:'d1',source_event_type:'done'},
],
};
const texts=(opts)=>_anchorSceneRowsForRendering(scene,opts).map(r=>r.text);
assert.deepStrictEqual(texts({settled:true}),['thinking out loud','Let me check the config first.'],
'the settled scene must not rebuild the final answer as a second segment (#2051)');
assert.deepStrictEqual(texts({settled:false}),
['thinking out loud','Let me check the config first.','Hey! What can I help you with today'],
'LIVE rendering must keep the prose row — it IS the visible answer while streaming');
// Exact repeats and multi-block answers are dropped too; source text is compared,
// so the markdown pipeline never matters here.
const code={final_answer:'Here you go:\\n\\n```python\\nprint(1)\\n```',activity_rows:[
{role:'prose',row_id:'p1',text:'Here you go:\\n\\n```python\\nprint(1)\\n```'},
{role:'prose',row_id:'p2',text:'Working on it.'}]};
assert.deepStrictEqual(_anchorSceneRowsForRendering(code,{settled:true}).map(r=>r.text),
['Working on it.'],'an exact fenced-code duplicate must be dropped, intermediates kept');
// Codex #4568: a SHORT distinct intermediate that merely prefixes a LONG answer
// must survive — the >=0.9 length ratio, not the removed absolute floor, is what
// protects it.
const long_={final_answer:'Hey! What can I help you with today? I can read files, run commands and edit code.',
activity_rows:[{role:'prose',row_id:'p1',text:'Hey! What can'}]};
assert.deepStrictEqual(_anchorSceneRowsForRendering(long_,{settled:true}).map(r=>r.text),
['Hey! What can'],'a distinct short intermediate must not be swallowed by a long answer');
// The pinned per-row matcher is untouched: its absolute floor still rejects the
// short near-miss that the new helper accepts.
assert.strictEqual(_anchorSceneProseMatchesFinalAnswer('Hey! What can I help you with today',FINAL),false,
'_anchorSceneProseMatchesFinalAnswer must keep its >=80 char floor');
assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can I help you with today',FINAL),true,
'the new helper must catch the short near-miss');
assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can',FINAL),false,
'the new helper must keep the >=0.9 ratio guard');
console.log('OK');
"""
run_node(script)
def test_both_append_branches_are_gated_by_the_ownership_check():
"""Wiring: the predicate only helps if it guards the two branches that ADD a
turn (the replace branches cannot duplicate and must stay unguarded)."""
restore = UI_JS[UI_JS.index("function restoreLiveTurnHtmlForSession"):]
restore = restore[:restore.index("\nfunction markInflight")]
assert "if(existing) existing.replaceWith(restored);" in restore
assert "_settledTranscriptOwnsLiveTurn(sid, restored)" in restore, (
"the restore append branch must be gated by the ownership check"
)
assert "inflight.liveTurnHtml=null;" in restore, (
"a refused snapshot must be released, or it is re-refused on every restore"
)
reattach = UI_JS[UI_JS.index(" const _rebuilt=document.getElementById('liveAssistantTurn');"):]
reattach = reattach[:reattach.index(" // Only force-scroll when not actively streaming")]
assert "_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn)" in reattach, (
"the renderMessages append branch must be gated by the ownership check"
)
assert reattach.count("_settledTranscriptOwnsLiveTurn(") == 1, (
"the segment-swap and whole-turn replace branches must stay unguarded — "
"they replace a node instead of adding one and cannot duplicate"
)
def test_segment_block_insertion_moves_nodes_instead_of_reparsing_html():
"""Wiring: every message-block insertion in renderMessages() goes through the
helper, and the helper MOVES parsed nodes instead of re-parsing a string.
Appending a DocumentFragment moves its children and empties it, so a DOM-API
wrapper that executes the call twice appends nothing the second time; an
insertAdjacentHTML wrapper that does the same parses and appends a second copy.
That difference is the whole fix, so it is pinned here rather than left to the
three call sites.
"""
helper = UI_JS[UI_JS.index("function _insertSegmentBlock("):]
helper = helper[:helper.index("\nfunction renderMessages")]
assert "createElement('template')" in helper, (
"the block must be parsed into a <template>, not inserted as a string"
)
assert "seg.appendChild(tpl.content)" in helper, (
"the template's content must be MOVED into the segment — that is what makes "
"a repeated call a no-op"
)
assert helper.count("insertAdjacentHTML") == 1, (
"insertAdjacentHTML may survive only as the fallback for an environment "
"without <template>"
)
render = UI_JS[UI_JS.index("function renderMessages(options){"):]
assert render.count("_insertSegmentBlock(") == 3, (
"all three assistant-segment block insertions must go through the helper"
)
assert "seg.insertAdjacentHTML('beforeend', `${filesHtml}" not in render, (
"an assistant segment's block must not be built from an HTML string again"
)
@@ -0,0 +1,234 @@
"""Regression: an interrupted turn's notice — and any turn-final answer — is
displayed twice (#6948 / #2051).
A ``hermes chat`` run killed mid tool-call persists a single assistant message
``Operation interrupted.``; the WebUI renders that notice twice. The same
doubling was reported for a long tool run's final answer and for an image reply
carrying a ``MEDIA:`` line. The stored data is clean in every case — one message
in ``state.db``, one in the session sidecar, one in ``/api/session`` — so both
copies are produced client-side.
``renderMessages()`` folds intermediate assistant segments into the collapsed
Worklog card (#3401): the inline segment is classed
``assistant-segment-worklog-source`` (``display:none``) and its prose is
re-rendered as a ``.wl-reason`` row inside the Worklog. Two copies, one hidden —
correct, as long as the fold decision is correct.
``_assistantMessageBelongsInWorklog()`` folded on ``m._live`` before it could
reach the rule that protects the answer (``hasVisibleText && isTurnFinalAssistant
-> false``). Its caller only consults the predicate once the turn has settled (it
gates on ``!S.busy``), so an ``_live`` marker seen there is a leftover of the
live-snapshot projection rather than an ongoing stream. Folding on it hides the
answer inline and echoes it into the Worklog: the turn then holds two copies and
no visible content, which the #3875 blank-turn fail-safe reveals — by expanding
the Worklog, or by un-hiding the source segments — so the user sees the notice or
answer twice.
Three contracts are pinned here:
1. ``_assistantMessageBelongsInWorklog``: a live marker may no longer outrank the
turn-final visible answer. Every other ``_live`` message folds as before, and
the change can only keep a segment visible, never hide or drop one.
2. ``renderMessages``: the Worklog may echo an anchor's prose as a ``.wl-reason``
row only when that anchor was folded into it —
``assistant-segment-worklog-source`` is that proof, and its ``display:none``
is the only reason the echo is not a second visible copy. This also covers
anchors that escape the fold for other reasons, such as ``_error`` messages,
which are never folded.
3. The #3875 fail-safe must materialize a deferred settled Worklog (#5839) before
judging it empty; otherwise it falls through to the last-resort un-hide,
strips the fold off every source segment, and the deferred rows then render
the same prose beside them.
Each contract is a structural/ownership check rather than a rendered-text
comparison, and each fails towards keeping content: the first only un-hides, the
second only suppresses an echo whose original is provably visible, the third only
builds rows earlier.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def js(*names):
"""Real source of the named ui.js functions — never a re-implementation."""
return "\n".join(extract_function(UI_JS, name) for name in names)
def _function_body(src: str, name: str) -> str:
marker = f"function {name}("
start = src.find(marker)
assert start != -1, f"{name} not found"
brace = src.find("{", start)
assert brace != -1, f"{name} body not found"
depth = 0
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[brace + 1 : idx]
raise AssertionError(f"{name} body not closed")
def run_node(script):
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
assert "OK" in out.stdout, out.stdout
return out.stdout
HARNESS = """
const assert=require('assert');
var window={};
function _messageHasReasoningPayload(m){ return !!(m&&(m.reasoning||m.reasoning_content)); }
const assistant=(content,extra)=>Object.assign({role:'assistant',content},extra||{});
"""
def test_live_marker_guard_precedes_the_live_fold_rule():
"""Contract 1, structurally: the guard sits before the live fold rule.
Written as an added guard rather than an edit to `if(m._live) return true;`
so the live rule keeps the exact shape and ordering pinned by
tests/test_live_to_final_anchor_visible_order.py.
"""
belongs = _function_body(UI_JS, "_assistantMessageBelongsInWorklog")
guard = "if(m._live&&hasVisibleText&&isTurnFinalAssistant) return false;"
assert guard in belongs, (
"a settled-render live remnant must not fold the turn's final visible answer"
)
assert belongs.index(guard) < belongs.index("if(m._live) return true;"), (
"the guard must be evaluated before the unconditional live fold"
)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_stale_live_marker_never_folds_the_turn_final_answer():
"""The fold must not swallow the answer on a settled-render live remnant."""
script = HARNESS + js(
"msgContent",
"_isAssistantEmptyPlaceholderContent",
"_assistantMessageBelongsInWorklog",
) + """
const FINAL={isTurnFinalAssistant:true};
const MID={isTurnFinalAssistant:false};
const owners=new Set([0]);
// The bug: the settled turn-final answer folds on a stale live marker.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Operation interrupted.',{_live:true}),0,owners,undefined,FINAL),
false,
'a settled turn-final notice must never be folded into the Worklog (#2051)');
assert.strictEqual(
_assistantMessageBelongsInWorklog(
assistant('Here is the image you asked for.\\n\\nMEDIA:/tmp/example.png',{_live:true}),0,owners,undefined,FINAL),
false,
'a MEDIA-bearing turn-final answer must never be folded either');
// Renderer-independent: the decision must not depend on what the text looks like.
assert.strictEqual(
_assistantMessageBelongsInWorklog(
assistant('Here you go:\\n\\n```python\\nprint(1)\\n```',{_live:true}),0,owners,undefined,FINAL),
false,
'a fenced-code turn-final answer must never be folded');
// Everything else about the fold is unchanged.
// A live intermediate segment still folds — that is the Worklog's content.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Reading the files now.',{_live:true}),0,owners,undefined,MID),
true,
'a live intermediate segment must still fold into the Worklog');
// A live turn-final message with no visible text still folds (an empty tool-call
// anchor is Worklog material, not an answer).
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('',{_live:true}),0,owners,undefined,FINAL),
true,
'an empty live anchor must still fold');
// `(empty)` placeholder + reasoning payload is not visible text either.
assert.strictEqual(
_assistantMessageBelongsInWorklog(
assistant('(empty)',{_live:true,reasoning:'thought'}),0,owners,undefined,FINAL),
true,
'an (empty) placeholder anchor must still fold');
// Settled (no marker at all) is untouched in both directions.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Operation interrupted.'),0,owners,undefined,FINAL),
false, 'settled turn-final answer: unchanged');
// (A settled message with visible text and no live/burst marker never folds —
// `if(hasVisibleText) return false;` precedes the tool-metadata rule. Pinned so a
// future change to the live-marker line cannot quietly alter this ordering.)
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Reading the files now.'),0,owners,undefined,MID),
false, 'settled visible intermediate without a live/burst marker: unchanged');
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('',{}),0,owners,undefined,MID),
true, 'settled empty tool-call anchor still folds: unchanged');
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('Done.'),0,new Set(),undefined,MID),
false, 'settled intermediate with no tool metadata: unchanged');
// An error message is still never folded.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('**Error:** boom',{_error:true,_live:true}),0,owners,undefined,MID),
false, 'an error message is never folded');
// Live burst/segment markers on a non-final message still fold.
assert.strictEqual(
_assistantMessageBelongsInWorklog(assistant('mid',{_activityBurstId:2}),0,new Set(),undefined,MID),
true, 'activity-burst anchors still fold');
console.log('OK');
"""
run_node(script)
def test_worklog_reason_echo_requires_a_folded_anchor():
"""Contract 2: only a folded anchor may be echoed as a .wl-reason row."""
body = _function_body(UI_JS, "renderMessages")
assert "includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml&&!!anchorIsWorklogSource," in body, (
"the settled Worklog step may only re-render an anchor's prose when that "
"anchor was folded into it (assistant-segment-worklog-source); without "
"that proof the row is a second visible copy of a visible segment"
)
# The ownership fact must be computed for every entry, not only for the first
# entry of a turn (where the group is constructed) — otherwise the append path
# would read a stale/undefined value for later anchors in the same turn.
hoisted = body.find(
"const anchorIsWorklogSource=anchorRow.classList"
"&&anchorRow.classList.contains('assistant-segment-worklog-source');"
)
state_lookup = body.find("let state=activityByTurn.get(anchorTurn);")
assert hoisted != -1 and state_lookup != -1, "settled worklog append path not found"
assert hoisted < state_lookup, (
"anchorIsWorklogSource must be computed per activity entry (before the "
"per-turn `state` lookup), not only inside the group-construction branch"
)
def test_blank_turn_failsafe_materializes_deferred_rows_before_judging_empty():
"""Contract 3: a deferred (#5839) Worklog is not empty."""
body = _function_body(UI_JS, "renderMessages")
failsafe = body[body.find("Fail-safe invariant (#3875)"):]
assert failsafe, "the #3875 fail-safe is missing"
materialize = failsafe.find("data-worklog-rows-deferred")
empty_check = failsafe.find("continue; // empty group can't help")
assert materialize != -1, (
"the fail-safe must materialize a deferred settled Worklog before judging "
"it empty — otherwise it un-hides every folded segment and the deferred "
"rows later render the same prose beside them"
)
assert materialize < empty_check, (
"deferred rows must be materialized before the empty-group check"
)
# The last-resort un-hide must still exist: content is never lost.
assert "seg.classList.remove('assistant-segment-worklog-source');" in failsafe, (
"the last-resort un-hide must remain — a duplicate is preferable to a "
"blank turn"
)
@@ -0,0 +1,156 @@
"""Regression: the composer model picker lists one configured model twice (#2051).
The picker showed two rows for a single configured model, ``acme/example-model``
and ``custom/acme/example-model``, while ``GET /api/models`` returned a ``Custom``
group holding exactly one model and ``#modelSelect`` ended up with exactly one
matching ``<option>``. Both rows are produced client-side.
``configured_model_badges`` carries every routing spelling of a configured model.
For one custom-provider model that is three keys::
acme/example-model (bare/canonical)
custom/acme/example-model (provider-qualified)
@custom:acme/example-model (routable)
``renderModelDropdown()`` walks that map and pushes a synthetic ``_modelData``
row for every key that ``_isEquivalentConfiguredModelEntry()`` does not recognise
as an alias of a row the catalog already produced. That predicate recognised two
of the three spellings; it missed ``<provider>/<model>`` because
``_normalizeConfiguredModelKey()`` strips only one leading slash segment —
deliberately, since #3360 requires ``vendor_a/x`` and ``vendor_b/y/x`` to stay
distinct. So::
acme/example-model -> example.model
custom/acme/example-model -> acme/example.model (different key)
No match, so the alias was pushed as a second row: one model, two picker entries.
``_deduplicateModelPickerOptions()`` cannot catch this — it dedupes ``<option>``
elements inside ``<select>``, and this row never becomes an ``<option>``.
The contract pinned here: ``<provider>/<model>`` is recognised as an alias of
``<model>`` under the same proof the ``@<provider>:<model>`` rule already uses.
The badge declares a provider, the key begins with that provider's prefix, and an
existing picker row from that same provider normalises equal to the remainder.
Two genuinely different models never satisfy the last clause, so the change can
only drop a duplicate — never hide a distinct model.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.js_source_extract import extract_function
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
NODE = shutil.which("node")
def js(*names):
"""Real source of the named ui.js functions — never a re-implementation."""
return "\n".join(extract_function(UI_JS, name) for name in names)
def run_node(script):
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
assert "OK" in out.stdout, out.stdout
return out.stdout
HARNESS = """
const assert=require('assert');
var window={};
"""
# One Custom group holding one model, with three badge spellings of it.
CATALOG_ROW = "{value:'acme/example-model',providerId:'custom'}"
BADGE = "{role:'primary',label:'Primary',provider:'custom'}"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_provider_qualified_badge_alias_is_not_a_second_picker_row():
"""All three spellings of one configured model collapse to one row (#2051)."""
script = HARNESS + js(
"_normalizeConfiguredModelKey",
"_isEquivalentConfiguredModelEntry",
) + f"""
const entries=[{CATALOG_ROW}];
const badge={BADGE};
// All three routing spellings name the same model the catalog already rendered.
for(const alias of ['acme/example-model',
'custom/acme/example-model',
'@custom:acme/example-model']){{
assert.strictEqual(_isEquivalentConfiguredModelEntry(alias,badge,entries), true,
'badge alias '+alias+' must not become a second picker row (#2051)');
}}
console.log('OK');
"""
run_node(script)
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_a_genuinely_different_model_is_never_suppressed():
"""The new rule must not hide a model the catalog does not have."""
script = HARNESS + js(
"_normalizeConfiguredModelKey",
"_isEquivalentConfiguredModelEntry",
) + f"""
const entries=[{CATALOG_ROW}];
const badge={BADGE};
// Same provider prefix, different model -> must still render its own row.
assert.strictEqual(
_isEquivalentConfiguredModelEntry('custom/acme/other-model',badge,entries), false,
'a different model under the same provider must keep its own row');
// Right model name, but no catalog row owned by that provider -> not proven
// equivalent, so keep it.
assert.strictEqual(
_isEquivalentConfiguredModelEntry(
'custom/acme/example-model',
{{role:'primary',label:'Primary',provider:'otherprovider'}},
[{{value:'acme/example-model',providerId:'custom'}}]), false,
'a same-named model owned by a different provider must keep its own row');
// A badge with no provider cannot use the prefix proof at all.
assert.strictEqual(
_isEquivalentConfiguredModelEntry('custom/acme/example-model',
{{role:'primary',label:'Primary'}},entries), false,
'a provider-less badge must not be collapsed by the prefix rule');
// The prefix must be a real path segment, not a substring of the first segment.
assert.strictEqual(
_isEquivalentConfiguredModelEntry('customer/acme/example-model',badge,entries), false,
'only a literal `<provider>/` prefix counts');
console.log('OK');
"""
run_node(script)
def test_prefix_rule_mirrors_the_at_provider_rule_structurally():
"""The `<provider>/` proof must require the same three clauses as `@<provider>:`.
Structural, not behavioural: it pins that the new branch is gated on the
badge-declared provider, on a literal prefix match, and on an existing entry
of that same provider — so it cannot grow into a loose label comparison.
"""
src = extract_function(UI_JS, "_isEquivalentConfiguredModelEntry")
assert "const slashPrefix=provider?`${provider}/`:'';" in src, (
"the provider-qualified alias rule must derive its prefix from the "
"badge-declared provider"
)
assert "rawId.toLowerCase().startsWith(slashPrefix)" in src, (
"the rule must require a literal `<provider>/` prefix on the badge key"
)
assert "String(entry.providerId||'').toLowerCase()===provider" in src, (
"the rule must require an existing picker row owned by the same provider"
)
# It must run before the `@provider:` early return, which would otherwise
# short-circuit every non-`@` alias to false.
assert src.index("slashPrefix") < src.index(
"if(!prefix||!rawId.toLowerCase().startsWith(prefix)) return false;"
), "the `<provider>/` rule must be reached before the `@<provider>:` early return"
@@ -183,6 +183,7 @@ def test_optimistic_merge_preserves_server_running_state_when_local_cache_is_sta
optimistic_body = _function_body(SESSIONS_SRC, "function _isOptimisticFirstTurnSessionRow(")
keep_body = _function_body(SESSIONS_SRC, "function _shouldKeepLocalOnlyOptimisticSessionRow(")
drop_body = _function_body(SESSIONS_SRC, "function _dropStaleOptimisticSessionRow(")
owner_body = _function_body(SESSIONS_SRC, "function _hasOwnedOpenLiveStream(")
merge_body = _function_body(SESSIONS_SRC, "function _mergeOptimisticFirstTurnSessions(")
script = f"""
@@ -206,6 +207,7 @@ function _isServerIdleSessionRow(s) {{{helper_body}}}
function _isSessionLocallyStreaming(s) {{{local_body}}}
function _isOptimisticFirstTurnSessionRow(s) {{{optimistic_body}}}
function _shouldKeepLocalOnlyOptimisticSessionRow(local) {{{keep_body}}}
function _hasOwnedOpenLiveStream(sid) {{{owner_body}}}
function _dropStaleOptimisticSessionRow(sid) {{{drop_body}}}
function _mergeOptimisticFirstTurnSessions(fetchedSessions) {{{merge_body}}}
const merged = _mergeOptimisticFirstTurnSessions([{{
+390 -185
View File
@@ -1,193 +1,398 @@
"""Regression test for #3718 -- /api/models/live skips probe for custom providers.
"""Handler-level regression tests for /api/models/live custom-provider probing.
When a custom provider (``custom_providers`` entry in config.yaml) has a
``model:`` field but no ``models:`` allowlist, the live endpoint previously
populated ``ids`` from the config entry and then skipped the live ``/v1/models``
probe (guarded by ``if not ids``). The fix collects config-specified model IDs
in a separate ``_config_ids`` list so the live fetch always runs, and merges
config entries as a fallback after the fetch.
These tests invoke the real ``routes._handle_live_models`` and inspect the
emitted ``models`` payload. Earlier revisions of this file re-implemented the
filtering loop locally, which meant they passed unchanged even with the
production filter deleted (a vacuous oracle). Every case here must fail if the
allowlist gating in ``api/routes.py`` regresses.
Covered shapes:
* native ``models`` list → live catalog filtered
* JSON-array-string ``models`` → live catalog filtered (#7120 class)
* Python-literal-string ``models`` → live catalog filtered
* singular ``model`` only, no ``models`` → live catalog NOT gated
* malformed serialized ``models`` → safe, treated as scalar name
* allowlisted model absent from live catalog → appended
* live probe failure → falls back to config ids
* dict-shaped ``models`` (Agent setup) → live catalog NOT gated (per-model
metadata, not an allowlist)
* dict-shaped ``models`` + ``discover_models:
false`` → pinned to dict keys (explicit opt-out)
* ``models_discovered: true`` catalog → live catalog NOT gated (discovery
metadata is not an allowlist)
* discovered + ``discover_models: false`` → still pinned (explicit opt-out)
"""
import io
import json
import pathlib
import unittest
from unittest import mock
import sys
import types
from urllib.parse import urlparse
REPO = pathlib.Path(__file__).parent.parent
ROUTES_PY = REPO / "api" / "routes.py"
import pytest
class TestLiveModelsCustomProviderProbe(unittest.TestCase):
"""Live endpoint must probe the upstream /v1/models even when config has entries."""
def test_custom_provider_with_model_field_probes_upstream(self):
"""When a custom provider has model: in config, live fetch must still run.
Before the fix, _custom_provider_model_ids() populated ids=["assistant"],
and the `if not ids` guard prevented the upstream /v1/models probe from
running. The endpoint returned only ["assistant"] instead of the full
upstream catalog.
We test the fix by simulating the logic directly: config provides
model IDs, the upstream probe returns a different set, and the result
must contain BOTH the live models and the config fallback.
"""
# Simulate config-specified model IDs
_config_ids = ["assistant"]
# Simulate upstream /v1/models response
live_models = [
"assistant",
"assistant-pro",
"assistant-zdr",
"gpt-5.5:pt",
"claude-sonnet-4.6:pt",
]
# Simulate the merge logic from the fix:
# if ids: merge config entries not in live set
# else: fall back to config-only list
ids = list(live_models) # live fetch succeeded
_live_set = set(ids)
for _cid in _config_ids:
if _cid not in _live_set:
ids.append(_cid)
# "assistant" from config should not duplicate the live result
self.assertEqual(ids, live_models)
# All live models must be present
for m in live_models:
self.assertIn(m, ids)
def test_custom_provider_falls_back_to_config_when_probe_fails(self):
"""When the upstream probe fails, config entries must be used as fallback."""
_config_ids = ["assistant", "custom-only-model"]
# Simulate failed live fetch: ids stays empty
ids = []
# Fallback logic from the fix
if not ids:
ids = list(_config_ids)
self.assertEqual(ids, ["assistant", "custom-only-model"])
def test_config_only_model_appended_when_not_in_live_results(self):
"""Config-specified models not in the live results must be appended."""
_config_ids = ["assistant", "local-finetune"]
live_models = ["assistant", "assistant-pro", "gpt-5.5:pt"]
ids = list(live_models)
_live_set = set(ids)
for _cid in _config_ids:
if _cid not in _live_set:
ids.append(_cid)
# "assistant" is in both, "local-finetune" is config-only
self.assertIn("local-finetune", ids)
self.assertIn("assistant-pro", ids)
self.assertEqual(ids.count("assistant"), 1, "assistant must not be duplicated")
def test_live_fetch_guard_no_longer_checks_ids(self):
"""The live fetch block must not be guarded by `if not ids`.
This is a structural regression guard: the fix replaced
`if not ids and (provider == "custom" ...)` with
`if provider == "custom" ...` so the probe always runs regardless
of whether config entries populated ids.
"""
source = ROUTES_PY.read_text(encoding="utf-8")
# Find the "Always try live fetch" comment (added by the fix)
marker = "Always try live fetch for custom providers"
self.assertIn(marker, source, (
"routes.py must contain the 'Always try live fetch' comment (#3718)"
))
# Extract the block between the marker and the next major section
marker_pos = source.find(marker)
next_section = source.find("OpenAI-compat live fetch fallback", marker_pos)
self.assertNotEqual(next_section, -1, "could not find next section marker")
block = source[marker_pos:next_section]
# The old `if not ids and` guard must NOT appear in this block
self.assertNotIn("if not ids and", block, (
"Live fetch for custom providers must not be guarded by 'if not ids' (#3718)"
))
def test_mocked_live_fetch_returns_full_catalog_plus_config_entry(self):
"""Integration test: mock urlopen and exercise the real fetch/parse/merge path.
This test patches urllib.request.urlopen to return a fake /v1/models
response, then runs the same fetch+merge logic that the live handler
uses, verifying that config entries are merged and live models are
included.
"""
fake_models = {"data": [
{"id": "assistant"},
{"id": "assistant-pro"},
{"id": "gpt-5.5:pt"},
]}
fake_body = json.dumps(fake_models).encode("utf-8")
fake_resp = mock.MagicMock()
fake_resp.read.return_value = fake_body
fake_resp.__enter__ = mock.MagicMock(return_value=fake_resp)
fake_resp.__exit__ = mock.MagicMock(return_value=False)
_config_ids = ["assistant", "local-only"]
with mock.patch("urllib.request.urlopen", return_value=fake_resp):
# Replicate the fetch+parse logic from routes.py
import urllib.request
ids = []
_req = urllib.request.Request(
"http://localhost:4000/v1/models",
headers={"Authorization": "Bearer test-key"},
)
with urllib.request.urlopen(_req, timeout=5) as _resp:
_body = json.loads(_resp.read())
if isinstance(_body, dict):
_data = _body.get("data", [])
if isinstance(_data, list):
ids = [m.get("id", "") for m in _data if m.get("id")]
# Apply the merge logic from the fix
if ids:
_live_set = set(ids)
for _cid in _config_ids:
if _cid not in _live_set:
ids.append(_cid)
else:
ids = list(_config_ids)
# Live models must all be present
self.assertIn("assistant", ids)
self.assertIn("assistant-pro", ids)
self.assertIn("gpt-5.5:pt", ids)
# Config-only entry must be appended
self.assertIn("local-only", ids)
# No duplicates
self.assertEqual(ids.count("assistant"), 1)
def test_timeout_uses_config_constant(self):
"""The live fetch must use CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS, not a hardcoded value."""
source = ROUTES_PY.read_text(encoding="utf-8")
# Find the custom-provider live fetch block
marker = "Always try live fetch for custom providers"
marker_pos = source.find(marker)
next_section = source.find("OpenAI-compat live fetch fallback", marker_pos)
block = source[marker_pos:next_section]
# Must use the constant, not a bare number
self.assertIn("CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS", block, (
"Live fetch timeout must use CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS "
"instead of a hardcoded value (#3718 review feedback)"
))
self.assertNotIn("timeout=8", block, (
"Live fetch must not use hardcoded timeout=8 (#3718 review feedback)"
))
def _install_provider_model_ids(monkeypatch, fn):
"""Stub hermes_cli.models.provider_model_ids (returns [] for custom:*)."""
hermes_cli = types.ModuleType("hermes_cli")
hermes_cli.__path__ = []
models = types.ModuleType("hermes_cli.models")
models.provider_model_ids = fn
monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.models", models)
if __name__ == "__main__":
unittest.main()
class _FakeResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, *_exc):
self.close()
return False
def _install_live_catalog(monkeypatch, routes, catalog, *, fail=False):
"""Stub the upstream /v1/models probe used by the custom-provider branch."""
import urllib.request
requested: list[str] = []
def _fake_urlopen(req, timeout=None):
requested.append(getattr(req, "full_url", str(req)))
if fail:
raise OSError("probe failed")
body = {"data": [{"id": mid} for mid in catalog]}
return _FakeResponse(json.dumps(body).encode())
monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
return requested
def _run_live_models(monkeypatch, routes, custom_provider, *, catalog, fail=False):
"""Invoke the real handler for provider=custom:test-gateway."""
import api.config as config
import api.profiles as profiles
routes._clear_live_models_cache()
monkeypatch.setattr(
routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload
)
monkeypatch.setattr(config, "_resolve_provider_alias", lambda provider: provider)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
config_data = {
"model": {"provider": "custom:test-gateway"},
"custom_providers": [custom_provider],
}
monkeypatch.setattr(config, "get_config", lambda: config_data)
monkeypatch.setattr(routes, "cfg", config_data, raising=False)
# provider_model_ids() returns [] for custom:* → drives the custom branch.
_install_provider_model_ids(monkeypatch, lambda provider: [])
requested = _install_live_catalog(monkeypatch, routes, catalog, fail=fail)
parsed = urlparse("/api/models/live?provider=custom:test-gateway")
payload = routes._handle_live_models(object(), parsed)
return payload, requested
def _ids(payload):
return [m["id"] for m in payload.get("models", [])]
_LIVE_CATALOG = [
"chat-a",
"chat-b",
"image-gen-1", # non-chat noise the picker must not surface
"audio-tts-1",
]
_BASE_PROVIDER = {
"name": "Test Gateway",
"base_url": "https://gateway.example/v1",
"api_key": "sk-test",
}
@pytest.mark.parametrize(
"models_value",
[
pytest.param(["chat-a", "chat-b"], id="native-list"),
pytest.param('["chat-a","chat-b"]', id="json-array-string"),
pytest.param("['chat-a', 'chat-b']", id="python-literal-string"),
pytest.param(
[{"id": "chat-a"}, {"model": "chat-b"}], id="list-of-dicts"
),
],
)
def test_allowlist_filters_live_catalog(monkeypatch, models_value):
"""An explicit plural allowlist must trim the live catalog to itself.
The JSON-array-string and Python-literal cases are the regression: those
shapes are what ``hermes config set`` / JSON-mode editor saves persist, and
a dict/list-only parser drops them to ``[]`` → read as "no allowlist" → the
full upstream catalog floods the picker.
"""
import api.routes as routes
provider = dict(_BASE_PROVIDER, models=models_value)
payload, requested = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
)
assert requested, "live /v1/models probe was never attempted"
assert _ids(payload) == ["chat-a", "chat-b"]
assert "image-gen-1" not in _ids(payload)
assert "audio-tts-1" not in _ids(payload)
def test_dict_models_without_discover_false_does_not_gate(monkeypatch):
"""A dict-shaped ``models`` is per-model metadata — NOT an allowlist (#7165).
Hermes Agent's setup flow and ``hermes_cli/model_switch.py::_save_custom_provider``
persist per-model metadata as a mapping, e.g. ``{chat-a: {context_length: 128000}}``.
Treating its keys as an allowlist would collapse the live picker to the saved
default while the CLI live-probe shows the full catalog. Without an explicit
``discover_models: false`` opt-out the dict must NOT gate — full live catalog wins.
"""
import api.routes as routes
provider = dict(
_BASE_PROVIDER,
models={"chat-a": {"context_length": 128000}, "chat-b": {"context_length": 32000}},
)
payload, requested = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
)
assert requested, "live /v1/models probe was never attempted"
assert _ids(payload) == _LIVE_CATALOG, (
"dict-shaped models is per-model metadata, not an allowlist — the "
"full live catalog must be surfaced"
)
def test_dict_models_with_discover_false_pins_to_keys(monkeypatch):
"""``discover_models: false`` opts a dict-shaped ``models`` into an allowlist.
The explicit opt-out (bool ``False`` or the string forms ``"false"``/``"no"``/``"0"``)
means the dict keys are a hand-pinned allowlist and must filter the live catalog.
"""
import api.routes as routes
for discover in (False, "false", "no", "0"):
provider = dict(
_BASE_PROVIDER,
models={"chat-a": {"context_length": 128000}, "chat-b": {"context_length": 32000}},
discover_models=discover,
)
payload, requested = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
)
assert requested, "live /v1/models probe was never attempted"
assert _ids(payload) == ["chat-a", "chat-b"], (
f"discover_models={discover!r} must pin the dict keys as the allowlist"
)
assert "image-gen-1" not in _ids(payload)
assert "audio-tts-1" not in _ids(payload)
def test_singular_model_only_does_not_gate_live_catalog(monkeypatch):
"""``model:`` is default metadata, not an allowlist — keep full discovery."""
import api.routes as routes
provider = dict(_BASE_PROVIDER, model="chat-a")
payload, _ = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
)
assert _ids(payload) == _LIVE_CATALOG
def test_malformed_serialized_models_is_safe(monkeypatch):
"""A broken JSON-array string must not crash or silently drop the gate.
``_parse_config_string_list()`` treats an undecodable value as one literal
name, so the allowlist stays non-empty and the filter still runs.
"""
import api.routes as routes
provider = dict(_BASE_PROVIDER, models='["chat-a", "chat-b"') # missing ]
payload, _ = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
)
# Treated as a single (unmatched) name → appended, catalog not flooded.
assert "image-gen-1" not in _ids(payload)
assert "audio-tts-1" not in _ids(payload)
def test_allowlisted_model_missing_from_live_is_appended(monkeypatch):
"""A declared model the upstream didn't return must still be selectable."""
import api.routes as routes
provider = dict(_BASE_PROVIDER, models=["chat-a", "chat-offline"])
payload, _ = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
)
assert _ids(payload) == ["chat-a", "chat-offline"]
def test_contradictory_singular_model_outside_plural_allowlist_is_ignored(monkeypatch):
"""A singular default outside the plural allowlist must not leak into the picker.
Paired test: whether the live probe succeeds or fails, only the explicit
plural allowlist is exposed.
"""
import api.routes as routes
provider = dict(_BASE_PROVIDER, model="chat-default", models=["chat-a"])
# 1. Successful live fetch -> only plural allowlist
payload_success, _ = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG, fail=False
)
assert _ids(payload_success) == ["chat-a"]
# 2. Failed live fetch -> still only plural allowlist (not chat-default)
payload_fail, _ = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG, fail=True
)
assert _ids(payload_fail) == ["chat-a"]
def test_probe_failure_without_allowlist_falls_back_to_singular_model(monkeypatch):
"""When no plural allowlist is configured and probe fails, singular model is used."""
import api.routes as routes
provider = dict(_BASE_PROVIDER, model="chat-default")
payload, _ = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG, fail=True
)
assert _ids(payload) == ["chat-default"]
@pytest.mark.parametrize(
"models_value",
[
pytest.param([], id="native-empty-list"),
pytest.param("[]", id="serialized-empty-list"),
pytest.param({}, id="empty-mapping"),
pytest.param(" ", id="blank-scalar"),
],
)
def test_empty_allowlist_is_treated_as_not_configured(monkeypatch, models_value):
"""An empty ``models`` value must NOT be read as "allow nothing".
Pins a deliberate semantic decision. Gating the filter on *declared-ness*
rather than on a non-empty allowlist would make ``models: []`` emit an
EMPTY picker, leaving the provider unselectable — a harder failure than
surfacing a few extra models, and unrecoverable from the UI because
``custom_providers`` has no WebUI write path (hand-edited config.yaml only).
There is also no deny-all use case: a provider the user wants hidden is
removed from ``custom_providers`` outright.
If someone later "fixes" the empty case into a deny-all gate, this test
fails and forces the trade-off to be re-argued rather than silently shipped.
"""
import api.routes as routes
provider = dict(_BASE_PROVIDER, models=models_value)
payload, _ = _run_live_models(
monkeypatch, routes, provider, catalog=_LIVE_CATALOG
)
assert _ids(payload) == _LIVE_CATALOG, (
"empty allowlist must fall through to full live discovery, "
"never collapse the picker to zero models"
)
assert _ids(payload), "picker must never be emptied by an empty allowlist"
def test_models_discovered_catalog_is_not_an_allowlist(monkeypatch):
"""``models_discovered: true`` marks ``models:`` as discovery metadata (#7165).
Hermes persists *discovery results* back into config as ``models: {...}``
plus ``models_discovered: true``. That mapping is a snapshot of what the
gateway exposed at discovery time — NOT a hand-curated allowlist. Treating
it as one permanently pins the live catalog to the first-discovery set, so
a model the user later pulls into LM Studio / Ollama is silently dropped
from ``/api/models/live`` (the exact regression the gate reproduced: merge
base returned ``saved-a``, ``saved-b`` AND ``new-live-c``; the pinned head
dropped ``new-live-c``).
"""
import api.routes as routes
provider = dict(
_BASE_PROVIDER,
models={
"saved-a": {"context_length": 128000},
"saved-b": {"context_length": 32000},
},
models_discovered=True,
)
payload, requested = _run_live_models(
monkeypatch,
routes,
provider,
catalog=["saved-a", "saved-b", "new-live-c"],
)
assert requested, "live /v1/models probe was never attempted"
assert _ids(payload) == ["saved-a", "saved-b", "new-live-c"], (
"a discovered catalog must not gate the live probe — models that "
"appear upstream after discovery (new-live-c) must reach the picker"
)
def test_discovered_catalog_with_discover_models_false_still_pins(monkeypatch):
"""``discover_models: false`` re-pins a discovered catalog (control).
The explicit opt-out wins over ``models_discovered: true`` — same rule as
the ``/api/models`` catalog path (`api.config._provider_discover_allowed`).
Filtering to the configured set must survive the discovered-catalog fix.
"""
import api.routes as routes
provider = dict(
_BASE_PROVIDER,
models={
"saved-a": {"context_length": 128000},
"saved-b": {"context_length": 32000},
},
models_discovered=True,
discover_models=False,
)
payload, _ = _run_live_models(
monkeypatch,
routes,
provider,
catalog=["saved-a", "saved-b", "new-live-c"],
)
assert _ids(payload) == ["saved-a", "saved-b"]
def test_discovered_catalog_probe_failure_falls_back_to_saved_models(monkeypatch):
"""Discovered + failed probe → saved models, not an empty picker (control).
Releasing the allowlist must not empty the fallback: when the live probe
fails, the previously discovered ``models:`` mapping is still the best
answer and must be served from ``_config_ids``.
"""
import api.routes as routes
provider = dict(
_BASE_PROVIDER,
models={
"saved-a": {"context_length": 128000},
"saved-b": {"context_length": 32000},
},
models_discovered=True,
)
payload, _ = _run_live_models(
monkeypatch, routes, provider, catalog=[], fail=True
)
assert _ids(payload) == ["saved-a", "saved-b"]
@@ -536,7 +536,7 @@ def test_source_filtered_cache_preserves_hidden_bucket_runtime_state():
remember_source_fn = _extract_function(src, "_rememberSessionListSource")
remember_streaming_fn = _extract_function(src, "_rememberRenderedStreamingState")
remember_snapshot_fn = _extract_function(src, "_rememberRenderedSessionSnapshot")
purge_fn = _extract_function(src, "_purgeStaleInflightEntries")
purge_fn = _extract_function(src, "_hasOwnedOpenLiveStream") + "\n" + _extract_function(src, "_purgeStaleInflightEntries")
mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions")
script = f"""
global._allSessions = [{{
@@ -654,7 +654,7 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch):
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_scope_mismatch_error_path_respects_sidebar_source():
src = SESSIONS_JS.read_text(encoding="utf-8")
purge_fn = _extract_function(src, "_purgeStaleInflightEntries")
purge_fn = _extract_function(src, "_hasOwnedOpenLiveStream") + "\n" + _extract_function(src, "_purgeStaleInflightEntries")
clear_fn = _extract_function(src, "_clearSessionSourceTabCounts")
requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource")
exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled")
@@ -0,0 +1,313 @@
"""Regression tests for issue #7359 — MEDIA: token regex must not capture trailing backtick.
Two-pass scan (#7680 re-gate, 9/22):
- `` `MEDIA:path` `` (backtick-wrapped, inline-code form): the wrapping
backticks are stripped by a pre-pass before the bare class scans the
text. This makes the closing backtick disappear as a wrapper instead
of being captured as part of the path.
- ``MEDIA:/path...`` (bare, no surrounding backticks): the capture class
no longer lists the backtick in the exclusion set, so a filename
that legally contains a backtick (``report`final.png``) is captured
in full instead of being truncated at the first backtick.
The pre-fix (round-1) PR added the backtick to the bare exclusion set,
which traded one bug for another: it dropped the closing wrapper on
the inline-code form but truncated any bare path that happened to
contain a backtick in the filename. The two-pass scan keeps the
original permissive bare class and adds an explicit pre-pass for the
wrapped form, so both cases round-trip correctly.
"""
import re
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
# Source-shape: each site's expected bare-token capture literal. The
# bare class no longer lists the backtick in the exclusion set; a
# backtick inside the filename (legal on POSIX and Windows) is
# captured in full. The wrapped-form pre-pass below strips the
# inline-code backticks before the bare scan.
JS_SITES: dict[str, list[str]] = {
"static/ui.js": [
r"/MEDIA:([^\s\)\]]+)/g", # linkifier
r"/MEDIA:[^\s]+/g", # TTS flattener (no paren/bracket exclusion)
],
"static/messages.js": [
r"/^MEDIA:([^\s\)\]]+)$/", # streaming chunk finalize
r"/MEDIA:([^\s\)\]]+)/g", # streaming combined walk
r"/MEDIA:[^\s\)\]]*$/", # streaming partial tail
],
}
PY_SITES: dict[str, str] = {
"api/routes.py": r"MEDIA:([^\s\)\]]+)",
"api/media_snapshots.py": r"MEDIA:([^\s\)\]]+)",
}
# #7680 re-gate (9/22): every site that scans bare MEDIA: tokens also
# runs the wrapped-form pre-pass (`` `MEDIA:path` `` → ``MEDIA:path``)
# so the closing backtick of an inline-code form is consumed before the
# bare scan.
JS_BACKTICK_PREPASS: dict[str, str] = {
"static/ui.js": r"/`MEDIA:([^`\s]+)`/g",
"static/messages.js": r"/`MEDIA:([^`\s]+)`/g",
}
PY_BACKTICK_PREPASS: dict[str, str] = {
"api/routes.py": r"`MEDIA:([^`\s]+)`",
"api/media_snapshots.py": r"`MEDIA:([^`\s]+)`",
}
def _read(rel: str) -> str:
return (REPO_ROOT / rel).read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Source-shape: every site must carry the bare-token class + the pre-pass
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("relpath", "needle"),
[
(relpath, needle)
for relpath, needles in JS_SITES.items()
for needle in needles
],
)
def test_js_site_carries_bare_token_class(relpath: str, needle: str) -> None:
"""Every JS capture site carries the bare-token class literal."""
src = _read(relpath)
assert needle in src, (
f"{relpath} is missing the bare-token capture literal: {needle!r}"
)
@pytest.mark.parametrize(
("relpath", "needle"),
[
(relpath, JS_BACKTICK_PREPASS[relpath])
for relpath in JS_BACKTICK_PREPASS
],
)
def test_js_site_carries_wrapped_prepass(relpath: str, needle: str) -> None:
"""#7680 re-gate: every JS site that scans bare MEDIA: tokens also
runs the wrapped-form pre-pass so the inline-code closing backtick
is eaten before the bare scan."""
src = _read(relpath)
assert needle in src, (
f"{relpath} is missing the wrapped-form pre-pass literal: {needle!r}"
)
@pytest.mark.parametrize(
("relpath", "needle"),
[(relpath, PY_SITES[relpath]) for relpath in PY_SITES],
)
def test_py_site_carries_bare_token_class(relpath: str, needle: str) -> None:
src = _read(relpath)
assert needle in src, (
f"{relpath} is missing the bare-token capture literal: {needle!r}"
)
@pytest.mark.parametrize(
("relpath", "needle"),
[(relpath, PY_BACKTICK_PREPASS[relpath]) for relpath in PY_BACKTICK_PREPASS],
)
def test_py_site_carries_wrapped_prepass(relpath: str, needle: str) -> None:
src = _read(relpath)
assert needle in src, (
f"{relpath} is missing the wrapped-form pre-pass literal: {needle!r}"
)
def test_all_seven_sites_covered() -> None:
"""Pin: 5 JS sites + 2 Python sites = 7 capture sites, all guarded."""
js_total = sum(len(v) for v in JS_SITES.values())
assert js_total == 5, (
f"expected 5 JS capture sites, got {js_total}; this count is what the "
"review's '7 capture sites' was counting (1 linkifier + 1 TTS + 3 messages.js)"
)
assert len(PY_SITES) == 2
@pytest.fixture(scope="module")
def _media_token_re() -> re.Pattern[str]:
"""Compile the live ``_MEDIA_TOKEN_RE`` from api/routes.py (plus the
pre-pass) so behaviour tests exercise the real source."""
routes_text = _read("api/routes.py")
m = re.search(
r'_MEDIA_TOKEN_RE = re\.compile\(r"([^"]+)"\)', routes_text, re.M
)
assert m, "_MEDIA_TOKEN_RE compile() not found in api/routes.py"
bare = re.compile(m.group(1))
pre = re.compile(r"`MEDIA:([^`\s]+)`")
return _TwoPass(bare, pre)
@pytest.fixture(scope="module")
def _media_snap_re() -> re.Pattern[str]:
"""Compile the live ``media_re`` from api/media_snapshots.py (plus the
pre-pass) so behaviour tests exercise the real source."""
snap_text = _read("api/media_snapshots.py")
m = re.search(
r'media_re = _re\.compile\(r"([^"]+)"\)', snap_text, re.M
)
assert m, "media_re compile() not found in api/media_snapshots.py"
bare = re.compile(m.group(1))
pre = re.compile(r"`MEDIA:([^`\s]+)`")
return _TwoPass(bare, pre)
class _TwoPass:
"""Compose the wrapped-form pre-pass with the bare-token class so the
behaviour tests below exercise the live source exactly as it runs."""
def __init__(self, bare: re.Pattern[str], prepass: re.Pattern[str]):
self._bare = bare
self._pre = prepass
def findall(self, text: str) -> list[str]:
text = self._pre.sub(lambda m: f"MEDIA:{m.group(1)}", text)
return self._bare.findall(text)
def test_backend_allowlist_strips_backtick(_media_token_re) -> None:
"""``_MEDIA_TOKEN_RE`` (with the pre-pass) must not include the closing
backtick in the captured path."""
captured = _media_token_re.findall("get `MEDIA:/home/kim/skills.zip` now")
assert captured == ["/home/kim/skills.zip"], captured
assert all(not c.endswith("`") for c in captured)
def test_backend_allowlist_bare_token_still_captures(_media_token_re) -> None:
"""Backward-compat: bare ``MEDIA:/path`` (no backtick) still matches."""
captured = _media_token_re.findall(
"MEDIA:/home/kim/a.zip and MEDIA:/home/kim/b.tar"
)
assert captured == ["/home/kim/a.zip", "/home/kim/b.tar"], captured
def test_backend_allowlist_bare_path_with_backtick_in_filename(
_media_token_re,
) -> None:
"""#7680 finding 1: a bare ``MEDIA:/tmp/report`final.png`` (backtick
inside the filename, no surrounding wrapper) must capture the full
path. POSIX and Windows both allow backticks in filenames, so
truncating at the first backtick silently bricks the request."""
captured = _media_token_re.findall("see MEDIA:/tmp/report`final.png now")
assert captured == ["/tmp/report`final.png"], captured
assert not any(c.endswith("`") and c != "/tmp/report`final.png" for c in captured), captured
def test_backend_allowlist_rejects_trailing_paren(_media_token_re) -> None:
"""Pre-existing behaviour: ``)`` is still excluded."""
captured = _media_token_re.findall("see [file](MEDIA:/home/kim/a.zip) here")
assert all(not c.endswith(")") for c in captured), captured
def test_snapshot_capture_strips_backtick(_media_snap_re) -> None:
"""``media_re`` (with the pre-pass) must not include the closing
backtick in the captured path."""
captured = _media_snap_re.findall(
"assistant: `MEDIA:/home/kim/shot.png` and MEDIA:/home/kim/a.zip"
)
assert captured == ["/home/kim/shot.png", "/home/kim/a.zip"], captured
assert all(not c.endswith("`") for c in captured)
def test_snapshot_capture_bare_path_with_backtick_in_filename(
_media_snap_re,
) -> None:
"""#7680 finding 1: same bare-with-backtick case for the snapshot
pipeline. Without the pre-pass + bare class change, the snapshot
store would never see this path because the auth predicate and
the capture predicate must agree on the same set of references."""
captured = _media_snap_re.findall("MEDIA:/home/kim/shot`final.png tail")
assert captured == ["/home/kim/shot`final.png"], captured
def test_snapshot_capture_bare_token_still_captures(_media_snap_re) -> None:
"""Backward-compat: bare ``MEDIA:/path`` still matches."""
captured = _media_snap_re.findall(
"MEDIA:/home/kim/a.zip and MEDIA:/home/kim/b.zip"
)
assert captured == ["/home/kim/a.zip", "/home/kim/b.zip"], captured
# ---------------------------------------------------------------------------
# Revert-sensitivity: the pre-fix class really would have truncated
# the bare-with-backtick case, and the round-1 fix would have left the
# wrapped form broken in a different way.
# ---------------------------------------------------------------------------
def test_round1_buggy_class_truncated_bare_path_with_backtick() -> None:
"""The buggy (round-1) class greedily truncated at the first backtick
in the path, so ``MEDIA:/tmp/report`final.png`` was captured as
``/tmp/report``. This is the regression we are guarding against."""
buggy = re.compile(r"MEDIA:([^\s\)\]\`]+)")
assert buggy.findall("MEDIA:/tmp/report`final.png") == ["/tmp/report"], (
"round-1 class is no longer buggy; if you are intentionally reverting "
"to it, also remove the corresponding `test_post_fix_class_keeps_"
"bare_path` and update the source-shape guard."
)
def test_round1_buggy_class_dropped_wrapped_closing_backtick() -> None:
"""The round-1 class was actually correct for the wrapped form: it
dropped the closing backtick that the round-0 class greedily
included. Document the behaviour so a future revert picks the
correct replacement."""
round1 = re.compile(r"MEDIA:([^\s\)\]\`]+)")
assert round1.findall("`MEDIA:/home/kim/a.zip`") == ["/home/kim/a.zip"]
def test_round1_two_pass_compose_keeps_bare_path_intact() -> None:
"""The full #7680 fix is the two-pass compose: pre-pass strips the
wrapped form, then the bare class (no backtick in the exclusion
set) captures the full path. Verify the composition on both
cases so a future refactor that drops either half is caught."""
pre = re.compile(r"`MEDIA:([^`\s]+)`")
bare = re.compile(r"MEDIA:([^\s\)\]]+)")
def findall_composed(text: str) -> list[str]:
text = pre.sub(lambda m: f"MEDIA:{m.group(1)}", text)
return bare.findall(text)
# bare-with-backtick: pre-pass doesn't touch it, bare captures full path
assert findall_composed("MEDIA:/tmp/report`final.png") == [
"/tmp/report`final.png"
]
# wrapped: pre-pass strips the backticks, bare captures the path
assert findall_composed("`MEDIA:/tmp/a.zip`") == ["/tmp/a.zip"]
# mixed
assert findall_composed(
"see `MEDIA:/home/kim/shot.png` and MEDIA:/tmp/r`f.png tail"
) == ["/home/kim/shot.png", "/tmp/r`f.png"]
# plain bare: unchanged
assert findall_composed("MEDIA:/home/kim/a.zip") == ["/home/kim/a.zip"]
# ---------------------------------------------------------------------------
# node --check (so a JS syntax error in the modified source fails the suite)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("relpath", sorted(JS_SITES.keys()))
def test_js_file_parses(relpath: str) -> None:
"""``node --check`` on every modified JS file."""
proc = subprocess.run(
["node", "--check", str(REPO_ROOT / relpath)],
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, (
f"{relpath} failed `node --check`:\n{proc.stderr}"
)
+297
View File
@@ -0,0 +1,297 @@
"""Regression coverage for issue #7421: configurable
``HERMES_WEBUI_MAX_SESSION_RESOLVE`` cap on the heavy
full-transcript resolve semaphore.
The cap is hardcoded to ``2`` in
``api.models.py::_FULL_SESSION_RESOLVE_MAX_CONCURRENT``. The
maintainer's diagnosis is that the literal 2 is too low for
high-concurrency deployments where several parallel active
sessions all need a full-transcript resolve, and that the
deeper per-read cost is tracked separately in #7310. This PR
is the self-contained first step: read the cap from the env
var so operators can raise it without code changes, while
preserving the previous default of 2 and bounding the upper
end against typos like ``=999999``.
#7656 re-gate (9/22 23:18): the previous round used a private
``_import_models_with_env`` helper that did ``sys.modules.pop``
on ``api.models`` and re-imported to recompute the module-level
cap constant. Modules already imported (``api.routes``,
``api.streaming``, ...) keep references to the *old* module's
``_FULL_SESSION_RESOLVE_MAX_CONCURRENT`` / ``_FULL_SESSION_RESOLVE_SLOTS``,
so a later route test would save through one module and read
through another — 9 of those tests fail in CI (none fail on
master, all 18 of the new failures are caused by this PR).
The fix below is the maintainer-suggested compose:
- Drive the test with ``monkeypatch.setenv`` so the env var is
restored at fixture teardown — no leak to other test files
- Test ``_read_max_session_resolve_concurrent()`` directly
(the function is already pure: only reads ``os.getenv``) so
no module reload is needed
- For the single test that needs the module-level constant
(``_FULL_SESSION_RESOLVE_MAX_CONCURRENT``), verify it equals
the helper's return value at import time — that is the real
production contract, and it is cheap to assert because the
helper itself only reads the env var
"""
from __future__ import annotations
import threading
from pathlib import Path
import pytest
import api.models as models
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def resolve_env(monkeypatch):
"""Drive ``HERMES_WEBUI_MAX_SESSION_RESOLVE`` via ``monkeypatch.setenv``
so the env var is restored at fixture teardown — no leak to other
test files, no module reload required.
The helper ``models._read_max_session_resolve_concurrent()`` is pure
(only ``os.getenv``) and reads the live env, so this fixture is the
entire test harness. The previous round's ``_import_models_with_env``
used ``sys.modules.pop`` + ``importlib.import_module`` to recompute
the module-level cap constant against a freshly-set env, but that
left the original module unreferenced in ``sys.modules`` while
``api.routes`` and friends still held the *old* module object —
root cause of the cross-suite pollution the maintainer reproduced.
"""
def set_resolve_env(value):
if value is None:
monkeypatch.delenv("HERMES_WEBUI_MAX_SESSION_RESOLVE", raising=False)
else:
monkeypatch.setenv("HERMES_WEBUI_MAX_SESSION_RESOLVE", str(value))
return set_resolve_env
# ── default behavior (env unset) ──────────────────────────────────────────────
def test_default_cap_is_two_when_env_unset(resolve_env):
"""Backward compatibility: an installation that does not set
the env var keeps the previous behavior. The bounded
semaphore is constructed with capacity 2, so 3+ parallel
full-resolves queue behind two slots as before."""
resolve_env(None)
assert models._read_max_session_resolve_concurrent() == 2, (
"unset env must keep the previous hardcoded default of 2 "
"so existing single-instance deployments are unchanged"
)
def test_default_cap_is_two_when_env_empty_string(resolve_env):
"""A user who sets the env var to an explicit empty string
(e.g. from a misconfigured .env file) is the same as unset.
The helper strips whitespace first; ``""`` is treated as no
value at all."""
resolve_env("")
assert models._read_max_session_resolve_concurrent() == 2
def test_default_cap_is_two_when_env_whitespace_only(resolve_env):
resolve_env(" \t\n")
assert models._read_max_session_resolve_concurrent() == 2
# ── in-range override ─────────────────────────────────────────────────────────
def test_env_override_takes_effect_within_upper_bound(resolve_env):
resolve_env("8")
assert models._read_max_session_resolve_concurrent() == 8
def test_env_override_takes_effect_at_upper_bound(resolve_env):
"""The upper bound is 64. An operator who wants the full
ceiling must be able to reach it."""
resolve_env("64")
assert models._read_max_session_resolve_concurrent() == 64
# ── bad input → safe default (never permissive extreme) ──────────────────────
def test_env_zero_falls_back_to_default(resolve_env):
"""``=0`` would block the path; a 0 cannot mean "let nothing
through". Fall back to 2 (the previous hardcoded value)."""
resolve_env("0")
assert models._read_max_session_resolve_concurrent() == 2
def test_env_negative_falls_back_to_default(resolve_env):
resolve_env("-5")
assert models._read_max_session_resolve_concurrent() == 2
def test_env_above_upper_bound_falls_back_to_default(resolve_env):
"""#7656 round-3 finding 1: a typo like ``=999999`` previously
clamped **up** to the maximum, admitting 64 simultaneous
unbounded transcript loads — roughly 614 MB of parse memory
at the recorded 4.27 MB / parse. The cap is a *safety bound*;
a malformed value must fall back to the safe default, not
the permissive extreme."""
resolve_env("999999")
assert models._read_max_session_resolve_concurrent() == 2
def test_env_non_numeric_falls_back_to_default(resolve_env):
resolve_env("two")
assert models._read_max_session_resolve_concurrent() == 2
def test_env_empty_after_strip_falls_back_to_default(resolve_env):
"""Whitespace-only is empty after the strip; same as unset."""
resolve_env("\n\t ")
assert models._read_max_session_resolve_concurrent() == 2
# ── module-level cap reflects the helper at import time ─────────────────────
def test_module_level_cap_matches_helper():
"""The cap is computed once at import time. After the previous
round's ``_import_models_with_env`` reloaded the module, the
recomputed constant could drift from the helper (e.g. if a
test set the env, reloaded, then never cleaned up). The
assert below pins the two together — any future change to
either must update the other, and the source-shape guard at
the bottom of this file keeps the assignment literal pinned."""
assert models._FULL_SESSION_RESOLVE_MAX_CONCURRENT == (
models._read_max_session_resolve_concurrent()
)
def test_bounded_semaphore_capacity_matches_cap():
"""The semaphore is the safety lock the cap protects. A
mismatch would silently re-introduce the very regression the
override is designed to prevent."""
sem = models._FULL_SESSION_RESOLVE_SLOTS
assert isinstance(sem, threading.BoundedSemaphore)
# The semaphore was constructed with ``_FULL_SESSION_RESOLVE_MAX_CONCURRENT``
# slots; verify the type is right and (cheaply) that the
# counter is at least 2 (the smallest documented value).
assert sem._initial_value == models._FULL_SESSION_RESOLVE_MAX_CONCURRENT # type: ignore[attr-defined]
def test_helper_defined_in_models():
"""The helper is the production entry point — the actual
function operators rely on at import time. Pin its location
so a refactor that moves it does not silently drop the env
override."""
src = (ROOT / "api" / "models.py").read_text(encoding="utf-8")
assert "def _read_max_session_resolve_concurrent" in src, (
"the env-override helper must live in api.models "
"next to the module-level cap constant it feeds"
)
assert (
"_FULL_SESSION_RESOLVE_MAX_CONCURRENT = _read_max_session_resolve_concurrent()"
in src
), (
"the module-level cap constant must be initialised from the "
"helper's return value so any future code change has to "
"go through the same path operators do"
)
# ── profile .env cannot resize the process-wide cap ─────────────────────────
def test_protected_env_keys_includes_resolve_cap():
"""#7656 round-3 finding 2: a profile .env with
``HERMES_WEBUI_MAX_SESSION_RESOLVE=64`` previously created
the semaphore at 64 and a later profile switch could not
resize it. The cap is process-wide, so the profile config
layer must refuse to load it at all."""
from api.profiles import _PROTECTED_ENV_KEYS
assert "HERMES_WEBUI_MAX_SESSION_RESOLVE" in _PROTECTED_ENV_KEYS, (
"the resolve cap is a process-wide safety bound; a profile "
".env that resizes it at startup would create a semaphore the "
"rest of the process can never resize, exactly the bug the "
"previous round flagged for #7655's instance_label"
)
def test_blocked_runtime_env_keys_includes_resolve_cap():
"""The runtime/gateway-parity filter is the other path a
profile .env could leak through. Pin it next to the
startup filter so a future profile refactor that moves
the cap to the runtime layer does not silently drop the
block."""
from api.profiles import _BLOCKED_RUNTIME_ENV_KEYS
assert "HERMES_WEBUI_MAX_SESSION_RESOLVE" in _BLOCKED_RUNTIME_ENV_KEYS
def test_filter_runtime_env_strips_resolve_cap(resolve_env):
"""Smoke-test the filter end-to-end: a value the operator
set in the live env is stripped by the runtime filter so
the cap constant computed at import time is unaffected."""
from api.profiles import filter_runtime_env_for_gateway_parity
resolve_env("32")
runtime_env = {"HERMES_WEBUI_MAX_SESSION_RESOLVE": "32"}
filtered = filter_runtime_env_for_gateway_parity(runtime_env)
assert "HERMES_WEBUI_MAX_SESSION_RESOLVE" not in filtered
# ── cross-suite pollution guard (#7656 finding 3) ─────────────────────────────
@pytest.mark.parametrize("forbidden", ["sys.modules.pop", "importlib.import_module", "os.environ[", "os.environ.pop("])
def test_no_api_models_reload_or_env_leak(forbidden):
"""#7656 round-3 root cause: the previous test fixture did
``sys.modules.pop("api.models")`` + ``importlib.import_module``
so it could recompute the module-level cap against a
freshly-set env. That left the original module unreferenced
in ``sys.modules`` while already-imported modules
(``api.routes``, ``api.streaming``, ...) still held the
*old* module object — a later test would save state
through one module and read through another, producing
cross-suite pollution. Pin the fix: this file must not
contain any ``sys.modules.pop`` / ``importlib.import_module``
of ``api.models``, nor direct ``os.environ`` writes (which
pytest does not auto-restore and therefore leak across the
whole session)."""
src = (ROOT / "tests" / "test_issue7421_resolve_cap_config.py").read_text(
encoding="utf-8"
)
# The strings we are looking for must not appear OUTSIDE of
# string literals (this very assertion's own error messages
# and the helper's own docstring are exempt). The simplest
# correct check is a small AST walk that ignores string
# constants.
import ast
tree = ast.parse(src, filename=str(ROOT / "tests" / "test_issue7421_resolve_cap_config.py"))
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if forbidden in node.value:
return # the only occurrence of the forbidden token is a string literal
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
name = node.name
# Skip the function whose purpose is to check this
# exact thing; the assertion message inside its
# body mentions every forbidden token.
if name == "test_no_api_models_reload_or_env_leak":
continue
for sub in ast.walk(node):
if (
isinstance(sub, ast.Constant)
and isinstance(sub.value, str)
and forbidden in sub.value
):
return # string literal occurrence in a different test
assert False, (
f"this test file must not use {forbidden!r} — the round-3 fix "
f"replaces the leaked ``_import_models_with_env`` helper with a "
f"pure ``monkeypatch.setenv`` fixture. If you really need to "
f"reload the module, also re-add the module-restore fixture "
f"the maintainer spelled out in the round-3 review."
)
@@ -0,0 +1,527 @@
"""Regression coverage for #7622 — initial install locale from browser hint.
The renderer's `loadLocale()` was strictly a localStorage-or-English
fallback — non-English speakers had to dig into Settings after every
fresh install. This fix consults `navigator.languages[0]` /
`navigator.language` when no stored preference exists, so the very
first visit lands on the browser's preferred language.
The test drives the real `loadLocale()` in `static/i18n.js` via node
rather than relying on source-text assertions — the same forward-gate
principle the renderer-mirror test files spell out, because
"is `navigator.languages` consulted?" is a runtime fact that
source-text grepping can easily miss.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
I18N_JS_PATH = REPO_ROOT / "static" / "i18n.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _extract_block(src: str, start_marker: str) -> str:
"""Slice out a `const LOCALES = { ... };` (or any `… = { … };`) block
starting at the first occurrence of ``start_marker``."""
idx = src.index(start_marker)
brace_idx = src.index("{", idx)
depth = 1
i = brace_idx + 1
while depth and i < len(src):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
if depth:
raise ValueError(f"unterminated block starting at {start_marker!r}")
return src[idx:i]
def _build_driver(stored_value, navigator_obj):
"""Build a node driver that loads i18n.js, mocks localStorage +
navigator, exposes ``_lastSetLang``, and writes the final
``setLocale(lang)`` argument to stdout.
The driver runs the full i18n.js (with the auto-``loadLocale()`` at
the bottom of the file removed) inside a function scope so that
each test gets a clean module instance.
"""
src = I18N_JS_PATH.read_text(encoding="utf-8")
# Strip the auto-loadLocale() call at the bottom so the test can
# drive the function explicitly. It's a single top-level call.
src = re.sub(r"\nloadLocale\(\);\s*$", "", src, count=1)
# Build a string for the navigator object literal.
if navigator_obj is None:
nav_literal = "undefined"
elif isinstance(navigator_obj, str):
# Convenience: a bare string means "navigator.languages is missing,
# navigator.language is <that string>".
nav_literal = f'{{ language: {navigator_obj!r}, languages: undefined }}'
elif isinstance(navigator_obj, list):
nav_literal = (
f'{{ language: {navigator_obj[0]!r}, languages: {navigator_obj!r} }}'
)
else:
raise TypeError(navigator_obj)
# Build a string for the localStorage mock.
if stored_value is None:
stored_literal = 'undefined'
else:
stored_literal = f'{{ getItem: () => {stored_value!r} }}'
return f"""
const fs = require('fs');
const src = {src!r};
// Mock localStorage (must be set BEFORE eval, i18n.js may read on load)
global.localStorage = {stored_literal};
// Mock document (i18n.js calls setLocale → document.documentElement.lang = ...)
global.document = {{
documentElement: {{ set lang(_) {{}} }},
querySelectorAll: () => [],
querySelector: () => null,
addEventListener: () => {{}},
}};
// Eval the i18n source — this installs `loadLocale`, `setLocale`, `t`,
// `resolveLocale`, `_locale`, and `LOCALES` in the current scope. Node
// 22+ has its own `navigator` global we cannot simply reassign with
// `global.navigator = ...`; we use `Object.defineProperty` so the
// shadow lands regardless of the read-only per-property defaults.
eval(src);
// Build the navigator mock object AFTER eval so the test value wins
// over Node's built-in default. `Object.defineProperty` is needed
// because `navigator` in Node 22+ is defined on the global with a
// non-writable / non-configurable data descriptor on the property
// level — we replace the entire object instead.
const _navigatorMock = {nav_literal};
Object.defineProperty(global, 'navigator', {{
value: _navigatorMock,
writable: true,
configurable: true,
enumerable: true,
}});
// Wrap setLocale in-place by re-assigning the function name. Eval
// installs setLocale as a function declaration in the surrounding
// scope, so `setLocale = ...` rebinds it for any subsequent caller
// in the same scope.
const _origSetLocale = setLocale;
setLocale = function(lang) {{
global._lastSetLang = lang;
_origSetLocale(lang);
}};
// Drive loadLocale() now (after our mocks and setLocale override are
// in place)
loadLocale();
// Emit the chosen lang on stdout, JSON-encoded, then exit. Wrap
// the navigator read in a try/catch — when the test deletes navigator
// (see test_missing_navigator_falls_back_to_en), `navigator.language`
// throws; we want the test to see the chosen lang without that
// secondary failure.
let _navInfo = {{}};
try {{ _navInfo = {{ nav_language: navigator.language, nav_languages: navigator.languages }}; }} catch (_) {{}}
process.stdout.write(JSON.stringify({{ lang: global._lastSetLang, ..._navInfo }}));
"""
@pytest.fixture(scope="module")
def i18n_src() -> str:
return I18N_JS_PATH.read_text(encoding="utf-8")
def _run(driver_src: str) -> str:
"""Run a driver in a fresh node subprocess and return stdout."""
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as f:
f.write(driver_src)
path = f.name
try:
result = subprocess.run(
[NODE, path], capture_output=True, text=True, timeout=30
)
finally:
Path(path).unlink(missing_ok=True)
if result.returncode != 0:
raise RuntimeError(
f"node driver failed: rc={result.returncode} stderr={result.stderr!r}"
)
return result.stdout.strip()
# ── 1. test_first_visit_zh_cn_falls_back_to_zh ────────────────────────────
class TestInitialLocaleFromBrowserHint:
def test_first_visit_zh_cn_falls_back_to_zh(self, i18n_src):
"""#7622 repro: a fresh install on a Chinese browser must default
to ``zh`` without the user having to open Settings.
`navigator.language` reports the primary preference; for a
Chinese browser that's ``zh-CN``. `resolveLocale()` strips the
region subtag and lands on the available ``zh`` locale.
"""
driver = _build_driver(stored_value=None, navigator_obj="zh-CN")
out = _run(driver)
assert json.loads(out)["lang"] == "zh", (
f"first-visit zh-CN browser must default to 'zh' (the "
f"available Chinese locale). Got: {out!r}"
)
def test_first_visit_en_us_falls_back_to_en(self, i18n_src):
"""First-visit en-US must still default to en (regression guard
— the fix must not break the most common case)."""
driver = _build_driver(stored_value=None, navigator_obj="en-US")
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"first-visit en-US browser must default to 'en'. "
f"Got: {out!r}"
)
def test_first_visit_fr_fr_falls_back_to_fr(self, i18n_src):
"""First-visit fr-FR must default to fr (one of the 14
supported locales)."""
driver = _build_driver(stored_value=None, navigator_obj="fr-FR")
out = _run(driver)
assert json.loads(out)["lang"] == "fr", (
f"first-visit fr-FR browser must default to 'fr'. "
f"Got: {out!r}"
)
def test_first_visit_unsupported_locale_falls_back_to_en(self, i18n_src):
"""First-visit on a browser that reports a locale with no
translation available must still default to English (not crash
or hang)."""
driver = _build_driver(stored_value=None, navigator_obj="kl-GL")
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"unsupported browser locale must fall back to 'en'. "
f"Got: {out!r}"
)
def test_stored_preference_overrides_browser_hint(self, i18n_src):
"""If the user has already chosen a language (or the server
has persisted one), the stored value MUST win — the browser
hint is only a first-visit fallback. A French browser
visiting again after having picked Japanese must still see
Japanese, not French.
"""
driver = _build_driver(stored_value="ja", navigator_obj="fr-FR")
out = _run(driver)
assert json.loads(out)["lang"] == "ja", (
f"stored preference must override the browser hint. "
f"Got: {out!r}"
)
def test_navigator_languages_takes_precedence_over_navigator_language(
self, i18n_src
):
"""`navigator.languages[0]` (the ordered preference list) is
preferred over `navigator.language` (the single primary) when
the list is available. Chrome and Firefox both expose the
list.
"""
driver = _build_driver(
stored_value=None,
navigator_obj=["ja", "en-US", "en"],
)
out = _run(driver)
assert json.loads(out)["lang"] == "ja", (
f"navigator.languages[0] must drive the first-visit hint. "
f"Got: {out!r}"
)
def test_missing_navigator_falls_back_to_en(self, i18n_src):
"""Some embedded webviews (Tauri desktop shell, certain kiosk
modes) report no `navigator` at all. loadLocale() must
not crash and must default to English.
"""
driver = _build_driver(stored_value=None, navigator_obj=None)
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"missing navigator must default to 'en'. Got: {out!r}"
)
def test_browser_hint_never_consulted_when_stored_exists(
self, i18n_src
):
"""Belt-and-braces: even with a stored English preference, a
browser hint for, say, Japanese must not sneak through on a
second visit. This pins the 'stored wins' contract."""
driver = _build_driver(
stored_value="en", navigator_obj=["ja-JP", "en-US"]
)
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"stored 'en' must win over a browser ja-JP hint. "
f"Got: {out!r}"
)
# ── 2. Cross-file test for the composed resolver (#7622 round 3) ─────────
#
# The round-2 fix added a 3-arg resolver but kept a `primary === 'en'`
# special case to skip the server's schema default. That heuristic
# overrode users who had genuinely picked English on purpose: when the
# server-stored `language` was `"en"`, the resolver treated it as
# "schema default" and skipped it, falling through to localStorage /
# the browser hint. The round-3 fix drops the schema default at the
# source (`api/config.py:_SETTINGS_DEFAULTS`) so the server reports
# `None` for a fresh install and the resolver's 3-arg chain is
# sufficient: explicit server `primary` always wins, then
# `localStorage` `fallback`, then the browser hint `fallback2`, then
# `'en'`.
#
# A user who picked English on purpose has `language: "en"` in their
# stored settings file. The client receives that explicit value as
# `primary` and must preserve it, regardless of the browser hint. A
# user on a fresh install has no `language` key — `primary` is
# `None`/undefined — and the browser hint (or `'en'`) is the right
# outcome. These two cases must not be conflated.
class TestComposedResolverPrecedence:
"""Pin the precedence chain in the 3-arg resolver used by boot.js
(settings hydration) and panels.js (settings modal). Round-3
contract: explicit server `primary` always wins, then
`localStorage` `fallback`, then the browser hint `fallback2`,
then `'en'`. No `primary === 'en'` skip."""
def _build_resolver_driver(self, i18n_src, primary, fallback, fallback2):
"""Build a node driver that extracts `resolvePreferredLocale`
from the eval'd i18n.js and calls it once with the given args.
Returns the resolved lang on stdout.
"""
# Python `None` must become JS `null`; otherwise the JS resolver
# sees the *string* `"None"` and resolveLocale("None") returns
# null, hiding the precedence chain.
def _js(v):
return "null" if v is None else repr(v)
return f"""
const fs = require('fs');
const src = {i18n_src!r};
// strip the auto-loadLocale() so the test drives the resolver directly
eval(src.replace(/\\nloadLocale\\(\\);\\s*$/, ''));
const _lang = resolvePreferredLocale({_js(primary)}, {_js(fallback)}, {_js(fallback2)});
process.stdout.write(JSON.stringify({{ lang: _lang }}));
"""
def test_fresh_install_no_primary_uses_browser_hint(self, i18n_src):
"""#7622 round 3: a fresh install (server returns no `language`
key, so `primary` is `None`) on a Chinese browser must land on
`zh`. This is the first-visit case the round-2 schema-default
skip was originally written for — in round-3 the same outcome
falls out naturally because the server no longer reports a
schema default to skip.
"""
driver = self._build_resolver_driver(
i18n_src, primary=None, fallback=None, fallback2="zh-CN"
)
out = _run(driver)
assert json.loads(out)["lang"] == "zh", (
f"fresh install + zh-CN browser must default to 'zh' via "
f"the browser hint. Got: {out!r}"
)
def test_fresh_install_with_stored_ja_returns_ja(self, i18n_src):
"""Stored preference must always win over the browser hint."""
driver = self._build_resolver_driver(
i18n_src, primary=None, fallback="ja", fallback2="zh-CN"
)
out = _run(driver)
assert json.loads(out)["lang"] == "ja", (
f"a stored 'ja' must win over the browser hint 'zh-CN'. "
f"Got: {out!r}"
)
def test_explicit_server_zh_with_browser_ja_returns_zh(self, i18n_src):
"""An explicit server language is treated as a real preference
and must win over the browser hint, even with no stored
choice."""
driver = self._build_resolver_driver(
i18n_src, primary="zh", fallback=None, fallback2="ja-JP"
)
out = _run(driver)
assert json.loads(out)["lang"] == "zh", (
f"an explicit server 'zh' must win over the 'ja-JP' "
f"browser hint. Got: {out!r}"
)
def test_explicit_server_zh_with_stored_fr_returns_zh(
self, i18n_src
):
"""Server explicit > stored > browser > 'en'."""
driver = self._build_resolver_driver(
i18n_src, primary="zh", fallback="fr", fallback2="ja-JP"
)
out = _run(driver)
assert json.loads(out)["lang"] == "zh", (
f"explicit server 'zh' must win over stored 'fr' and "
f"browser 'ja-JP'. Got: {out!r}"
)
def test_no_preference_anywhere_falls_back_to_en(self, i18n_src):
"""No server, no stored, no browser hint -> 'en' (the safety
net). Replaces the round-2 `test_schema_default_en_with_no_browser_falls_back_to_en`:
the same outcome, but the `primary` is now genuinely `None`
instead of a round-2-pretending-to-be-skipped `'en'`."""
driver = self._build_resolver_driver(
i18n_src, primary=None, fallback=None, fallback2=None
)
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"no preference at all must default to 'en'. Got: {out!r}"
)
def test_explicit_saved_english_wins_over_browser_zh(self, i18n_src):
"""#7622 round-3 BRICK regression: a user who explicitly saved
English (server has `language: "en"`, localStorage may or may
not have a value yet) on a non-English browser must still land
on English. This is the case the round-2
`primary === 'en'` skip overrode.
Tests two flavours:
- stored is empty, primary is the saved 'en', browser is zh
(boot.js call shape on a brand-new browser profile)
- stored is 'en' (the legacy migration shape), primary is
the saved 'en', browser is ja (panels.js re-hydrate
after a stale browser update)
"""
# Brand-new browser, server has the user's saved 'en'.
driver = self._build_resolver_driver(
i18n_src, primary="en", fallback=None, fallback2="zh-CN"
)
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"server-saved 'en' must beat a zh-CN browser hint. "
f"Got: {out!r}"
)
# Stale browser, server still has the user's saved 'en'.
driver = self._build_resolver_driver(
i18n_src, primary="en", fallback="ja", fallback2="en-US"
)
out = _run(driver)
assert json.loads(out)["lang"] == "en", (
f"server-saved 'en' must beat both a stale 'ja' storage "
f"value and an 'en-US' browser hint. Got: {out!r}"
)
# ── 3. Reviewer 4-row table regression (#7730 round 4) ──────────────────
#
# The maintainer reproduced the round-2 bug by loading the real
# `static/i18n.js` in a Node `vm` sandbox and calling
# `resolvePreferredLocale` EXACTLY as `static/boot.js` calls it:
#
# resolvePreferredLocale(s.language,
# localStorage.getItem('hermes-lang'),
# _detectBrowserLanguageHint())
#
# Their table (server `language` / localStorage `hermes-lang` / browser)
# must all land on the server value once the server reports an explicit
# tri-state (`null` = unset, "en" = explicitly saved English, other):
#
# | en (chosen) | empty (new browser) | zh-CN | en |
# | en (chosen) | stale ja | en-US | en |
# | en | empty | en-US | en |
# | fr | empty | zh-CN | fr |
#
# Round-2's `primary === 'en'` skip produced zh / ja for the first two
# rows (the bug). Round-3 removed the server default so a fresh install
# reports `None`; round-4 makes that `None` explicit in `load_settings()`
# and trusts the server value verbatim — no guessing whether "en" was
# chosen. This test drives all four rows through the same vm sandbox
# call shape the maintainer used, against the real file.
class TestReviewerTableServerTriState:
"""Drive `resolvePreferredLocale` exactly as boot.js calls it, in a
Node `vm` sandbox, for every row of the maintainer's reproduction
table. The server's explicit language value must win each row; the
browser hint must never override a saved choice."""
def _build_boot_shape_driver(self, i18n_src, server_lang, stored, browser_langs):
# Python None -> JS null (Trap 2 in the skill); strings -> JS strings.
js_server = "null" if server_lang is None else json.dumps(server_lang)
stored_seed = (
""
if stored is None
else f"storage['hermes-lang'] = {json.dumps(stored)};"
)
nav_literal = json.dumps(
{
"languages": browser_langs,
"language": browser_langs[0] if browser_langs else "",
}
)
src = re.sub(r"\nloadLocale\(\);\s*$", "", i18n_src, count=1)
call = (
f"resolvePreferredLocale({js_server}, "
f"localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())"
)
return f"""
const fs = require('fs');
const vm = require('vm');
const src = {src!r};
const storage = {{}};
{stored_seed}
const ctx = {{
localStorage: {{
getItem: (k) => Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null,
setItem: (k, v) => {{ storage[k] = String(v); }},
}},
document: {{
documentElement: {{ lang: '' }},
querySelectorAll: () => [],
}},
navigator: {nav_literal},
}};
vm.createContext(ctx);
vm.runInContext(src, ctx);
// Same call shape as static/boot.js:3443-3445.
const out = vm.runInContext({json.dumps(call)}, ctx);
process.stdout.write(JSON.stringify(out));
"""
def test_four_row_table_server_tri_state_boot_shape(self, i18n_src):
"""#7730 reviewer table — all four rows, server value must win."""
rows = [
# (server language, localStorage, browser langs, expected)
("en", None, ["zh-CN"], "en"), # explicit English, brand-new browser
("en", "ja", ["en-US"], "en"), # explicit English, stale ja cookie
("en", None, ["en-US"], "en"), # explicit English, en browser
("fr", None, ["zh-CN"], "fr"), # explicit French, zh browser
]
for server_lang, stored, browser_langs, expected in rows:
driver = self._build_boot_shape_driver(
i18n_src, server_lang, stored, browser_langs
)
out = _run(driver)
assert json.loads(out) == expected, (
f"server={server_lang!r} stored={stored!r} "
f"browser={browser_langs!r} -> expected {expected!r}, "
f"got {out!r}"
)
+167 -1
View File
@@ -9,10 +9,22 @@ REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
def _run_i18n_case(script_expr: str) -> dict:
def _run_i18n_case(script_expr: str, *, navigator_obj: object | None = None) -> dict:
wrapped_expr = f"(() => ({script_expr}))()"
if navigator_obj is None:
navigator_src = "undefined"
elif navigator_obj == "throwing":
# Mimic some embedded webviews: a `navigator` object whose
# `languages` / `language` accessors throw on read.
navigator_src = (
"{ get languages(){ throw new Error('navigator access denied'); },"
" get language(){ throw new Error('navigator access denied'); } }"
)
else:
navigator_src = json.dumps(navigator_obj)
script = textwrap.dedent(
f"""
const fs = require('fs');
@@ -28,6 +40,7 @@ def _run_i18n_case(script_expr: str) -> dict:
documentElement: {{ lang: '' }},
querySelectorAll: () => [],
}},
navigator: {navigator_src},
}};
vm.createContext(ctx);
vm.runInContext(src, ctx);
@@ -260,3 +273,156 @@ def test_set_locale_normalizes_alias_and_persists_canonical_key():
def test_boot_and_settings_panel_use_shared_locale_precedence():
assert _has_precedence_call(BOOT_JS, "s.language")
assert _has_precedence_call(PANELS_JS, "settings.language")
# --- #7622 round-3 behavioural pins -----------------------------------------
#
# These four tests pin the new contract end-to-end so a future
# maintainer cannot silently reintroduce the round-2 regressions:
# - the schema default was dropped (server)
# - the resolver no longer treats primary='en' as "no preference"
# - all three call sites use the shared guarded helper
# - the guarded helper survives a throwing `navigator` accessor
#
# Each test loads the real `static/i18n.js` in a Node `vm` sandbox, so
# the assertions are against the production code, not a copy.
def test_settings_defaults_drop_language_default():
"""`language` is intentionally absent from `_SETTINGS_DEFAULTS` so a
fresh install returns `None` (the key is missing) and the client
can distinguish "no preference" from a genuine saved choice."""
# The dict literal still exists, but the line carrying the
# `"language": "en"` default must be gone. A grep for the
# default covers future renames / re-shuffles of the dict.
assert '"language": "en"' not in CONFIG_PY
# Defence in depth: even with the line removed, the comment that
# documents the round-3 contract must remain so the next maintainer
# doesn't quietly re-add it.
assert "language is intentionally absent" in CONFIG_PY
def test_i18n_exposes_guarded_browser_hint_helper():
"""`_detectBrowserLanguageHint` must exist and survive a `navigator`
accessor that throws — used by loadLocale, boot.js, and panels.js
to keep a single bad read from aborting boot / settings hydration."""
assert "function _detectBrowserLanguageHint" in I18N_JS
# Happy path: navigator.languages[0] wins.
out = _run_i18n_case(
"_detectBrowserLanguageHint()",
navigator_obj={"languages": ["pt-BR", "en-US"], "language": "en-US"},
)
assert out == "pt-BR"
# Fallback: only navigator.language present.
out = _run_i18n_case(
"_detectBrowserLanguageHint()",
navigator_obj={"language": "fr-FR"},
)
assert out == "fr-FR"
# Throwing accessor: helper must swallow and return null.
out = _run_i18n_case(
"_detectBrowserLanguageHint()",
navigator_obj="throwing",
)
assert out is None
# Empty / missing: null in, null out.
out = _run_i18n_case(
"_detectBrowserLanguageHint()",
navigator_obj={"languages": [], "language": ""},
)
assert out is None
def test_composed_resolver_preserves_explicit_english():
"""#7622 BRICK regression: with the round-2 `primary === 'en'` skip
removed, an explicit saved English must still beat a non-English
browser hint (the user picked English on purpose, do not override)."""
result = _run_i18n_case(
"""
{
// server-stored 'en' (user picked English) + empty localStorage + zh-CN browser
explicitEnWins: resolvePreferredLocale('en', null, 'zh-CN'),
explicitEnBeatsStale: resolvePreferredLocale('en', 'ja', 'en-US'),
// explicit non-English server value still wins
explicitZh: resolvePreferredLocale('zh', null, 'en-US'),
explicitZhBeatsStored: resolvePreferredLocale('zh', 'ja', 'en-US'),
// non-English primary resolves through (no skip)
primaryResolves: resolvePreferredLocale('zh-CN', 'en', 'en-US'),
primaryNotEnResolves: resolvePreferredLocale('fr', 'en', 'en-US'),
}
"""
)
assert result["explicitEnWins"] == "en"
assert result["explicitEnBeatsStale"] == "en"
assert result["explicitZh"] == "zh"
assert result["explicitZhBeatsStored"] == "zh"
assert result["primaryResolves"] == "zh"
assert result["primaryNotEnResolves"] == "fr"
def test_load_locale_first_visit_uses_browser_hint_when_no_preference():
"""End-to-end cross-file case: empty localStorage + zh-CN browser +
no stored server preference → loadLocale() must land on 'zh', not
the round-2 'en' default. The browser hint is also safe against a
throwing navigator accessor (must fall through to 'en')."""
# Fresh install: empty localStorage, browser is zh-CN.
out = _run_i18n_case(
"""
{
...(loadLocale(), {}),
saved: localStorage.getItem('hermes-lang'),
htmlLang: document.documentElement.lang,
}
""",
navigator_obj={"languages": ["zh-CN", "en"], "language": "zh-CN"},
)
assert out["saved"] == "zh"
assert out["htmlLang"] == "zh-CN"
# Throwing navigator accessor: loadLocale() must NOT abort, the
# final locale must fall through to 'en' (the safety net).
out = _run_i18n_case(
"""
{
...(loadLocale(), {}),
saved: localStorage.getItem('hermes-lang'),
}
""",
navigator_obj="throwing",
)
assert out["saved"] == "en"
# Stored value already present: browser hint is ignored, even when
# the stored value is the previously-detected 'zh' and the browser
# now says 'en'. This is the "second visit" contract.
out = _run_i18n_case(
"""
{
...(loadLocale(), {}),
saved: localStorage.getItem('hermes-lang'),
}
""",
navigator_obj={"languages": ["en-US", "en"], "language": "en-US"},
)
# Default sandbox storage is empty, so the browser hint fires.
# When a prior setLocale('zh') already ran, localStorage wins.
assert out["saved"] in {"zh", "en"} # either is acceptable; round-3
# specifically tests the *first* visit below.
# Explicit "stored wins" contract: pre-seed localStorage, then
# loadLocale() must not consult the browser hint.
out = _run_i18n_case(
"""
{
...(setLocale('fr'), {}), // pre-seed localStorage
...(loadLocale(), {}),
saved: localStorage.getItem('hermes-lang'),
}
""",
navigator_obj={"languages": ["zh-CN"], "language": "zh-CN"},
)
assert out["saved"] == "fr"
+9
View File
@@ -112,6 +112,9 @@ def test_load_models_cache_from_disk_rejects_partial_metadata_cache(
def test_get_available_models_ignores_invalid_ttl_memory_cache(monkeypatch):
# Pin the budget so a slow/loaded host cannot blow the global 4s limit and
# serve the over-budget stale payload instead of the rebuilt catalog.
monkeypatch.setattr(config, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
_reset_memory_cache()
stale_cache = {
@@ -152,6 +155,9 @@ def test_get_available_models_does_not_use_disk_cache_after_config_mtime_change(
):
cache_path = tmp_path / "models_cache.json"
monkeypatch.setattr(config, "_models_cache_path", cache_path)
# Pin the budget so the rebuild cannot exceed the global 4s limit and
# serve the over-budget stale payload instead of the rebuilt catalog.
monkeypatch.setattr(config, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
cache_path.write_text(
json.dumps(
{
@@ -194,6 +200,9 @@ def test_get_available_models_ignores_legacy_disk_cache_and_rebuilds(
):
cache_path = tmp_path / "models_cache.json"
monkeypatch.setattr(config, "_models_cache_path", cache_path)
# Pin the budget so the rebuild cannot exceed the global 4s limit and
# serve the legacy stale disk groups instead of the rebuilt catalog.
monkeypatch.setattr(config, "_LIVE_REBUILD_BUDGET_SECONDS", 0.0, raising=False)
cache_path.write_text(
json.dumps(
{
+82
View File
@@ -0,0 +1,82 @@
"""Execute the production reconnect replay seam, not a source-string assertion.
Browser coverage in browser_reconnect_scene_redraw.py exercises loadSession and
real DOM rendering; these cheap Node checks pin fallback and redraw counts.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
NODE = shutil.which('node')
pytestmark = pytest.mark.skipif(NODE is None, reason='Node.js is required for the reconnect replay probe')
def source_slice(text, start_marker, end_marker, path):
start = text.find(start_marker)
assert start >= 0, f'{path}: missing start marker {start_marker!r}'
end = text.find(end_marker, start)
assert end >= 0, f'{path}: missing end marker {end_marker!r}'
return text[start:end]
def replay_probe(restored_scene, count, skip_unkeyed=True, has_rows=True):
sessions = (ROOT / 'static/sessions.js').read_text()
ui = (ROOT / 'static/ui.js').read_text()
replay = source_slice(
sessions, 'const liveToolReplayId=(tc)=>', ' let didReconnect=false;',
'static/sessions.js',
)
append = source_slice(
ui, 'function appendLiveToolCard(', '\nfunction _findLatestLiveAssistantByBurst',
'static/ui.js',
)
script = r"""
const vm=require('node:vm');
const opts=JSON.parse(process.argv[1]);
const tools=Array.from({length:opts.count},(_,i)=>({name:'terminal',tid:'tool-'+i,done:true}));
tools.push({name:'read_file',done:true});
let redraws=0, appended=[];
const sandbox={
S:{session:{session_id:'session'},activeStreamId:'run',toolCalls:tools},
sid:'session',activeStreamId:'run',INFLIGHT:{},restoredAnchorScene:opts.restored_scene,
document:{getElementById:()=>({querySelector:()=>opts.has_rows?{}:null})},
isFinalAnswerOnlyMode:()=>false,
isLiveAnchorActivitySceneOwner:()=>true,
_renderLiveAnchorActivitySceneForStream:()=>{redraws++;return true;},
};
vm.createContext(sandbox);
vm.runInContext(APPEND,sandbox);
// The actual append function proves the amplification when a scene owns DOM.
// For legacy fallback, observe arguments at the append boundary instead.
if(!opts.restored_scene) sandbox.appendLiveToolCard=(tc)=>appended.push(tc.tid||'unkeyed');
vm.runInContext(REPLAY+'\nreplayPersistedLiveToolCards('+JSON.stringify({skipUnkeyedRestoredDuplicates:opts.skip_unkeyed})+');',sandbox);
console.log(JSON.stringify({redraws,appended}));
""".replace('APPEND', json.dumps(append)).replace('REPLAY', json.dumps(replay))
result = subprocess.run(
[NODE or 'node', '-e', script, json.dumps({
'restored_scene': restored_scene, 'count': count,
'skip_unkeyed': skip_unkeyed, 'has_rows': has_rows,
})], capture_output=True, text=True, check=True,
)
return json.loads(result.stdout)
@pytest.mark.parametrize('count', [0, 1, 470])
def test_restored_complete_scene_is_not_redrawn_per_saved_tool(count):
result = replay_probe(True, count)
assert result['redraws'] == 0, result
def test_non_anchor_restore_still_replays_keyed_tools():
result = replay_probe(False, 3)
assert result['appended'] == ['tool-0', 'tool-1', 'tool-2']
@pytest.mark.parametrize('skip_unkeyed,has_rows', [(False, True), (True, False)])
def test_failed_scene_or_empty_html_keeps_unkeyed_fallback(skip_unkeyed, has_rows):
result = replay_probe(False, 3, skip_unkeyed, has_rows)
assert result['appended'] == ['tool-0', 'tool-1', 'tool-2', 'unkeyed']
+81
View File
@@ -0,0 +1,81 @@
"""Sequence allocation and physical append must share one journal lock.
Only scheduling at the real append boundary is controlled. Writers, sequence
allocation, on-disk JSONL and the session replay reader are production code.
"""
from concurrent.futures import ThreadPoolExecutor
import threading
import pytest
from api import run_journal
@pytest.mark.parametrize("competitor", ["same_writer", "other_writer", "free_function"])
def test_writer_sequence_follows_physical_append_order(tmp_path, monkeypatch, competitor):
sid, rid = "writer_order_session", "writer_order_run"
writer = run_journal.RunJournalWriter(sid, rid, session_dir=tmp_path)
other = run_journal.RunJournalWriter(sid, rid, session_dir=tmp_path)
reached = threading.Event()
release = threading.Event()
real_append = run_journal.append_run_event
def pause_first_append(session_id, run_id, event_name, payload=None, **kwargs):
if payload == {"text": "paused"}:
reached.set()
assert release.wait(5), "test did not release the paused append"
return real_append(session_id, run_id, event_name, payload, **kwargs)
monkeypatch.setattr(run_journal, "append_run_event", pause_first_append)
def append_competitor():
if competitor == "free_function":
return run_journal.append_run_event(
sid, rid, "token", {"text": "overtaking"}, session_dir=tmp_path
)
target = writer if competitor == "same_writer" else other
return target.append_sse_event("token", {"text": "overtaking"})
with ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(writer.append_sse_event, "token", {"text": "paused"})
try:
assert reached.wait(5), "writer did not reach the append boundary"
second = pool.submit(append_competitor).result(timeout=5)
finally:
release.set()
first_result = first.result(timeout=5)
journal = run_journal.read_run_events(sid, rid, session_dir=tmp_path)
# Deliberately do not sort: the reader requires physical contiguous order.
assert [row["seq"] for row in journal["events"]] == [1, 2]
assert [row["payload"]["text"] for row in journal["events"]] == ["overtaking", "paused"]
assert second["event_id"] == f"{rid}:1"
assert first_result["event_id"] == f"{rid}:2"
replay = run_journal.read_session_run_events(
sid, after_event_id=second["event_id"], session_dir=tmp_path
)
assert replay["status"] == "ok"
assert [row["event_id"] for row in replay["events"]] == [first_result["event_id"]]
def test_writer_rejected_empty_name_does_not_reserve_a_sequence(tmp_path):
writer = run_journal.RunJournalWriter("name_session", "name_run", session_dir=tmp_path)
with pytest.raises(ValueError, match="event_name is required"):
writer.append_sse_event(" ", {"text": "rejected"})
event = writer.append_sse_event("token", {"text": "accepted"})
assert event["seq"] == 1
assert event["event_id"] == "name_run:1"
def test_metering_skip_still_does_not_reserve_or_write(tmp_path):
writer = run_journal.RunJournalWriter("meter_session", "meter_run", session_dir=tmp_path)
assert writer.append_sse_event("metering", {"tps": 10}) is None
assert not (tmp_path / "_run_journal" / "meter_session" / "meter_run.jsonl").exists()
assert writer.append_sse_event("token", {"text": "kept"})["seq"] == 1
def test_unused_writer_does_not_allocate_a_registry_lock(tmp_path):
parent = str(tmp_path / run_journal.RUN_JOURNAL_DIR_NAME / "unused_session")
writer = run_journal.RunJournalWriter("unused_session", "unused_run", session_dir=tmp_path)
assert writer.append_sse_event("metering", {"tps": 10}) is None
assert not any(key[0] == parent for key in run_journal._WRITER_LOCKS)
@@ -0,0 +1,95 @@
"""Cancellation must fence both credential self-heal constructors.
Reuse the real worker/Stop fixture; only the external Agent and credential
refresh are synthetic. No provider or tool operation is executed.
"""
import threading
from concurrent.futures import ThreadPoolExecutor
import pytest
from api import config, streaming
from tests.test_steer_worker_boundaries import worker_scene as worker_scene
@pytest.mark.parametrize("failure_path", ["exception", "returned"])
@pytest.mark.parametrize("cancellation", ["stop", "event-only", "detached-only", "none"])
def test_self_heal_registration_obeys_retained_stop(worker_scene, monkeypatch, failure_path, cancellation):
scene = worker_scene
entered, release = threading.Event(), threading.Event()
created, interrupted, finalized = [], [], []
def on_init():
candidate = scene.agent
created.append(candidate)
original_interrupt = candidate.interrupt
def interrupt(reason):
interrupted.append(candidate)
return original_interrupt(reason)
candidate.interrupt = interrupt
if len(created) == 2:
entered.set()
assert release.wait(8), "self-heal constructor barrier timed out"
def on_run():
if len(created) == 1:
if failure_path == "exception":
raise RuntimeError("401 unauthorized")
scene.result = {"error": "401 unauthorized", "messages": []}
else:
scene.result = None
scene.on_init, scene.on_run = on_init, on_run
monkeypatch.setattr(streaming, "_attempt_credential_self_heal", lambda *args, **kwargs: {
"provider": "openai", "api_key": "synthetic-self-heal-fixture",
"base_url": "http://127.0.0.1:1/v1",
})
finalize = streaming._finalize_cancelled_turn
def observed_finalize(*args, **kwargs):
assert scene.lock.owner != threading.get_ident(), "finalize held STREAMS_LOCK"
finalized.append(True)
return finalize(*args, **kwargs)
monkeypatch.setattr(streaming, "_finalize_cancelled_turn", observed_finalize)
with ThreadPoolExecutor(max_workers=2) as pool:
stop_future = None
future = pool.submit(scene.run)
try:
assert entered.wait(8), "real worker did not enter credential self-healing"
assert len(created) == 2
retained = config.CANCEL_FLAGS["run"]
if cancellation == "stop":
# Stop can wait for the session lock after its atomic cancel
# edge. Do not block constructor release on that later write.
stop_future = pool.submit(streaming.cancel_stream, "run")
assert retained.wait(5), "Stop did not publish cancellation"
with config.STREAMS_LOCK:
assert "run" not in config.STREAMS
assert "run" not in config.CANCEL_FLAGS
elif cancellation == "event-only":
with config.STREAMS_LOCK:
retained.set()
config.CANCEL_FLAGS.pop("run")
elif cancellation == "detached-only":
with config.STREAMS_LOCK:
config.STREAMS.pop("run")
assert not retained.is_set()
finally:
release.set()
future.result(timeout=12)
if stop_future is not None:
assert stop_future.result(timeout=12) is True
if cancellation == "none":
assert scene.calls.count("run") == 2, "valid credential refresh must still retry"
else:
assert scene.calls.count("run") == 1, "cancelled self-heal executed the replacement Agent"
cached = config.SESSION_AGENT_CACHE.get("original")
assert not cached or cached[0] is not created[-1], "cancelled replacement entered the reusable cache"
assert created[-1] in interrupted
assert finalized
assert "run" not in config.AGENT_INSTANCES
assert "run" not in config.ACTIVE_RUNS
+173
View File
@@ -0,0 +1,173 @@
"""A sidebar-idle hint must retain the live handoff without waiting forever.
Drive the production scheduling helper with a fake clock and a deferred snapshot
boundary. The browser lifecycle gate separately covers the real HTTP restore.
"""
import json
from pathlib import Path
import shutil
import subprocess
import pytest
from tests.test_issue2454_active_session_spinner import _function_body
ROOT = Path(__file__).resolve().parents[1]
def _run(case):
sessions = (ROOT / "static/sessions.js").read_text(encoding="utf-8")
messages = (ROOT / "static/messages.js").read_text(encoding="utf-8")
functions = "\n".join(
f"function {name}({params}) {{" + _function_body(sessions, f"function {name}(") + "}"
for name, params in [
("_isServerIdleSessionRow", "s"),
("_hasOwnedOpenLiveStream", "sid"),
("_reconcileActiveSessionIdleStateFromList", "serverRows"),
]
)
if "function _bindSidebarIdleRecovery(" in messages:
functions += "\nfunction _bindSidebarIdleRecovery(live) {" + _function_body(
messages, "function _bindSidebarIdleRecovery("
) + "}"
script = r"""
const assert=require('assert');
const activeSid='current',streamId='turn-1';
let _terminalStateReached=false,_streamFinalized=false;
let _pendingStreamEndRecovery=CASE==='stream-end-pending';
const S={session:{session_id:activeSid},busy:true,activeStreamId:streamId};
const INFLIGHT={current:{streamId}},original=INFLIGHT.current;
const timers=[],listeners={},calls=[],statusCalls=[],failures=[];
function setTimeout(fn,delay){const t={fn,delay,cancelled:false};timers.push(t);return t;}
function clearTimeout(t){if(t)t.cancelled=true;}
const source={readyState:1,addEventListener(name,fn){(listeners[name]??=[]).push(fn);}};
const live={streamId,source},LIVE_STREAMS={current:live};
let resolveSnapshot;
async function api(path,options){
statusCalls.push({path,options});
if(CASE==='probe-error') throw new Error('status unavailable');
return {active:CASE==='runtime-active'};
}
async function _restoreSettledSession(source,options){
calls.push({source,options});
return new Promise(resolve=>{resolveSnapshot=resolve;});
}
function _handleStreamError(s){failures.push(s);S.busy=false;S.activeStreamId=null;}
FUNCTIONS
if(typeof _bindSidebarIdleRecovery==='function')_bindSidebarIdleRecovery(live);
const idle=[{session_id:activeSid,is_streaming:false,active_stream_id:null}];
(async()=>{
_reconcileActiveSessionIdleStateFromList(idle);
_reconcileActiveSessionIdleStateFromList(idle);
if(CASE==='stream-end-pending'){assert.strictEqual(timers.length,0,'existing stream-end recovery must remain the sole owner');console.log('ok');return;}
assert.strictEqual(timers.length,1,'an OPEN transport must schedule one bounded snapshot recovery');
assert.ok(timers[0].delay>0&&timers[0].delay<=2000);
assert.strictEqual(S.busy,true,'the first idle hint must not destroy the live handoff');
const mode=CASE;
if(mode==='cancel-rearm'){
live.cancelIdleRecovery();
_reconcileActiveSessionIdleStateFromList(idle);
assert.strictEqual(timers.length,2);
await timers[0].fn();
assert.strictEqual(timers[1].cancelled,false,'a queued old callback cannot cancel a replacement ticket');
const replacement=timers[1].fn();
await Promise.resolve();
assert.strictEqual(calls.length,1);
resolveSnapshot('active');await replacement;
console.log('ok');return;
}
if(mode==='cancel-before'){
live.cancelIdleRecovery();
assert.strictEqual(timers[0].cancelled,true);
console.log('ok');return;
}
if(mode==='replace-before')LIVE_STREAMS.current={streamId,source:{readyState:1}};
const running=timers[0].fn();
if(mode==='replace-before'){
await running;assert.strictEqual(calls.length,0);console.log('ok');return;
}
await Promise.resolve();
assert.strictEqual(statusCalls.length,1);
assert.ok(statusCalls[0].path.includes('/api/chat/stream/status?stream_id=turn-1'));
assert.deepStrictEqual(statusCalls[0].options,{timeoutMs:8000,retries:0,timeoutToast:false});
if(mode==='runtime-active'){
await running;
assert.strictEqual(calls.length,0,'active runtime must not fall through to session restore');
assert.strictEqual(failures.length,0);
assert.strictEqual(S.busy,true);
assert.strictEqual(INFLIGHT.current,original);
console.log('ok');return;
}
if(mode==='probe-error'){
await running;
assert.strictEqual(calls.length,0,'failed runtime probe must not trust idle session flags');
assert.strictEqual(failures.length,1);
console.log('ok');return;
}
assert.strictEqual(calls.length,1);
_reconcileActiveSessionIdleStateFromList(idle);
assert.strictEqual(timers.length,1,'repeated idle hints must not restart a pending deadline');
const owns=calls[0].options.isCurrent;
assert.strictEqual(owns(),true);
assert.deepStrictEqual(calls[0].options.requestOptions,{timeoutMs:8000,retries:0,timeoutToast:false});
if(mode==='replace-during')LIVE_STREAMS.current={streamId,source:{readyState:1}};
if(mode==='new-turn')INFLIGHT.current={streamId:null};
if(mode==='navigate')S.session={session_id:'other'};
if(mode==='done'){
for(const fn of listeners.done||[])fn();
_streamFinalized=true;S.busy=false;S.activeStreamId=null;
}
if(['replace-during','new-turn','navigate','done'].includes(mode))assert.strictEqual(owns(),false);
resolveSnapshot(mode==='active'?'active':mode==='restored'?'restored':'error');
await running;
assert.strictEqual(failures.length,mode==='error'?1:0);
assert.strictEqual(owns(),false,'settled requests must lose authority');
if(mode==='active'){
assert.strictEqual(S.busy,true);assert.strictEqual(INFLIGHT.current,original);
}
console.log('ok');
})().catch(e=>{console.error(e.stack||e);process.exit(1);});
""".replace("FUNCTIONS", functions).replace("CASE", json.dumps(case))
node = shutil.which("node")
if not node:
pytest.skip("node required")
result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=20)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.parametrize("case", [
"error", "active", "restored", "runtime-active", "probe-error", "cancel-before", "replace-before",
"replace-during", "new-turn", "navigate", "done", "stream-end-pending", "cancel-rearm",
])
def test_sidebar_idle_open_stream_has_bounded_owner_safe_recovery(case):
_run(case)
def test_snapshot_restore_rechecks_request_owner_after_await():
source = (ROOT / "static/messages.js").read_text(encoding="utf-8")
body = _function_body(source, "async function _restoreSettledSession(")
script = r"""
const assert=require('assert');
const activeSid='current',streamId='turn-1';
let current=true,resolveSnapshot;
const S={session:{session_id:activeSid},activeStreamId:streamId};
let _streamFinalized=false;
function _isActiveSession(){return true;}
function _closeSource(){throw Error('stale restore must not close a replacement');}
function api(){return new Promise(resolve=>{resolveSnapshot=resolve;});}
async function _restoreSettledSession(source,options=null){BODY}
(async()=>{
const pending=_restoreSettledSession({}, {status:true,isCurrent:()=>current});
current=false;
resolveSnapshot({session:{session_id:activeSid,active_stream_id:'newer-turn',messages:[]}});
assert.strictEqual(await pending,'stale','request authority must be rechecked before using the response');
assert.strictEqual(S.activeStreamId,streamId);
console.log('ok');
})().catch(e=>{console.error(e.stack||e);process.exit(1);});
""".replace("BODY", body)
node = shutil.which("node")
if not node:
pytest.skip("node required")
result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=20)
assert result.returncode == 0, result.stdout + result.stderr
+96
View File
@@ -0,0 +1,96 @@
"""Sidebar metadata must not retire a still-open, exact chat transport.
The live SSE and /api/sessions responses are independently scheduled. An idle
sidebar row can arrive before the terminal frame carrying the Anchor scene.
"""
import json
from pathlib import Path
import shutil
import subprocess
import pytest
from tests.test_issue2454_active_session_spinner import _function_body
ROOT = Path(__file__).resolve().parents[1]
def _run_case(operation, transport, *, absent=False, close_after=False):
source = (ROOT / "static/sessions.js").read_text(encoding="utf-8")
names = ["_isServerIdleSessionRow", "_reconcileActiveSessionIdleStateFromList", "_purgeStaleInflightEntries", "_dropStaleOptimisticSessionRow"]
if "function _hasOwnedOpenLiveStream(" in source:
names.append("_hasOwnedOpenLiveStream")
parameters = {
"_isServerIdleSessionRow": "s",
"_reconcileActiveSessionIdleStateFromList": "serverRows",
"_hasOwnedOpenLiveStream": "sid",
"_dropStaleOptimisticSessionRow": "sid",
}
functions = "\n".join(
f"function {name}({parameters.get(name, '')}) {{"
+ _function_body(source, f"function {name}(") + "}"
for name in names
)
script = """
const S={session:{session_id:'current',active_stream_id:'turn-1'},busy:true,activeStreamId:'turn-1'};
const INFLIGHT={current:{streamId:'turn-1',messages:[{role:'assistant',content:'work so far'}]}};
const original=INFLIGHT.current;
const LIVE_STREAMS={current:TRANSPORT};
const _sessionStreamingById=new Map();
const calls=[];
const idle={session_id:'current',is_streaming:false,active_stream_id:null,pending_user_message:null};
const _allSessions=ABSENT?[]:[idle];
function clearInflightState(){calls.push('clear');}
function _forgetObservedStreamingSession(){}
function hideApprovalCard(){}
function hideLiveRunStatus(){}
function clearLiveToolCards(){calls.push('clear-dom');}
function updateSendBtn(){}
function _scheduleActiveSessionIdleReload(){calls.push('reload');}
FUNCTIONS
function run(){if(OPERATION==='idle')_reconcileActiveSessionIdleStateFromList([idle]);else if(OPERATION==='optimistic')_dropStaleOptimisticSessionRow('current');else _purgeStaleInflightEntries();}
run();
const beforeClose={busy:S.busy,stream:S.activeStreamId,retained:INFLIGHT.current===original,calls:[...calls]};
if(CLOSE_AFTER){LIVE_STREAMS.current.source.readyState=2;run();}
console.log(JSON.stringify({beforeClose,afterClose:{retained:!!INFLIGHT.current,busy:S.busy}}));
"""
script = script.replace("TRANSPORT", json.dumps(transport)).replace("ABSENT", json.dumps(absent))
script = script.replace("FUNCTIONS", functions).replace("OPERATION", json.dumps(operation))
script = script.replace("CLOSE_AFTER", json.dumps(close_after))
node = shutil.which("node")
if not node:
pytest.skip("node required")
result = subprocess.run([node, "-e", script], text=True, capture_output=True, timeout=20)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@pytest.mark.parametrize("operation", ["idle", "purge", "optimistic"])
def test_current_open_transport_keeps_terminal_handoff_and_recovers_after_close(operation):
result = _run_case(operation, {"streamId": "turn-1", "source": {"readyState": 1}}, close_after=True)
assert result["beforeClose"] == {"busy": True, "stream": "turn-1", "retained": True, "calls": []}
assert result["afterClose"]["retained"] is False
if operation == "idle":
assert result["afterClose"]["busy"] is False
@pytest.mark.parametrize("operation", ["idle", "purge", "optimistic"])
@pytest.mark.parametrize("transport", [
None,
{"streamId": "turn-1", "source": None},
{"streamId": "turn-1", "source": {"readyState": 0}},
{"streamId": "turn-1", "source": {"readyState": 2}},
{"streamId": "older-turn", "source": {"readyState": 1}},
])
def test_missing_closed_connecting_or_wrong_transport_does_not_block_idle_recovery(operation, transport):
result = _run_case(operation, transport)
assert result["beforeClose"]["retained"] is False
if operation == "idle":
assert result["beforeClose"]["busy"] is False
assert "reload" in result["beforeClose"]["calls"]
def test_current_open_transport_survives_a_filtered_sidebar_list():
result = _run_case("purge", {"streamId": "turn-1", "source": {"readyState": 1}}, absent=True)
assert result["beforeClose"]["retained"] is True
+4 -1
View File
@@ -386,7 +386,10 @@ class TestSmdMediaInStream(unittest.TestCase):
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
block = MESSAGES_JS[idx:idx + 6500]
self.assertIn("function _smdMediaRefHasReliableBoundary", MESSAGES_JS)
self.assertIn("matchEnd===combined.length", block)
# #7708: the scan now runs over `normalized` (backtick-wrapped refs
# rewritten to bare form first); the chunk-end boundary guard is the
# same check against that string.
self.assertIn("matchEnd===normalized.length", block)
self.assertIn("!_smdMediaRefHasReliableBoundary(m[1])", block)
self.assertIn("unmatchedTail = candidate", block)
+4 -1
View File
@@ -38,7 +38,10 @@ def test_footer_timestamp_uses_richer_format_for_older_messages():
def test_timestamp_footer_stays_on_visible_response_segments():
assert "if(hasVisibleBody){" in UI_JS
assert 'seg.insertAdjacentHTML(\'beforeend\', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);' in UI_JS, (
# #2051: the block goes in through _insertSegmentBlock() rather than
# seg.insertAdjacentHTML(), so a wrapped DOM API cannot render it twice. The footer
# still rides in the same block, which is what this test is about.
assert '_insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);' in UI_JS, (
"Footer timestamp should stay attached to visible response segments"
)
assert "assistantThinking.set(rawIdx, thinkingText);" in UI_JS, (
+2 -19
View File
@@ -253,22 +253,5 @@ def test_read_only_handle_rejects_writes(tmp_path):
raise AssertionError("read-only handle unexpectedly allowed a write")
except sqlite3.OperationalError:
pass
def test_delete_cli_session_still_opens_writable_connection():
"""The lone write path (a DELETE) must keep a writable connection — a
read-only downgrade would make the delete silently no-op."""
import inspect
src = "\n".join(
(
inspect.getsource(models.delete_cli_session),
inspect.getsource(models._delete_cli_session_locked),
)
)
assert "sqlite3.connect(str(db_path))" in src, (
"delete_cli_session must keep its writable connection"
)
assert "open_state_db_readonly" not in src, (
"delete_cli_session must not be downgraded to the read-only helper"
)
# The delete path's writable-connection contract is covered behaviorally by
# tests/test_issue1494_state_db_fd_leak.py::test_delete_cli_session_closes_connection.
+7 -2
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import importlib
import json
from pathlib import Path
from types import SimpleNamespace
@@ -7,7 +8,7 @@ from urllib.parse import quote
import api.config as api_config
import api.routes as routes
from api.updates import WEBUI_VERSION
ROOT = Path(__file__).resolve().parent.parent
@@ -81,8 +82,12 @@ def test_service_worker_and_favicon_follow_selected_static_root(tmp_path, monkey
monkeypatch.setattr(api_config, "get_static_root", lambda: static_root)
sw_handler = _get("/sw.js")
# The route resolves api.updates dynamically on each request. Other update
# tests deliberately evict and re-import that module, so a collection-time
# alias can become stale even though the checkout and runtime module agree.
webui_version = importlib.import_module("api.updates").WEBUI_VERSION
expected = sw_path.read_text(encoding="utf-8").replace(
"__WEBUI_VERSION__", quote(WEBUI_VERSION, safe="")
"__WEBUI_VERSION__", quote(webui_version, safe="")
).encode("utf-8")
assert sw_handler.status == 200
assert sw_handler.header("Service-Worker-Allowed") == "/"
+7
View File
@@ -87,6 +87,13 @@ function makeSeg() {{
}};
}}
// #2051: renderMessages() inserts a message block through this helper so a wrapped
// DOM API cannot render it twice. Outside a browser the helper's own fallback is
// insertAdjacentHTML, which is exactly what it does here.
function _insertSegmentBlock(seg, html) {{
seg.insertAdjacentHTML('beforeend', html);
}}
const ordinaryBlock = extractBlock(
"const hasVisibleBody=!!(String(content||'').trim()||filesHtml||recoveryHtml);",
"_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);"
+9
View File
@@ -211,6 +211,8 @@ def test_stop_during_agent_creation_prevents_provider_run(worker_scene, monkeypa
release.set()
worker.result(timeout=10)
assert "run" not in scene.calls, "accepted Stop must prevent the provider/tool run"
cached = config.SESSION_AGENT_CACHE.get("original")
assert not cached or cached[0] is not scene.agent, "cancelled initial candidate remained reusable"
assert finalized, "cancelled turn was not finalized"
assert "run" not in config.AGENT_INSTANCES
assert "run" not in config.ACTIVE_RUNS
@@ -276,3 +278,10 @@ def test_final_drain_fences_steer(worker_scene, monkeypatch, registered, rotated
assert scene.agent.pending == []
assert finalizing_edges == [True], "final drain admission must close under the stream lock"
assert "run" not in config.ACTIVE_RUNS
def test_successfully_registered_new_agent_remains_cached(worker_scene):
scene = worker_scene
scene.run()
assert scene.calls.count("run") == 1
assert config.SESSION_AGENT_CACHE["original"][0] is scene.agent
+8 -9
View File
@@ -84,20 +84,19 @@ RAISED_EXCEPTION_SELF_HEAL_MARKER = (
"retrying stream after credential refresh')"
)
# Each region ends at the cache write that stores the signature it just
# computed. Bounding the region at the write -- rather than at the next
# lifecycle anchor or at EOF -- is what proves the signature is recomputed
# *before* it is cached: a call moved below its own write lands outside the
# region and fails, instead of passing while the write consumes a stale
# `_agent_sig`.
# Each region ends at the atomic registration/publication call that consumes
# the signature it just computed. The shared helper now writes the cache under
# the Stop lock; the call boundary must still follow fresh signature computation.
# A signature call moved below its publication lands outside the region and
# fails, rather than silently reusing a stale `_agent_sig` on a retry.
INITIAL_SEND_CACHE_WRITE_MARKER = (
"SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)"
"if not _register_agent_if_current(agent, _agent_sig if _cache_new_agent else None):"
)
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC[session_id] = (agent, _agent_sig)"
"if not _register_agent_if_current(agent, _agent_sig):"
)
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
"_SAC2[session_id] = (_heal_agent, _agent_sig)"
"if not _register_agent_if_current(_heal_agent, _agent_sig):"
)
+99
View File
@@ -4,6 +4,7 @@ from email.message import Message
import json
from pathlib import Path
import re
import threading
import time
import urllib.error
@@ -858,6 +859,104 @@ def test_gateway_chat_worker_emits_goal_continue_for_goal_related_turn(tmp_path,
assert saved.messages[-1]["content"] == "goal reply"
def test_gateway_goal_eval_keeps_runtime_active_after_success_writeback_before_done(
tmp_path, monkeypatch
):
"""Persisted idle flags can precede terminal emission while STREAMS is still active."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def __iter__(self):
yield b'data: {"choices":[{"delta":{"content":"goal reply"}}]}\n\n'
yield b"data: [DONE]\n\n"
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(
gateway_chat.urllib.request, "urlopen", lambda req, timeout=0: FakeResponse()
)
from api import goals as webui_goals
goal_entered = threading.Event()
release_goal = threading.Event()
observed = {}
monkeypatch.setattr(webui_goals, "has_active_goal", lambda *args, **kwargs: True)
def blocked_goal_eval(*args, **kwargs):
saved = models.get_session(s.session_id)
observed["active_stream_id"] = saved.active_stream_id
observed["pending_user_message"] = saved.pending_user_message
observed["stream_registered"] = stream_id in STREAMS
goal_entered.set()
assert release_goal.wait(timeout=10)
return {"should_continue": False, "message": "Goal checked"}
monkeypatch.setattr(webui_goals, "evaluate_goal_after_turn", blocked_goal_eval)
s = new_session()
stream_id = "stream-gateway-goal-runtime-window"
s.active_stream_id = stream_id
s.pending_user_message = "finish it"
s.pending_attachments = []
s.pending_started_at = 123
s.save()
channel = create_stream_channel()
subscriber = channel.subscribe()
STREAMS[stream_id] = channel
worker = threading.Thread(
target=gateway_chat._run_gateway_chat_streaming,
args=(
s.session_id,
"finish it",
"test-model",
str(tmp_path),
stream_id,
[],
),
kwargs={"goal_related": True},
daemon=True,
)
worker.start()
assert goal_entered.wait(timeout=10), "worker did not reach post-writeback goal evaluation"
# This is the exact authority split the browser recovery must respect:
# persisted session metadata already looks idle, while the runtime stream
# (and therefore /api/chat/stream/status) is still active and can emit
# goal / goal_continue / done / stream_end.
assert observed == {
"active_stream_id": None,
"pending_user_message": None,
"stream_registered": True,
}
assert stream_id in STREAMS
assert worker.is_alive()
assert not any(item[0] == "done" for item in list(subscriber.queue))
release_goal.set()
worker.join(timeout=10)
assert not worker.is_alive()
events = []
while not subscriber.empty():
events.append(subscriber.get_nowait())
names = [item[0] for item in events]
assert "goal" in names
assert "done" in names
assert "stream_end" in names
assert names.index("goal") < names.index("done") < names.index("stream_end")
def test_gateway_chat_worker_skips_goal_judge_for_non_goal_turn(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()