Files
hermes-webui/tests/test_run_journal_frontend_static.py
T
nesquena-hermesandt3chn0pr13st 6bac70d298 fix: preserve live stream output across session switches (cross-client)
Adds a server-side run-journal live snapshot (_run_journal_live_snapshot) returned
in GET /api/session as runtime_journal_snapshot, so a FRESH client (another device,
or a tab with no in-memory snapshot) opening an in-progress session immediately sees
the already-streamed assistant text + tool cards rebuilt from the server. Composes
with the existing _replay_run_journal cursor path (seeds lastRunJournalSeq so replay
resumes from the snapshot cutoff, not duplicating it) and keys tool cards by the same
5 id aliases (tid/id/tool_call_id/tool_use_id/call_id) as #3763 so SSE replay replaces
rather than duplicates snapshot cards. Payload values truncated; redaction test added.

Co-authored-by: t3chn0pr13st <technopriest@live.ru>
2026-06-10 20:12:05 +00:00

142 lines
6.2 KiB
Python

from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MESSAGES_SRC = (ROOT / "static" / "messages.js").read_text()
SESSIONS_SRC = (ROOT / "static" / "sessions.js").read_text()
def test_reattach_path_uses_replay_when_status_reports_journal():
reattach_pos = MESSAGES_SRC.index("let replayOnly=false;")
block = MESSAGES_SRC[reattach_pos : reattach_pos + 1200]
assert "st.replay_available" in block
assert "replayOnly=true" in block
assert "(reconnecting||replayOnly)?_runJournalReplayParams():''" in block
assert "_clearOwnerInflightState()" in block
def test_error_reconnect_path_can_restore_from_journal():
reconnect_pos = MESSAGES_SRC.index("setComposerStatus('Reconnecting")
block = MESSAGES_SRC[reconnect_pos : reconnect_pos + 900]
assert "st.active" in block
assert "st.replay_available" in block
assert "Restoring stream" in block
assert "_runJournalReplayParams()" in block
def test_frontend_replay_cursor_uses_eventsource_last_event_id():
cursor_pos = MESSAGES_SRC.index("function _rememberRunJournalCursor")
block = MESSAGES_SRC[cursor_pos : cursor_pos + 1000]
assert "e.lastEventId" in block
assert "lastIndexOf(':')" in block
assert "_lastRunJournalSeq=seq" in block
assert "source.addEventListener(_runJournalEventName,_rememberRunJournalCursor)" in MESSAGES_SRC
assert "after_seq=${encodeURIComponent(String(_runJournalReplayAfterSeq()))}" in MESSAGES_SRC
assert "after_seq=0" not in MESSAGES_SRC
def test_replayed_long_task_events_enter_the_same_live_timeline_handlers():
"""Run-journal replay must not grow a parallel long-task renderer.
The run-state consistency contract depends on replayed journal events
flowing through the same EventSource handlers as live streams. Otherwise a
live long task can render as Thinking -> progress text -> tool cards, while
the same journaled event sequence replays as a flattened or reordered scene.
"""
wire_pos = MESSAGES_SRC.index("function _wireSSE(source)")
wire_block = MESSAGES_SRC[wire_pos : MESSAGES_SRC.index("async function _restoreSettledSession", wire_pos)]
replay_events = [
"reasoning",
"interim_assistant",
"tool",
"tool_complete",
"compressing",
"compressed",
"metering",
"done",
"apperror",
]
for event_name in replay_events:
assert f"source.addEventListener('{event_name}'" in wire_block, (
f"{event_name} must be handled by the shared live/replay SSE pipeline"
)
thinking_helper = MESSAGES_SRC[
MESSAGES_SRC.index("function _updateLiveThinkingCard") :
MESSAGES_SRC.index("// Split a content string", MESSAGES_SRC.index("function _updateLiveThinkingCard"))
]
assert "_updateLiveThinkingCard(" in wire_block, "reasoning replay should use the live Thinking card path"
assert "updateThinking(text, opts)" in thinking_helper and "appendThinking(text, opts)" in thinking_helper, (
"the shared Thinking helper should still route replay/live reasoning into the Worklog Thinking card path"
)
assert "appendLiveToolCard(tc" in wire_block, "tool replay should use live tool-card rendering"
# Compression replay must dispatch through setCompressionUi(...). The handler
# body may build the state object inline (`setCompressionUi({...})`) or hoist
# it into a `state` variable first (`setCompressionUi(state)`) — both forms
# use the same compression-card path, so accept either. Pinning the literal
# `{` after the open-paren was over-specific and broke in v0.51.76 when
# PR #2347 hoisted the state object to share it with `appendLiveCompressionCard`.
assert ("setCompressionUi({" in wire_block) or ("setCompressionUi(state)" in wire_block), (
"compression replay should use the compression card path"
)
assert "_runJournalReplayParams()" in MESSAGES_SRC, "replay attachments should enter _wireSSE via EventSource"
def test_run_journal_cursor_tracks_every_long_task_timeline_event():
"""Every user-visible long-task event needs cursor tracking for parity replay."""
cursor_loop_pos = MESSAGES_SRC.index("for(const _runJournalEventName of [")
cursor_loop = MESSAGES_SRC[cursor_loop_pos : MESSAGES_SRC.index("]", cursor_loop_pos)]
timeline_events = [
"token",
"interim_assistant",
"reasoning",
"tool",
"tool_complete",
"compressing",
"compressed",
"metering",
"done",
"apperror",
"cancel",
]
for event_name in timeline_events:
assert f"'{event_name}'" in cursor_loop, (
f"{event_name} must advance the replay cursor to avoid duplicate timeline replay"
)
def test_server_runtime_journal_snapshot_restores_structured_inflight_state():
helper_pos = SESSIONS_SRC.index("function _serverLiveSnapshotToolId")
helper_block = SESSIONS_SRC[helper_pos : helper_pos + 2600]
load_pos = SESSIONS_SRC.index("async function loadSession")
load_block = SESSIONS_SRC[load_pos : load_pos + 18000]
assert "runtime_journal_snapshot" in load_block
assert "_serverLiveSnapshotInflight(S.session.runtime_journal_snapshot" in load_block
assert "!_inflightHasVisibleLiveState(INFLIGHT[sid])" in load_block
assert "journalSnapshot:true" in helper_block
assert "lastRunJournalSeq" in helper_block
assert "last_assistant_text" in helper_block
assert "activity_burst_anchors" in helper_block
for key in ("tid", "id", "tool_call_id", "tool_use_id", "call_id"):
assert key in helper_block
def test_live_tool_matching_uses_the_same_aliases_as_live_card_dedup():
live_tid_pos = MESSAGES_SRC.index("function _liveToolTid")
live_tid_block = MESSAGES_SRC[live_tid_pos : live_tid_pos + 450]
find_pos = MESSAGES_SRC.index("function _findPendingLiveToolCallIndex")
find_block = MESSAGES_SRC[find_pos : find_pos + 900]
upsert_pos = MESSAGES_SRC.index("function upsertLiveToolCall")
upsert_block = MESSAGES_SRC[upsert_pos : upsert_pos + 600]
for key in ("tid", "id", "tool_call_id", "tool_use_id", "call_id"):
assert key in live_tid_block
assert key in find_block
assert key in upsert_block