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:
nesquena-hermes authored and GitHub committed 2026-06-01 12:52:18 -07:00
commit 1c6e34b7b6
8 files changed
+453 -8

No files matched your search

+6
View File
@@ -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
+134
View File
@@ -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
View File
@@ -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;
+101
View File
@@ -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)"
)
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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():