mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Stage 404: PR #2716 — Performance optimizations by @dobby-d-elf (nesquena APPROVED)
This commit is contained in:
+19
-50
@@ -2153,9 +2153,27 @@ def all_sessions(diag=None):
|
||||
_diag_stage(diag, "all_sessions.prune_index")
|
||||
with LOCK:
|
||||
in_memory_ids = set(SESSIONS.keys())
|
||||
try:
|
||||
persisted_ids = {
|
||||
p.stem
|
||||
for p in SESSION_DIR.glob('*.json')
|
||||
if not p.name.startswith('_')
|
||||
}
|
||||
except Exception:
|
||||
persisted_ids = None
|
||||
index = [
|
||||
s for s in index
|
||||
if _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids)
|
||||
if (
|
||||
str(s.get('session_id') or '') in in_memory_ids
|
||||
or (
|
||||
persisted_ids is not None
|
||||
and str(s.get('session_id') or '') in persisted_ids
|
||||
)
|
||||
or (
|
||||
persisted_ids is None
|
||||
and _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids)
|
||||
)
|
||||
)
|
||||
]
|
||||
backfilled = []
|
||||
for i, s in enumerate(index):
|
||||
@@ -3032,55 +3050,6 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False, pr
|
||||
return msgs
|
||||
|
||||
|
||||
def get_state_db_session_summary(sid) -> dict:
|
||||
"""Return cheap message count/max timestamp for one state.db session.
|
||||
|
||||
This is intentionally narrower than ``get_state_db_session_messages`` for
|
||||
metadata-only WebUI polling: callers only need a staleness signal, not a
|
||||
fully materialized transcript with tool/reasoning metadata.
|
||||
"""
|
||||
import os
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return {}
|
||||
|
||||
db_path = _active_state_db_path()
|
||||
if not sid or not db_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with closing(sqlite3.connect(str(db_path))) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(messages)")
|
||||
available = {str(row['name']) for row in cur.fetchall()}
|
||||
if not {'session_id', 'timestamp'}.issubset(available):
|
||||
return {}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS message_count, MAX(timestamp) AS last_message_at
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(str(sid),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
count = int(row['message_count'] or 0)
|
||||
last_message_at = row['last_message_at']
|
||||
result = {'message_count': count}
|
||||
if last_message_at not in (None, ''):
|
||||
try:
|
||||
result['last_message_at'] = float(last_message_at)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _normalized_message_timestamp_for_key(value):
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
|
||||
+46
-25
@@ -2036,6 +2036,37 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list:
|
||||
return sidecar_messages
|
||||
|
||||
|
||||
def _message_summary(messages) -> dict:
|
||||
messages = list(messages or [])
|
||||
last_message_at = 0.0
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
try:
|
||||
last_message_at = max(last_message_at, float(msg.get("timestamp") or 0))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return {"message_count": len(messages), "last_message_at": last_message_at}
|
||||
|
||||
|
||||
def _metadata_only_message_summary(sid: str, profile: str | None = None) -> dict:
|
||||
"""Return the reconciled message summary used by metadata-only session loads.
|
||||
|
||||
Threads ``profile=`` through to ``get_state_db_session_messages`` so
|
||||
background-thread reads land on the correct profile's state.db (per the
|
||||
cookie-bound profile selector — fixes the same TLS-vs-thread race the
|
||||
#2762 fix addressed for write paths).
|
||||
"""
|
||||
sidecar_session = Session.load(sid)
|
||||
sidecar_messages = []
|
||||
if sidecar_session:
|
||||
sidecar_messages = getattr(sidecar_session, "messages", []) or []
|
||||
state_db_messages = get_state_db_session_messages(sid, profile=profile)
|
||||
return _message_summary(
|
||||
merge_session_messages_append_only(sidecar_messages, state_db_messages)
|
||||
)
|
||||
|
||||
|
||||
def _session_requires_cli_metadata_lookup(session) -> bool:
|
||||
"""Return True when a sidecar/session row still needs CLI metadata.
|
||||
|
||||
@@ -3792,7 +3823,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta)
|
||||
cli_messages = []
|
||||
state_db_messages = []
|
||||
sidecar_metadata_messages = None
|
||||
metadata_summary = None
|
||||
_session_profile = getattr(s, 'profile', None) or None
|
||||
if is_messaging_session:
|
||||
cli_messages = get_cli_session_messages(sid)
|
||||
@@ -3800,17 +3831,11 @@ def handle_get(handler, parsed) -> bool:
|
||||
state_db_messages = get_state_db_session_messages(sid, profile=_session_profile)
|
||||
elif not is_messaging_session:
|
||||
# Metadata-only callers still need the same append-only
|
||||
# reconciliation contract as full loads. A raw state.db summary
|
||||
# can count stale rows that the merge intentionally filters out,
|
||||
# which makes sidebar polling think the transcript is always
|
||||
# newer than the loaded conversation.
|
||||
state_db_messages = get_state_db_session_messages(sid, profile=_session_profile)
|
||||
sidecar_metadata_session = Session.load(sid)
|
||||
sidecar_metadata_messages = (
|
||||
getattr(sidecar_metadata_session, "messages", []) or []
|
||||
if sidecar_metadata_session
|
||||
else []
|
||||
)
|
||||
# reconciliation contract as full loads so stale/replayed
|
||||
# state.db rows do not make sidebar polling think the
|
||||
# transcript is always newer. Helper threads profile= to
|
||||
# honor #2827's TLS-vs-thread fix.
|
||||
metadata_summary = _metadata_only_message_summary(sid, profile=_session_profile)
|
||||
_t2 = _time.monotonic()
|
||||
effective_model = (
|
||||
_resolve_effective_session_model_for_display(s)
|
||||
@@ -3840,12 +3865,16 @@ def handle_get(handler, parsed) -> bool:
|
||||
sidecar_messages = getattr(s, "messages", []) or []
|
||||
_all_msgs = merge_session_messages_append_only(cli_messages, sidecar_messages)
|
||||
else:
|
||||
_metadata_sidecar = sidecar_metadata_messages
|
||||
if _metadata_sidecar is None:
|
||||
_metadata_sidecar = getattr(s, "messages", []) or []
|
||||
_all_msgs = merge_session_messages_append_only(_metadata_sidecar, state_db_messages)
|
||||
if metadata_summary is None:
|
||||
metadata_summary = _message_summary(getattr(s, "messages", []) or [])
|
||||
_summary_message_count = metadata_summary["message_count"]
|
||||
_summary_last_message_at = metadata_summary["last_message_at"]
|
||||
_all_msgs = []
|
||||
if not load_messages:
|
||||
_summary_message_count = len(_all_msgs)
|
||||
if metadata_summary is None:
|
||||
metadata_summary = _message_summary(_all_msgs)
|
||||
_summary_message_count = metadata_summary["message_count"]
|
||||
_summary_last_message_at = metadata_summary["last_message_at"]
|
||||
if _summary_message_count == 0:
|
||||
# Legacy session with no loaded sidecar and no state.db summary —
|
||||
# fall back to the persisted metadata count from session JSON.
|
||||
@@ -3858,14 +3887,6 @@ def handle_get(handler, parsed) -> bool:
|
||||
_summary_message_count = max(0, int(metadata_count))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
_summary_last_message_at = max(
|
||||
float((m or {}).get("timestamp") or 0)
|
||||
for m in _all_msgs
|
||||
if isinstance(m, dict)
|
||||
) if _all_msgs else 0
|
||||
except (TypeError, ValueError):
|
||||
_summary_last_message_at = 0
|
||||
else:
|
||||
_summary_message_count = None
|
||||
_summary_last_message_at = None
|
||||
|
||||
+7
-1
@@ -1624,7 +1624,10 @@ function applyBotName(){
|
||||
else if(typeof syncModelChip==='function') syncModelChip();
|
||||
}
|
||||
if(S.session) syncTopbar();
|
||||
}).catch(()=>{});
|
||||
}).catch(e=>{
|
||||
window._modelDropdownReady=null;
|
||||
throw e;
|
||||
});
|
||||
const _startBootModelDropdown=()=>{
|
||||
const ready=window._modelDropdownReady;
|
||||
if(ready&&typeof ready.then==='function') return ready;
|
||||
@@ -1634,6 +1637,9 @@ function applyBotName(){
|
||||
};
|
||||
window._modelDropdownReady=null;
|
||||
window._ensureModelDropdownReady=_startBootModelDropdown;
|
||||
setTimeout(()=>{
|
||||
try{Promise.resolve(_startBootModelDropdown()).catch(()=>{});}catch(_){}
|
||||
},0);
|
||||
// Start independent boot fetches without holding the conversation list behind
|
||||
// them. The sidebar can render from /api/sessions while workspace/onboarding
|
||||
// metadata settles in parallel.
|
||||
|
||||
+7
-4
@@ -6736,10 +6736,13 @@ function _refreshModelDropdownsAfterProviderChange(){
|
||||
if(typeof window._invalidateSlashModelCache==='function'){
|
||||
window._invalidateSlashModelCache();
|
||||
}
|
||||
if(typeof populateModelDropdown==='function'){
|
||||
// Fire-and-forget: don't block the providers panel refresh on a
|
||||
// dropdown rebuild. The composer/Settings dropdowns will catch up
|
||||
// on the very next paint frame.
|
||||
// Fire-and-forget: don't block the providers panel refresh on a
|
||||
// dropdown rebuild. The composer/Settings dropdowns will catch up
|
||||
// on the very next paint frame.
|
||||
if(typeof window._ensureModelDropdownReady==='function'){
|
||||
window._modelDropdownReady=null;
|
||||
Promise.resolve(window._ensureModelDropdownReady()).catch(()=>{});
|
||||
}else if(typeof populateModelDropdown==='function'){
|
||||
Promise.resolve(populateModelDropdown()).catch(()=>{});
|
||||
}
|
||||
}catch(_e){
|
||||
|
||||
+4
-1
@@ -789,7 +789,10 @@ async function loadSession(sid){
|
||||
syncTopbar();renderMessages();
|
||||
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
|
||||
const _dirP=loadDir('.');
|
||||
await _dirP;
|
||||
// Workspace refresh is guarded by session id inside loadDir(); do not
|
||||
// block session-load completion, draft restore, or model resolution on
|
||||
// file-tree IO for users focused on the chat.
|
||||
if(_dirP&&typeof _dirP.catch==='function') _dirP.catch(()=>{});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-7
@@ -5992,7 +5992,7 @@ function renderMessages(options){
|
||||
const msgCount=S.messages.length;
|
||||
if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid);
|
||||
const renderWindowSize=_currentMessageRenderWindowSize();
|
||||
const renderSignature=_messageRenderCacheSignature();
|
||||
let cachedRenderSignature=null;
|
||||
const hasTransientTranscriptUi=!!(
|
||||
(window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) ||
|
||||
(window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid))
|
||||
@@ -6007,6 +6007,8 @@ function renderMessages(options){
|
||||
// cross-channel handoff summaries; otherwise the cached transcript returns
|
||||
// before those cards can be inserted.
|
||||
if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){
|
||||
const renderSignature=_messageRenderCacheSignature();
|
||||
cachedRenderSignature=renderSignature;
|
||||
const cached=_sessionHtmlCache.get(sid);
|
||||
if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize&&cached.signature===renderSignature){
|
||||
inner.innerHTML=cached.html;
|
||||
@@ -6128,6 +6130,13 @@ function renderMessages(options){
|
||||
const assistantSegments=new Map();
|
||||
const assistantThinking=new Map();
|
||||
const userRows=new Map();
|
||||
const toolCallAssistantIdxs=new Set();
|
||||
if(Array.isArray(S.toolCalls)){
|
||||
for(const tc of S.toolCalls){
|
||||
if(!tc) continue;
|
||||
toolCallAssistantIdxs.add(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1);
|
||||
}
|
||||
}
|
||||
// Windowed render loop replaces the legacy full loop:
|
||||
// for(let vi=0;vi<visWithIdx.length;vi++)
|
||||
for(let vi=0;vi<renderVisWithIdx.length;vi++){
|
||||
@@ -6374,11 +6383,12 @@ function renderMessages(options){
|
||||
// a display list from per-message tool_calls (OpenAI format) stored in each
|
||||
// assistant message. This covers the reload case described in issue #140.
|
||||
if(!S.busy && (!S.toolCalls||!S.toolCalls.length)){
|
||||
// Pass 1: index tool outputs by tool_call_id / tool_use_id so the
|
||||
// Index tool outputs by tool_call_id / tool_use_id so the
|
||||
// fallback-built cards carry their result snippet (not just the command).
|
||||
// Without this step CLI-origin sessions reload with empty tool cards.
|
||||
const resultsByTid={};
|
||||
S.messages.forEach(m=>{
|
||||
const fallbackToolSources=[];
|
||||
S.messages.forEach((m,rawIdx)=>{
|
||||
if(!m) return;
|
||||
// OpenAI / Hermes CLI format: role=tool with tool_call_id
|
||||
if(m.role==='tool'){
|
||||
@@ -6398,10 +6408,14 @@ function renderMessages(options){
|
||||
resultsByTid[tid]=_cliToolResultSnippet(raw);
|
||||
});
|
||||
}
|
||||
if(m.role==='assistant'){
|
||||
const hasTopLevelToolCalls=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
|
||||
const hasContentToolUse=Array.isArray(m.content)&&m.content.some(p=>p&&typeof p==='object'&&p.type==='tool_use');
|
||||
if(hasTopLevelToolCalls||hasContentToolUse) fallbackToolSources.push({m,rawIdx});
|
||||
}
|
||||
});
|
||||
const derived=[];
|
||||
S.messages.forEach((m,rawIdx)=>{
|
||||
if(m.role!=='assistant') return;
|
||||
fallbackToolSources.forEach(({m,rawIdx})=>{
|
||||
// OpenAI format: top-level tool_calls field on the assistant message
|
||||
(m.tool_calls||[]).forEach(tc=>{
|
||||
if(!tc||typeof tc!=='object') return;
|
||||
@@ -6548,7 +6562,7 @@ function renderMessages(options){
|
||||
const hasTurnUsage=!!msg._turnUsage;
|
||||
const compactActivityForMessage=isSimplifiedToolCalling()&&(
|
||||
assistantThinking.has(mi)||
|
||||
(S.toolCalls||[]).some(tc=>tc&&(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1)===mi)
|
||||
toolCallAssistantIdxs.has(mi)
|
||||
);
|
||||
const durationText=compactActivityForMessage?'':_formatTurnDuration(msg._turnDuration);
|
||||
if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText) continue;
|
||||
@@ -6615,10 +6629,11 @@ function renderMessages(options){
|
||||
if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(inner);
|
||||
// Populate session cache so switching back here skips a full rebuild.
|
||||
_sessionHtmlCacheSid=sid;
|
||||
if(sid&&!hasTransientTranscriptUi){
|
||||
if(sid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){
|
||||
const _html=inner.innerHTML;
|
||||
// Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions.
|
||||
if(_html.length<300_000){
|
||||
const renderSignature=cachedRenderSignature===null?_messageRenderCacheSignature():cachedRenderSignature;
|
||||
_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature});
|
||||
if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);}
|
||||
}
|
||||
|
||||
+12
-4
@@ -105,13 +105,15 @@ function _restoreExpandedDirs(){
|
||||
|
||||
async function loadDir(path){
|
||||
if(!S.session)return;
|
||||
const sessionId=S.session.session_id;
|
||||
try{
|
||||
if(!path||path==='.'){
|
||||
S._dirCache={};
|
||||
_restoreExpandedDirs(); // restore per-workspace expanded state on root load
|
||||
}
|
||||
S.currentDir=path||'.';
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`);
|
||||
if(!S.session||S.session.session_id!==sessionId)return;
|
||||
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
||||
// Pre-fetch contents of restored expanded dirs so they render without a second click
|
||||
// (parallelized — avoids serial waterfall when multiple dirs are expanded)
|
||||
@@ -120,10 +122,11 @@ async function loadDir(path){
|
||||
const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]);
|
||||
if(pending.length){
|
||||
const results=await Promise.all(pending.map(dirPath=>
|
||||
api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`)
|
||||
api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(dirPath)}`)
|
||||
.then(dc=>({dirPath,entries:dc.entries||[]}))
|
||||
.catch(()=>({dirPath,entries:[]}))
|
||||
));
|
||||
if(!S.session||S.session.session_id!==sessionId)return;
|
||||
for(const {dirPath,entries} of results) S._dirCache[dirPath]=entries;
|
||||
}
|
||||
if(expanded.size>0)renderFileTree();
|
||||
@@ -143,8 +146,10 @@ async function loadDir(path){
|
||||
async function _refreshGitBadge(){
|
||||
const badge=$('gitBadge');
|
||||
if(!badge||!S.session)return;
|
||||
const sessionId=S.session.session_id;
|
||||
try{
|
||||
const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`);
|
||||
const data=await api(`/api/git-info?session_id=${encodeURIComponent(sessionId)}`);
|
||||
if(!S.session||S.session.session_id!==sessionId)return;
|
||||
if(data.git&&data.git.is_git){
|
||||
const g=data.git;
|
||||
let text=g.branch||'git';
|
||||
@@ -158,7 +163,10 @@ async function _refreshGitBadge(){
|
||||
badge.style.display='none';
|
||||
badge.textContent='';
|
||||
}
|
||||
}catch(e){badge.style.display='none';}
|
||||
}catch(e){
|
||||
if(!S.session||S.session.session_id!==sessionId)return;
|
||||
badge.style.display='none';
|
||||
}
|
||||
}
|
||||
|
||||
function navigateUp(){
|
||||
|
||||
@@ -164,6 +164,15 @@ class TestProviderRemoveInvalidatesDropdowns:
|
||||
"response (covers the dropdown + badge surfaces from #1539)."
|
||||
)
|
||||
|
||||
def test_dropdown_flush_reuses_shared_model_ready_promise(self):
|
||||
src = _read_static("panels.js")
|
||||
body = _extract_function_body(src, "function _refreshModelDropdownsAfterProviderChange(")
|
||||
ensure_pos = body.index("typeof window._ensureModelDropdownReady")
|
||||
reset_pos = body.index("window._modelDropdownReady=null", ensure_pos)
|
||||
call_pos = body.index("window._ensureModelDropdownReady()", reset_pos)
|
||||
|
||||
assert ensure_pos < reset_pos < call_pos
|
||||
|
||||
def test_dropdown_flush_is_resilient_to_missing_modules(self):
|
||||
"""If commands.js or ui.js failed to load, the providers panel must
|
||||
still update — the dropdown flush is best-effort (#1539)."""
|
||||
|
||||
@@ -50,6 +50,13 @@ def test_load_dir_keeps_workspace_panel_open_when_clearing_preview():
|
||||
)
|
||||
|
||||
|
||||
def test_load_dir_ignores_stale_session_results():
|
||||
block = _function_block(WORKSPACE_JS, "loadDir")
|
||||
assert "const sessionId=S.session.session_id" in block
|
||||
assert "encodeURIComponent(sessionId)" in block
|
||||
assert "if(!S.session||S.session.session_id!==sessionId)return;" in block
|
||||
|
||||
|
||||
def test_file_preview_breadcrumb_uses_directory_navigation_for_root():
|
||||
block = _function_block(WORKSPACE_JS, "renderFileBreadcrumb")
|
||||
assert "loadDir('.')" in block, "The preview root breadcrumb should navigate to the workspace root."
|
||||
|
||||
@@ -88,9 +88,11 @@ class TestLoadSessionIdleOverlap:
|
||||
"The idle path should rely on renderMessages()'s consolidated "
|
||||
"post-render pass instead of running a second highlight pass."
|
||||
)
|
||||
assert "await" in block and "_dirP" in block, (
|
||||
"loadDir() result should still be stored and awaited."
|
||||
assert "_dirP" in block and "await _dirP" not in block, (
|
||||
"loadDir() should refresh the workspace without blocking "
|
||||
"session-load completion."
|
||||
)
|
||||
assert "_dirP.catch" in block
|
||||
break
|
||||
|
||||
assert found, (
|
||||
|
||||
+10
-10
@@ -123,8 +123,8 @@ def test_all_sessions_backfills_last_message_at_for_legacy_index_rows():
|
||||
assert persisted[0].get("last_message_at") == 100.0
|
||||
|
||||
|
||||
def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch):
|
||||
"""Index pruning should not reacquire the session lock for every row."""
|
||||
def test_all_sessions_prune_batches_persisted_id_snapshot(monkeypatch):
|
||||
"""Index pruning should not probe each backing file through the helper."""
|
||||
index_file = models.SESSION_INDEX_FILE
|
||||
entries = [
|
||||
{
|
||||
@@ -152,22 +152,22 @@ def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch):
|
||||
"archived": False,
|
||||
},
|
||||
]
|
||||
for entry in entries:
|
||||
(models.SESSION_DIR / f"{entry['session_id']}.json").write_text(
|
||||
"{}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_index_file(index_file, entries)
|
||||
|
||||
seen = []
|
||||
def _assert_not_called(session_id, in_memory_ids=None):
|
||||
raise AssertionError("all_sessions should batch persisted ids before pruning")
|
||||
|
||||
def _assert_snapshot_used(session_id, in_memory_ids=None):
|
||||
assert in_memory_ids is not None, "all_sessions should snapshot SESSIONS once before pruning"
|
||||
seen.append(session_id)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(models, "_index_entry_exists", _assert_snapshot_used)
|
||||
monkeypatch.setattr(models, "_index_entry_exists", _assert_not_called)
|
||||
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
|
||||
|
||||
rows = models.all_sessions()
|
||||
|
||||
assert [row["session_id"] for row in rows] == ["sess_a", "sess_b"]
|
||||
assert seen == ["sess_a", "sess_b"]
|
||||
|
||||
|
||||
# ── 6. test_incremental_patch_correctness ─────────────────────────────────
|
||||
|
||||
@@ -68,6 +68,42 @@ def test_boot_does_not_block_session_restore_on_model_catalog():
|
||||
assert "await populateModelDropdown()" not in src
|
||||
|
||||
|
||||
def test_boot_primes_model_catalog_without_awaiting_it():
|
||||
"""The boot-time prime must NOT await the model-catalog hydration before
|
||||
rendering the session list. A later awaited hydration inside the saved-
|
||||
session restore path at ``if(S.session) await _startBootModelDropdown();``
|
||||
is intentional — that one re-applies the saved session's model after the
|
||||
live catalog hydrates so the chip never shows a stale static default
|
||||
(see comment in static/boot.js next to the saved-session restore).
|
||||
"""
|
||||
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
|
||||
ensure_pos = src.index("window._ensureModelDropdownReady=_startBootModelDropdown;")
|
||||
prime_pos = src.index("Promise.resolve(_startBootModelDropdown()).catch(()=>{});", ensure_pos)
|
||||
session_restore_pos = src.index("await renderSessionList();", prime_pos)
|
||||
|
||||
assert ensure_pos < prime_pos < session_restore_pos
|
||||
|
||||
# No await on the boot-prime path itself: between ensure_pos and the first
|
||||
# session_restore await, the dropdown is fired-and-forgotten.
|
||||
boot_prelude = src[ensure_pos:session_restore_pos]
|
||||
assert "await _startBootModelDropdown()" not in boot_prelude, (
|
||||
"Boot prelude must not await _startBootModelDropdown — the prime is "
|
||||
"fire-and-forget so the sidebar can render before /api/models returns."
|
||||
)
|
||||
assert "await populateModelDropdown()" not in boot_prelude
|
||||
|
||||
|
||||
def test_failed_boot_model_catalog_prime_is_retryable():
|
||||
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
start = src.index("const _hydrateBootModelDropdown=()=>populateModelDropdown().then")
|
||||
end = src.index("const _startBootModelDropdown=()=>", start)
|
||||
block = src[start:end]
|
||||
|
||||
assert "window._modelDropdownReady=null;" in block
|
||||
assert "throw e;" in block
|
||||
|
||||
|
||||
def test_boot_primes_visible_default_model_without_catalog_fetch():
|
||||
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
default_block_start = src.index("if(s.default_model){")
|
||||
|
||||
@@ -512,6 +512,40 @@ def test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant(m
|
||||
assert handler.response_json["session"]["message_count"] == 2
|
||||
|
||||
|
||||
def test_metadata_fast_path_matches_reconciliation_for_restamped_replays(monkeypatch, tmp_path):
|
||||
"""#2716 invariant: metadata-only /api/session uses merge_session_messages_append_only
|
||||
(not a raw state.db COUNT) so restamped replay rows don't make sidebar polling think
|
||||
the transcript is always newer than the loaded conversation."""
|
||||
import api.routes as routes
|
||||
|
||||
sid = "webui_reconcile_metadata_replay"
|
||||
_install_test_session(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "old user", "timestamp": 1000.0},
|
||||
{"role": "assistant", "content": "old assistant", "timestamp": 1001.0},
|
||||
],
|
||||
)
|
||||
_make_state_db(
|
||||
tmp_path / "state.db",
|
||||
sid,
|
||||
[
|
||||
{"role": "user", "content": "old user", "timestamp": 1002.0},
|
||||
],
|
||||
)
|
||||
|
||||
handler = _GetHandler(f"/api/session?session_id={sid}&messages=0&resolve_model=0")
|
||||
routes.handle_get(handler, urlparse(handler.path))
|
||||
|
||||
assert handler.status == 200
|
||||
session = handler.response_json["session"]
|
||||
assert session["messages"] == []
|
||||
assert session["message_count"] == 2
|
||||
assert session["last_message_at"] == 1001.0
|
||||
|
||||
|
||||
def test_state_db_reconciliation_preserves_tool_metadata(monkeypatch, tmp_path):
|
||||
import api.routes as routes
|
||||
|
||||
|
||||
Reference in New Issue
Block a user