diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 72be8bc8..3fb292ff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -511,7 +511,9 @@ to the active conversation rather than a global app setting. Session management: newSession() POST /api/session/new, update S.session, save to localStorage - loadSession(sid) GET /api/session?session_id=X, check INFLIGHT first, update S + loadSession(sid) GET /api/session?session_id=X (initial load uses the + bounded tail `msg_limit=30`; jump-to-start and outline + jump pass `msg_limit=all`), check INFLIGHT first, update S deleteSession(sid) POST /api/session/delete, handle active/inactive cases correctly renderSessionList() GET /api/sessions, rebuild #sessionList DOM @@ -1379,7 +1381,14 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3). / Returns full HTML app (index page) /index.html Same as / /health {"status":"ok","sessions":N} - /api/session ?session_id=X -> full session + messages. 400 if no ID. + /api/session ?session_id=X -> session + messages. 400 if no ID. + Bare (no msg_limit) keeps the historical full-transcript + contract. Recovery paths request a bounded tail + (`msg_limit=30`) and restore `_messages_truncated` / + `_messages_offset` before persisting anchor-scene + metadata. Outline jump and jump-to-start opt in to the + full transcript via the explicit `msg_limit=all` + escape hatch. See #7310 / #7625 / #7628. /api/sessions List of all session compact() dicts, sorted by updated_at /api/list ?session_id=X&path=. -> directory listing for session workspace /api/file ?session_id=X&path=rel -> file content (text, 200KB limit) diff --git a/static/messages.js b/static/messages.js index 0397738c..a4555bbf 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6640,6 +6640,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.session=d.session; const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role); if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session); + // Same persist-before-offset trap as settle/cancel: refresh paging + // before attaching the projected scene (#7628). + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!d.session._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=d.session._messages_offset||0; _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); if(S.session&&S.session.session_id){ @@ -6881,13 +6885,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.session=sessionPayload; const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role); if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload); - _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); - S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); // A bounded cancel-recovery reload returns a tail window: keep the - // Load-earlier paging gate honest instead of hardcoding "not truncated" - // (#7310/#7625). + // Load-earlier paging gate honest, and refresh _oldestIdx BEFORE + // persisting the projected scene (#7310/#7625/#7628). if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!sessionPayload._messages_truncated; if(typeof _oldestIdx!=='undefined') _oldestIdx=sessionPayload._messages_offset||0; + _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); + S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); clearLiveToolCards();if(!assistantText)removeThinking(); _markSessionViewed(activeSid, sessionPayload.message_count ?? S.messages.length); @@ -7037,6 +7041,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role); const _currentMessages=Array.isArray(S.messages)?S.messages:[]; const _currentVisibleMessages=_filterRecoveryControlMessages(_currentMessages || []); + // Restore paging from the bounded tail BEFORE attaching/persisting the + // projected scene: _persistSettledAnchorScene reads _oldestIdx to + // compute the absolute transcript index (#7628). + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0; + if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session); + if(S.session&&S.session.session_id){ + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} + if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); + } const _stagedMessages=_carryForwardEphemeralTurnFields(_currentMessages, _nextMsgs3018); const _currentVisibleEndsWithTerminalMarker=( _currentVisibleMessages.length>0 && @@ -7052,22 +7066,32 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return !!stagedKey && stagedKey===currentKey; }) ); - const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&_stagedMatchesCurrentPrefix; + // Bounded settle returns a suffix of the durable transcript, not a + // prefix of the currently visible window. On a long session the + // prefix check fails and would drop the terminal recovery marker + // (#7628). Treat a suffix match the same as a prefix match. + const _durableVisibleCount=_currentVisibleEndsWithTerminalMarker + ? _currentVisibleMessages.length-1 + : _currentVisibleMessages.length; + const _stagedSuffixStart=_durableVisibleCount-_stagedMessages.length; + const _stagedMatchesCurrentSuffix=( + _stagedMessages.length>0 && + _stagedSuffixStart>=0 && + _stagedMessages.length<_currentVisibleMessages.length && + _currentVisibleEndsWithTerminalMarker && + _stagedMessages.every((message, idx)=>{ + const stagedKey=_messageIdentityKey(message); + const currentKey=_messageIdentityKey(_currentVisibleMessages[_stagedSuffixStart+idx]); + return !!stagedKey && stagedKey===currentKey; + }) + ); + const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_stagedMatchesCurrentPrefix||_stagedMatchesCurrentSuffix); const _resolvedMessages=_preserveCurrentTranscript - ? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)] + ? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMatchesCurrentPrefix?_stagedMessages.length:_stagedSuffixStart+_stagedMessages.length)] : _stagedMessages; S.messages=_filterRecoveryControlMessages(_resolvedMessages || []); _attachProjectedAnchorSceneToLastAssistant(S.messages); if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); - if(S.session&&S.session.session_id){ - try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} - if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); - } - // Bounded settle reload returns a tail window: keep the Load-earlier - // paging gate honest (#7310/#7625). - if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated; - if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0; - if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session); const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); const hasMessageToolMetadata=S.messages.some(m=>{ diff --git a/tests/test_issue5224_terminal_error_transcript_preserve.py b/tests/test_issue5224_terminal_error_transcript_preserve.py index 29ac384d..1a381f6b 100644 --- a/tests/test_issue5224_terminal_error_transcript_preserve.py +++ b/tests/test_issue5224_terminal_error_transcript_preserve.py @@ -109,7 +109,6 @@ function buildRuntime() { globalThis.removeThinking = () => calls.push('removeThinking'); globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning'); globalThis._applyToAnchor = () => calls.push('applyToAnchor'); - globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected'); globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos'); globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup'); globalThis._smdEndParser = () => calls.push('smdEndParser'); @@ -141,6 +140,15 @@ function buildRuntime() { globalThis._messageRenderWindowSize = 20; globalThis._streamFinalized = !!scenario.streamFinalized; globalThis._persistTimer = null; + globalThis._oldestIdx = Number.isFinite(scenario.oldestIdx) ? scenario.oldestIdx : 0; + globalThis._messagesTruncated = !!scenario.messagesTruncated; + globalThis.oldestIdxAtAttach = null; + globalThis.messagesTruncatedAtAttach = null; + globalThis._attachProjectedAnchorSceneToLastAssistant = () => { + calls.push('attachProjected'); + globalThis.oldestIdxAtAttach = globalThis._oldestIdx; + globalThis.messagesTruncatedAtAttach = globalThis._messagesTruncated; + }; globalThis.api = async () => scenario.apiPayload || { session: null }; globalThis.msgContent = undefined; globalThis._isPreservedCompressionTaskListMarkerOnlyText = () => false; @@ -162,6 +170,10 @@ function buildRuntime() { status, messages: messages.map((m) => ({ role: m.role, content: m.content })), terminalMarkerCount, + oldestIdx: globalThis._oldestIdx, + oldestIdxAtAttach: globalThis.oldestIdxAtAttach, + messagesTruncated: !!globalThis._messagesTruncated, + messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach, calls, })); return; @@ -179,6 +191,10 @@ function buildRuntime() { status, messages: messages.map((m) => ({ role: m.role, content: m.content })), terminalMarkerCount, + oldestIdx: globalThis._oldestIdx, + oldestIdxAtAttach: globalThis.oldestIdxAtAttach, + messagesTruncated: !!globalThis._messagesTruncated, + messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach, calls, })); return; @@ -421,3 +437,109 @@ def test_terminal_error_marker_is_single_instance_and_not_duplicated(driver_path assert outcome["terminalMarkerCount"] == 1, f"terminal marker should be deduped to one, got {outcome['terminalMarkerCount']}" assert outcome["messages"][-1]["content"].startswith("**Connection interrupted:**") + + +def test_bounded_tail_preserves_terminal_marker_when_server_returns_suffix(driver_path): + """A bounded settle tail is a suffix of the visible window, not a prefix. + + Long-session recovery used to compare the 30-row tail against the start of + S.messages. The prefix check failed, so preserveVisibleOnShorterTerminalSnapshot + dropped the client-side Connection-interrupted marker (#7628). + """ + historical = [] + for i in range(2): + historical.append({"role": "user", "content": f"Earlier question {i}", "_ts": f"u{i}"}) + historical.append({"role": "assistant", "content": f"Earlier answer {i}", "_ts": f"a{i}"}) + tail = [ + {"role": "user", "content": "Current question", "_ts": "u-cur"}, + {"role": "assistant", "content": "Current fragment one", "_ts": "a-cur"}, + ] + terminal = { + "role": "assistant", + "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", + "_ts": "err", + } + outcome = _run_scenario(driver_path, { + "action": "restore_shorter_terminal", + "oldestIdx": 0, + "messagesTruncated": False, + "state": { + "session": {"session_id": "session-5224", "message_count": 7}, + "messages": historical + tail + [terminal], + "activeStreamId": "stream-5224", + }, + "apiPayload": { + "session": { + "session_id": "session-5224", + "active_stream_id": None, + "pending_user_message": None, + "messages": tail, + "_messages_truncated": True, + "_messages_offset": 4, + }, + }, + "activeSid": "session-5224", + "streamId": "stream-5224", + "isActiveSession": True, + "isSessionCurrentPane": True, + "isSessionActivelyViewed": False, + }) + + observed = [(item["role"], item["content"]) for item in outcome["messages"]] + assert observed == [ + ("user", "Current question"), + ("assistant", "Current fragment one"), + ("assistant", "**Connection interrupted:** The browser lost the live SSE connection before the response finished."), + ], f"bounded tail must keep the terminal recovery suffix: {observed}" + assert outcome["terminalMarkerCount"] == 1, ( + f"expected the terminal marker to survive a suffix-only tail, got {outcome['terminalMarkerCount']}" + ) + assert outcome["oldestIdx"] == 4, f"paging offset should come from the bounded response, got {outcome['oldestIdx']}" + assert outcome["oldestIdxAtAttach"] == 4, ( + f"anchor persist must see the new offset, got {outcome['oldestIdxAtAttach']}" + ) + assert outcome["messagesTruncated"] is True + assert outcome["messagesTruncatedAtAttach"] is True + + +def test_settle_refreshes_paging_offset_before_anchor_persist(driver_path): + """_persistSettledAnchorScene reads _oldestIdx; a stale 0 would write the scene to the wrong row.""" + outcome = _run_scenario(driver_path, { + "action": "restore_fuller_terminal", + "oldestIdx": 0, + "messagesTruncated": False, + "state": { + "session": {"session_id": "session-5224", "message_count": 1}, + "messages": [ + {"role": "assistant", "content": "Old interrupted shell", "_ts": "old"}, + ], + "activeStreamId": "stream-5224", + }, + "apiPayload": { + "session": { + "session_id": "session-5224", + "active_stream_id": None, + "pending_user_message": None, + "messages": [ + {"role": "user", "content": "Question about data?", "_ts": "u1"}, + {"role": "assistant", "content": "Settled first segment", "_ts": "a1"}, + {"role": "assistant", "content": "Settled second segment", "_ts": "a2"}, + {"role": "assistant", "content": "Settled third segment", "_ts": "a3"}, + ], + "_messages_truncated": True, + "_messages_offset": 40, + }, + }, + "activeSid": "session-5224", + "streamId": "stream-5224", + "isActiveSession": True, + "isSessionCurrentPane": True, + "isSessionActivelyViewed": False, + }) + + assert outcome["status"] == "restored" + assert outcome["oldestIdx"] == 40 + assert outcome["oldestIdxAtAttach"] == 40, ( + f"stale offset 0 must not leak into anchor persist, got {outcome['oldestIdxAtAttach']}" + ) + assert outcome["messagesTruncatedAtAttach"] is True diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 77ae81e7..90dc5c38 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -816,17 +816,20 @@ def test_stream_end_restore_attaches_projected_anchor_scene_before_render(): filter_idx = restore.index("S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);") attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);") render_idx = restore.index("syncTopbar();renderMessages({preserveScroll:true})") - assert carry_idx < filter_idx < attach_idx < render_idx + offset_idx = restore.index("_oldestIdx=session._messages_offset||0") + assert offset_idx < carry_idx < filter_idx < attach_idx < render_idx + assert "_stagedMatchesCurrentSuffix" in restore def test_cancel_settlement_attaches_projected_anchor_scene_before_render(): cancel = _event_listener_body(MESSAGES_JS, "cancel") fetch_idx = cancel.index("const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);") + offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0") attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);") carry_idx = cancel.index("S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);") render_idx = cancel.index("renderMessages({preserveScroll:true});") - assert fetch_idx < attach_idx < carry_idx < render_idx + assert fetch_idx < offset_idx < attach_idx < carry_idx < render_idx embedded_idx = cancel.index("if(_applyCancelSessionPayload(_cancelSessionPayload)) return;") # #7310/#7625: the HTTP fallback is a bounded tail now (full-transcript diff --git a/tests/test_session_default_window.py b/tests/test_session_default_window.py index e3850879..8e94e455 100644 --- a/tests/test_session_default_window.py +++ b/tests/test_session_default_window.py @@ -111,12 +111,57 @@ def test_cancel_payload_restores_truncation_signal(): def test_settle_restores_truncation_signal(): - """"_restoreSettledSession keeps the Load-earlier paging gate honest after + """_restoreSettledSession keeps the Load-earlier paging gate honest after a bounded settle reload.""" assert "!!session._messages_truncated" in _MESSAGES_JS assert "session._messages_offset||0" in _MESSAGES_JS +def _restore_settled_session_body(): + start = _MESSAGES_JS.index("async function _restoreSettledSession") + end = _MESSAGES_JS.index("function _handleStreamError", start) + return _MESSAGES_JS[start:end] + + +def _apply_cancel_session_payload_body(): + start = _MESSAGES_JS.index("const _applyCancelSessionPayload=(sessionPayload)=>") + end = _MESSAGES_JS.index("Prefer the canonical session snapshot", start) + return _MESSAGES_JS[start:end] + + +def test_settle_refreshes_paging_before_anchor_persist(): + """Anchor persist reads _oldestIdx; a bounded tail must refresh it first (#7628).""" + restore = _restore_settled_session_body() + offset_idx = restore.index("_oldestIdx=session._messages_offset||0") + attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);") + assert offset_idx < attach_idx + + +def test_cancel_refreshes_paging_before_anchor_persist(): + """Cancel recovery has the same persist-before-offset trap as settle (#7628).""" + cancel = _apply_cancel_session_payload_body() + offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0") + attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);") + assert offset_idx < attach_idx + + +def test_settle_preserves_terminal_marker_against_bounded_suffix(): + """Long-session bounded tails are suffixes of S.messages, not prefixes (#7628).""" + restore = _restore_settled_session_body() + assert "_stagedMatchesCurrentSuffix" in restore + assert "_preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_stagedMatchesCurrentPrefix||_stagedMatchesCurrentSuffix)" in restore + + +def test_apperror_embedded_session_refreshes_paging_before_anchor_persist(): + """The apperror embedded-session path has the same persist-before-offset trap (#7628).""" + start = _MESSAGES_JS.index("source.addEventListener('apperror'") + end = _MESSAGES_JS.index("source.addEventListener('warning'", start) + block = _MESSAGES_JS[start:end] + offset_idx = block.index("_oldestIdx=d.session._messages_offset||0") + attach_idx = block.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);") + assert offset_idx < attach_idx + + def test_compress_preflight_uses_bounded_tail(): """/compress preflight only needs session existence + the current tail.""" assert _BOUNDED in _COMMANDS_JS