Merge pull request #3354 from nesquena/release/stage-batch12
v0.51.200: remote-gateway health probe + ephemeral-turn-field preservation
This commit is contained in:
8 files changed
+453
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+50
-3
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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 = <var>;` 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 = <var>;` 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-"
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user