From 9d95ba0b926a24174147bbe379eb375703b57640 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Sun, 24 May 2026 18:08:41 +0000 Subject: [PATCH] =?UTF-8?q?Stage=20404:=20PR=20#2716=20=E2=80=94=20Perform?= =?UTF-8?q?ance=20optimizations=20by=20@dobby-d-elf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nesquena APPROVED 2026-05-22. Cherry-picked onto post-v0.51.127 master via 3-way apply. Resolved api/routes.py conflict: master had the inline correctness fix from the deep-review iteration; PR refactors it into _metadata_only_message_summary() helper. Took the helper AND added profile= threading (post-#2827 master adds profile-aware state.db reads). Kept master's pre-existing test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant alongside the PR's new test_metadata_fast_path_matches_reconciliation_for_restamped_replays. Co-authored-by: dobby-d-elf --- api/models.py | 69 +++++------------- api/routes.py | 71 ++++++++++++------- static/boot.js | 8 ++- static/panels.js | 11 +-- static/sessions.js | 5 +- static/ui.js | 29 ++++++-- static/workspace.js | 16 +++-- ..._provider_removal_dropdown_invalidation.py | 9 +++ ..._issue1785_workspace_preview_breadcrumb.py | 7 ++ tests/test_parallel_session_switch.py | 6 +- tests/test_session_index.py | 20 +++--- tests/test_session_metadata_fast_path.py | 36 ++++++++++ tests/test_webui_state_db_reconciliation.py | 34 +++++++++ 13 files changed, 217 insertions(+), 104 deletions(-) diff --git a/api/models.py b/api/models.py index f6416650..3c8d895d 100644 --- a/api/models.py +++ b/api/models.py @@ -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 "" diff --git a/api/routes.py b/api/routes.py index d55df8fa..7a36e632 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 diff --git a/static/boot.js b/static/boot.js index 1ff1a72d..2b6d8347 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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. diff --git a/static/panels.js b/static/panels.js index 89ad0d20..e1519cf4 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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){ diff --git a/static/sessions.js b/static/sessions.js index 8a55fef1..262cedcf 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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(()=>{}); } } diff --git a/static/ui.js b/static/ui.js index 0d4c8e4e..b7c581f6 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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{ + 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);} } diff --git a/static/workspace.js b/static/workspace.js index b4c37ae1..03320f9a 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -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(){ diff --git a/tests/test_issue1539_provider_removal_dropdown_invalidation.py b/tests/test_issue1539_provider_removal_dropdown_invalidation.py index 59ada50d..bc51c0d4 100644 --- a/tests/test_issue1539_provider_removal_dropdown_invalidation.py +++ b/tests/test_issue1539_provider_removal_dropdown_invalidation.py @@ -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).""" diff --git a/tests/test_issue1785_workspace_preview_breadcrumb.py b/tests/test_issue1785_workspace_preview_breadcrumb.py index 8c75b254..f76fa920 100644 --- a/tests/test_issue1785_workspace_preview_breadcrumb.py +++ b/tests/test_issue1785_workspace_preview_breadcrumb.py @@ -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." diff --git a/tests/test_parallel_session_switch.py b/tests/test_parallel_session_switch.py index 75496ec8..97fb5b35 100644 --- a/tests/test_parallel_session_switch.py +++ b/tests/test_parallel_session_switch.py @@ -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, ( diff --git a/tests/test_session_index.py b/tests/test_session_index.py index 6944da84..62ec9508 100644 --- a/tests/test_session_index.py +++ b/tests/test_session_index.py @@ -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 ───────────────────────────────── diff --git a/tests/test_session_metadata_fast_path.py b/tests/test_session_metadata_fast_path.py index 645e89eb..477b42de 100644 --- a/tests/test_session_metadata_fast_path.py +++ b/tests/test_session_metadata_fast_path.py @@ -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){") diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py index f977b6c2..ad6bd83b 100644 --- a/tests/test_webui_state_db_reconciliation.py +++ b/tests/test_webui_state_db_reconciliation.py @@ -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