Merge origin/master into fix/7535-provider-base-url-ownership
This commit is contained in:
commit
a44e329997
60 files changed
+4956
-330
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+115
-48
@@ -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,13 +11203,17 @@ 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)
|
||||
_cache_new_agent = True
|
||||
|
||||
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
|
||||
@@ -11159,8 +11234,6 @@ def _run_agent_streaming(
|
||||
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
|
||||
@@ -11189,25 +11262,6 @@ def _run_agent_streaming(
|
||||
logger.debug('[webui] Evicted LRU agent from cache: %s', _evicted_sid)
|
||||
logger.debug('[webui] Created new agent for session %s', session_id)
|
||||
|
||||
# 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")
|
||||
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
|
||||
|
||||
# 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
+74
-6
@@ -26985,13 +26985,66 @@ function resolveLocale(lang) {
|
||||
* Resolve locale with precedence:
|
||||
* 1) primary (typically server setting)
|
||||
* 2) fallback (typically localStorage)
|
||||
* 3) English
|
||||
* @param {string} primary
|
||||
* @param {string} fallback
|
||||
* 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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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' }};
|
||||
|
||||
@@ -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([{{
|
||||
|
||||
@@ -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 _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)
|
||||
|
||||
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.
|
||||
class _FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
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"]
|
||||
def __exit__(self, *_exc):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
# 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
|
||||
def _install_live_catalog(monkeypatch, routes, catalog, *, fail=False):
|
||||
"""Stub the upstream /v1/models probe used by the custom-provider branch."""
|
||||
import urllib.request
|
||||
ids = []
|
||||
_req = urllib.request.Request(
|
||||
"http://localhost:4000/v1/models",
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
|
||||
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
|
||||
)
|
||||
with urllib.request.urlopen(_req, timeout=5) as _resp:
|
||||
_body = json.loads(_resp.read())
|
||||
monkeypatch.setattr(config, "_resolve_provider_alias", lambda provider: provider)
|
||||
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
|
||||
|
||||
if isinstance(_body, dict):
|
||||
_data = _body.get("data", [])
|
||||
if isinstance(_data, list):
|
||||
ids = [m.get("id", "") for m in _data if m.get("id")]
|
||||
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)
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
# 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)"
|
||||
))
|
||||
parsed = urlparse("/api/models/live?provider=custom:test-gateway")
|
||||
payload = routes._handle_live_models(object(), parsed)
|
||||
return payload, requested
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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.
|
||||
@@ -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") == "/"
|
||||
|
||||
@@ -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);"
|
||||
|
||||
@@ -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
|
||||
@@ -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):"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user