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:
19 files changed
+1451
-75
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
));
|
||||
|
||||
@@ -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
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user