From 0ec4aad9495a0176c23d5e09ae05d40bf28ac4fe Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Fri, 1 May 2026 12:47:36 +0800 Subject: [PATCH] fix: anchor active sessions per browser tab --- api/routes.py | 2 +- static/boot.js | 5 +- static/commands.js | 1 + static/index.html | 2 +- static/messages.js | 4 +- static/sessions.js | 76 ++++++++++++++++--- static/terminal.js | 4 +- static/ui.js | 8 +- static/workspace.js | 2 +- ...est_issue1103_reasoning_chip_visibility.py | 5 +- tests/test_pwa_manifest_sw.py | 4 +- tests/test_session_cross_tab_sync.py | 76 ++++++++++++++++++- tests/test_session_lineage_collapse.py | 70 +++++++++++++++-- 13 files changed, 222 insertions(+), 37 deletions(-) diff --git a/api/routes.py b/api/routes.py index 6abd5a2b..4237f37d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -848,7 +848,7 @@ button:hover{background:rgba(124,185,255,.25)} def handle_get(handler, parsed) -> bool: """Handle all GET routes. Returns True if handled, False for 404.""" - if parsed.path in ("/", "/index.html"): + if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"): from urllib.parse import quote from api.updates import WEBUI_VERSION version_token = quote(WEBUI_VERSION, safe="") diff --git a/static/boot.js b/static/boot.js index f3cf8ea6..a15b1792 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2,7 +2,7 @@ async function cancelStream(){ const streamId = S.activeStreamId; if(!streamId) return; try{ - await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{credentials:'include'}); + await fetch(new URL(`api/chat/cancel?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{credentials:'include'}); }catch(e){/* cancel request failed — cleanup below still runs */} // Clear status unconditionally after the cancel request completes. // The SSE cancel event may also fire, but if the connection is already @@ -939,7 +939,8 @@ function applyBotName(){ const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = ''; // Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load) if(typeof fetchReasoningChip==='function') fetchReasoningChip(); - const saved=localStorage.getItem('hermes-webui-session'); + const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; + const saved=urlSession||localStorage.getItem('hermes-webui-session'); if(saved){ try{ await loadSession(saved); diff --git a/static/commands.js b/static/commands.js index 8dc5fa6f..dc806f19 100644 --- a/static/commands.js +++ b/static/commands.js @@ -397,6 +397,7 @@ async function _runManualCompression(focusTopic){ S.toolCalls=data.session.tool_calls||[]; clearLiveToolCards(); localStorage.setItem('hermes-webui-session',S.session.session_id); + if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); syncTopbar(); renderMessages(); await renderSessionList(); diff --git a/static/index.html b/static/index.html index 502c7541..854c5e68 100644 --- a/static/index.html +++ b/static/index.html @@ -14,7 +14,7 @@ - + diff --git a/static/messages.js b/static/messages.js index c05d4dd5..d9ea8fba 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1009,7 +1009,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const st=await api(`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`); if(st.active){ setComposerStatus('Reconnected'); - _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{withCredentials:true})); + _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true})); return; } }catch(_){} @@ -1160,7 +1160,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } }catch(_){} } - _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,location.href).href,{withCredentials:true})); + _wireSSE(new EventSource(new URL(`api/chat/stream?stream_id=${encodeURIComponent(streamId)}`,document.baseURI||location.href).href,{withCredentials:true})); })(); } diff --git a/static/sessions.js b/static/sessions.js index 2f5e2efa..818bcdc2 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -282,6 +282,7 @@ async function newSession(flash){ S.lastUsage={...(data.session.last_usage||{})}; if(flash)S.session._flash=true; localStorage.setItem('hermes-webui-session',S.session.session_id); + _setActiveSessionUrl(S.session.session_id); _setSessionViewedCount(S.session.session_id, S.session.message_count || 0); // Sync chat-header dropdown to the session's model so the UI reflects // the default model the server actually used (#872). @@ -362,6 +363,7 @@ async function loadSession(sid){ _setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0)); _clearSessionCompletionUnread(S.session.session_id); localStorage.setItem('hermes-webui-session',S.session.session_id); + _setActiveSessionUrl(S.session.session_id); const activeStreamId=S.session.active_stream_id||null; @@ -648,6 +650,41 @@ let _sessionActionMenu = null; let _sessionActionAnchor = null; let _sessionActionSessionId = null; +function _sessionIdFromLocation(){ + if(typeof window==='undefined'||!window.location) return null; + const marker='/session/'; + const path=window.location.pathname||''; + const idx=path.indexOf(marker); + if(idx>=0){ + const raw=path.slice(idx+marker.length).split('/')[0]; + if(raw){try{return decodeURIComponent(raw);}catch(_e){return raw;}} + } + try{ + const qs=new URLSearchParams(window.location.search||''); + return qs.get('session')||null; + }catch(_e){return null;} +} +function _sessionUrlForSid(sid){ + const encoded=encodeURIComponent(sid); + let base; + try{base=new URL(`session/${encoded}`, document.baseURI||window.location.origin+'/');} + catch(_e){base=new URL(`/session/${encoded}`, window.location.origin);} + try{ + const current=new URL(window.location.href); + current.searchParams.delete('session'); + base.search=current.searchParams.toString(); + base.hash=current.hash; + }catch(_e){} + return base.pathname+base.search+base.hash; +} +function _setActiveSessionUrl(sid){ + if(typeof window==='undefined'||!window.history||!sid) return; + const next=_sessionUrlForSid(sid); + if(next && next!==(window.location.pathname+window.location.search+window.location.hash)){ + window.history.replaceState({session_id:sid},'',next); + } +} + // ── Batch select mode ── function toggleSessionSelectMode(){ _sessionSelectMode=!_sessionSelectMode; @@ -1216,6 +1253,13 @@ function _sessionLineageKey(s){ return s._lineage_root_id || s.lineage_root_id || s.parent_session_id || null; } +function _sessionLineageContainsSession(s, sid){ + if(!s||!sid) return false; + if(s.session_id===sid) return true; + if(!Array.isArray(s._lineage_segments)) return false; + return s._lineage_segments.some(seg=>seg&&seg.session_id===sid); +} + function _collapseSessionLineageForSidebar(sessions){ const result=[]; const groups=new Map(); @@ -1227,17 +1271,25 @@ function _collapseSessionLineageForSidebar(sessions){ } for(const items of groups.values()){ if(items.length<=1){result.push(items[0]);continue;} - const chosen=[...items].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a))[0]; - result.push({...chosen,_lineage_collapsed_count:items.length}); + const sorted=[...items].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); + const chosen=sorted[0]; + result.push({...chosen,_lineage_collapsed_count:items.length,_lineage_segments:sorted}); } return result; } +function _activeSessionIdForSidebar(){ + if(S.session&&S.session.session_id) return S.session.session_id; + if(typeof _sessionIdFromLocation==='function') return _sessionIdFromLocation(); + return null; +} + function renderSessionListFromCache(){ // Don't re-render while user is actively renaming a session (would destroy the input) if(_renamingSid) return; closeSessionActionMenu(); const q=($('sessionSearch').value||'').toLowerCase(); + const activeSidForSidebar=_activeSessionIdForSidebar(); const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions; // Merge content matches (deduped): content matches appended after title matches const titleIds=new Set(titleMatches.map(s=>s.session_id)); @@ -1246,7 +1298,7 @@ function renderSessionListFromCache(){ // real once the first message is sent. The server already filters them, but this // guard ensures a brand-new active session doesn't flash into the list while // _allSessions is stale from a prior render (#1171). - const withMessages=allMatched.filter(s=>(s.message_count||0)>0 || (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0)); + const withMessages=allMatched.filter(s=>(s.message_count||0)>0 || (activeSidForSidebar&&s.session_id===activeSidForSidebar) || (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0)); // Filter by active profile (unless "All profiles" is toggled on) // Server backfills profile='default' for legacy sessions, so every session has a profile. // Show only sessions tagged to the active profile; 'All profiles' toggle overrides. @@ -1407,7 +1459,7 @@ function renderSessionListFromCache(){ // Note: declared after the groups loop but available via function hoisting. function _renderOneSession(s, isPinnedGroup=false){ const el=document.createElement('div'); - const isActive=S.session&&s.session_id===S.session.session_id; + const isActive=_sessionLineageContainsSession(s,activeSidForSidebar); const isStreaming=_isSessionEffectivelyStreaming(s); _rememberRenderedStreamingState(s, isStreaming); _rememberRenderedSessionSnapshot(s); @@ -1643,18 +1695,18 @@ function renderSessionListFromCache(){ async function _handleActiveSessionStorageEvent(e){ if(!e || e.key !== 'hermes-webui-session') return; - const sid = e.newValue; - if(!sid || (S.session && S.session.session_id === sid)) return; - if(S.busy){ - if(typeof showToast==='function') showToast('Active session changed in another tab. Finish the current turn before switching.',3000); - return; - } - await loadSession(sid); - renderSessionListFromCache(); + // Do not treat localStorage as a global active-session bus. Each tab owns its + // active conversation via its URL (/session/), so another tab switching + // sessions must not force this tab to navigate away from an in-flight turn. + if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); } if(typeof window!=='undefined'){ window.addEventListener('storage', (e) => { void _handleActiveSessionStorageEvent(e); }); + window.addEventListener('popstate', () => { + const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; + if(sid && (!S.session || S.session.session_id!==sid)) void loadSession(sid); + }); } async function deleteSession(sid){ diff --git a/static/terminal.js b/static/terminal.js index f2ae5b07..3b78fb53 100644 --- a/static/terminal.js +++ b/static/terminal.js @@ -373,7 +373,7 @@ function _connectTerminalOutput(){ try{TERMINAL_UI.source.close();}catch(_){} TERMINAL_UI.source=null; } - const url=new URL('api/terminal/output',location.href); + const url=new URL('api/terminal/output',document.baseURI||location.href); url.searchParams.set('session_id',sid); const source=new EventSource(url.href,{withCredentials:true}); TERMINAL_UI.source=source; @@ -605,7 +605,7 @@ async function _resizeComposerTerminal(){ window.addEventListener('beforeunload',()=>{ if(TERMINAL_UI.source)try{TERMINAL_UI.source.close();}catch(_){} if(TERMINAL_UI.sessionId){ - const url=new URL('api/terminal/close',location.href).href; + const url=new URL('api/terminal/close',document.baseURI||location.href).href; const body=JSON.stringify({session_id:TERMINAL_UI.sessionId}); try{ navigator.sendBeacon(url,new Blob([body],{type:'application/json'})); diff --git a/static/ui.js b/static/ui.js index 70ead2a3..69d9abba 100644 --- a/static/ui.js +++ b/static/ui.js @@ -287,7 +287,7 @@ async function populateModelDropdown(){ const sel=$('modelSelect'); if(!sel) return; try{ - const _modelsRes=await fetch(new URL('api/models',location.href).href,{credentials:'include'}); + const _modelsRes=await fetch(new URL('api/models',document.baseURI||location.href).href,{credentials:'include'}); if(_redirectIfUnauth(_modelsRes)) return; const data=await _modelsRes.json(); if(!data.groups||!data.groups.length) return; // keep HTML defaults @@ -405,7 +405,7 @@ async function _fetchLiveModels(provider, sel){ } _liveModelFetchPending.add(provider); try{ - const url=new URL('api/models/live',location.href); + const url=new URL('api/models/live',document.baseURI||location.href); url.searchParams.set('provider',provider); const _liveRes=await fetch(url.href,{credentials:'include'}); if(_redirectIfUnauth(_liveRes)) return; @@ -2754,7 +2754,7 @@ function syncTopbar(){ if(!deferModelCorrection){ S.session.model=first.value; // Persist the correction so the session doesn't re-inject on next load. - fetch(new URL('api/session/update',location.href).href,{ + fetch(new URL('api/session/update',document.baseURI||location.href).href,{ method:'POST',credentials:'include', headers:{'Content-Type':'application/json'}, body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:first.value}) @@ -5004,7 +5004,7 @@ async function uploadPendingFiles(){ fd.append('session_id',S.session.session_id);fd.append('file',f,f.name); try{ const isArchive=_ARCHIVE_EXTS.test(f.name); - const url=new URL(isArchive?'api/upload/extract':'api/upload',location.href).href; + const url=new URL(isArchive?'api/upload/extract':'api/upload',document.baseURI||location.href).href; const res=await fetch(url,{method:'POST',credentials:'include',body:fd}); if(_redirectIfUnauth(res)) return; if(!res.ok){const err=await res.text();throw new Error(err);} diff --git a/static/workspace.js b/static/workspace.js index 611774fa..c81ed5df 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -1,7 +1,7 @@ async function api(path,opts={}){ // Strip leading slash so URL resolves relative to location.href (supports subpath mounts) const rel = path.startsWith('/') ? path.slice(1) : path; - const url=new URL(rel,location.href); + const url=new URL(rel,document.baseURI||location.href); // Retry up to 2 times on network errors (e.g. stale keep-alive after long idle). // Server errors (4xx/5xx) are NOT retried — only connection failures. let lastErr; diff --git a/tests/test_issue1103_reasoning_chip_visibility.py b/tests/test_issue1103_reasoning_chip_visibility.py index 91d57c2d..31f83606 100644 --- a/tests/test_issue1103_reasoning_chip_visibility.py +++ b/tests/test_issue1103_reasoning_chip_visibility.py @@ -16,8 +16,9 @@ def test_boot_call_before_session_load(): """fetchReasoningChip() should be called before session load in boot sequence.""" with open("static/boot.js") as f: src = f.read() - # Find the boot session load: "const saved=localStorage.getItem('hermes-webui-session')" - boot_marker = "const saved=localStorage.getItem('hermes-webui-session')" + # Find the boot session load; URL-anchored tabs may prefer a URL session id + # before falling back to the stored session id. + boot_marker = "localStorage.getItem('hermes-webui-session')" boot_pos = src.index(boot_marker) fetch_pos = src.index("fetchReasoningChip()") # fetchReasoningChip must be called just before the saved session load diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index 82e956ff..febb85a8 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -163,7 +163,9 @@ class TestIndexHtmlIntegration: def test_index_route_url_encodes_asset_version(self): src = ROUTES.read_text(encoding="utf-8") idx = src.find('parsed.path in ("/", "/index.html")') - assert idx != -1, "routes.py must handle / and /index.html" + if idx == -1: + idx = src.find('parsed.path.startswith("/session/")') + assert idx != -1, "routes.py must handle /, /index.html, and /session/" block = src[idx:idx + 800] assert "quote(WEBUI_VERSION, safe=\"\")" in block, ( "index route must URL-encode the cache-busting version token before " diff --git a/tests/test_session_cross_tab_sync.py b/tests/test_session_cross_tab_sync.py index bf7ec5d0..38cf81b2 100644 --- a/tests/test_session_cross_tab_sync.py +++ b/tests/test_session_cross_tab_sync.py @@ -1,8 +1,16 @@ -"""Regression tests for cross-tab active session synchronization.""" +"""Regression tests for cross-tab active session behavior.""" +import json +import re +import subprocess from pathlib import Path REPO_ROOT = Path(__file__).parent.parent.resolve() SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8") +COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8") +MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") +INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8") +ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") def test_sessions_js_listens_for_active_session_storage_changes(): @@ -11,6 +19,66 @@ def test_sessions_js_listens_for_active_session_storage_changes(): assert "_handleActiveSessionStorageEvent" in SESSIONS_JS -def test_storage_sync_does_not_switch_while_busy(): - marker = "if(S.busy)" - assert marker in SESSIONS_JS, "cross-tab storage sync must not switch sessions during an active turn" +def test_storage_event_does_not_globally_switch_tabs(): + handler_pos = SESSIONS_JS.find("async function _handleActiveSessionStorageEvent") + assert handler_pos != -1 + next_pos = SESSIONS_JS.find("if(typeof window", handler_pos) + assert next_pos != -1 + block = SESSIONS_JS[handler_pos:next_pos] + assert "loadSession(sid)" not in block + assert "Each tab owns its" in block + assert "renderSessionListFromCache" in block + + +def test_session_switch_updates_url_path_for_tab_local_anchor(): + assert "function _sessionIdFromLocation()" in SESSIONS_JS + assert "function _setActiveSessionUrl(sid)" in SESSIONS_JS + assert "'/session/'" in SESSIONS_JS + assert "_setActiveSessionUrl(S.session.session_id)" in SESSIONS_JS + assert "_setActiveSessionUrl(S.session.session_id)" in COMMANDS_JS + assert "addEventListener('popstate'" in SESSIONS_JS or 'addEventListener("popstate"' in SESSIONS_JS + + +def test_boot_prefers_url_session_over_local_storage_session(): + assert "const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;" in BOOT_JS + assert "const saved=urlSession||localStorage.getItem('hermes-webui-session');" in BOOT_JS + + +def test_api_helper_resolves_against_document_base_not_session_path(): + workspace_js = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8") + assert "new URL(rel,document.baseURI||location.href)" in workspace_js + assert "new URL(rel,location.href)" not in workspace_js + + +def test_long_lived_stream_urls_resolve_against_document_base(): + for rel in ("static/messages.js", "static/boot.js", "static/terminal.js"): + src = (REPO_ROOT / rel).read_text(encoding="utf-8") + assert "document.baseURI||location.href" in src + + +def test_session_url_route_serves_index_and_base_href_handles_session_path(): + assert 'parsed.path.startswith("/session/")' in ROUTES_PY + assert "marker='/session/'" in INDEX_HTML + assert "path.slice(0,i+1)" in INDEX_HTML + + +def _evaluate_base_href_for_path(path: str) -> str: + script_match = re.search(r"", INDEX_HTML) + assert script_match, "index.html should include the dynamic base href script" + script = script_match.group(0).removeprefix("") + node = f""" +const location={{origin:'https://example.test', pathname:{json.dumps(path)}}}; +let written=''; +const document={{write:(s)=>{{written+=s;}}}}; +{script} +console.log(written); +""" + return subprocess.check_output(["node", "-e", node], text=True).strip() + + +def test_base_href_resolution_handles_session_urls_under_subpath_mounts(): + assert _evaluate_base_href_for_path("/session/abc123") == '' + assert _evaluate_base_href_for_path("/myapp/session/abc123") == '' + assert _evaluate_base_href_for_path("/myapp/session/abc123/extra") == '' + assert _evaluate_base_href_for_path("/session-tools/session/abc123") == '' + assert _evaluate_base_href_for_path("/session-tools/page") == '' diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 5b3fd729..f1dba6cd 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -1,4 +1,5 @@ """Regression tests for sidebar lineage collapse helpers.""" +import json import shutil import subprocess from pathlib import Path @@ -53,8 +54,67 @@ const sessions = [ const collapsed = _collapseSessionLineageForSidebar(sessions); console.log(JSON.stringify(collapsed)); """ - collapsed = _run_node(source) - assert '"session_id":"tip"' in collapsed - assert '"session_id":"root"' not in collapsed - assert '"_lineage_collapsed_count":2' in collapsed - assert '"session_id":"solo"' in collapsed + collapsed = json.loads(_run_node(source)) + by_sid = {row["session_id"]: row for row in collapsed} + assert set(by_sid) == {"tip", "solo"} + assert by_sid["tip"]["_lineage_collapsed_count"] == 2 + assert [seg["session_id"] for seg in by_sid["tip"]["_lineage_segments"]] == ["tip", "root"] + + +def test_sidebar_active_state_can_fall_back_to_url_session_during_boot(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +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'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +global.S = {{ session: null }}; +global.window = {{ location: {{ pathname: '/session/url-active', search: '', hash: '' }} }}; +eval(extractFunc('_sessionIdFromLocation')); +eval(extractFunc('_activeSessionIdForSidebar')); +console.log(_activeSessionIdForSidebar()); +""" + assert _run_node(source) == "url-active" + + +def test_collapsed_lineage_contains_active_hidden_segment(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +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'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_sessionLineageKey')); +eval(extractFunc('_collapseSessionLineageForSidebar')); +eval(extractFunc('_sessionLineageContainsSession')); +const sessions = [ + {{session_id:'root', title:'Hermes WebUI', message_count:10, updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip'}}, + {{session_id:'tip', title:'Hermes WebUI', message_count:20, updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}}, +]; +const collapsed = _collapseSessionLineageForSidebar(sessions); +console.log(JSON.stringify({{sid: collapsed[0].session_id, containsRoot: _sessionLineageContainsSession(collapsed[0], 'root')}})); +""" + result = _run_node(source) + assert '"sid":"tip"' in result + assert '"containsRoot":true' in result