Files
hermes-webui/tests/test_issue3040_reattach_invariant.py
T
442b033e67 Release v0.51.268 — Release IJ (stage-b1 — low-risk perf + provider/clarify fixes) (#3671)
* perf(providers): O(1) codex cache merge membership checks (#3656)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* fix(models): add MiniMax-M3 to WebUI MiniMax fallback catalog test (#3627)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* fix(config): make DeepSeek reasoning-effort heuristic position-independent (#3650)

Co-authored-by: happy5318 <happy5318@users.noreply.github.com>

* fix(clarify): don't stash clarify draft while submission is in flight (#3651)

Co-authored-by: carryzuo00 <carryzuo00@gmail.com>

* perf(sessions): batch lineage report child fetch by parent id (#3659)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* perf(sessions): batch orphan sidecar state.db existence probes (#3657)

Co-authored-by: Pamnard <pamnard@users.noreply.github.com>

* test(streaming): pin DOM-INFLIGHT reattach invariant (#3572)

Co-authored-by: Rod Boev <rod.boev@gmail.com>

* docs(changelog): v0.51.268 — Release IJ (stage-b1)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: Pamnard <pamnard@users.noreply.github.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: carryzuo00 <carryzuo00@gmail.com>
2026-06-05 10:44:02 -07:00

213 lines
10 KiB
Python

"""Static-analysis contract tests for the DOM-INFLIGHT reattach sync invariant (#3040).
Every site that writes to INFLIGHT[] must persist the change so that a reconnect
or session-switch can seed the new closure from durable state rather than a stale
DOM snapshot. These tests pin the structural invariants to catch regressions that
remove a persistence call or add a new INFLIGHT write without pairing it with one.
"""
from __future__ import annotations
import re
from pathlib import Path
MESSAGES_JS = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text(
encoding="utf-8"
)
_LINES = MESSAGES_JS.splitlines()
def _line_window(line_no: int, radius: int = 20) -> str:
"""Return up to `radius` lines on either side of `line_no` (1-based) as one string."""
start = max(0, line_no - 1 - radius)
end = min(len(_LINES), line_no - 1 + radius + 1)
return "\n".join(_LINES[start:end])
def _inflight_write_line_numbers() -> list[int]:
"""Return 1-based line numbers for every INFLIGHT[activeSid] assignment site."""
results = []
for i, line in enumerate(_LINES, start=1):
if re.search(r"INFLIGHT\[activeSid\]\s*=\s*\{", line) or re.search(
r"INFLIGHT\[activeSid\]\s*\|\|\s*\(INFLIGHT\[activeSid\]\s*=\s*\{", line
):
results.append(i)
return results
def test_inflight_write_sites_exist():
"""Sanity-check: messages.js must contain at least 3 INFLIGHT[activeSid] write sites."""
sites = _inflight_write_line_numbers()
assert len(sites) >= 3, (
f"Expected at least 3 INFLIGHT[activeSid] assignment sites, found {len(sites)}. "
"If send() was refactored, update the expected count."
)
def _extract_send_body() -> str:
"""Extract the body of the send() function up to attachLiveStream() as one string."""
# send() starts with `async function send(` and the INFLIGHT writes in the
# optimistic path and catch block both precede the attachLiveStream call.
send_start = MESSAGES_JS.find("async function send(")
assert send_start != -1, "async function send() not found in messages.js."
attach_call = MESSAGES_JS.find("attachLiveStream(activeSid, streamId", send_start)
assert attach_call != -1, "attachLiveStream call not found inside send()."
return MESSAGES_JS[send_start:attach_call]
def test_send_inflight_writes_are_paired_with_saveInflightState():
"""The INFLIGHT[activeSid] assignments in send() must be covered by saveInflightState()
calls within the same send() scope.
send() makes the optimistic UI write before calling /api/chat/start so that a tab
reload during the network round-trip can restore the user's message. If the write
is not persisted, the only recovery window is lost.
There are 3 write sites but only 2 saveInflightState calls because the catch-block
fallback write (line ~530) shares its persistence call with the post-start path:
if /api/chat/start succeeds, the post-start block's saveInflightState covers it;
if /api/chat/start fails, the error handler deletes INFLIGHT[activeSid] anyway.
A minimum of 2 saveInflightState calls pins this intentional design.
"""
send_body = _extract_send_body()
# Count how many INFLIGHT assignments appear in send() before attachLiveStream.
write_count = len(re.findall(r"INFLIGHT\[activeSid\]\s*=\s*\{", send_body))
assert write_count >= 2, (
f"Expected at least 2 INFLIGHT[activeSid] write sites in send(), found {write_count}. "
"The optimistic write and the catch-block fallback should both be present."
)
# There must be at least 2 saveInflightState calls covering the write paths.
# (The catch-block fallback write shares the post-start persistence call.)
persist_count = send_body.count("saveInflightState(")
assert persist_count >= 2, (
f"send() has only {persist_count} saveInflightState() call(s) before attachLiveStream "
"(expected at least 2: one before /api/chat/start for optimistic reload recovery, "
"one after start for stream-id persistence). "
"Removing either leaves a reload window with unrecoverable INFLIGHT state."
)
def test_syncInflightAssistantMessage_calls_throttled_persist():
"""syncInflightAssistantMessage() must call _throttledPersist() to flush the live
assistant text into persistent INFLIGHT state on every token and interim_assistant event."""
fn_start = MESSAGES_JS.find("function syncInflightAssistantMessage(){")
assert fn_start != -1, "syncInflightAssistantMessage() not found in messages.js."
fn_body_start = MESSAGES_JS.find("{", fn_start)
# Walk braces to find the closing brace of the function body.
depth = 1
i = fn_body_start + 1
while i < len(MESSAGES_JS) and depth:
if MESSAGES_JS[i] == "{":
depth += 1
elif MESSAGES_JS[i] == "}":
depth -= 1
i += 1
fn_body = MESSAGES_JS[fn_body_start:i]
assert "_throttledPersist()" in fn_body, (
"syncInflightAssistantMessage() must call _throttledPersist() so each "
"live-text update is scheduled for persistence. Without this, a reconnect "
"seeds from the last tool-event snapshot and loses all interim assistant text."
)
def test_interim_assistant_calls_syncInflightAssistantMessage_after_accumulating_text():
"""The interim_assistant event handler must call syncInflightAssistantMessage()
after it appends to assistantText and visibleInterimSnippets.
Without this call, the INFLIGHT assistant message is not updated before a
potential reconnect, causing already-streamed content to be lost.
"""
handler_start = MESSAGES_JS.find("source.addEventListener('interim_assistant',")
assert handler_start != -1, "interim_assistant event listener not found in messages.js."
# Extract the handler body up to the matching closing brace.
brace_pos = MESSAGES_JS.find("{", handler_start)
depth = 1
i = brace_pos + 1
while i < len(MESSAGES_JS) and depth:
if MESSAGES_JS[i] == "{":
depth += 1
elif MESSAGES_JS[i] == "}":
depth -= 1
i += 1
handler_body = MESSAGES_JS[brace_pos:i]
assert "assistantText" in handler_body, (
"interim_assistant handler does not reference assistantText — handler may have been refactored."
)
assert "visibleInterimSnippets" in handler_body, (
"interim_assistant handler does not reference visibleInterimSnippets — "
"interim snippet tracking was removed."
)
# syncInflightAssistantMessage() must appear AFTER the accumulation lines.
accum_pos = handler_body.find("visibleInterimSnippets.push(")
sync_pos = handler_body.find("syncInflightAssistantMessage()")
assert sync_pos != -1, (
"interim_assistant handler does not call syncInflightAssistantMessage(). "
"Without this, INFLIGHT is not updated with interim text and a reconnect loses progress."
)
assert accum_pos != -1 and sync_pos > accum_pos, (
"syncInflightAssistantMessage() must appear AFTER visibleInterimSnippets.push() "
"so that the persisted snapshot includes the freshly accumulated snippet."
)
def test_attachLiveStream_reconnect_seeds_from_INFLIGHT_not_querySelector():
"""On reconnect, attachLiveStream() must seed assistantText from INFLIGHT state,
not from a live DOM query (querySelector).
Seeding from the DOM is fragile: an off-screen or BFCache-restored session may
have no rendered content, so the closure would start empty and duplicate tokens
would overwrite the already-displayed response.
"""
fn_start = MESSAGES_JS.find("function attachLiveStream(")
assert fn_start != -1, "attachLiveStream() not found in messages.js."
# Capture up to 60 lines after the function signature for the seed section.
fn_head_end = fn_start
for _ in range(60):
fn_head_end = MESSAGES_JS.find("\n", fn_head_end + 1)
if fn_head_end == -1:
break
seed_section = MESSAGES_JS[fn_start:fn_head_end]
# The reconnect seed must come from INFLIGHT[activeSid].
assert "INFLIGHT[activeSid]" in seed_section, (
"attachLiveStream() seed section does not reference INFLIGHT[activeSid]. "
"Reconnect must read accumulated text from durable INFLIGHT state."
)
assert "reconnecting" in seed_section, (
"attachLiveStream() seed section does not branch on `reconnecting`. "
"The reconnect path must distinguish a fresh open from a re-attach."
)
# The seed must NOT use querySelector to read DOM content for the initial text.
idx = seed_section.find("let assistantText")
assert idx != -1, "let assistantText not found in seed section — capture window too narrow or variable renamed"
seed_up_to_assistantText = seed_section[:idx]
assert "querySelector" not in seed_up_to_assistantText, (
"attachLiveStream() reads DOM via querySelector before seeding assistantText. "
"Use INFLIGHT state exclusively; DOM queries are unreliable for off-screen sessions."
)
def test_syncInflightAssistantMessage_exists_as_closure_inside_attachLiveStream():
"""syncInflightAssistantMessage() must be defined as a closure inside attachLiveStream()
so it closes over the live assistantText and reasoningText accumulators.
If it were a module-level function it could not access the per-stream state,
breaking the token→INFLIGHT sync for every concurrent stream.
"""
attach_start = MESSAGES_JS.find("function attachLiveStream(")
assert attach_start != -1, "attachLiveStream() not found."
# syncInflightAssistantMessage definition must appear after attachLiveStream opens.
sync_def = MESSAGES_JS.find("function syncInflightAssistantMessage(){", attach_start)
assert sync_def != -1, (
"syncInflightAssistantMessage() is not defined inside attachLiveStream(). "
"It must be a closure over the stream accumulators (assistantText, reasoningText)."
)
# And there must NOT be a top-level definition before attachLiveStream.
top_level_def = MESSAGES_JS.find("function syncInflightAssistantMessage(){")
assert top_level_def == sync_def, (
"syncInflightAssistantMessage() has a top-level definition. "
"It must live inside attachLiveStream() to close over per-stream state."
)