Merge pull request #5277 from nesquena/release/stage-w1

Release v0.51.765 — Wave 1 batch (#5276 #5274 #5272 #5256 #5254 #5257)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-30 09:34:21 -07:00
commit 8d5ff4c11e
19 files changed
+1451 -75

No files matched your search

+7
View File
@@ -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)
+44 -17
View File
@@ -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,
+18 -2
View File
@@ -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 @@ from api.models import (
_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
+30 -11
View File
@@ -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):
+34 -32
View File
@@ -356,38 +356,40 @@
placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off">
<div id="settingsSearchResults" class="settings-search-results" style="display:none"></div>
</div>
<button type="button" class="side-menu-item active" data-settings-section="conversation" onclick="switchSettingsSection('conversation',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span data-i18n="settings_tab_conversation">Conversation</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="appearance" onclick="switchSettingsSection('appearance',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<span data-i18n="settings_tab_appearance">Appearance</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="preferences" onclick="switchSettingsSection('preferences',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span data-i18n="settings_tab_preferences">Preferences</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="providers" onclick="switchSettingsSection('providers',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
<span data-i18n="providers_tab_title">Providers</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3 7h7l-5.5 4.3 2.1 7L12 16.2 5.4 20.3l2.1-7L2 9h7z"/></svg>
<span data-i18n="settings_tab_plugins">Plugins</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="extensions" onclick="switchSettingsSection('extensions',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2v6"/><path d="M15 2v6"/><path d="M7 8h10v4a5 5 0 0 1-10 0V8z"/><path d="M12 17v5"/></svg>
<span data-i18n="settings_tab_extensions">Extensions</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span data-i18n="settings_tab_system">System</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="help" onclick="switchSettingsSection('help',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span data-i18n="settings_tab_help">Help</span>
</button>
<div class="settings-menu-items">
<button type="button" class="side-menu-item active" data-settings-section="conversation" onclick="switchSettingsSection('conversation',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span data-i18n="settings_tab_conversation">Conversation</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="appearance" onclick="switchSettingsSection('appearance',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<span data-i18n="settings_tab_appearance">Appearance</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="preferences" onclick="switchSettingsSection('preferences',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span data-i18n="settings_tab_preferences">Preferences</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="providers" onclick="switchSettingsSection('providers',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
<span data-i18n="providers_tab_title">Providers</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="plugins" onclick="switchSettingsSection('plugins',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2l3 7h7l-5.5 4.3 2.1 7L12 16.2 5.4 20.3l2.1-7L2 9h7z"/></svg>
<span data-i18n="settings_tab_plugins">Plugins</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="extensions" onclick="switchSettingsSection('extensions',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2v6"/><path d="M15 2v6"/><path d="M7 8h10v4a5 5 0 0 1-10 0V8z"/><path d="M12 17v5"/></svg>
<span data-i18n="settings_tab_extensions">Extensions</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="system" onclick="switchSettingsSection('system',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><line x1="6" y1="7" x2="6.01" y2="7"/><line x1="6" y1="17" x2="6.01" y2="17"/></svg>
<span data-i18n="settings_tab_system">System</span>
</button>
<button type="button" class="side-menu-item" data-settings-section="help" onclick="switchSettingsSection('help',{fromSidebarItem:true})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span data-i18n="settings_tab_help">Help</span>
</button>
</div>
</div>
</div>
<div class="resize-handle" id="sidebarResize"></div>
+14 -2
View File
@@ -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.
+5 -1
View File
@@ -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'
));
+9
View File
@@ -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; }
+82 -3
View File
@@ -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});
}
+10 -1
View File
@@ -36,7 +36,7 @@ class TestSidebarCancelAction:
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 @@ class TestSidebarCancelAction:
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"
)
+29
View File
@@ -6,6 +6,7 @@ users to find settings across all tabs without having to click through each sect
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 @@ class TestSettingsSearch:
"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()")
@@ -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
@@ -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
@@ -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'<button type="button" class="settings-search-result" '
f'onclick="window.__clicked = true">\n'
f" <span class='settings-search-label'>Result {i}</span>\n"
f' <span class="settings-search-section">Section</span>\n'
f"</button>\n"
)
for i in range(1, 10)
)
menu_lines = "".join(
(
f'<button type="button" class="side-menu-item" '
f"data-settings-section='section{i}' "
f"onclick=\"switchSettingsSection('section{i}',{{fromSidebarItem:true}})\">\n"
f" <span>Section {i}</span>\n"
f"</button>\n"
)
for i in range(1, 18)
)
return f"""
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
{STYLE_CSS}
#settingsMenu {{
width: 320px;
height: 150px;
margin: 12px;
}}
body {{ margin: 0; padding: 0; }}
</style>
</head>
<body>
<div id="settingsMenu" class="side-menu">
<div class="settings-search sidebar-search">
<input id="settingsSearch" value="result"/>
<div id="settingsSearchResults" class="settings-search-results" style="display:block">
{item_lines}
</div>
</div>
<div class="settings-menu-items">
{menu_lines}
</div>
</div>
</body>
</html>
"""
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"
)
@@ -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
@@ -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
@@ -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"
@@ -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<src.length; i++){{
if(src[i] === '(') depth++;
else if(src[i] === ')'){{
depth--;
if(depth === 0){{ close = i; break; }}
}}
}}
const brace = src.indexOf('{{', close);
depth = 0;
for(let i=brace; i<src.length; i++){{
if(src[i] === '{{') depth++;
else if(src[i] === '}}'){{
depth--;
if(depth === 0) return src.slice(start, i + 1);
}}
}}
throw new Error(name + ' body did not close');
}}
class FakeElement {{
constructor(tag='div'){{
this.tagName=tag.toUpperCase();
this.children=[];
this.parentNode=null;
this.attributes={{}};
this.dataset={{}};
this.style={{}};
this.hidden=false;
this.id='';
this.textContent='';
this._classes=new Set();
const self=this;
this.classList={{
add(...names){{ names.forEach(name=>self._classes.add(name)); }},
remove(...names){{ names.forEach(name=>self._classes.delete(name)); }},
contains(name){{ return self._classes.has(name); }},
toggle(name, force){{
const on=force===undefined?!self._classes.has(name):!!force;
if(on) self._classes.add(name); else self._classes.delete(name);
return on;
}},
}};
}}
get parentElement(){{ return this.parentNode; }}
get firstChild(){{ return this.children[0]||null; }}
setAttribute(name,value){{
const str=String(value);
this.attributes[name]=str;
if(name==='id') this.id=str;
if(name.startsWith('data-')){{
const key=name.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase());
this.dataset[key]=str;
}}
}}
getAttribute(name){{ return Object.prototype.hasOwnProperty.call(this.attributes,name)?this.attributes[name]:null; }}
hasAttribute(name){{ return Object.prototype.hasOwnProperty.call(this.attributes,name); }}
removeAttribute(name){{
delete this.attributes[name];
if(name==='id') this.id='';
if(name.startsWith('data-')){{
const key=name.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase());
delete this.dataset[key];
}}
}}
appendChild(child){{
if(child.parentNode) child.remove();
child.parentNode=this;
this.children.push(child);
return child;
}}
insertBefore(child, ref){{
if(child.parentNode) child.remove();
child.parentNode=this;
const idx=this.children.indexOf(ref);
if(idx<0) this.children.push(child);
else this.children.splice(idx,0,child);
return child;
}}
remove(){{
if(!this.parentNode) return;
const siblings=this.parentNode.children;
const idx=siblings.indexOf(this);
if(idx>=0) siblings.splice(idx,1);
this.parentNode=null;
}}
matches(selector){{ return matchesSelector(this, selector); }}
querySelector(selector){{ return this.querySelectorAll(selector)[0]||null; }}
querySelectorAll(selector){{
const out=[];
const walk=(node)=>{{
for(const child of node.children){{
if(matchesSelector(child, selector)) out.push(child);
walk(child);
}}
}};
walk(this);
return out;
}}
closest(selector){{
let node=this;
while(node){{
if(matchesSelector(node, selector)) return node;
node=node.parentNode;
}}
return null;
}}
}}
function matchesSelector(el, selector){{
return String(selector||'').split(',').some(part=>matchesSimple(el, part.trim()));
}}
function matchesSimple(el, selector){{
if(!selector) return false;
selector=selector.replace(/^:scope\\s*>\\s*/, '').trim();
if(selector.includes(' ')) selector=selector.split(/\\s+/).pop();
const idMatch=selector.match(/#([A-Za-z0-9_-]+)/);
if(idMatch && el.id!==idMatch[1]) return false;
for(const match of selector.matchAll(/\\.([A-Za-z0-9_-]+)/g)){{
if(!el._classes.has(match[1])) return false;
}}
for(const match of selector.matchAll(/\\[([^=\\]]+)(?:="([^"]*)")?\\]/g)){{
const attr=match[1];
const expected=match[2];
if(!el.hasAttribute(attr)) return false;
if(expected!==undefined && el.getAttribute(attr)!==expected) return false;
}}
return !!(idMatch || selector.includes('.') || selector.includes('['));
}}
function findById(root, id){{
if(root.id===id) return root;
for(const child of root.children){{
const found=findById(child,id);
if(found) return found;
}}
return null;
}}
const emptyState=new FakeElement('div');
const msgInner=new FakeElement('div');
const messages=new FakeElement('div');
messages.scrollHeight=1000;
messages.scrollTop=1000;
messages.clientHeight=500;
global.document={{createElement:(tag)=>new FakeElement(tag)}};
global.window={{}};
global.CSS={{escape:(value)=>String(value)}};
global.requestAnimationFrame=(fn)=>fn();
global.S={{session:{{session_id:'sid-1', pending_started_at:123}}, activeStreamId:'stream-1'}};
global.$=(id)=>{{
if(id==='emptyState') return emptyState;
if(id==='msgInner') return msgInner;
if(id==='messages') return messages;
return findById(msgInner,id);
}};
let transparentMode=true;
global.isTransparentStream=()=>transparentMode;
global.isCompactWorklogMode=()=>!transparentMode;
global._anchorSceneRowsForRendering=(scene)=>scene.activity_rows||[];
global._createAssistantTurn=()=>{{ const el=new FakeElement('div'); el.classList.add('assistant-turn'); return el; }};
global._assistantTurnBlocks=(turn)=>turn;
global._captureMessageScrollSnapshot=()=>({{scrollHeight:1000}});
global._prepareLiveAnchorScrollRebuildGuard=()=>({{readerAwayFromBottom:false, release:null}});
global._restoreMessageScrollSnapshotSameFrame=()=>{{}};
global.scrollIfPinned=()=>{{}};
global._moveLiveRunStatusToTurnEnd=()=>{{}};
global._messageUserUnpinned=false;
global._anchorSceneNodeForRow=(row)=>{{ const node=new FakeElement('div'); node.classList.add('assistant-segment'); node.textContent=row.text||''; return node; }};
global._thinkingActivityNode=(text)=>{{ const node=new FakeElement('div'); node.classList.add('agent-activity-thinking'); node.textContent=text||''; return node; }};
global._anchorSceneToolCallFromRow=(row)=>({{name:row.tool&&row.tool.name||row.tool_name||'tool', done:true}});
global.buildToolCard=(toolCall)=>{{ const node=new FakeElement('div'); node.classList.add('tool-card-row'); node.setAttribute('data-tool-name',toolCall.name); return node; }};
global._decorateTransparentEventRow=(node,opts)=>{{
node.classList.add('transparent-event-row');
node.setAttribute('data-transparent-event-row','1');
node.setAttribute('data-event-type',opts.type);
if(opts.text) node.setAttribute('data-text',opts.text);
if(opts.name) node.setAttribute('data-tool-name',opts.name);
if(opts.status) node.setAttribute('data-event-status',opts.status);
return node;
}};
global._transparentToolStatus=()=>'Completed';
let syncCalls=0;
global._syncTransparentEventControls=(turn)=>{{
syncCalls++;
turn.setAttribute('data-sync-count',String(syncCalls));
}};
let compactGroups=0;
let compactRenders=0;
global._captureWorklogDetailDisclosureState=()=>null;
global._restoreWorklogDetailDisclosureState=()=>{{}};
global._startActivityElapsedTimer=()=>{{}};
global._dedupeLiveProcessedWorklogAnchors=()=>{{}};
global._anchorSceneWorklogGroup=(blocks, opts)=>{{
compactGroups++;
const group=new FakeElement('div');
group.classList.add('tool-worklog-group');
group.setAttribute('data-anchor-scene-owner','1');
group.setAttribute('data-anchor-stream-id',opts.streamId||'');
blocks.appendChild(group);
return group;
}};
global._renderAnchorSceneRowsIntoWorklog=(group, rows)=>{{
compactRenders++;
group.setAttribute('data-rendered-count',String(rows.length));
return true;
}};
global._syncToolCallGroupSummary=()=>{{}};
eval(extractFunc('_anchorSceneTransparentNodeForRow'));
eval(extractFunc('renderLiveAnchorActivityScene'));
eval(extractFunc('_renderLiveAnchorActivitySceneTransparent'));
const existingTurn=global._createAssistantTurn();
existingTurn.id='liveAssistantTurn';
existingTurn.dataset.sessionId='sid-1';
msgInner.appendChild(existingTurn);
const legacyOne=new FakeElement('div');
legacyOne.setAttribute('data-live-assistant','1');
legacyOne.textContent='legacy process one';
existingTurn.appendChild(legacyOne);
const legacyTwo=new FakeElement('div');
legacyTwo.setAttribute('data-live-assistant','1');
legacyTwo.textContent='legacy process two';
existingTurn.appendChild(legacyTwo);
const staleTool=new FakeElement('div');
staleTool.classList.add('transparent-event-row');
staleTool.classList.add('tool-card-row');
staleTool.setAttribute('data-live-tid','legacy-tool');
existingTurn.appendChild(staleTool);
const scene={{
version:'activity_scene_v1',
activity_rows:[
{{row_id:'p1', role:'prose', source_event_type:'process_prose', text:'progress one'}},
{{row_id:'t1', role:'tool', source_event_type:'tool_completed', tool:{{name:'read_file'}}, tool_call_id:'call-1'}},
{{row_id:'r1', role:'thinking', source_event_type:'reasoning', text:'thinking'}},
{{row_id:'p2', role:'prose', source_event_type:'process_prose', text:'progress two'}},
],
}};
assert.strictEqual(renderLiveAnchorActivityScene('stream-1', scene, {{sessionId:'sid-1'}}), true);
assert.strictEqual(renderLiveAnchorActivityScene('stream-1', scene, {{sessionId:'sid-1'}}), true);
const liveTurn=$('liveAssistantTurn');
const rows=liveTurn.querySelectorAll('[data-anchor-live-scene-row="1"]');
const staleRows=liveTurn.querySelectorAll('[data-live-tid]');
const legacySegments=liveTurn.querySelectorAll('[data-live-assistant="1"]');
const roles=rows.map(row=>row.getAttribute('data-anchor-row-role'));
const eventTypes=rows.map(row=>row.getAttribute('data-anchor-source-event-type'));
const hiddenLegacy=legacySegments.map(row=>({{
hidden:row.hidden,
ariaHidden:row.getAttribute('aria-hidden'),
source:row.classList.contains('assistant-segment-worklog-source'),
}}));
const settledProbe=_anchorSceneTransparentNodeForRow(
{{row_id:'settled-p1', role:'prose', source_event_type:'process_prose', text:'settled progress'}},
{{settled:true, finalAnswer:''}}
);
transparentMode=false;
global.S.activeStreamId='stream-compact';
delete liveTurn.dataset.sessionId;
assert.strictEqual(renderLiveAnchorActivityScene('stream-compact', scene, {{sessionId:'sid-1'}}), true);
process.stdout.write(JSON.stringify({{
rowCount:rows.length,
roles,
eventTypes,
staleRowCount:staleRows.length,
hiddenLegacy,
settledMarker:settledProbe&&settledProbe.getAttribute('data-anchor-settled-scene-row'),
settledLiveMarker:settledProbe&&settledProbe.getAttribute('data-anchor-live-scene-row'),
syncCalls,
compactGroups,
compactRenders,
compactSessionId:liveTurn.dataset.sessionId,
compactRenderedCount:liveTurn.querySelector('.tool-worklog-group').getAttribute('data-rendered-count'),
}}));
"""
result = _run_node_script(script)
assert result["rowCount"] == 4
assert result["roles"] == ["prose", "tool", "thinking", "prose"]
assert result["eventTypes"] == ["process_prose", "tool_completed", "reasoning", "process_prose"]
assert result["staleRowCount"] == 0
assert result["hiddenLegacy"] == [
{"hidden": True, "ariaHidden": "true", "source": True},
{"hidden": True, "ariaHidden": "true", "source": True},
]
assert result["settledMarker"] == "1"
assert result["settledLiveMarker"] is None
assert result["syncCalls"] == 2
assert result["compactGroups"] == 1
assert result["compactRenders"] == 1
assert result["compactSessionId"] == "sid-1"
assert result["compactRenderedCount"] == "4"
def test_transparent_anchor_intermediate_prose_preserved_only_final_answer_suppressed():
"""#4568 combined fix: intermediate between-tool progress prose must render in
Transparent Stream reload (not be blanket-dropped like the first pass did);
@@ -164,7 +164,7 @@ def test_chat_start_foreign_persisted_session_returns_404_before_start_run(monke
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": "chat_foreign", "message": "hello"})
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda sid: persisted_foreign)
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda sid, **_kwargs: persisted_foreign)
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
monkeypatch.setattr(routes, "_start_run", lambda *_, **__: (_ for _ in ()).throw(AssertionError("_start_run should not run")))
@@ -185,7 +185,7 @@ def test_chat_start_body_profile_cannot_retag_visible_empty_session_without_acti
"read_body",
lambda _handler: {"session_id": "chat_empty", "message": "hello", "profile": "other"},
)
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda sid: empty_visible)
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda sid, **_kwargs: empty_visible)
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda *_args, **_kwargs: "/workspace")
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *_args, **_kwargs: (None, None))