diff --git a/CHANGELOG.md b/CHANGELOG.md index e090ce809c3..8f9f6c7c4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,13 @@ ### Fixed +- **The actively-viewed session no longer shows a stale unread badge after compaction.** The unread marker and the viewed marker were computed from two different message-count sources (`completedSession.message_count` vs `message_count ?? S.messages.length`), which diverged after a compaction, leaving the session you were actively looking at with a lingering unread dot. Both now read one unified `completedMessageCount` on done-settle, so the badge clears correctly. Thanks @rodboev. (#5276, fixes #5273) +- **A CLI-origin session continued in the WebUI no longer loses its immediate prior context.** When a session started in the Hermes CLI was continued in the WebUI, the stitched CLI transcript could be shadowed by a stale sidecar context prefix on chat-start. `_get_or_materialize_session` now refreshes the CLI messages from `get_cli_session_messages` on chat-start and keeps the stitched CLI transcript authoritative (the compaction/compression-anchor path is preserved). Thanks @rodboev. (#5274, fixes #5270) +- **Internal: split the terminal-failure transcript evaluator into a focused helper.** Behavior-preserving extraction of `_turn_transcript_lacks_final_assistant_answer` (operating on the already-merged transcript) with the original name kept as a thin delegating wrapper, so the terminal-failure settlement path is easier to reason about; the `error:''` silent-failure sentinel and `_terminal_failure` handling are unchanged. Thanks @nankingjing. (#5272, #5141) +- **Context-menu Delete now removes the sidebar session row immediately, matching swipe-delete.** Menu-delete now passes an immediate `beforeDelete` hook through the existing `deleteSession(sid, beforeDelete)` path so the row disappears optimistically instead of lingering until the server round-trip completes; failure-rollback is handled by the existing flow. Thanks @franksong2702. (#5256, fixes #5255) +- **Settings search results no longer get clipped by the panel boundary.** The settings side-menu buttons are wrapped in a scrolling `.settings-menu-items` container and `#settingsMenu` is set to `overflow: visible`, so the absolutely-positioned search-results dropdown escapes the scroll-clip instead of being cut off. Thanks @rodboev. (#5254, fixes #5250) +- **Transparent Streaming live-activity rows now replay in the correct anchor scene order.** Live-activity rows render into the live assistant turn before the compact gate and idempotently replace legacy live-activity surfaces as the source of truth (via `data-anchor-scene` attributes + a scroll-rebuild guard), fixing live→final ordering on transparent-streaming turns without the prior scroll jump. Thanks @franksong2702. (#5257) + - **The `/goal` loop now continues after a turn on the gateway chat backend, not just the local one.** When WebUI chat is routed through the Hermes Gateway backend, the gateway worker persisted the final assistant turn and ended the stream *before* the goal judge ran, so a standing `/goal` silently stopped after one turn. The gateway worker now evaluates the goal locally after the turn settles (the same `evaluate_goal_after_turn` path the local backend uses) and emits the existing `goal` / `goal_continue` events, so the browser continues the goal exactly as it does on the local backend. Evaluation only runs when the turn is goal-related and a goal is active, so ordinary gateway turns are unaffected. Thanks @rodboev. (#5251, fixes #5092) - **Session-scoped API endpoints now enforce the active profile on every request-supplied session ID.** On a multi-profile box, endpoints that accept a `session_id` (in the query string or the JSON/multipart body) — session read/duplicate/rename/delete, file operations, uploads, and chat start — looked up the session without confirming it belongs to the request's active profile, so a caller could reference another profile's session by id. A central preflight now rejects a request-supplied session id that isn't visible to the active profile (404), wired across all the session verbs plus the upload and chat-start paths; stream IDs are authorized through a synchronously-registered owner map so the check doesn't depend on worker-startup timing. Single-profile / no-auth deployments and same-profile access are unchanged. Thanks @starship-s. (#5198) diff --git a/api/models.py b/api/models.py index eb423121f15..3668b5d2119 100644 --- a/api/models.py +++ b/api/models.py @@ -5627,6 +5627,17 @@ def _session_message_merge_key(msg: dict): ) +def _session_messages_have_prefix(messages, prefix) -> bool: + messages = list(messages or []) + prefix = list(prefix or []) + if len(prefix) > len(messages): + return False + for idx, expected in enumerate(prefix): + if _session_message_merge_key(messages[idx]) != _session_message_merge_key(expected): + return False + return True + + _SESSION_MESSAGE_DISPLAY_METADATA_KEYS = ( "_turnDuration", "_turnTps", @@ -6457,23 +6468,39 @@ def reconciled_state_db_messages_for_session( state_messages = get_state_db_session_messages(getattr(session, 'session_id', None)) if prefer_context and local_messages: if using_context_messages: - compressed_context = _context_messages_include_compression_marker(local_messages) - anchor_key = getattr(session, "compression_anchor_message_key", None) - if compressed_context: - if not anchor_key: - logger.debug( - "Compressed context for session %s has no compression anchor; using context_messages only", - getattr(session, "session_id", None), - ) - return list(local_messages) - anchor_index = _state_db_anchor_index(state_messages, anchor_key) - if anchor_index is None: - logger.debug( - "Compressed context for session %s has an unverifiable compression anchor; using context_messages only", - getattr(session, "session_id", None), - ) - return list(local_messages) - state_messages = list(state_messages or [])[anchor_index + 1 :] + sidecar_messages = getattr(session, 'messages', None) or [] + if ( + getattr(session, 'is_cli_session', False) + and not getattr(session, 'read_only', False) + and sidecar_messages + and len(sidecar_messages) > len(local_messages) + and _session_messages_have_prefix(sidecar_messages, local_messages) + ): + # A claimed CLI sidecar can carry a stale context prefix while the + # stitched CLI transcript already landed in session.messages. On the + # first WebUI follow-up, prefer that longer authoritative transcript + # unless context_messages intentionally diverged via compaction or + # another non-prefix transform. + local_messages = sidecar_messages + using_context_messages = False + if using_context_messages: + compressed_context = _context_messages_include_compression_marker(local_messages) + anchor_key = getattr(session, "compression_anchor_message_key", None) + if compressed_context: + if not anchor_key: + logger.debug( + "Compressed context for session %s has no compression anchor; using context_messages only", + getattr(session, "session_id", None), + ) + return list(local_messages) + anchor_index = _state_db_anchor_index(state_messages, anchor_key) + if anchor_index is None: + logger.debug( + "Compressed context for session %s has an unverifiable compression anchor; using context_messages only", + getattr(session, "session_id", None), + ) + return list(local_messages) + state_messages = list(state_messages or [])[anchor_index + 1 :] state_messages = state_db_delta_after_context(local_messages, state_messages) return merge_session_messages_append_only( local_messages, diff --git a/api/routes.py b/api/routes.py index 9495e5114a0..138ab59d38c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4418,7 +4418,7 @@ def _handle_session_anchor_scene(handler, body): return j(handler, {"ok": True, "message_index": idx, "message_ref": ref}) -def _get_or_materialize_session(sid: str): +def _get_or_materialize_session(sid: str, *, refresh_cli_messages: bool = False): """Get a session, materializing from CLI/agent metadata if not in WebUI store. Mirrors the fallback logic in /api/session/archive (routes.py:~8530). @@ -4437,6 +4437,21 @@ def _get_or_materialize_session(sid: str): # below (and the heuristic record-check would mis-trip on mock sessions). if getattr(s, "read_only", False): raise PermissionError("read-only imported session") + if refresh_cli_messages and getattr(s, "is_cli_session", False): + latest_messages = get_cli_session_messages( + sid, + profile=getattr(s, "profile", None), + ) + current_messages = list(getattr(s, "messages", None) or []) + if ( + latest_messages + and len(latest_messages) >= len(current_messages) + and _session_messages_have_prefix(latest_messages, current_messages) + ): + # Keep the stitched CLI transcript authoritative on the first + # WebUI continuation path without clobbering later divergent + # WebUI-owned turns. + s.messages = list(latest_messages) return s except KeyError: pass @@ -7559,6 +7574,7 @@ def _keep_latest_messaging_session_per_source( _active_stream_ids, _merge_session_display_metadata, _session_message_merge_key, + _session_messages_have_prefix, _session_message_visible_key, _message_timestamp_as_float, _is_empty_partial_activity_message, @@ -18116,7 +18132,7 @@ def _handle_chat_start(handler, body, diag=None): return bad(handler, str(e)) diag.stage("get_session") if diag else None try: - s = _get_or_materialize_session(body["session_id"]) + s = _get_or_materialize_session(body["session_id"], refresh_cli_messages=True) except KeyError: # No WebUI sidecar. If this is a foreign-origin session (CLI, # TUI, Desktop) with recoverable state.db messages, claim it by diff --git a/api/streaming.py b/api/streaming.py index 04211a249ae..962bd431b35 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5121,23 +5121,16 @@ def _session_lacks_final_assistant_answer(messages) -> bool: return True -def _merged_transcript_lacks_final_assistant_answer( +def _turn_transcript_lacks_final_assistant_answer( + merged_messages, previous_display, - previous_context, - result_messages, msg_text, source: str = "webui", drop_replayed_assistant: bool = False, ) -> bool: - """Return True when the current turn still lacks a final assistant answer.""" + """Return True when an already-merged transcript still lacks a final assistant answer.""" + merged_messages = list(merged_messages or []) previous_display = list(previous_display or []) - merged_messages = _merge_display_messages_after_agent_result( - previous_display, - previous_context, - _restore_reasoning_metadata(previous_display, result_messages), - msg_text, - source=source, - ) current_user_idx = _find_current_user_turn(merged_messages, msg_text) if current_user_idx is None or current_user_idx < len(previous_display): # The active turn lives after the durable transcript boundary. If the @@ -5179,6 +5172,32 @@ def _merged_transcript_lacks_final_assistant_answer( return _session_lacks_final_assistant_answer(filtered_messages) +def _merged_transcript_lacks_final_assistant_answer( + previous_display, + previous_context, + result_messages, + msg_text, + source: str = "webui", + drop_replayed_assistant: bool = False, +) -> bool: + """Return True when the current turn still lacks a final assistant answer.""" + previous_display = list(previous_display or []) + merged_messages = _merge_display_messages_after_agent_result( + previous_display, + previous_context, + _restore_reasoning_metadata(previous_display, result_messages), + msg_text, + source=source, + ) + return _turn_transcript_lacks_final_assistant_answer( + merged_messages, + previous_display, + msg_text, + source=source, + drop_replayed_assistant=drop_replayed_assistant, + ) + + def _agent_result_terminal_failure(result) -> bool: """Return True for agent results that must not be finalized as done.""" if not isinstance(result, dict): diff --git a/static/index.html b/static/index.html index 6eb732d43b6..3f17fb2e5e1 100644 --- a/static/index.html +++ b/static/index.html @@ -356,38 +356,40 @@ placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off">
- - - - - - - - + diff --git a/static/messages.js b/static/messages.js index f4d54845847..092bc0a3759 100644 --- a/static/messages.js +++ b/static/messages.js @@ -5030,9 +5030,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const isSessionViewed=_isSessionActivelyViewed(activeSid); const completedSession=d.session||{session_id:activeSid}; const completedSid=completedSession.session_id||activeSid; + const completedMessageCount=completedSession.message_count != null + ? completedSession.message_count + : ( + Array.isArray(completedSession.messages) + ? completedSession.messages.length + : ( + (S.session&&((S.session.session_id||activeSid)===completedSid)&&S.session.message_count != null) + ? S.session.message_count + : ((Array.isArray(S.messages)&&S.messages.length)||0) + ) + ); if(!isSessionViewed && typeof _markSessionCompletionUnread==='function'){ - _markSessionCompletionUnread(completedSid, completedSession.message_count); + _markSessionCompletionUnread(completedSid, completedMessageCount); } + if(isSessionViewed) _markSessionViewed(completedSid, completedMessageCount); _clearOwnerInflightState(); if(typeof _markSessionCompletedInList==='function'){ _markSessionCompletedInList(completedSession, activeSid); @@ -5175,7 +5187,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // No-reply guard (#373): if agent returned nothing, show inline error if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});} if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); - if(isSessionViewed) _markSessionViewed(completedSid, completedSession.message_count ?? S.messages.length); + if(isSessionViewed) _markSessionViewed(completedSid, completedMessageCount); // Cooldown: prevent refreshActiveSessionIfExternallyUpdated from // force-reloading immediately after "done" — the event already // delivered the final messages and tool calls. diff --git a/static/sessions.js b/static/sessions.js index c5ef9467335..8763fbf2ed4 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3899,7 +3899,11 @@ function _openSessionActionMenu(session, anchorEl){ ICONS.trash, async()=>{ closeSessionActionMenu(); - await deleteSession(session.session_id); + // Menu Delete has no swipe/removal animation to wait for. Pass an + // immediate beforeDelete hook so deleteSession() removes the sidebar row + // optimistically while slow backend cleanup (/api/session/delete, + // state.db/FTS/journal cleanup) continues. + await deleteSession(session.session_id,()=>Promise.resolve()); }, 'danger' )); diff --git a/static/style.css b/static/style.css index 73bd23e5d49..edf5629e837 100644 --- a/static/style.css +++ b/static/style.css @@ -6705,6 +6705,15 @@ html[data-conversation-outline="enabled"] #outlineToggleBtn:not([hidden]){displa } /* Settings search */ +#settingsMenu { overflow: visible; min-height: 0; } +#settingsMenu .settings-menu-items { + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; + flex: 1; + overflow-y: auto; +} #settingsMenu .settings-search { position: relative; } .settings-search-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 200; background: var(--dropdown-bg, var(--bg)); border: 1px solid var(--border2); border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); overflow: hidden; margin-top: 2px; } .settings-search-result { display: flex; align-items: center; gap: 4px; width: 100%; padding: 7px 12px; background: none; border: none; text-align: left; cursor: pointer; font-size: 12px; color: var(--text); transition: background 0.1s; } diff --git a/static/ui.js b/static/ui.js index c5064caa295..15b2b2ac414 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10028,6 +10028,7 @@ function _anchorSceneNodeForRow(row, opts){ } function _anchorSceneTransparentNodeForRow(row, opts){ const settled=!!(opts&&opts.settled); + const live=!!(opts&&opts.live); if(!row) return null; let node=null; const meta={ @@ -10079,10 +10080,14 @@ function _anchorSceneTransparentNodeForRow(row, opts){ } if(!node) return null; node.setAttribute('data-anchor-scene-row','1'); - node.setAttribute('data-anchor-settled-scene-row','1'); + if(settled) node.setAttribute('data-anchor-settled-scene-row','1'); + if(live) node.setAttribute('data-anchor-live-scene-row','1'); node.setAttribute('data-anchor-row-id',String(row.row_id||row.local_id||'')); node.setAttribute('data-anchor-row-role',String(row.role||'activity')); node.setAttribute('data-anchor-source-event-type',String(row.source_event_type||'')); + if(opts&&opts.streamId) node.setAttribute('data-anchor-stream-id',String(opts.streamId)); + if(opts&&opts.sessionId) node.setAttribute('data-session-id',String(opts.sessionId)); + if(live) node.setAttribute('data-live-stream-owned','1'); return node; } // Whitespace-insensitive compare so a scene prose row that IS the final answer @@ -10259,6 +10264,9 @@ function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){ } function renderLiveAnchorActivityScene(streamId, scene, opts){ opts=opts||{}; + if(typeof isTransparentStream==='function'&&isTransparentStream()){ + return _renderLiveAnchorActivitySceneTransparent(streamId,scene,opts); + } if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false; if(!S.session||!S.activeStreamId) return false; if(opts.sessionId&&S.session.session_id!==opts.sessionId) return false; @@ -10269,11 +10277,11 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ if(!turn){ turn=_createAssistantTurn(); turn.id='liveAssistantTurn'; - if(S.session) turn.dataset.sessionId=S.session.session_id; $('msgInner').appendChild(turn); } turn.setAttribute('data-anchor-scene-live-owner','1'); turn.setAttribute('data-anchor-stream-id',String(streamId||'')); + // Re-stamp when reusing a turn restored or previously rendered in another mode. if(S.session) turn.dataset.sessionId=S.session.session_id; const blocks=_assistantTurnBlocks(turn); if(!blocks) return false; @@ -10319,8 +10327,79 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return true; } +function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ + opts=opts||{}; + if(!S.session||!S.activeStreamId) return false; + if(opts.sessionId&&S.session.session_id!==opts.sessionId) return false; + if(streamId&&S.activeStreamId!==streamId) return false; + const rows=_anchorSceneRowsForRendering(scene,{settled:false}); + if(!rows.length) return false; + $('emptyState').style.display='none'; + let turn=$('liveAssistantTurn'); + if(!turn){ + turn=_createAssistantTurn(); + turn.id='liveAssistantTurn'; + $('msgInner').appendChild(turn); + } + turn.setAttribute('data-anchor-scene-live-owner','1'); + turn.setAttribute('data-anchor-stream-id',String(streamId||'')); + turn.setAttribute('data-live-assistant-turn','1'); + if(S.session) turn.dataset.sessionId=S.session.session_id; + const blocks=_assistantTurnBlocks(turn); + if(!blocks) return false; + const scrollSnapshot=_captureMessageScrollSnapshot(); + const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(scrollSnapshot); + blocks.querySelectorAll('[data-anchor-scene-owner="1"],[data-anchor-scene-row="1"]').forEach(el=>el.remove()); + // Clear every legacy live activity surface this renderer can replace. The + // anchor-scene rows are now the source of truth for visible live activity. + blocks.querySelectorAll( + '.live-worklog[data-live-worklog-shell="1"],'+ + '.tool-worklog-group[data-live-tool-call-group="1"],'+ + '.tool-call-group[data-live-tool-call-group="1"],'+ + '.tool-card-row[data-live-tid],'+ + '.agent-activity-thinking[data-live-thinking="1"],'+ + '.transparent-event-row[data-live-tid],'+ + '[data-live-stream-owned="1"],'+ + '.interim-collapse-toggle' + ).forEach(el=>el.remove()); + // Match the compact path: keep legacy live segments as hidden anchors so + // stream-owned metadata survives while the anchor scene owns visible activity. + blocks.querySelectorAll('[data-live-assistant="1"]').forEach(el=>{ + el.classList.add('assistant-segment-worklog-source'); + el.setAttribute('aria-hidden','true'); + el.hidden=true; + }); + const liveFooter=blocks.querySelector('#liveRunStatus'); + let wrote=false; + for(const row of rows){ + const node=_anchorSceneTransparentNodeForRow(row,{ + live:true, + settled:false, + streamId:streamId||S.activeStreamId||'', + sessionId:S.session&&S.session.session_id, + }); + if(!node) continue; + if(liveFooter&&liveFooter.parentElement===blocks) blocks.insertBefore(node,liveFooter); + else blocks.appendChild(node); + wrote=true; + } + if(wrote) _syncTransparentEventControls(turn); + if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); + _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + if(scrollRebuildGuard&&scrollRebuildGuard.release){ + requestAnimationFrame(()=>{ + scrollRebuildGuard.release(); + if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + }); + } + if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); + return wrote; +} function _renderLiveAnchorActivitySceneForStream(streamId, sessionId, opts){ - const scene=_projectLiveAnchorActivitySceneForStream(streamId,(opts&&opts.mode)||'compact_worklog'); + const mode=(typeof isTransparentStream==='function'&&isTransparentStream()) + ? 'transparent_stream' + : ((opts&&opts.mode)||'compact_worklog'); + const scene=_projectLiveAnchorActivitySceneForStream(streamId,mode); if(!scene) return false; return renderLiveAnchorActivityScene(streamId,scene,{...(opts||{}),sessionId}); } diff --git a/tests/test_1466_sidebar_cancel_clarify.py b/tests/test_1466_sidebar_cancel_clarify.py index 2bac9203a50..0dc7c291ede 100644 --- a/tests/test_1466_sidebar_cancel_clarify.py +++ b/tests/test_1466_sidebar_cancel_clarify.py @@ -36,7 +36,7 @@ def test_running_sidebar_sessions_get_stop_action(self): assert "cancelSessionStream(session)" in body, ( "running sidebar sessions must expose a stop action that cancels that session" ) - assert body.find("cancelSessionStream(session)") < body.find("deleteSession(session.session_id)"), ( + assert body.find("cancelSessionStream(session)") < body.find("deleteSession(session.session_id"), ( "stop action should appear before destructive delete action" ) @@ -91,3 +91,12 @@ def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self): second = body.find("t('session_delete')") assert first > 0 and second > 0, "menu actions should still include duplicate/delete nodes" assert first < second, "duplicate action should render before delete action" + + def test_menu_delete_optimistically_removes_sidebar_row_before_backend_cleanup(self): + """Context-menu Delete should not keep the row visible while slow DELETE cleanup runs.""" + body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 8400) + compact_body = "".join(body.split()) + assert "awaitdeleteSession(session.session_id,()=>Promise.resolve())" in compact_body, ( + "menu Delete must pass an immediate beforeDelete hook so deleteSession() " + "optimistically removes the sidebar row before awaiting /api/session/delete" + ) diff --git a/tests/test_3850_settings_search.py b/tests/test_3850_settings_search.py index e2415d13568..465129f2ff7 100644 --- a/tests/test_3850_settings_search.py +++ b/tests/test_3850_settings_search.py @@ -6,6 +6,7 @@ Issue: #3850 (Add search input at top of Settings panel) """ from pathlib import Path +import re INDEX_HTML = (Path(__file__).parent.parent / "static" / "index.html").read_text(encoding="utf-8") PANELS_JS = (Path(__file__).parent.parent / "static" / "panels.js").read_text(encoding="utf-8") @@ -135,6 +136,34 @@ def test_style_css_has_search_positioning(self): "style.css must make .settings-search-results absolutely positioned" ) + def test_settings_menu_layout_ownership_contract(self): + """Settings search should be anchored in the menu while scrolling is owned by the button list.""" + assert 'class="settings-menu-items"' in INDEX_HTML, ( + "index.html must keep the section buttons inside .settings-menu-items" + ) + + menu_match = re.search( + r"(^|\n)\s*#settingsMenu\s*\{[^}]*\}", + STYLE_CSS, + re.MULTILINE, + ) + assert menu_match is not None, "style.css must have a #settingsMenu rule" + menu_rules = menu_match.group(0) + assert "overflow: visible" in menu_rules, ( + "settings menu must not own vertical clipping overflow" + ) + + items_match = re.search( + r"(^|\n)\s*#settingsMenu\s+\.settings-menu-items\s*\{[^}]*\}", + STYLE_CSS, + re.MULTILINE, + ) + assert items_match is not None, "style.css must have a .settings-menu-items rule" + items_rules = items_match.group(0) + assert "overflow-y: auto" in items_rules, ( + "settings menu items wrapper must own vertical scrolling" + ) + def test_panels_js_handles_providers_pane(self): """panels.js must handle the Providers pane in index building.""" idx = PANELS_JS.find("function _buildSettingsIndex()") diff --git a/tests/test_issue3975_cron_reply_materialization.py b/tests/test_issue3975_cron_reply_materialization.py index fe4f7738d08..c1aa6f0c6d3 100644 --- a/tests/test_issue3975_cron_reply_materialization.py +++ b/tests/test_issue3975_cron_reply_materialization.py @@ -47,7 +47,7 @@ def test_chat_start_materializes_cron_session_before_reply(monkeypatch, tmp_path def fail_get_session(_sid): raise KeyError(_sid) - def materialize(_sid): + def materialize(_sid, **_kwargs): captured["materialize_sid"] = _sid return materialized diff --git a/tests/test_issue5141_terminal_failure_transcript_evaluator.py b/tests/test_issue5141_terminal_failure_transcript_evaluator.py new file mode 100644 index 00000000000..48da1e724b1 --- /dev/null +++ b/tests/test_issue5141_terminal_failure_transcript_evaluator.py @@ -0,0 +1,128 @@ +"""Tests for #5141 terminal-failure transcript evaluator split.""" + +from __future__ import annotations + +from unittest import mock + +import api.streaming as streaming + + +def test_turn_evaluator_matches_merged_wrapper_without_replay_filter(): + previous_display = [{"role": "user", "content": "hello"}] + previous_context = list(previous_display) + result_messages = previous_context + [ + {"role": "user", "content": "follow up"}, + ] + msg_text = "follow up" + + merged = streaming._merge_display_messages_after_agent_result( + previous_display, + previous_context, + streaming._restore_reasoning_metadata(previous_display, result_messages), + msg_text, + source="webui", + ) + direct = streaming._turn_transcript_lacks_final_assistant_answer( + merged, + previous_display, + msg_text, + source="webui", + drop_replayed_assistant=False, + ) + wrapped = streaming._merged_transcript_lacks_final_assistant_answer( + previous_display, + previous_context, + result_messages, + msg_text, + source="webui", + drop_replayed_assistant=False, + ) + assert direct is wrapped + assert direct is True + + +def test_turn_evaluator_matches_merged_wrapper_with_final_answer(): + previous_display = [{"role": "user", "content": "hello"}] + previous_context = list(previous_display) + result_messages = previous_context + [ + {"role": "user", "content": "follow up"}, + {"role": "assistant", "content": "done"}, + ] + msg_text = "follow up" + + merged = streaming._merge_display_messages_after_agent_result( + previous_display, + previous_context, + streaming._restore_reasoning_metadata(previous_display, result_messages), + msg_text, + source="webui", + ) + direct = streaming._turn_transcript_lacks_final_assistant_answer( + merged, + previous_display, + msg_text, + source="webui", + drop_replayed_assistant=False, + ) + wrapped = streaming._merged_transcript_lacks_final_assistant_answer( + previous_display, + previous_context, + result_messages, + msg_text, + source="webui", + drop_replayed_assistant=False, + ) + assert direct is wrapped + assert direct is False + + +def test_turn_evaluator_materializes_pending_user_after_display_boundary(): + previous_display = [{"role": "user", "content": "older"}] + merged = list(previous_display) + msg_text = "new prompt" + + assert streaming._turn_transcript_lacks_final_assistant_answer( + merged, + previous_display, + msg_text, + source="webui", + drop_replayed_assistant=False, + ) is True + + +def test_merged_wrapper_delegates_to_turn_evaluator(): + calls = [] + + def _fake_evaluator(merged_messages, previous_display, msg_text, source="webui", drop_replayed_assistant=False): + calls.append( + { + "merged_len": len(list(merged_messages or [])), + "previous_len": len(list(previous_display or [])), + "msg_text": msg_text, + "source": source, + "drop_replayed_assistant": drop_replayed_assistant, + } + ) + return True + + previous_display = [{"role": "user", "content": "hello"}] + with mock.patch.object( + streaming, + "_turn_transcript_lacks_final_assistant_answer", + side_effect=_fake_evaluator, + ): + result = streaming._merged_transcript_lacks_final_assistant_answer( + previous_display, + previous_display, + previous_display, + "hello", + source="cli", + drop_replayed_assistant=True, + ) + assert result is True + assert len(calls) == 1 + assert calls[0]["previous_len"] == 1 + assert calls[0]["msg_text"] == "hello" + assert calls[0]["source"] == "cli" + assert calls[0]["drop_replayed_assistant"] is True + assert calls[0]["merged_len"] >= 1 diff --git a/tests/test_issue5250_settings_search_dropdown_escape.py b/tests/test_issue5250_settings_search_dropdown_escape.py new file mode 100644 index 00000000000..2bd794479a3 --- /dev/null +++ b/tests/test_issue5250_settings_search_dropdown_escape.py @@ -0,0 +1,114 @@ +"""Regression test for the settings search dropdown escaping settings menu clipping.""" + +from pathlib import Path + +import pytest + + +STYLE_CSS = (Path(__file__).parent.parent / "static" / "style.css").read_text( + encoding="utf-8" +) + + +def _issue_html() -> str: + item_lines = "".join( + ( + f'\n" + ) + for i in range(1, 10) + ) + menu_lines = "".join( + ( + f'\n" + ) + for i in range(1, 18) + ) + return f""" + + + + + + + + + + +""" + + +def test_issue5250_settings_search_dropdown_escape(): + """A point below the visible menu boundary should land on a search result.""" + try: + from playwright.sync_api import sync_playwright + except Exception: # pragma: no cover - dependency missing path + pytest.skip( + "playwright is unavailable; run manual local browser hit-test for issue #5250" + ) + + with sync_playwright() as playwright: + browser = playwright.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + page = browser.new_page(viewport={"width": 480, "height": 320}) + page.set_content(_issue_html()) + hit = page.evaluate( + """ + () => { + const menu = document.querySelector('#settingsMenu'); + const results = document.querySelector('#settingsSearchResults'); + const menuRect = menu.getBoundingClientRect(); + const pointX = Math.floor(menuRect.left + 22); + const pointY = Math.floor(menuRect.bottom + 12); + const target = document.elementFromPoint(pointX, pointY); + const targetResult = target && target.closest('.settings-search-result'); + const searchRect = results ? results.getBoundingClientRect() : { top: 0, bottom: 0 }; + return { + menuBottom: Math.floor(menuRect.bottom), + pointY: pointY, + resultBottom: Math.floor(searchRect.bottom), + isResultHit: !!targetResult, + targetTag: target ? target.tagName : null, + targetClass: target ? target.className : '', + }; + } + """ + ) + browser.close() + + assert hit["isResultHit"], ( + "the dropdown result should be hit-testable below #settingsMenu's visible boundary" + ) + assert hit["pointY"] > hit["menuBottom"], ( + "the hit-test point should be below the visible settings menu boundary" + ) + assert hit["resultBottom"] > hit["menuBottom"], ( + "rendered results must extend below #settingsMenu so the regression is meaningful" + ) diff --git a/tests/test_issue5270_cli_webui_continuity.py b/tests/test_issue5270_cli_webui_continuity.py new file mode 100644 index 00000000000..ac06b8c9667 --- /dev/null +++ b/tests/test_issue5270_cli_webui_continuity.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import queue +import sqlite3 +from collections import OrderedDict +from pathlib import Path + +import pytest + + +pytestmark = pytest.mark.requires_agent_modules + + +CLI_PROMPT = "Reply with exactly CLI-ORIGIN-OK and nothing else." +CLI_REPLY = "CLI-ORIGIN-OK" +WEBUI_FOLLOWUP = "What exact string did you just reply with? Answer with only that string." + + +def _make_cli_continuation_state_db(path: Path, *, parent_sid: str, child_sid: str, workspace: str) -> None: + conn = sqlite3.connect(path) + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + title TEXT, + model TEXT, + cwd TEXT, + started_at REAL, + ended_at REAL, + end_reason TEXT, + parent_session_id TEXT, + message_count INTEGER + ) + """ + ) + conn.execute( + """ + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL + ) + """ + ) + conn.execute( + """ + INSERT INTO sessions ( + id, source, title, model, cwd, started_at, ended_at, end_reason, parent_session_id, message_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (parent_sid, "cli", "CLI parent", "test-model", workspace, 1.0, 2.0, "cli_close", None, 2), + ) + conn.execute( + """ + INSERT INTO sessions ( + id, source, title, model, cwd, started_at, ended_at, end_reason, parent_session_id, message_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (child_sid, "cli", "CLI child", "test-model", workspace, 3.0, None, None, parent_sid, 0), + ) + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", + (parent_sid, "user", CLI_PROMPT, 1.0), + ) + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", + (parent_sid, "assistant", CLI_REPLY, 2.0), + ) + conn.commit() + conn.close() + + +def _install_cli_continuity_env(monkeypatch, tmp_path): + import api.config as config + import api.models as models + import api.profiles as profiles + import api.routes as routes + import api.streaming as streaming + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + index_file = session_dir / "_index.json" + state_db = tmp_path / "state.db" + + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file) + monkeypatch.setattr(models, "SESSIONS", OrderedDict(), raising=False) + monkeypatch.setattr(models, "_active_state_db_path", lambda: state_db, raising=False) + monkeypatch.setattr(config, "SESSION_DIR", session_dir, raising=False) + monkeypatch.setattr(config, "SESSION_INDEX_FILE", index_file, raising=False) + monkeypatch.setattr(streaming, "SESSION_DIR", session_dir, raising=False) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path, raising=False) + monkeypatch.setattr(routes, "SESSION_INDEX_FILE", index_file, raising=False) + + config.STREAMS.clear() + config.CANCEL_FLAGS.clear() + config.AGENT_INSTANCES.clear() + config.SESSION_AGENT_LOCKS.clear() + streaming.STREAMS.clear() + streaming.CANCEL_FLAGS.clear() + streaming.AGENT_INSTANCES.clear() + streaming.STREAM_PARTIAL_TEXT.clear() + streaming.STREAM_REASONING_TEXT.clear() + streaming.STREAM_LIVE_TOOL_CALLS.clear() + + return config, models, routes, streaming, state_db + + +def _capture_streaming_history(monkeypatch, config, streaming, session, *, stream_id: str, tmp_path: Path): + captured: dict[str, list] = {} + + class FakeAgent: + def __init__(self, **_kwargs): + self.session_id = session.session_id + self.context_compressor = None + self.ephemeral_system_prompt = None + self.session_prompt_tokens = 0 + self.session_completion_tokens = 0 + self.session_estimated_cost_usd = None + self.session_cache_read_tokens = 0 + self.session_cache_write_tokens = 0 + self._last_error = None + + def run_conversation(self, **kwargs): + history = list(kwargs.get("conversation_history") or []) + captured["conversation_history"] = history + return { + "completed": True, + "final_response": "ok", + "messages": history + [ + {"role": "user", "content": kwargs.get("persist_user_message", "")}, + {"role": "assistant", "content": "ok"}, + ], + } + + def interrupt(self, _message): + return None + + monkeypatch.setattr(streaming, "_get_ai_agent", lambda: FakeAgent) + monkeypatch.setattr(streaming, "resolve_model_provider", lambda *args, **kwargs: ("test-model", None, None)) + monkeypatch.setattr(streaming, "get_config", lambda: {}) + monkeypatch.setattr(config, "get_config", lambda: {}) + monkeypatch.setattr(config, "_resolve_cli_toolsets", lambda *args, **kwargs: []) + + session.active_stream_id = stream_id + session.pending_user_message = WEBUI_FOLLOWUP + session.pending_started_at = 10.0 + session.save(touch_updated_at=False) + config.STREAMS[stream_id] = queue.Queue() + try: + streaming._run_agent_streaming( + session_id=session.session_id, + msg_text=WEBUI_FOLLOWUP, + model="test-model", + workspace=str(tmp_path), + stream_id=stream_id, + attachments=[], + ) + finally: + config.STREAMS.pop(stream_id, None) + return captured.get("conversation_history") or [] + + +def _history_contents(history): + return [msg.get("content") for msg in history if isinstance(msg, dict)] + + +def test_first_webui_followup_receives_immediate_cli_assistant_context(monkeypatch, tmp_path): + config, models, routes, streaming, state_db = _install_cli_continuity_env(monkeypatch, tmp_path) + sid = "issue5270_cli_child_fresh" + _make_cli_continuation_state_db( + state_db, + parent_sid="issue5270_cli_parent_fresh", + child_sid=sid, + workspace=str(tmp_path), + ) + + session, reason = routes._claim_or_synthesize_cli_session(sid) + + assert reason == "materialized" + assert session is not None + assert session.read_only is False + + session.save(touch_updated_at=False) + models.SESSIONS[sid] = session + history = _capture_streaming_history( + monkeypatch, + config, + streaming, + session, + stream_id="stream-issue5270-fresh", + tmp_path=tmp_path, + ) + + assert _history_contents(history) == [CLI_PROMPT, CLI_REPLY] + + +def test_already_claimed_cli_sidecar_still_sees_cli_prior_assistant_on_first_webui_turn(monkeypatch, tmp_path): + config, models, routes, streaming, state_db = _install_cli_continuity_env(monkeypatch, tmp_path) + sid = "issue5270_cli_child_claimed" + _make_cli_continuation_state_db( + state_db, + parent_sid="issue5270_cli_parent_claimed", + child_sid=sid, + workspace=str(tmp_path), + ) + + stale_sidecar = models.Session( + session_id=sid, + title="Claimed CLI child", + workspace=str(tmp_path), + model="test-model", + messages=[ + {"role": "user", "content": CLI_PROMPT, "timestamp": 1.0}, + ], + context_messages=[ + {"role": "user", "content": CLI_PROMPT, "timestamp": 1.0}, + ], + is_cli_session=True, + source_tag="cli", + raw_source="cli", + session_source="cli", + source_label="CLI", + read_only=False, + ) + stale_sidecar.save(touch_updated_at=False) + models.SESSIONS[sid] = stale_sidecar + + session = routes._get_or_materialize_session(sid, refresh_cli_messages=True) + history = _capture_streaming_history( + monkeypatch, + config, + streaming, + session, + stream_id="stream-issue5270-claimed", + tmp_path=tmp_path, + ) + + assert _history_contents(history) == [CLI_PROMPT, CLI_REPLY] + + +def test_chat_start_refreshes_cli_messages_before_first_webui_turn(monkeypatch, tmp_path): + _config, models, routes, _streaming, _state_db = _install_cli_continuity_env(monkeypatch, tmp_path) + + session = models.Session( + session_id="issue5270_cli_child_chat_start", + title="Claimed CLI child", + workspace=str(tmp_path), + model="test-model", + messages=[{"role": "user", "content": CLI_PROMPT, "timestamp": 1.0}], + context_messages=[{"role": "user", "content": CLI_PROMPT, "timestamp": 1.0}], + is_cli_session=True, + source_tag="cli", + raw_source="cli", + session_source="cli", + source_label="CLI", + read_only=False, + ) + + seen: dict[str, bool] = {} + + def _fake_get_or_materialize_session(sid, *, refresh_cli_messages=False): + seen["refresh_cli_messages"] = refresh_cli_messages + assert sid == session.session_id + return session + + def _fake_start_run(s, **kwargs): + assert s is session + assert seen["refresh_cli_messages"] is True + return {"ok": True} + + monkeypatch.setattr(routes, "_get_or_materialize_session", _fake_get_or_materialize_session) + monkeypatch.setattr(routes, "_session_visible_to_active_profile", lambda *args, **kwargs: True) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda *args, **kwargs: str(tmp_path)) + monkeypatch.setattr(routes, "_read_profile_model_config", lambda *args, **kwargs: (None, None)) + monkeypatch.setattr( + routes, + "_resolve_compatible_session_model_state", + lambda *args, **kwargs: ("test-model", None, "test-model"), + ) + monkeypatch.setattr(routes, "_start_run", _fake_start_run) + monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200: {"status": status, **payload}) + + response = routes._handle_chat_start( + None, + { + "session_id": session.session_id, + "message": WEBUI_FOLLOWUP, + }, + ) + + assert seen["refresh_cli_messages"] is True + assert response["ok"] is True + assert response["status"] == 200 + + +def test_regular_cli_sessions_remain_writable_after_fix(monkeypatch, tmp_path): + _config, _models, routes, _streaming, state_db = _install_cli_continuity_env(monkeypatch, tmp_path) + sid = "issue5270_cli_child_writable" + _make_cli_continuation_state_db( + state_db, + parent_sid="issue5270_cli_parent_writable", + child_sid=sid, + workspace=str(tmp_path), + ) + + session, reason = routes._claim_or_synthesize_cli_session(sid) + + assert reason == "materialized" + assert session is not None + assert session.read_only is False + assert session.is_cli_session is True diff --git a/tests/test_issue5273_active_done_viewed_sync.py b/tests/test_issue5273_active_done_viewed_sync.py new file mode 100644 index 00000000000..26fada8c8f6 --- /dev/null +++ b/tests/test_issue5273_active_done_viewed_sync.py @@ -0,0 +1,262 @@ +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 0 + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : idx] + raise AssertionError(f"function {name} body not found") + + +def _extract_function(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + body = _function_body(src, name) + sig = src[start : src.index("{", start)] + return f"{sig}{{{body}}}" + + +def _done_handler_body() -> str: + marker = "source.addEventListener('done',e=>{" + start = MESSAGES_JS.index(marker) + brace = MESSAGES_JS.index("{", start) + depth = 0 + for idx in range(brace, len(MESSAGES_JS)): + ch = MESSAGES_JS[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return MESSAGES_JS[brace + 1 : idx] + raise AssertionError("done handler body not found") + + +def _has_pre_list_view_sync() -> bool: + body = _done_handler_body() + list_call = "_markSessionCompletedInList(completedSession, activeSid);" + viewed_call = "_markSessionViewed(completedSid," + assert list_call in body, "done handler must update the cached sidebar row" + assert viewed_call in body, "done handler must sync viewed state for active sessions" + return body.index(viewed_call) < body.index(list_call) + + +def _completed_message_count_assignment() -> str: + body = _done_handler_body() + marker = "const completedMessageCount=" + start = body.index(marker) + end = body.index(";\n", start) + 1 + return body[start:end] + + +def _run_done_compaction_harness( + *, + is_session_viewed: bool, + include_message_count: bool = True, + include_completed_messages: bool = True, + session_sid_matches_completed: bool = True, + session_message_count: int | None = 4, + visible_message_count: int = 4, +) -> dict: + helpers = "\n".join( + [ + _extract_function(MESSAGES_JS, "_markSessionViewed"), + _extract_function(SESSIONS_JS, "_getSessionViewedCounts"), + _extract_function(SESSIONS_JS, "_saveSessionViewedCounts"), + _extract_function(SESSIONS_JS, "_setSessionViewedCount"), + _extract_function(SESSIONS_JS, "_getSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_markSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_clearSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"), + _extract_function(SESSIONS_JS, "_hasUnreadForSession"), + _extract_function(SESSIONS_JS, "_markSessionCompletedInList"), + ] + ) + has_pre_list_sync = _has_pre_list_view_sync() + completed_message_count_assignment = textwrap.indent( + _completed_message_count_assignment(), + " ", + ).rstrip() + harness = textwrap.dedent( + f""" + const SESSION_VIEWED_COUNTS_KEY='session-viewed-counts'; + const SESSION_COMPLETION_UNREAD_KEY='session-completion-unread'; + let _sessionViewedCounts=null; + let _sessionCompletionUnread=null; + const visibleMessages=Array.from({{length:{visible_message_count}}}, (_, idx)=>({{ + role:'assistant', + content:`message-${{idx + 1}}`, + }})); + let _allSessions=[{{ + session_id:'active-before-compact', + message_count:3, + last_message_at:10, + updated_at:10, + is_streaming:true, + }}]; + const _sessionStreamingById=new Map(); + const _sessionListSnapshotById=new Map(); + const _sessionListSourceById=new Map(); + const storage=new Map(); + const localStorage={{ + getItem(key){{ return storage.has(key) ? storage.get(key) : null; }}, + setItem(key, value){{ storage.set(key, String(value)); }}, + removeItem(key){{ storage.delete(key); }}, + }}; + function renderSessionListFromCache(){{}} + function _forgetObservedStreamingSession(){{}} + function _rememberSessionListSource(){{}} + {helpers} + _setSessionViewedCount('active-after-compact', 3); + const activeSid='active-before-compact'; + const completedSid='active-after-compact'; + const S={{ + session:{{session_id:{json.dumps("active-after-compact" if session_sid_matches_completed else "different-session")}}}, + messages:visibleMessages, + }}; + if ({json.dumps(session_message_count is not None)}) S.session.message_count = {json.dumps(session_message_count)}; + const completedSession={{ + session_id:completedSid, + updated_at:20, + last_message_at:20, + }}; + if ({str(include_completed_messages).lower()}) completedSession.messages = visibleMessages.slice(); + if ({str(include_message_count).lower()}) completedSession.message_count = 4; +{completed_message_count_assignment} + if ({str(is_session_viewed).lower()} && {json.dumps(has_pre_list_sync)}) {{ + _markSessionViewed(completedSid, completedMessageCount); + }} + if (!{str(is_session_viewed).lower()}) {{ + _markSessionCompletionUnread(completedSid, completedMessageCount); + }} + _markSessionCompletedInList(completedSession, activeSid); + const cacheRow=_allSessions.find(s=>s&&s.session_id===completedSid); + const unreadAfterCacheUpdate=_hasUnreadForSession(cacheRow); + const viewedCountAfterCacheUpdate=_getSessionViewedCounts()[completedSid] ?? null; + if ({str(is_session_viewed).lower()}) {{ + _markSessionViewed(completedSid, completedMessageCount); + }} + console.log(JSON.stringify({{ + hasPreListSync:{json.dumps(has_pre_list_sync)}, + unreadAfterCacheUpdate, + viewedCountAfterCacheUpdate, + unreadAfterActiveBranchSync:_hasUnreadForSession(cacheRow), + hasCompletionUnread:_hasSessionCompletionUnread(completedSid), + completionUnreadMessageCount:_getSessionCompletionUnread()[completedSid]?.message_count ?? null, + viewedCount:_getSessionViewedCounts()[completedSid] ?? null, + cacheRow, + }})); + """ + ) + result = subprocess.run( + ["node", "-e", harness], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip()) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_actively_viewed_done_completion_stays_read_after_sidebar_cache_update(): + result = _run_done_compaction_harness(is_session_viewed=True) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 4 + assert result["unreadAfterCacheUpdate"] is False, ( + "actively viewed done settle must sync viewed_count before the sidebar cache " + "re-render sees the higher compacted message_count" + ) + assert result["viewedCountAfterCacheUpdate"] == 4 + assert result["unreadAfterActiveBranchSync"] is False + assert result["hasCompletionUnread"] is False + assert result["viewedCount"] == 4 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_actively_viewed_done_completion_uses_messages_fallback_when_message_count_missing(): + result = _run_done_compaction_harness( + is_session_viewed=True, + include_message_count=False, + ) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 4 + assert result["unreadAfterCacheUpdate"] is False + assert result["viewedCountAfterCacheUpdate"] == 4 + assert result["unreadAfterActiveBranchSync"] is False + assert result["hasCompletionUnread"] is False + assert result["viewedCount"] == 4 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_actively_viewed_done_completion_uses_session_fallback_when_payload_counts_are_missing(): + result = _run_done_compaction_harness( + is_session_viewed=True, + include_message_count=False, + include_completed_messages=False, + ) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 3 + assert result["unreadAfterCacheUpdate"] is False + assert result["viewedCountAfterCacheUpdate"] == 4 + assert result["unreadAfterActiveBranchSync"] is False + assert result["hasCompletionUnread"] is False + assert result["viewedCount"] == 4 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_actively_viewed_done_completion_uses_visible_messages_fallback_when_session_count_is_missing(): + result = _run_done_compaction_harness( + is_session_viewed=True, + include_message_count=False, + include_completed_messages=False, + session_sid_matches_completed=False, + session_message_count=None, + ) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 3 + assert result["unreadAfterCacheUpdate"] is False + assert result["viewedCountAfterCacheUpdate"] == 4 + assert result["unreadAfterActiveBranchSync"] is False + assert result["hasCompletionUnread"] is False + assert result["viewedCount"] == 4 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_background_done_completion_stays_unread_after_sidebar_cache_update(): + result = _run_done_compaction_harness(is_session_viewed=False) + + assert result["cacheRow"]["session_id"] == "active-after-compact" + assert result["cacheRow"]["message_count"] == 4 + assert result["unreadAfterCacheUpdate"] is True + assert result["viewedCountAfterCacheUpdate"] == 3 + assert result["unreadAfterActiveBranchSync"] is True + assert result["hasCompletionUnread"] is True + assert result["completionUnreadMessageCount"] == 4 + assert result["viewedCount"] == 3 diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index e52d645338f..79eb911324c 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -66,16 +66,17 @@ def test_background_done_sets_marker_when_session_not_actively_viewed(): assert "const isSessionViewed=_isSessionActivelyViewed(activeSid);" in done_block assert "const completedSession=d.session||{session_id:activeSid};" in done_block assert "const completedSid=completedSession.session_id||activeSid;" in done_block + assert "const completedMessageCount=completedSession.message_count != null" in done_block assert "if(!isSessionViewed && typeof _markSessionCompletionUnread==='function')" in done_block - assert "_markSessionCompletionUnread(completedSid, completedSession.message_count);" in done_block + assert "_markSessionCompletionUnread(completedSid, completedMessageCount);" in done_block def test_background_done_uses_rotated_session_id_for_completion_unread(): done_block = _done_block() completed_sid_idx = done_block.find("const completedSid=completedSession.session_id||activeSid;") - marker_idx = done_block.find("_markSessionCompletionUnread(completedSid, completedSession.message_count);") - viewed_idx = done_block.find("_markSessionViewed(completedSid, completedSession.message_count") + marker_idx = done_block.find("_markSessionCompletionUnread(completedSid, completedMessageCount);") + viewed_idx = done_block.find("_markSessionViewed(completedSid, completedMessageCount);") assert completed_sid_idx != -1, "done handler must derive the final post-compression session id" assert marker_idx != -1, "background completion marker must be stored on the final session id" diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 4d3d222e76f..8df7b084704 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -804,6 +804,349 @@ def test_transparent_stream_renders_persisted_anchor_scene_after_reload(): assert "if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue;" in render +def test_live_anchor_scene_snapshot_renders_transparent_rows_before_compact_gate(): + live = _function_body(UI_JS, "renderLiveAnchorActivityScene") + transparent = _function_body(UI_JS, "_renderLiveAnchorActivitySceneTransparent") + stream = _function_body(UI_JS, "_renderLiveAnchorActivitySceneForStream") + row = _function_body(UI_JS, "_anchorSceneTransparentNodeForRow") + + transparent_gate = "return _renderLiveAnchorActivitySceneTransparent(streamId,scene,opts);" + compact_gate = "if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false;" + assert transparent_gate in live + assert compact_gate in live + assert live.index(transparent_gate) < live.index(compact_gate), ( + "transparent live snapshots must render before the compact-only guard" + ) + + assert "_anchorSceneRowsForRendering(scene,{settled:false})" in transparent + assert "turn.id='liveAssistantTurn'" in transparent + assert "turn.dataset.sessionId=S.session.session_id" in transparent + assert "turn.setAttribute('data-anchor-scene-live-owner','1')" in transparent + assert "turn.setAttribute('data-anchor-stream-id',String(streamId||''))" in transparent + assert "turn.setAttribute('data-live-assistant-turn','1')" in transparent + assert "_anchorSceneTransparentNodeForRow(row,{" in transparent + assert "live:true" in transparent + assert "streamId:streamId||S.activeStreamId||''" in transparent + assert "sessionId:S.session&&S.session.session_id" in transparent + assert "_syncTransparentEventControls(turn)" in transparent + assert "blocks.querySelectorAll('[data-live-assistant=\"1\"]').forEach" in transparent + assert ".transparent-event-row[data-live-tid]" in transparent + assert "[data-live-stream-owned=\"1\"]" in transparent + + assert "'transparent_stream'" in stream + assert "_projectLiveAnchorActivitySceneForStream(streamId,mode)" in stream + assert "data-anchor-live-scene-row" in row + assert "data-live-stream-owned" in row + assert "if(settled) node.setAttribute('data-anchor-settled-scene-row','1')" in row + + +@pytest.mark.skipif(NODE is None, reason="node is required for DOM-executed anchor render tests") +def test_live_anchor_scene_transparent_snapshot_render_is_idempotent_and_hides_legacy_rows(): + script = f""" +const assert = require('assert'); +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); +function extractFunc(name){{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if(start < 0) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for(let i=params; i