From 329aae697995e827afeda0c623201639fbf0d6fe Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 19:38:00 +0000 Subject: [PATCH 1/4] fix: probe remote gateway via HERMES_API_URL before local fallback (#3312) Rebased onto master + removed an unused 'io' import flagged by the ruff gate. Co-authored-by: Sanjays2402 --- api/agent_health.py | 134 ++++++++++++++++++++++++++++++ tests/test_agent_health_remote.py | 101 ++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 tests/test_agent_health_remote.py diff --git a/api/agent_health.py b/api/agent_health.py index 897827ab..ef68f101 100644 --- a/api/agent_health.py +++ b/api/agent_health.py @@ -24,9 +24,14 @@ from __future__ import annotations import importlib import json +import os +import threading +import time from datetime import datetime, timezone from pathlib import Path from typing import Any +from urllib import error as urllib_error +from urllib import request as urllib_request _GATEWAY_PID_FILE = "gateway.pid" _GATEWAY_RUNTIME_STATUS_FILE = "gateway_state.json" @@ -285,6 +290,125 @@ def _runtime_detail_subset(runtime_status: dict[str, Any] | None) -> dict[str, A return details +# Remote-gateway probe (#3281) +# ------------------------------------------------------------------ +# In multi-container Docker deployments the WebUI container does not ship the +# ``gateway`` Python package. The lazy ``importlib.import_module("gateway.status")`` +# therefore raises ``ModuleNotFoundError`` and the payload falls through to +# ``gateway_not_configured`` even though ``HERMES_API_URL`` points at a perfectly +# reachable remote gateway. The Tasks/Cron banner then shows a spurious amber +# "Gateway not configured" warning. +# +# When ``HERMES_API_URL`` is set we treat that as an explicit declaration that +# the gateway lives elsewhere, and probe it over HTTP before touching any local +# filesystem / module signal. The probe result is cached briefly so a dashboard +# rerender that fans out to multiple panels does not hammer the gateway. + +_REMOTE_PROBE_TIMEOUT_S: float = 2.0 +_REMOTE_PROBE_CACHE_TTL_S: float = 5.0 +_REMOTE_PROBE_PATHS: tuple[str, ...] = ("/health", "/status", "/api/gateway/status") + +_remote_probe_lock = threading.Lock() +_remote_probe_cache: dict[str, Any] = {"url": None, "expires_at": 0.0, "result": None} + + +def _remote_gateway_base_url() -> str | None: + raw = os.environ.get("HERMES_API_URL") + if not isinstance(raw, str): + return None + url = raw.strip() + if not url: + return None + return url.rstrip("/") + + +def _http_probe(url: str, timeout_s: float) -> tuple[bool, int | None, str | None]: + """GET ``url`` and return (ok, status_code, error_name). + + ``ok`` is True only for a 2xx response. 5xx and network errors are not OK. + 4xx is also treated as "responded" (the gateway is up, just answering 404 + on this particular path) so the caller can move on to the next path. + """ + req = urllib_request.Request(url, method="GET") + try: + with urllib_request.urlopen(req, timeout=timeout_s) as resp: # noqa: S310 - trusted env var URL + status = getattr(resp, "status", None) or resp.getcode() + return (200 <= int(status) < 300, int(status), None) + except urllib_error.HTTPError as exc: + return (False, int(exc.code), "HTTPError") + except Exception as exc: # urllib_error.URLError, socket.timeout, ssl, etc. + return (False, None, type(exc).__name__) + + +def _probe_remote_gateway(base_url: str, *, now: float | None = None) -> dict[str, Any]: + """Return an agent-health payload dict for a remote gateway base URL. + + Result is cached for ``_REMOTE_PROBE_CACHE_TTL_S`` seconds per base_url. + """ + current = time.monotonic() if now is None else now + with _remote_probe_lock: + if ( + _remote_probe_cache.get("url") == base_url + and _remote_probe_cache.get("expires_at", 0.0) > current + and _remote_probe_cache.get("result") is not None + ): + cached = _remote_probe_cache["result"] + # Refresh checked_at so the UI shows a current timestamp without + # actually re-hitting the gateway. + return {**cached, "checked_at": _checked_at()} + + last_status: int | None = None + last_error: str | None = None + for path in _REMOTE_PROBE_PATHS: + ok, status, err = _http_probe(base_url + path, _REMOTE_PROBE_TIMEOUT_S) + if ok: + payload = { + "alive": True, + "checked_at": _checked_at(), + "details": { + "state": "alive", + "reason": "remote_gateway", + "endpoint": base_url + path, + "status_code": status, + }, + } + break + # Remember the most informative failure signal we saw. + if status is not None: + last_status = status + if err is not None: + last_error = err + else: + details: dict[str, Any] = { + "state": "down", + "reason": "remote_gateway_unreachable", + "endpoint": base_url, + } + if last_status is not None: + details["status_code"] = last_status + if last_error is not None: + details["error"] = last_error + payload = { + "alive": False, + "checked_at": _checked_at(), + "details": details, + } + + with _remote_probe_lock: + _remote_probe_cache["url"] = base_url + _remote_probe_cache["expires_at"] = current + _REMOTE_PROBE_CACHE_TTL_S + _remote_probe_cache["result"] = payload + return payload + + +def _reset_remote_probe_cache_for_tests() -> None: + """Test hook: clear the in-process remote-probe cache.""" + with _remote_probe_lock: + _remote_probe_cache["url"] = None + _remote_probe_cache["expires_at"] = 0.0 + _remote_probe_cache["result"] = None + + def build_agent_health_payload() -> dict[str, Any]: """Return `{alive, checked_at, details}` for the Hermes gateway/agent. @@ -295,6 +419,16 @@ def build_agent_health_payload() -> dict[str, Any]: probably not configured with a separate gateway process. """ checked_at = _checked_at() + + # Multi-container deployments (#3281): when HERMES_API_URL is set the + # gateway lives in another container/host. Probe it over HTTP before + # touching local module/pid/state-file signals, otherwise a missing + # ``gateway`` Python package in this image masquerades as + # "gateway_not_configured" and produces a spurious banner. + remote_base = _remote_gateway_base_url() + if remote_base is not None: + return _probe_remote_gateway(remote_base) + try: gateway_status = _gateway_status_module() except Exception as exc: diff --git a/tests/test_agent_health_remote.py b/tests/test_agent_health_remote.py new file mode 100644 index 00000000..b6c7b0ef --- /dev/null +++ b/tests/test_agent_health_remote.py @@ -0,0 +1,101 @@ +"""Tests for HERMES_API_URL remote gateway probe (#3281).""" +from __future__ import annotations + +from unittest import mock + +import pytest + +from api import agent_health + + +@pytest.fixture(autouse=True) +def _clear_cache(): + agent_health._reset_remote_probe_cache_for_tests() + yield + agent_health._reset_remote_probe_cache_for_tests() + + +class _FakeResp: + def __init__(self, status: int = 200): + self.status = status + + def getcode(self) -> int: + return self.status + + def read(self) -> bytes: + return b"" + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + +def test_remote_gateway_healthy_when_200(monkeypatch): + monkeypatch.setenv("HERMES_API_URL", "http://gateway:8080") + calls: list[str] = [] + + def fake_urlopen(req, timeout=None): + calls.append(req.full_url) + return _FakeResp(200) + + with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen): + payload = agent_health.build_agent_health_payload() + + assert payload["alive"] is True + assert payload["details"]["reason"] == "remote_gateway" + assert payload["details"]["status_code"] == 200 + assert calls and calls[0].startswith("http://gateway:8080/") + + +def test_remote_gateway_unreachable_when_network_error(monkeypatch): + monkeypatch.setenv("HERMES_API_URL", "http://gateway:8080/") + + def fake_urlopen(req, timeout=None): + raise OSError("connection refused") + + with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen): + payload = agent_health.build_agent_health_payload() + + assert payload["alive"] is False + assert payload["details"]["reason"] == "remote_gateway_unreachable" + assert payload["details"]["endpoint"] == "http://gateway:8080" + assert "error" in payload["details"] + + +def test_falls_back_to_local_when_no_env(monkeypatch): + monkeypatch.delenv("HERMES_API_URL", raising=False) + + # Force the local importlib path to fail so we hit the well-known + # "gateway_not_configured" terminal state — proving the remote probe was + # NOT invoked and the legacy local path ran. + def boom(name): + raise ModuleNotFoundError(name) + + with mock.patch.object(agent_health.importlib, "import_module", boom): + payload = agent_health.build_agent_health_payload() + + assert payload["alive"] is None + assert payload["details"]["reason"] == "gateway_status_unavailable" + + +def test_remote_probe_result_cached_for_5s(monkeypatch): + monkeypatch.setenv("HERMES_API_URL", "http://gateway:8080") + call_count = {"n": 0} + + def fake_urlopen(req, timeout=None): + call_count["n"] += 1 + return _FakeResp(200) + + with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen): + first = agent_health.build_agent_health_payload() + second = agent_health.build_agent_health_payload() + + assert first["alive"] is True + assert second["alive"] is True + assert second["details"]["reason"] == "remote_gateway" + # Second call must NOT have hit the network. + assert call_count["n"] == 1 + # checked_at is refreshed even on cache hit so the UI shows a current time. + assert "checked_at" in second From 0cae7c644c401de0ba5800b553044e3c52aa663e Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 19:38:00 +0000 Subject: [PATCH 2/4] fix: preserve ephemeral turn fields when loadSession force-reloads (#3313) Co-authored-by: Sanjays2402 --- static/sessions.js | 53 ++++++- ...est_issue3306_loadsession_carry_forward.py | 143 ++++++++++++++++++ 2 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue3306_loadsession_carry_forward.py diff --git a/static/sessions.js b/static/sessions.js index ddde4a12..dad1a083 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -17,6 +17,13 @@ const ICONS={ // responses from in-flight requests when the user switches sessions again // before the first request completes (#1060). let _loadingSessionId = null; +// #3306: Snapshot of S.messages captured by loadSession() right before it +// clears them on a force-reload of the active session. Consumed by +// _ensureMessagesLoaded() when calling _carryForwardEphemeralTurnFields so +// ephemeral fields (_turnUsage, _turnDuration, _turnTps, _gatewayRouting, +// _statusCard) survive the wholesale replace. null when there is nothing +// to carry forward (initial load, switch-to-different-session, etc.). +let _pendingCarryForwardSnapshot = null; // ── Composer draft persistence ──────────────────────────────────────────────── @@ -611,6 +618,18 @@ async function loadSession(sid){ _saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []); } if (currentSid !== sid || forceReload) { + // #3306: When force-reloading the currently-active session (e.g. external + // poll triggering a refresh), snapshot the existing messages BEFORE we + // clear them. _ensureMessagesLoaded() runs the ephemeral-field + // carry-forward (_turnUsage, _turnDuration, _turnTps, _gatewayRouting, + // _statusCard) against S.messages, but by the time the API fetch returns + // S.messages has already been reset to [] here and the carry-forward is a + // no-op. The visible symptom is the token-usage badge vanishing ~10s + // after each assistant turn completes. Stash the snapshot so the + // carry-forward call can consume it. + _pendingCarryForwardSnapshot = (currentSid === sid && forceReload) + ? (S.messages || []).slice() + : null; S.messages = []; S.toolCalls = []; _messagesTruncated = false; @@ -1355,7 +1374,14 @@ async function _ensureMessagesLoaded(sid) { // #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration, // _turnTps, _gatewayRouting, _statusCard) across the loadSession replace. if(typeof window._carryForwardEphemeralTurnFields==='function'){ - msgs=window._carryForwardEphemeralTurnFields(S.messages||[], msgs); + // #3306: Prefer the pre-clear snapshot stashed by loadSession() on a + // force-reload of the active session; S.messages was reset to [] there + // and would otherwise yield an empty carry-forward. + const _prev = (Array.isArray(_pendingCarryForwardSnapshot) && _pendingCarryForwardSnapshot.length) + ? _pendingCarryForwardSnapshot + : (S.messages || []); + msgs=window._carryForwardEphemeralTurnFields(_prev, msgs); + _pendingCarryForwardSnapshot = null; } S.messages = msgs; if(S.session&&S.session.session_id===sid){ @@ -1512,6 +1538,12 @@ async function _loadOlderMessages() { // Use $('messages') — the scrollable container (#msgInner is not scrollable). const container = $('messages'); const prevScrollH = container ? container.scrollHeight : 0; + // Carry forward ephemeral turn fields (_turnUsage/_turnDuration/_turnTps/ + // _gatewayRouting/_statusCard) before the wholesale replace so the badge + // does not briefly appear and disappear during older-message expansion. + if (typeof window._carryForwardEphemeralTurnFields === 'function') { + nextMessages = window._carryForwardEphemeralTurnFields(S.messages || [], nextMessages); + } S.messages = nextMessages; _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls); // renderMessages() windows long transcripts from the end. If we do not @@ -1595,7 +1627,15 @@ async function _ensureAllMessagesLoaded() { // prefetch (whose snapshot was taken before this call's mutex // acquisition) sees the new value and aborts. _bumpMessagesGeneration(); - S.messages = msgs; + // #3306: Same ephemeral-field carry-forward as _ensureMessagesLoaded. + // Loading older messages also does a wholesale replace of S.messages + // and would otherwise drop _turnUsage/_turnDuration/_turnTps/ + // _gatewayRouting/_statusCard on the existing turns. + let _msgsToAssign = msgs; + if (typeof window._carryForwardEphemeralTurnFields === 'function') { + _msgsToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], msgs); + } + S.messages = _msgsToAssign; _messagesTruncated = false; _oldestIdx = 0; _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls); @@ -2846,7 +2886,14 @@ function startGatewaySSE(){ const next = res.session.messages.filter(m => m && m.role); if (next.length < prev) return; if (prev > 0 && !_isCliImportRefreshPrefixMatch(S.messages, next)) return; - S.messages = next; + // Carry forward ephemeral turn fields (_turnUsage/ + // _turnDuration/_turnTps/_gatewayRouting/_statusCard) so + // gateway-driven CLI refreshes do not drop the badge. + let _nextToAssign = next; + if (typeof window._carryForwardEphemeralTurnFields === 'function') { + _nextToAssign = window._carryForwardEphemeralTurnFields(S.messages || [], next); + } + S.messages = _nextToAssign; if(S.session && S.session.session_id === activeSid){ S.session.message_count = next.length; const newest = next.length ? next[next.length - 1] : null; diff --git a/tests/test_issue3306_loadsession_carry_forward.py b/tests/test_issue3306_loadsession_carry_forward.py new file mode 100644 index 00000000..5074da74 --- /dev/null +++ b/tests/test_issue3306_loadsession_carry_forward.py @@ -0,0 +1,143 @@ +"""Regression test pinning the #3306 fix. + +#3306: `loadSession()` in static/sessions.js cleared `S.messages = []` BEFORE +issuing the API fetch and BEFORE `_ensureMessagesLoaded()` invoked the #3018 +ephemeral-field carry-forward (`_carryForwardEphemeralTurnFields(S.messages||[], +msgs)`). Because the clear happened first, the carry-forward saw an empty +array and ephemeral turn fields (_turnUsage, _turnDuration, _turnTps, +_gatewayRouting, _statusCard) were dropped on every force-reload. The visible +symptom: the token-usage badge vanished ~10s after each assistant turn finished +when an external poll triggered loadSession(..., forceReload). + +Fix: snapshot S.messages BEFORE the clear (only when force-reloading the +currently-active session) into a module-level `_pendingCarryForwardSnapshot`, +then consume it in `_ensureMessagesLoaded()` ahead of the live S.messages +(which is now []). + +This file is the targeted source-text pin in the same style as +tests/test_issue3162_ensure_messages_loaded.py. +""" +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _load_session_clear_block() -> str: + """The if(currentSid!==sid||forceReload){...} block in loadSession().""" + start = SESSIONS_JS.index("async function loadSession(sid)") + return SESSIONS_JS[start: start + 4000] + + +def _ensure_messages_loaded_body() -> str: + start = SESSIONS_JS.index("async function _ensureMessagesLoaded") + return SESSIONS_JS[start: start + 2500] + + +def test_pending_carry_forward_snapshot_declared_at_module_scope(): + assert "let _pendingCarryForwardSnapshot" in SESSIONS_JS, ( + "module-level _pendingCarryForwardSnapshot is the bridge between " + "loadSession()'s pre-clear snapshot and _ensureMessagesLoaded()'s " + "carry-forward call — required by #3306 fix" + ) + + +def test_loadsession_snapshots_messages_before_clearing(): + body = _load_session_clear_block() + # The assignment to _pendingCarryForwardSnapshot must appear before the + # `S.messages = [];` clear inside the if-block. + assign_idx = body.find("_pendingCarryForwardSnapshot =") + clear_idx = body.find("S.messages = [];") + assert assign_idx != -1, ( + "#3306: loadSession() must snapshot S.messages into " + "_pendingCarryForwardSnapshot before clearing — snapshot assignment missing" + ) + assert clear_idx != -1, "S.messages = [] clear not found in loadSession()" + assert assign_idx < clear_idx, ( + "#3306: _pendingCarryForwardSnapshot must be assigned BEFORE " + "`S.messages = []`, otherwise the snapshot captures an already-empty array" + ) + + +def test_loadsession_snapshot_gated_on_force_reload_of_active_session(): + body = _load_session_clear_block() + # The snapshot should only happen when reloading the currently-active session. + assert "currentSid === sid && forceReload" in body, ( + "#3306: snapshot must be gated on `currentSid === sid && forceReload` " + "so switching to a different session still gets a clean carry-forward " + "(prior messages would belong to a different conversation)" + ) + + +def test_ensure_messages_loaded_consumes_snapshot_then_clears_it(): + body = _ensure_messages_loaded_body() + assert "_pendingCarryForwardSnapshot" in body, ( + "#3306: _ensureMessagesLoaded() must consult _pendingCarryForwardSnapshot " + "when calling _carryForwardEphemeralTurnFields" + ) + # And must reset it afterwards so subsequent non-force loads don't reuse + # a stale snapshot. + assert "_pendingCarryForwardSnapshot = null" in body, ( + "#3306: _ensureMessagesLoaded() must reset _pendingCarryForwardSnapshot " + "to null after consuming it, to avoid leaking stale ephemeral fields " + "into a later unrelated load" + ) + + +def _load_older_messages_body() -> str: + start = SESSIONS_JS.index("async function _loadOlderMessages") + return SESSIONS_JS[start: start + 6000] + + +def _start_gateway_sse_body() -> str: + start = SESSIONS_JS.index("function startGatewaySSE") + return SESSIONS_JS[start: start + 4000] + + +def test_load_older_messages_tail_match_carries_forward(): + """#3306 follow-up: _loadOlderMessages does a wholesale `S.messages = nextMessages` + on the tail-match path. Without carry-forward this drops ephemeral turn fields + (the intermittent "badge appears then disappears" symptom flagged in review).""" + body = _load_older_messages_body() + cf_idx = body.find("_carryForwardEphemeralTurnFields(S.messages || [], nextMessages)") + assign_idx = body.find("S.messages = nextMessages;") + assert cf_idx != -1, ( + "#3306: _loadOlderMessages must carry forward ephemeral turn fields " + "into nextMessages before the wholesale S.messages assignment" + ) + assert assign_idx != -1, "S.messages = nextMessages assignment not found" + assert cf_idx < assign_idx, ( + "#3306: carry-forward call must precede `S.messages = nextMessages` " + "in _loadOlderMessages, otherwise the fields are already gone" + ) + + +def test_start_gateway_sse_import_cli_carries_forward(): + """#3306 follow-up: the gateway SSE → import_cli refresh path also does a + wholesale `S.messages = next` for CLI sessions; it must carry forward + ephemeral turn fields too.""" + body = _start_gateway_sse_body() + cf_idx = body.find("_carryForwardEphemeralTurnFields(S.messages || [], next)") + assign_idx = body.find("S.messages = _nextToAssign;") + assert cf_idx != -1, ( + "#3306: startGatewaySSE import_cli branch must carry forward " + "ephemeral turn fields before assigning S.messages" + ) + assert assign_idx != -1, ( + "expected `S.messages = _nextToAssign;` after carry-forward in " + "startGatewaySSE import_cli branch" + ) + assert cf_idx < assign_idx, ( + "#3306: carry-forward call must precede the S.messages assignment " + "in the startGatewaySSE import_cli branch" + ) + + +def test_carry_forward_call_still_present(): + """If the #3018 carry-forward is ever removed, this fix becomes moot — + flag that explicitly so reviewers reconsider the snapshot machinery.""" + body = _ensure_messages_loaded_body().replace(" ", "") + assert "_carryForwardEphemeralTurnFields" in body, ( + "the #3018 carry-forward is gone — re-evaluate whether " + "_pendingCarryForwardSnapshot is still needed (#3306)" + ) From c0cfc1b975ddd8bae29517107fba54e231761fb4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 19:38:30 +0000 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20v0.51.200=20release=20header=20(sta?= =?UTF-8?q?ge-batch12=20=E2=80=94=20#3312=20#3313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e6e826..e1d9b29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.200] — 2026-06-01 — Release FT (stage-batch12 — remote-gateway health probe + ephemeral-turn-field preservation) + +### Fixed +- The Tasks/Cron panel no longer shows a spurious "Gateway not configured" banner in multi-container Docker deployments where the WebUI image doesn't ship the `gateway` Python package: agent-health now probes the remote gateway via `HERMES_API_URL` before falling back to the local `gateway.status` import. Closes #3281 (#3312, @Sanjays2402). +- Force-reloading the active session (`loadSession(sid, {forceReload:true})`) no longer drops ephemeral turn fields (`_turnUsage`, `_turnDuration`, `_turnTps`, `_gatewayRouting`, `_statusCard`): the ephemeral-field carry-forward now reads the prior `S.messages` before it's reset, so the token-usage badge and status cards survive an external refresh. Closes #3306 (#3313, @Sanjays2402). + ## [v0.51.199] — 2026-06-01 — Release FS (stage-batch11 — pinned-scroll recovery + inline-math currency false-positive) ### Fixed From 042294e2a12dc089b9d588556927f94597fb1287 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 1 Jun 2026 19:46:36 +0000 Subject: [PATCH 4/4] test: update sessions.js string-assertions for #3313 carry-forward rename #3313 applies the ephemeral-field carry-forward at all 5 wholesale S.messages replace sites (loadSession/_ensureMessagesLoaded/_loadOlderMessages/ _ensureAllMessagesLoaded/startGatewaySSE), renaming the replace RHS from msgs/next to _msgsToAssign/_nextToAssign. These pre-existing tests pinned the old literals; updated to match the new RHS (or made LHS-agnostic) while preserving the invariants they protect (bump-before-replace, session-switch guard, shorter-transcript guard). --- ...t_issue1937_endless_scroll_jumpstart_race.py | 17 ++++++++++++++--- tests/test_session_import_cli_sse_refresh.py | 5 ++++- tests/test_tool_call_history_paging.py | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/test_issue1937_endless_scroll_jumpstart_race.py b/tests/test_issue1937_endless_scroll_jumpstart_race.py index f2ec54f4..c772a4b0 100644 --- a/tests/test_issue1937_endless_scroll_jumpstart_race.py +++ b/tests/test_issue1937_endless_scroll_jumpstart_race.py @@ -122,10 +122,16 @@ def test_load_older_generation_check_runs_before_replace(): # --------------------------------------------------------------------------- def test_ensure_all_bumps_generation_before_replace(): - """Bump must happen BEFORE `S.messages = msgs` so racing prefetch sees it.""" + """Bump must happen BEFORE the wholesale `S.messages =` replace so racing prefetch sees it.""" + import re body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") bump_idx = body.rindex("_bumpMessagesGeneration()") - replace_idx = body.index("S.messages = msgs;") + # Match the wholesale replace by its LHS, not the RHS variable name — #3306 + # added an ephemeral-field carry-forward so the RHS is now `_msgsToAssign` + # rather than the literal `msgs`. The invariant we protect is bump-before-replace. + m = re.search(r"S\.messages\s*=\s*\w+;", body) + assert m is not None, "expected a wholesale `S.messages = ;` replace in _ensureAllMessagesLoaded" + replace_idx = m.start() assert bump_idx < replace_idx, ( "_ensureAllMessagesLoaded must bump the generation token BEFORE the " "wholesale replace, otherwise an in-flight prefetch's post-await " @@ -201,10 +207,15 @@ def test_ensure_all_resets_oldest_idx(): def test_ensure_all_guards_against_session_switch_mid_await(): """Same-session check must run after await — old version skipped this.""" + import re body = _function_body(SESSIONS_JS, "_ensureAllMessagesLoaded") await_idx = body.index("await api(") sid_check_idx = body.index("S.session.session_id !== sid", await_idx) - replace_idx = body.index("S.messages = msgs;", await_idx) + # #3306 renamed the replace RHS from `msgs` to `_msgsToAssign` (carry-forward); + # match by LHS so the ordering invariant survives the rename. + m = re.search(r"S\.messages\s*=\s*\w+;", body[await_idx:]) + assert m is not None, "expected a wholesale `S.messages = ;` replace after the await" + replace_idx = await_idx + m.start() assert await_idx < sid_check_idx < replace_idx, ( "_ensureAllMessagesLoaded must guard against session-switch races " "(re-check S.session.session_id after await) BEFORE wholesale-" diff --git a/tests/test_session_import_cli_sse_refresh.py b/tests/test_session_import_cli_sse_refresh.py index 62b9ad7a..3a726dc5 100644 --- a/tests/test_session_import_cli_sse_refresh.py +++ b/tests/test_session_import_cli_sse_refresh.py @@ -16,7 +16,10 @@ def test_sse_import_cli_guard_skips_shorter_transcript_overwrite(): assert "const next = res.session.messages.filter(m => m && m.role);" in sse_block assert "if (next.length < prev) return;" in sse_block assert "if (prev > 0 && !_isCliImportRefreshPrefixMatch(S.messages, next)) return;" in sse_block - assert "S.messages = next;" in sse_block + # #3306 added an ephemeral-field carry-forward before the assignment, so the + # replace RHS is now `_nextToAssign` (= carry-forward of `next`). The guard + # invariants above are what this test protects; the wholesale replace remains. + assert "S.messages = _nextToAssign;" in sse_block assert "S.session.message_count = next.length;" in sse_block assert "renderMessages({preserveScroll:true});" in sse_block diff --git a/tests/test_tool_call_history_paging.py b/tests/test_tool_call_history_paging.py index 23cf9b34..f7528c86 100644 --- a/tests/test_tool_call_history_paging.py +++ b/tests/test_tool_call_history_paging.py @@ -16,7 +16,7 @@ def test_sessions_js_resyncs_tool_calls_after_history_window_replacement(): assert "function _syncToolCallsForLoadedMessages(messages, sessionToolCalls)" in SESSIONS_JS assert "_syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);" in SESSIONS_JS assert "S.messages = nextMessages;\n _syncToolCallsForLoadedMessages(nextMessages, responseSession.tool_calls);" in SESSIONS_JS - assert "S.messages = msgs;\n _messagesTruncated = false;\n _oldestIdx = 0;\n _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);" in SESSIONS_JS + assert "S.messages = _msgsToAssign;\n _messagesTruncated = false;\n _oldestIdx = 0;\n _syncToolCallsForLoadedMessages(msgs, data.session.tool_calls);" in SESSIONS_JS def test_sessions_js_clears_session_tool_calls_when_messages_have_own_metadata():