Files
hermes-webui/tests/test_session_lineage_collapse.py
T
2026-05-14 21:10:50 +08:00

653 lines
26 KiB
Python

"""Regression tests for sidebar lineage collapse helpers."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _run_node(source: str) -> str:
# Pass source via stdin rather than `-e <source>` argv — the latter is
# capped at MAX_ARG_STRLEN (131072 bytes on Linux) and tests that embed
# the entire sessions.js file can exceed that. stdin has no such limit.
result = subprocess.run(
[NODE],
input=source,
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise RuntimeError(result.stderr)
return result.stdout.strip()
def test_sidebar_lineage_collapse_keeps_latest_tip_and_counts_segments():
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('_isChildSession'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
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:'root'}},
{{session_id:'tip', title:'Hermes WebUI', message_count:20, updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
{{session_id:'solo', title:'Other', message_count:5, updated_at:15, last_message_at:15}},
];
const collapsed = _collapseSessionLineageForSidebar(sessions);
console.log(JSON.stringify(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('_isChildSession'));
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
def test_stale_optimistic_compression_tips_collapse_even_when_parents_are_visible():
"""Active compression can leave old streaming tips in browser memory.
The server/index already expose only the latest tip, but client-side
optimistic rows from previous tips may still include parent_session_id links.
Those rows carry explicit lineage metadata and must collapse as one sidebar
conversation instead of rendering 7/8/9/10 segment duplicates.
"""
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('_isChildSession'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
const sessions = [
{{session_id:'seg7', title:'Graphify', parent_session_id:'seg6', message_count:1141, updated_at:70, last_message_at:70, _lineage_root_id:'root', _compression_segment_count:7}},
{{session_id:'seg8', title:'Graphify', parent_session_id:'seg7', message_count:1254, updated_at:80, last_message_at:80, _lineage_root_id:'root', _compression_segment_count:8, pending_user_message:'old'}},
{{session_id:'seg9', title:'Graphify', parent_session_id:'seg8', message_count:1404, updated_at:90, last_message_at:90, _lineage_root_id:'root', _compression_segment_count:9, active_stream_id:'old-stream'}},
{{session_id:'seg10', title:'Graphify', parent_session_id:'seg9', message_count:1490, updated_at:100, last_message_at:100, _lineage_root_id:'root', _compression_segment_count:10, active_stream_id:'current-stream'}},
];
const collapsed = _collapseSessionLineageForSidebar(sessions);
console.log(JSON.stringify(collapsed));
"""
collapsed = json.loads(_run_node(source))
assert [row["session_id"] for row in collapsed] == ["seg10"]
assert collapsed[0]["_lineage_collapsed_count"] == 4
assert collapsed[0]["_compression_segment_count"] == 10
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg10", "seg9", "seg8", "seg7"]
def test_sidebar_lineage_collapse_prefers_highest_compression_segment_over_touched_parent():
"""A touched parent segment must not hide the newer compressed tip.
Opening or polling an older segment can refresh its updated_at without adding
messages. The collapsed sidebar row must still pick the highest compression
segment, otherwise the visible chat jumps back to a parent that lacks the
completed assistant answer.
"""
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('_isChildSession'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
const sessions = [
{{session_id:'seg13', title:'Schaue dir die Release (fork)', message_count:2490, updated_at:200, last_message_at:200, _lineage_root_id:'root', _compression_segment_count:13}},
{{session_id:'seg14', title:'Schaue dir die Release (fork)', message_count:2532, updated_at:150, last_message_at:150, _lineage_root_id:'root', _compression_segment_count:14}},
];
const collapsed = _collapseSessionLineageForSidebar(sessions);
console.log(JSON.stringify(collapsed));
"""
collapsed = json.loads(_run_node(source))
assert [row["session_id"] for row in collapsed] == ["seg14"]
assert collapsed[0]["_lineage_collapsed_count"] == 2
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["seg14", "seg13"]
def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage():
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('_isChildSession'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const raw = [
{{session_id:'root', title:'Root', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
{{session_id:'tip', title:'Tip', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}},
{{session_id:'child', title:'Subtask', parent_session_id:'tip', relationship_type:'child_session', _parent_lineage_root_id:'root', updated_at:30, last_message_at:30}},
];
const collapsed = _collapseSessionLineageForSidebar(raw);
const attached = _attachChildSessionsToSidebarRows(collapsed, raw);
console.log(JSON.stringify(attached));
"""
rows = json.loads(_run_node(source))
assert [row["session_id"] for row in rows] == ["tip"]
assert rows[0]["_child_session_count"] == 1
assert rows[0]["_child_sessions"][0]["session_id"] == "child"
def test_cross_surface_webui_child_session_remains_top_level_when_parent_is_messaging():
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('_isChildSession'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const collapsed = [{{session_id:'telegram_parent', title:'Telegram parent', source_label:'Telegram'}}];
const raw = [
collapsed[0],
{{
session_id:'webui_tip',
title:'Current WebUI continuation',
parent_session_id:'telegram_parent',
relationship_type:'child_session',
parent_source:'telegram',
source_label:'Telegram',
session_source:'messaging',
raw_source:'telegram',
_cross_surface_child_session:true,
}},
];
const rows = _attachChildSessionsToSidebarRows(collapsed, raw);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert [row["session_id"] for row in rows] == ["telegram_parent", "webui_tip"]
assert rows[1].get("_orphan_child_session") is True
assert "_child_sessions" not in rows[0]
def test_session_segment_count_prefers_visible_collapsed_backend_and_materialized_counts():
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('_sessionSegmentCount'));
const cases = [
_sessionSegmentCount({{_lineage_collapsed_count:3, _compression_segment_count:2, _lineage_segments:[{{session_id:'a'}}, {{session_id:'b'}}]}}),
_sessionSegmentCount({{_compression_segment_count:25}}),
_sessionSegmentCount({{_lineage_segments:[{{session_id:'tip'}}, {{session_id:'root'}}, {{session_id:'older'}}]}}),
_sessionSegmentCount({{_lineage_collapsed_count:1, _compression_segment_count:1}}),
_sessionSegmentCount(null),
];
console.log(JSON.stringify(cases));
"""
assert json.loads(_run_node(source)) == [3, 25, 3, 0, 0]
def test_sidebar_lineage_segment_badge_is_detailed_density_only_and_localized():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
assert "session-lineage-count" in js
assert "const density=(window._sidebarDensity==='detailed'?'detailed':'compact');" in js
assert "const showLineageMetadata=density==='detailed';" in js
assert "const segmentCount=showLineageMetadata?_sessionSegmentCount(s):0;" in js
assert "const lineageSegments=showLineageMetadata?_lineageSegmentsForRender(s,lineageKey):[];" in js
assert "const needsLineageReport=showLineageMetadata?_lineageReportNeedsFetch(s,lineageKey,segmentCount):false;" in js
assert "const canExpandLineageSegments=showLineageMetadata&&Boolean(" in js
assert "t('session_meta_segments', segmentCount)" in js
assert "titleRow.appendChild(segmentCountEl);" in js
assert ".session-lineage-count{" in css
def test_lineage_segment_expansion_static_contract():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
assert "const _expandedLineageKeys = new Set();" in js
assert "const _lineageReportCache = new Map();" in js
assert "const _lineageReportInflight = new Map();" in js
assert "session-lineage-count,.session-lineage-segments,.session-lineage-segment" in js
assert "segmentCountEl.setAttribute('aria-expanded'" in js
assert "_expandedLineageKeys.has(lineageKey)" in js
assert "_expandedLineageKeys.add(lineageKey)" in js
assert "_expandedLineageKeys.delete(lineageKey)" in js
assert "_fetchLineageReportForRow(s,lineageKey).then" in js
assert "'/api/session/lineage/report?session_id='" in js
assert "encodeURIComponent(s.session_id)" in js
assert "className='session-lineage-segments'" in js
assert "className='session-lineage-segment'" in js
assert "const segTitle=_sessionDisplayTitle(seg)||t('session_lineage_segment_untitled');" in js
assert "row.title=t('session_lineage_segment_open');" in js
assert "await loadSession(seg.session_id);" in js
assert ".session-lineage-count.expandable{" in css
assert ".session-lineage-count.expandable:hover" in css
assert ".session-lineage-segments{" in css
assert ".session-lineage-segment{" in css
def test_lineage_report_fetch_is_needed_only_when_backend_count_exceeds_materialized_segments():
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);
}}
const _lineageReportCache = new Map();
const _lineageReportInflight = new Map();
eval(extractFunc('_lineageReportCacheKey'));
eval(extractFunc('_lineageLocalSegmentCount'));
eval(extractFunc('_lineageReportNeedsFetch'));
const backendOnly = {{session_id:'tip', _lineage_key:'root', _compression_segment_count:25}};
const localFull = {{
session_id:'tip',
_lineage_key:'root',
_compression_segment_count:2,
_lineage_segments:[{{session_id:'tip'}}, {{session_id:'root'}}],
}};
const before = _lineageReportNeedsFetch(backendOnly, 'root', 25);
_lineageReportCache.set('root', {{segments:[{{session_id:'tip'}}, {{session_id:'root'}}]}});
const afterCache = _lineageReportNeedsFetch(backendOnly, 'root', 25);
const fullLocal = _lineageReportNeedsFetch(localFull, 'root', 2);
console.log(JSON.stringify({{before, afterCache, fullLocal}}));
"""
assert json.loads(_run_node(source)) == {"before": True, "afterCache": False, "fullLocal": False}
def test_cached_lineage_report_segments_merge_with_materialized_segments_without_duplicates_or_children():
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);
}}
const _lineageReportCache = new Map();
eval(extractFunc('_lineageReportCacheKey'));
eval(extractFunc('_lineageSegmentsForRender'));
_lineageReportCache.set('root', {{
segments:[
{{session_id:'tip', title:'Tip', role:'tip', started_at:30}},
{{session_id:'root', title:'Root', role:'hidden_segment', started_at:20}},
{{session_id:'older', title:'Older', role:'hidden_segment', started_at:10}},
{{session_id:'child', title:'Child', role:'child_session', started_at:40}},
],
children:[{{session_id:'child', title:'Child', role:'child_session'}}],
}});
const row = {{
session_id:'tip',
_lineage_key:'root',
_lineage_segments:[{{session_id:'tip', title:'Tip'}}, {{session_id:'root', title:'Root'}}],
}};
const segments = _lineageSegmentsForRender(row, 'root').map(seg => seg.session_id);
console.log(JSON.stringify(segments));
"""
assert json.loads(_run_node(source)) == ["root", "older"]
def test_lineage_report_fetch_uses_endpoint_once_and_caches_result():
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);
}}
const _lineageReportCache = new Map();
const _lineageReportInflight = new Map();
let _lineageReportCacheGeneration = 0;
const calls = [];
function api(path) {{
calls.push(path);
return Promise.resolve({{found:true, segments:[{{session_id:'tip'}}, {{session_id:'root'}}]}});
}}
eval(extractFunc('_lineageReportCacheKey'));
eval(extractFunc('_fetchLineageReportForRow'));
(async()=>{{
const row = {{session_id:'tip', _lineage_key:'root'}};
const [first, second] = await Promise.all([
_fetchLineageReportForRow(row, 'root'),
_fetchLineageReportForRow(row, 'root'),
]);
await _fetchLineageReportForRow(row, 'root');
console.log(JSON.stringify({{
calls,
cached:_lineageReportCache.has('root'),
same:first===second,
}}));
}})().catch(err=>{{console.error(err); process.exit(1);}});
"""
result = json.loads(_run_node(source))
assert result == {
"calls": ["/api/session/lineage/report?session_id=tip"],
"cached": True,
"same": True,
}
def test_active_hidden_lineage_segment_auto_expands_parent():
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);
}}
const _expandedChildSessionKeys = new Set();
const _expandedLineageKeys = new Set();
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_syncSidebarExpansionForActiveSession'));
const rows = [{{
session_id:'seg10',
_lineage_key:'root',
_lineage_segments:[
{{session_id:'seg10', updated_at:100}},
{{session_id:'seg9', updated_at:90}},
{{session_id:'seg8', updated_at:80}},
],
}}];
_syncSidebarExpansionForActiveSession(rows, 'seg8');
console.log(JSON.stringify({{lineage:[..._expandedLineageKeys], child:[..._expandedChildSessionKeys]}}));
"""
assert json.loads(_run_node(source)) == {"lineage": ["root"], "child": []}
def test_lineage_segment_locale_keys_are_defined_for_sidebar_locales():
i18n = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
required = [
"session_meta_segments:",
"session_lineage_segment_untitled:",
"session_lineage_segment_open:",
]
locale_count = i18n.count("session_meta_messages:")
for key in required:
assert i18n.count(key) >= locale_count, f"{key} missing from one or more locale blocks"
def test_session_meta_segments_softened_label_no_literal_segment_in_english():
"""Regression: the sidebar badge for compressed/lineage rows must not visibly
say 'X segments' by default — the technical internal term should be replaced
with softer user-facing copy (#2155).
This verifies the English base locale's session_meta_segments key so that
t() fallback for untranslated locales also produces softened copy.
"""
import re
i18n_text = (REPO_ROOT / 'static' / 'i18n.js').read_text(encoding='utf-8')
# Locate the English base-locale block (first occurrence, before any _lang guard).
first_lang = i18n_text.index('_lang: \'en\'')
second_lang = i18n_text.index('_lang:', first_lang + 1)
english_slice = i18n_text[first_lang:second_lang]
assert 'session_meta_segments:' in english_slice, 'session_meta_segments missing from English locale'
# Capture only the arrow-function value (not the key name which also contains 'segment').
match = re.search(
r"session_meta_segments:\s*(\(\w+\)\s*=>\s*[^,]+)",
english_slice,
)
assert match, 'session_meta_segments value not found in English locale'
rendered = match.group(1)
assert 'segment' not in rendered, (
f"session_meta_segments English value still contains the technical word 'segment': {rendered}. "
"Expected softened copy like 'prior turn(s)' instead. See #2155."
)
def test_sidebar_search_and_rows_use_read_only_display_title():
"""Stale persisted titles should not drive sidebar search/render when display_title exists."""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
assert "function _sessionDisplayTitle" in js
assert "function _sessionTitleTags" in js
assert "_allSessions.filter(s=>_sessionDisplayTitle(s).toLowerCase().includes(q))" in js
assert "_allSessions.filter(s => _sessionDisplayTitle(s).toLowerCase().includes(q.toLowerCase()))" in js
assert "const rawTitle=_sessionDisplayTitle(s);" in js
assert "const tags=_sessionTitleTags(rawTitle);" in js
assert "const segTitle=_sessionDisplayTitle(seg)||t('session_lineage_segment_untitled');" in js
assert "const childTitle=_sessionDisplayTitle(child)||'Untitled child session';" in js
def test_child_session_parent_segment_note_uses_display_title():
"""A child attached through a hidden parent segment should show the reconciled segment title."""
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('_isChildSession'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_sessionDisplayTitle'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const parentRow={{
session_id:'tip',
title:'Hermes WebUI #8',
_lineage_root_id:'root',
_lineage_segments:[
{{session_id:'tip', title:'Hermes WebUI #8', display_title:'Hermes WebUI #177'}},
{{session_id:'old-parent', title:'Hermes WebUI #8', display_title:'Hermes WebUI #176'}},
],
}};
const child={{
session_id:'child',
title:'Child Session',
relationship_type:'child_session',
parent_session_id:'old-parent',
}};
const rows = _attachChildSessionsToSidebarRows([parentRow], [parentRow, child]);
console.log(JSON.stringify(rows[0]._child_sessions[0]));
"""
child = json.loads(_run_node(source))
assert child["_parent_segment_id"] == "old-parent"
assert child["_parent_segment_title"] == "Hermes WebUI #176"
def test_default_webui_numbered_titles_are_not_treated_as_hash_tags():
"""The reconciled title 'Hermes WebUI #177' must render with its number intact."""
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('_sessionTitleIsDefaultWebUI'));
eval(extractFunc('_sessionTitleTags'));
console.log(JSON.stringify({{
webui:_sessionTitleTags('Hermes WebUI #177'),
custom:_sessionTitleTags('Deploy #prod'),
}}));
"""
assert json.loads(_run_node(source)) == {"webui": [], "custom": ["#prod"]}