Nest delegated subagent sessions under the parent conversation that spawned them instead of forcing them to the sidebar top level. Co-authored-by: lowlandsheperd <lowlandsheperd@users.noreply.github.com>
2375 lines
93 KiB
Python
2375 lines
93 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,
|
|
encoding="utf-8",
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
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_parent_present_webui_compression_child_without_lineage_metadata_collapses():
|
|
"""WebUI-native compression continuations may only carry parent_session_id.
|
|
|
|
When both the preserved parent snapshot and the new continuation are present
|
|
in the sidebar payload, the continuation should still collapse with its
|
|
parent instead of appearing as a separate branch-like conversation (#2489).
|
|
"""
|
|
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:'parent', title:'Long WebUI conversation', message_count:50, updated_at:10, last_message_at:10, pre_compression_snapshot:true}},
|
|
{{session_id:'child', title:'Long WebUI conversation', parent_session_id:'parent', message_count:12, updated_at:20, last_message_at:20}},
|
|
];
|
|
const collapsed = _collapseSessionLineageForSidebar(sessions);
|
|
console.log(JSON.stringify(collapsed));
|
|
"""
|
|
collapsed = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in collapsed] == ["child"]
|
|
assert collapsed[0]["_lineage_key"] == "parent"
|
|
assert collapsed[0]["_lineage_collapsed_count"] == 2
|
|
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["child", "parent"]
|
|
|
|
|
|
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_lineage_collapse_prefers_current_tip_over_same_segment_snapshot():
|
|
"""A preserved parent snapshot can share the child's backend segment count.
|
|
|
|
Loading/polling the parent refreshes its timestamp, but the collapsed row
|
|
must still open the non-snapshot continuation tip. Otherwise a reload after
|
|
compression jumps back to the older parent transcript and looks like the
|
|
active conversation disappeared.
|
|
"""
|
|
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:'parent', title:'Duplicate Assistant Text Blocks', message_count:64, updated_at:300, last_message_at:300, pre_compression_snapshot:true, _lineage_root_id:'parent', _compression_segment_count:2}},
|
|
{{session_id:'child', title:'Duplicate Assistant Text Blocks', parent_session_id:'parent', message_count:86, updated_at:200, last_message_at:200, _lineage_root_id:'parent', _compression_segment_count:2}},
|
|
];
|
|
const collapsed = _collapseSessionLineageForSidebar(sessions);
|
|
console.log(JSON.stringify(collapsed));
|
|
"""
|
|
collapsed = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in collapsed] == ["child"]
|
|
assert collapsed[0]["_lineage_collapsed_count"] == 2
|
|
assert [seg["session_id"] for seg in collapsed[0]["_lineage_segments"]] == ["child", "parent"]
|
|
|
|
|
|
|
|
def test_direct_parent_restore_resolves_to_visible_compression_tip():
|
|
"""A stale /session/<parent> URL should reopen the visible continuation tip.
|
|
|
|
The sidebar payload may omit the archived pre-compression parent but still
|
|
include the latest continuation with lineage metadata pointing back to the
|
|
parent. Boot restore should use that visible tip instead of loading the old
|
|
parent transcript and making the continuation look lost.
|
|
"""
|
|
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);
|
|
}}
|
|
var _allSessions = [
|
|
{{session_id:'child', title:'Duplicate Assistant Text Blocks', parent_session_id:'parent', message_count:86, updated_at:200, last_message_at:200, _lineage_root_id:'parent', _compression_segment_count:2}},
|
|
{{session_id:'other', title:'Other', message_count:4, updated_at:100, last_message_at:100}},
|
|
];
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sessionLineageContainsSession'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_resolveSessionIdFromSidebarLineage'));
|
|
console.log(JSON.stringify({{parent:_resolveSessionIdFromSidebarLineage('parent'), child:_resolveSessionIdFromSidebarLineage('child'), other:_resolveSessionIdFromSidebarLineage('other')}}));
|
|
"""
|
|
result = json.loads(_run_node(source))
|
|
assert result == {"parent": "child", "child": "child", "other": "other"}
|
|
|
|
|
|
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('_isForkWithResolvableParent'));
|
|
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', _parent_lineage_tip_id:'tip', 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_mixed_source_live_refresh_keeps_authoritative_tip_and_child_set():
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sessionLineageContainsSession'));
|
|
eval(extractFunc('_authoritativeLineageTipId'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_sessionDisplayTitle'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
function summarize(raw) {{
|
|
const collapsed = _collapseSessionLineageForSidebar(raw);
|
|
const rows = _attachChildSessionsToSidebarRows(collapsed, raw);
|
|
const row = rows[0];
|
|
return {{
|
|
visibleSid: row.session_id,
|
|
badgeKind: row._child_session_count > 0 ? 'children' : 'prior-turns',
|
|
childSids: (row._child_sessions || []).map(child => child.session_id),
|
|
segmentSids: (row._lineage_segments || []).map(seg => seg.session_id),
|
|
}};
|
|
}}
|
|
const refreshA = [
|
|
{{session_id:'root', title:'Optimizing Hermes Development', updated_at:40, last_message_at:40, _lineage_root_id:'root', _lineage_tip_id:'tip', _compression_segment_count:2}},
|
|
{{session_id:'tip', title:'Optimizing Hermes Development', parent_session_id:'root', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip', _compression_segment_count:2}},
|
|
{{session_id:'fork-child', title:'Optimizing Hermes Development child', parent_session_id:'root', relationship_type:'child_session', session_source:'fork', _parent_lineage_root_id:'root', _parent_lineage_tip_id:'tip', updated_at:50, last_message_at:50}},
|
|
];
|
|
const refreshB = [
|
|
{{session_id:'root', title:'Optimizing Hermes Development', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip', _compression_segment_count:2}},
|
|
{{session_id:'tip', title:'Optimizing Hermes Development', parent_session_id:'root', updated_at:60, last_message_at:60, _lineage_root_id:'root', _lineage_tip_id:'tip', _compression_segment_count:2}},
|
|
{{session_id:'fork-child', title:'Optimizing Hermes Development child', parent_session_id:'root', relationship_type:'child_session', session_source:'fork', _parent_lineage_root_id:'root', _parent_lineage_tip_id:'tip', updated_at:50, last_message_at:50}},
|
|
];
|
|
console.log(JSON.stringify([summarize(refreshA), summarize(refreshB)]));
|
|
"""
|
|
assert json.loads(_run_node(source)) == [
|
|
{
|
|
"visibleSid": "tip",
|
|
"badgeKind": "children",
|
|
"childSids": ["fork-child"],
|
|
"segmentSids": ["root", "tip"],
|
|
},
|
|
{
|
|
"visibleSid": "tip",
|
|
"badgeKind": "children",
|
|
"childSids": ["fork-child"],
|
|
"segmentSids": ["tip", "root"],
|
|
},
|
|
]
|
|
|
|
|
|
|
|
def test_cross_surface_subagent_child_stacks_under_visible_webui_parent():
|
|
"""Delegate subagent rows are cross-source but still belong under their WebUI 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);
|
|
}}
|
|
function _isExternalSession(s) {{ return !!(s && (s.is_cli_session || s.session_source === 'messaging')); }}
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const collapsed = [{{
|
|
session_id:'webui_parent',
|
|
title:'Parent WebUI conversation',
|
|
raw_source:'webui',
|
|
source_tag:'webui',
|
|
session_source:'webui',
|
|
message_count:3,
|
|
}}];
|
|
const raw = [
|
|
collapsed[0],
|
|
{{
|
|
session_id:'subagent_child',
|
|
title:'Subagent Session',
|
|
parent_session_id:'webui_parent',
|
|
relationship_type:'child_session',
|
|
raw_source:'subagent',
|
|
source_tag:'subagent',
|
|
session_source:'other',
|
|
source_label:'Subagent',
|
|
_parent_lineage_root_id:'webui_parent',
|
|
_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] == ["webui_parent"]
|
|
assert rows[0]["_child_session_count"] == 1
|
|
assert rows[0]["_child_sessions"][0]["session_id"] == "subagent_child"
|
|
assert "_orphan_child_session" not in rows[0]["_child_sessions"][0]
|
|
|
|
|
|
def test_cross_surface_subagents_stack_under_visible_messaging_parent():
|
|
"""Delegated subagents stay nested even when their visible parent is external."""
|
|
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);
|
|
}}
|
|
function _isExternalSession(s) {{ return !!(s && (s.is_cli_session || s.session_source === 'messaging')); }}
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const collapsed = [{{
|
|
session_id:'messaging_parent',
|
|
title:'Messaging parent',
|
|
raw_source:'weixin',
|
|
source_tag:'weixin',
|
|
session_source:'messaging',
|
|
message_count:3,
|
|
}}];
|
|
const raw = [
|
|
collapsed[0],
|
|
...['delegate_a', 'delegate_b', 'delegate_c'].map(session_id => ({{
|
|
session_id,
|
|
title:'Subagent Session',
|
|
parent_session_id:'messaging_parent',
|
|
relationship_type:'child_session',
|
|
raw_source:'subagent',
|
|
source_tag:'subagent',
|
|
session_source:'other',
|
|
source_label:'Subagent',
|
|
_parent_lineage_root_id:'messaging_parent',
|
|
_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] == ["messaging_parent"]
|
|
assert rows[0]["_child_session_count"] == 3
|
|
assert [child["session_id"] for child in rows[0]["_child_sessions"]] == [
|
|
"delegate_a",
|
|
"delegate_b",
|
|
"delegate_c",
|
|
]
|
|
assert all("_orphan_child_session" not in child for child in rows[0]["_child_sessions"])
|
|
|
|
|
|
|
|
|
|
def test_delegated_subagent_source_precedence_ignores_stale_metadata():
|
|
"""Only the first nonblank raw-role marker may assert delegation."""
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const parent = {{
|
|
session_id:'messaging_parent',
|
|
title:'Messaging parent',
|
|
raw_source:'weixin',
|
|
source_tag:'weixin',
|
|
session_source:'messaging',
|
|
}};
|
|
function runCase(name, overrides, includeChildInCollapsed=false) {{
|
|
const child = {{
|
|
session_id:name,
|
|
title:name,
|
|
parent_session_id:'messaging_parent',
|
|
relationship_type:'child_session',
|
|
raw_source:'api_server',
|
|
source_tag:'api_server',
|
|
source:'api_server',
|
|
session_source:'api',
|
|
_parent_lineage_root_id:'messaging_parent',
|
|
_cross_surface_child_session:true,
|
|
...overrides,
|
|
}};
|
|
const collapsed = includeChildInCollapsed ? [{{...parent}}, child] : [{{...parent}}];
|
|
const rows = _attachChildSessionsToSidebarRows(collapsed, [collapsed[0], child]);
|
|
const parentRow = rows.find(row => row.session_id === 'messaging_parent');
|
|
return {{
|
|
name,
|
|
topLevel:rows.map(row => row.session_id),
|
|
nested:(parentRow._child_sessions || []).map(row => row.session_id),
|
|
}};
|
|
}}
|
|
const results = [
|
|
runCase('stale_source_tag', {{
|
|
raw_source:'api_server',
|
|
source_tag:'subagent',
|
|
}}),
|
|
runCase('stale_session_source', {{
|
|
raw_source:'api_server',
|
|
source_tag:'api_server',
|
|
source:'api_server',
|
|
session_source:'subagent',
|
|
}}),
|
|
runCase('source_tag_fallback', {{
|
|
raw_source:' ',
|
|
source_tag:' SubAgent ',
|
|
source:'api_server',
|
|
session_source:'other',
|
|
}}),
|
|
runCase('authentic_raw_source', {{
|
|
raw_source:'subagent',
|
|
source_tag:'api_server',
|
|
source:'api_server',
|
|
session_source:'other',
|
|
}}),
|
|
runCase('not_child_session', {{
|
|
relationship_type:null,
|
|
raw_source:'subagent',
|
|
source_tag:'subagent',
|
|
source:'subagent',
|
|
session_source:'other',
|
|
}}, true),
|
|
];
|
|
console.log(JSON.stringify(results));
|
|
"""
|
|
assert json.loads(_run_node(source)) == [
|
|
{
|
|
"name": "stale_source_tag",
|
|
"topLevel": ["messaging_parent", "stale_source_tag"],
|
|
"nested": [],
|
|
},
|
|
{
|
|
"name": "stale_session_source",
|
|
"topLevel": ["messaging_parent", "stale_session_source"],
|
|
"nested": [],
|
|
},
|
|
{
|
|
"name": "source_tag_fallback",
|
|
"topLevel": ["messaging_parent"],
|
|
"nested": ["source_tag_fallback"],
|
|
},
|
|
{
|
|
"name": "authentic_raw_source",
|
|
"topLevel": ["messaging_parent"],
|
|
"nested": ["authentic_raw_source"],
|
|
},
|
|
{
|
|
"name": "not_child_session",
|
|
"topLevel": ["messaging_parent", "not_child_session"],
|
|
"nested": [],
|
|
},
|
|
]
|
|
|
|
|
|
def test_cross_surface_subagent_child_stacks_under_visible_fork_parent():
|
|
"""Forked WebUI conversations can still own subagent child rows."""
|
|
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);
|
|
}}
|
|
function _isExternalSession(s) {{ return !!(s && (s.is_cli_session || s.session_source === 'messaging')); }}
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const collapsed = [{{
|
|
session_id:'fork_parent',
|
|
title:'Forked WebUI conversation',
|
|
session_source:'fork',
|
|
parent_session_id:'original_parent',
|
|
message_count:3,
|
|
}}];
|
|
const raw = [
|
|
collapsed[0],
|
|
{{
|
|
session_id:'subagent_child',
|
|
title:'Subagent Session',
|
|
parent_session_id:'fork_parent',
|
|
relationship_type:'child_session',
|
|
raw_source:'subagent',
|
|
source_tag:'subagent',
|
|
session_source:'other',
|
|
source_label:'Subagent',
|
|
_parent_lineage_root_id:'fork_parent',
|
|
_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] == ["fork_parent"]
|
|
assert rows[0]["_child_session_count"] == 1
|
|
assert rows[0]["_child_sessions"][0]["session_id"] == "subagent_child"
|
|
|
|
|
|
def test_read_only_child_sessions_are_stacked_after_writable_children():
|
|
"""Read-only delegated children should render after fork/writable 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);
|
|
}}
|
|
eval(extractFunc('_isReadOnlySession'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const collapsed = [{{
|
|
session_id:'parent',
|
|
title:'Parent conversation',
|
|
message_count:3,
|
|
updated_at: 1700000010,
|
|
last_message_at: 1700000010,
|
|
}}];
|
|
const raw = [
|
|
collapsed[0],
|
|
{{
|
|
session_id:'read_only_subagent',
|
|
title:'Retained Subagent',
|
|
parent_session_id:'parent',
|
|
relationship_type:'child_session',
|
|
read_only: true,
|
|
raw_source:'subagent',
|
|
source_tag:'subagent',
|
|
session_source:'other',
|
|
source_label:'Subagent',
|
|
updated_at: 1700000030,
|
|
last_message_at: 1700000030,
|
|
}},
|
|
{{
|
|
session_id:'fork_child',
|
|
title:'Fork',
|
|
parent_session_id:'parent',
|
|
session_source:'fork',
|
|
raw_source:'webui',
|
|
source_tag:'webui',
|
|
source_label:'WebUI',
|
|
updated_at: 1700000040,
|
|
last_message_at: 1700000040,
|
|
}},
|
|
{{
|
|
session_id:'writable_subagent',
|
|
title:'Writable Subagent',
|
|
parent_session_id:'parent',
|
|
relationship_type:'child_session',
|
|
read_only: false,
|
|
raw_source:'subagent',
|
|
source_tag:'subagent',
|
|
session_source:'other',
|
|
source_label:'Subagent',
|
|
updated_at: 1700000020,
|
|
last_message_at: 1700000020,
|
|
}},
|
|
];
|
|
const rows = _attachChildSessionsToSidebarRows(collapsed, raw);
|
|
console.log(JSON.stringify(rows[0]._child_sessions.map(row => row.session_id)));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert rows == ["fork_child", "writable_subagent", "read_only_subagent"]
|
|
assert "const sortedChildren=[...s._child_sessions];" in js
|
|
assert "const sortedChildren=[...s._child_sessions].sort" not in js
|
|
|
|
|
|
def test_parent_source_label_display_text_does_not_force_external_orphaning():
|
|
"""Display-only source labels must not drive machine source classification."""
|
|
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);
|
|
}}
|
|
function _isExternalSession(s) {{ return !!(s && (s.is_cli_session || s.session_source === 'messaging')); }}
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const collapsed = [{{session_id:'parent', title:'Parent', source_label:'WebUI Continuation'}}];
|
|
const raw = [
|
|
collapsed[0],
|
|
{{session_id:'child', title:'Subagent', parent_session_id:'parent', relationship_type:'child_session', raw_source:'subagent', source_tag:'subagent', session_source:'other', _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] == ["parent"]
|
|
assert rows[0]["_child_session_count"] == 1
|
|
|
|
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const collapsed = [{{session_id:'telegram_parent', title:'Telegram parent', session_source:'messaging', raw_source:'telegram', 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_sidebar_reference_rows_suppress_source_filtered_archived_parent_child():
|
|
"""Source-filtered payloads keep archived parents as non-rendered references.
|
|
|
|
The hidden archived parent reaches the render via the scoped reference helper
|
|
(`_scopedSidebarReferenceRows`), mirroring how renderSessionListFromCache feeds
|
|
project/source-scoped references into _renderSidebarRowsFromRawSessions.
|
|
"""
|
|
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);
|
|
}}
|
|
var _showArchived = false;
|
|
var NO_PROJECT_FILTER = '__none__';
|
|
var _activeProject;
|
|
var _sidebarReferenceSessions = [{{session_id:'parent', title:'Archived parent', archived:true, updated_at:10, last_message_at:10, source:'webui'}}];
|
|
function _isMessagingSession(s) {{ return false; }}
|
|
function _isCliSession(s) {{ return !!(s && (s.source === 'cli' || s.raw_source === 'cli')); }}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
eval(extractFunc('_scopedSidebarReferenceRows'));
|
|
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
|
const child = {{session_id:'child', title:'Subagent', parent_session_id:'parent', relationship_type:'child_session', updated_at:20, last_message_at:20, source:'webui'}};
|
|
const refs = _scopedSidebarReferenceRows(false);
|
|
const rows = _renderSidebarRowsFromRawSessions([child], [child, ...refs]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
assert json.loads(_run_node(source)) == []
|
|
|
|
|
|
def test_sidebar_reference_rows_do_not_suppress_cross_project_child():
|
|
"""A cross-project archived parent must NOT hide a visible child in another project.
|
|
|
|
Regression for the silent project-scope leak: sidebar_reference_sessions arrive
|
|
project-unscoped from /api/sessions, so an archived parent in project B must not
|
|
enter a project-A-filtered render's suppression context and erase a visible
|
|
project-A fork. _scopedSidebarReferenceRows filters references to the active
|
|
project/source before they feed the render.
|
|
"""
|
|
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);
|
|
}}
|
|
var _showArchived = false;
|
|
var NO_PROJECT_FILTER = '__none__';
|
|
var _activeProject = 'projA';
|
|
var _sidebarReferenceSessions = [{{session_id:'parent', title:'Archived parent (projB)', archived:true, project_id:'projB', updated_at:10, last_message_at:10, source:'webui'}}];
|
|
function _isMessagingSession(s) {{ return false; }}
|
|
function _isCliSession(s) {{ return !!(s && (s.source === 'cli' || s.raw_source === 'cli')); }}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
eval(extractFunc('_scopedSidebarReferenceRows'));
|
|
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
|
const child = {{session_id:'child', title:'Fork in projA', parent_session_id:'parent', relationship_type:'child_session', project_id:'projA', message_count:3, updated_at:20, last_message_at:20, source:'webui'}};
|
|
const refs = _scopedSidebarReferenceRows(false);
|
|
const rows = _renderSidebarRowsFromRawSessions([child], [child, ...refs]);
|
|
console.log(JSON.stringify(rows.map(r => r.session_id)));
|
|
"""
|
|
assert json.loads(_run_node(source)) == ["child"]
|
|
|
|
|
|
|
|
def test_archived_hidden_parent_suppresses_cross_surface_child_orphan():
|
|
"""A cross-surface child should not leak when its archived parent is hidden."""
|
|
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);
|
|
}}
|
|
var _showArchived = false;
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const parent = {{session_id:'parent', title:'Archived parent', archived:true, updated_at:10, last_message_at:10}};
|
|
const child = {{
|
|
session_id:'child',
|
|
title:'Subagent',
|
|
parent_session_id:'parent',
|
|
relationship_type:'child_session',
|
|
raw_source:'subagent',
|
|
source_tag:'subagent',
|
|
session_source:'other',
|
|
_cross_surface_child_session:true,
|
|
updated_at:20,
|
|
last_message_at:20,
|
|
}};
|
|
const rows = _attachChildSessionsToSidebarRows([child], [child], [parent, child]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
assert json.loads(_run_node(source)) == []
|
|
|
|
|
|
def test_archived_hidden_parent_suppresses_child_and_fork_orphans():
|
|
"""Archived parents should not leave child/delegate clutter behind (#4293)."""
|
|
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);
|
|
}}
|
|
var _showArchived = false;
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const parent = {{session_id:'parent', title:'Archived parent', archived:true, updated_at:10, last_message_at:10}};
|
|
const child = {{session_id:'child', title:'Child', parent_session_id:'parent', relationship_type:'child_session', updated_at:20, last_message_at:20}};
|
|
const fork = {{session_id:'fork', title:'Fork', session_source:'fork', parent_session_id:'parent', updated_at:30, last_message_at:30}};
|
|
const visibleRows = [child, fork];
|
|
const referenceRows = [parent, child, fork];
|
|
const rows = _attachChildSessionsToSidebarRows(visibleRows, visibleRows, referenceRows);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
assert json.loads(_run_node(source)) == []
|
|
|
|
|
|
def test_archived_hidden_ancestor_suppresses_nested_child_and_fork_orphans():
|
|
"""Nested descendants of an archived hidden parent must not leak as orphans (#4293)."""
|
|
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);
|
|
}}
|
|
var _showArchived = false;
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const parent = {{session_id:'parent', title:'Archived parent', archived:true, updated_at:10, last_message_at:10}};
|
|
const child = {{session_id:'child', title:'Child', parent_session_id:'parent', relationship_type:'child_session', updated_at:20, last_message_at:20}};
|
|
const grandchild = {{session_id:'grandchild', title:'Grandchild', parent_session_id:'child', relationship_type:'child_session', updated_at:30, last_message_at:30}};
|
|
const fork1 = {{session_id:'fork1', title:'Fork 1', session_source:'fork', parent_session_id:'parent', updated_at:40, last_message_at:40}};
|
|
const fork2 = {{session_id:'fork2', title:'Fork 2', session_source:'fork', parent_session_id:'fork1', updated_at:50, last_message_at:50}};
|
|
const visibleRows = [child, grandchild, fork1, fork2];
|
|
const referenceRows = [parent, child, grandchild, fork1, fork2];
|
|
const rows = _attachChildSessionsToSidebarRows(visibleRows, visibleRows, referenceRows);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
assert json.loads(_run_node(source)) == []
|
|
|
|
|
|
def test_cross_project_archived_parent_does_not_hide_active_project_fork():
|
|
"""Archived parents outside the active project must not suppress an active-project fork (#4293)."""
|
|
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);
|
|
}}
|
|
var _showArchived = false;
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
|
const otherProjectParent = {{session_id:'parent', title:'Other project parent', archived:true, project_id:'other', updated_at:10, last_message_at:10}};
|
|
const activeProjectFork = {{session_id:'fork', title:'Active project fork', session_source:'fork', parent_session_id:'parent', project_id:'active', updated_at:20, last_message_at:20}};
|
|
const activeProjectRows = [activeProjectFork];
|
|
const activeProjectReferenceRows = [activeProjectFork];
|
|
const crossProjectReferenceRows = [otherProjectParent, activeProjectFork];
|
|
const correctRows = _renderSidebarRowsFromRawSessions(activeProjectRows, activeProjectReferenceRows);
|
|
const wrongRows = _renderSidebarRowsFromRawSessions(activeProjectRows, crossProjectReferenceRows);
|
|
console.log(JSON.stringify({{correctRows, wrongRows}}));
|
|
"""
|
|
result = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in result["correctRows"]] == ["fork"]
|
|
assert "_orphan_child_session" not in result["correctRows"][0]
|
|
# This documents why render references must be active-project scoped: a
|
|
# cross-project archived parent would make the active-project fork vanish.
|
|
assert result["wrongRows"] == []
|
|
|
|
|
|
def test_inactive_source_tab_count_uses_its_own_archived_parent_references():
|
|
"""Inactive source-count renders must not borrow another source's reference rows (#4293)."""
|
|
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);
|
|
}}
|
|
var _showArchived = false;
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
|
const webuiParent = {{session_id:'webui-parent', title:'Archived WebUI parent', archived:true, updated_at:10, last_message_at:10}};
|
|
const webuiChild = {{session_id:'webui-child', title:'WebUI child', parent_session_id:'webui-parent', relationship_type:'child_session', updated_at:20, last_message_at:20}};
|
|
const cliParent = {{session_id:'cli-parent', title:'Archived CLI parent', archived:true, is_cli_session:true, session_source:'cli', updated_at:30, last_message_at:30}};
|
|
const cliChild = {{session_id:'cli-child', title:'CLI child', parent_session_id:'cli-parent', relationship_type:'child_session', is_cli_session:true, session_source:'cli', updated_at:40, last_message_at:40}};
|
|
const webuiVisibleRows = [webuiChild];
|
|
const cliVisibleRows = [cliChild];
|
|
const webuiReferenceRows = [webuiParent, webuiChild];
|
|
const cliReferenceRows = [cliParent, cliChild];
|
|
const wrongWebuiCount = _renderSidebarRowsFromRawSessions(webuiVisibleRows, cliReferenceRows).length;
|
|
const rightWebuiCount = _renderSidebarRowsFromRawSessions(webuiVisibleRows, webuiReferenceRows).length;
|
|
const wrongCliCount = _renderSidebarRowsFromRawSessions(cliVisibleRows, webuiReferenceRows).length;
|
|
const rightCliCount = _renderSidebarRowsFromRawSessions(cliVisibleRows, cliReferenceRows).length;
|
|
console.log(JSON.stringify({{wrongWebuiCount,rightWebuiCount,wrongCliCount,rightCliCount}}));
|
|
"""
|
|
counts = json.loads(_run_node(source))
|
|
assert counts == {
|
|
"wrongWebuiCount": 1,
|
|
"rightWebuiCount": 0,
|
|
"wrongCliCount": 1,
|
|
"rightCliCount": 0,
|
|
}
|
|
|
|
|
|
def test_child_archive_render_rebuild_drops_stale_decorated_children():
|
|
"""A previous decorated parent copy must not retain an archived child (#4293)."""
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const decoratedParent = {{
|
|
session_id:'parent',
|
|
title:'Parent',
|
|
updated_at:10,
|
|
last_message_at:10,
|
|
_child_sessions:[{{session_id:'archived-child', title:'Archived child'}}],
|
|
_child_session_count:1,
|
|
_child_session_streaming:true,
|
|
_child_session_has_unread:true,
|
|
_child_session_attention:{{kind:'approval', count:1}},
|
|
}};
|
|
const rows = _attachChildSessionsToSidebarRows([decoratedParent], [decoratedParent]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in rows] == ["parent"]
|
|
assert "_child_sessions" not in rows[0]
|
|
assert "_child_session_count" not in rows[0]
|
|
assert "_child_session_streaming" not in rows[0]
|
|
assert "_child_session_has_unread" not in rows[0]
|
|
assert "_child_session_attention" not in rows[0]
|
|
|
|
|
|
def test_fork_child_with_visible_parent_is_nested_once():
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
|
|
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', updated_at:20, last_message_at:20}};
|
|
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in rows] == ["parent"]
|
|
assert rows[0]["_child_session_count"] == 1
|
|
assert [child["session_id"] for child in rows[0]["_child_sessions"]] == ["fork1"]
|
|
|
|
|
|
def test_fork_child_without_visible_parent_stays_top_level():
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'missing', updated_at:20, last_message_at:20}};
|
|
const rows = _attachChildSessionsToSidebarRows([fork], [fork]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in rows] == ["fork1"]
|
|
assert "_child_sessions" not in rows[0]
|
|
|
|
|
|
def test_pinned_fork_with_visible_parent_stays_top_level():
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
|
|
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', pinned:true, updated_at:20, last_message_at:20}};
|
|
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in rows] == ["parent", "fork1"]
|
|
assert "_child_sessions" not in rows[0]
|
|
|
|
|
|
def test_nested_fork_tracks_latest_child_without_changing_parent_bucket_timestamp():
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
|
|
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', updated_at:20, last_message_at:20}};
|
|
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
|
|
console.log(JSON.stringify({{row: rows[0], timestampMs: _sessionTimestampMs(rows[0])}}));
|
|
"""
|
|
result = json.loads(_run_node(source))
|
|
row = result["row"]
|
|
assert row["session_id"] == "parent"
|
|
# Keep the parent row's persisted timestamp intact while still recording
|
|
# the newest attached child/subagent metadata.
|
|
assert row["last_message_at"] == 10
|
|
assert row["_child_session_latest_at"] == 20
|
|
assert "_sidebar_activity_at" not in row
|
|
assert result["timestampMs"] == 10_000
|
|
|
|
|
|
def test_hidden_archived_lineage_child_bubbles_running_state_and_latest_child_timestamp_without_changing_parent_bucket():
|
|
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);
|
|
}}
|
|
function _isSessionEffectivelyStreaming(session) {{
|
|
return !!(session && (session.is_streaming || session.active_stream_id));
|
|
}}
|
|
function _hasUnreadForSession(session) {{
|
|
return !!(session && session.has_unread);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const visibleTip = {{
|
|
session_id:'visible-tip',
|
|
title:'Visible lineage row',
|
|
parent_session_id:'parent-root',
|
|
session_source:'webui',
|
|
_lineage_root_id:'parent-root',
|
|
updated_at:10,
|
|
last_message_at:10,
|
|
}};
|
|
const archivedTip = {{
|
|
session_id:'archived-tip',
|
|
title:'Archived running tip',
|
|
parent_session_id:'parent-root',
|
|
session_source:'webui',
|
|
archived:true,
|
|
_lineage_root_id:'parent-root',
|
|
is_streaming:true,
|
|
active_stream_id:'stream-1',
|
|
updated_at:30,
|
|
last_message_at:30,
|
|
}};
|
|
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
|
|
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
|
|
"""
|
|
result = json.loads(_run_node(source))
|
|
row = result["row"]
|
|
assert result["count"] == 1
|
|
assert row["session_id"] == "visible-tip"
|
|
assert "_child_sessions" not in row
|
|
assert row["_child_session_streaming"] is True
|
|
assert row["_child_session_latest_at"] == 30
|
|
assert "_sidebar_activity_at" not in row
|
|
assert result["timestampMs"] == 10_000
|
|
|
|
|
|
def test_archived_lineage_child_without_root_id_preserves_parent_bucket_timestamp():
|
|
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);
|
|
}}
|
|
function _isSessionEffectivelyStreaming(session) {{
|
|
return !!(session && (session.is_streaming || session.active_stream_id));
|
|
}}
|
|
function _hasUnreadForSession(session) {{
|
|
return !!(session && session.has_unread);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const visibleTip = {{
|
|
session_id:'visible-tip',
|
|
title:'Visible lineage row',
|
|
parent_session_id:'parent-root',
|
|
session_source:'webui',
|
|
_lineage_root_id:'parent-root',
|
|
updated_at:10,
|
|
last_message_at:10,
|
|
}};
|
|
const archivedTip = {{
|
|
session_id:'archived-tip',
|
|
title:'Archived running tip',
|
|
parent_session_id:'parent-root',
|
|
session_source:'webui',
|
|
archived:true,
|
|
is_streaming:true,
|
|
active_stream_id:'stream-1',
|
|
updated_at:30,
|
|
last_message_at:30,
|
|
}};
|
|
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
|
|
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
|
|
"""
|
|
result = json.loads(_run_node(source))
|
|
row = result["row"]
|
|
assert result["count"] == 1
|
|
assert row["session_id"] == "visible-tip"
|
|
assert "_child_sessions" not in row
|
|
assert row["_child_session_streaming"] is True
|
|
assert row["_child_session_latest_at"] == 30
|
|
assert "_sidebar_activity_at" not in row
|
|
assert result["timestampMs"] == 10_000
|
|
|
|
|
|
def test_non_archived_reference_only_lineage_row_does_not_bubble_to_visible_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);
|
|
}}
|
|
function _isSessionEffectivelyStreaming(session) {{
|
|
return !!(session && (session.is_streaming || session.active_stream_id));
|
|
}}
|
|
function _hasUnreadForSession(session) {{
|
|
return !!(session && session.has_unread);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const visibleTip = {{
|
|
session_id:'visible-tip',
|
|
title:'Visible lineage row',
|
|
parent_session_id:'parent-root',
|
|
session_source:'webui',
|
|
_lineage_root_id:'parent-root',
|
|
updated_at:10,
|
|
last_message_at:10,
|
|
}};
|
|
const filteredReferenceOnly = {{
|
|
session_id:'filtered-tip',
|
|
title:'Filtered non-archived tip',
|
|
parent_session_id:'parent-root',
|
|
session_source:'webui',
|
|
_lineage_root_id:'parent-root',
|
|
archived:false,
|
|
is_streaming:true,
|
|
active_stream_id:'stream-1',
|
|
updated_at:30,
|
|
last_message_at:30,
|
|
}};
|
|
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, filteredReferenceOnly]);
|
|
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
|
|
"""
|
|
result = json.loads(_run_node(source))
|
|
row = result["row"]
|
|
assert result["count"] == 1
|
|
assert row["session_id"] == "visible-tip"
|
|
assert "_child_sessions" not in row
|
|
assert "_child_session_streaming" not in row
|
|
assert "_child_session_latest_at" not in row
|
|
assert "_sidebar_activity_at" not in row
|
|
assert result["timestampMs"] == 10_000
|
|
|
|
|
|
def test_stale_active_stream_id_on_hidden_archived_child_keeps_parent_bucket_and_latest_child_timestamp():
|
|
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);
|
|
}}
|
|
function _isSessionEffectivelyStreaming(session) {{
|
|
return !!(session && (session.is_streaming || session.pending_user_message || session.has_pending_user_message));
|
|
}}
|
|
function _hasUnreadForSession(session) {{
|
|
return !!(session && session.has_unread);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const visibleTip = {{
|
|
session_id:'visible-tip',
|
|
title:'Visible lineage row',
|
|
parent_session_id:'parent-root',
|
|
session_source:'webui',
|
|
_lineage_root_id:'parent-root',
|
|
updated_at:10,
|
|
last_message_at:10,
|
|
}};
|
|
const archivedTip = {{
|
|
session_id:'archived-tip',
|
|
title:'Archived stale tip',
|
|
parent_session_id:'parent-root',
|
|
session_source:'webui',
|
|
archived:true,
|
|
_lineage_root_id:'parent-root',
|
|
is_streaming:false,
|
|
active_stream_id:'dead-stream',
|
|
updated_at:30,
|
|
last_message_at:30,
|
|
}};
|
|
const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]);
|
|
console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}}));
|
|
"""
|
|
result = json.loads(_run_node(source))
|
|
row = result["row"]
|
|
assert result["count"] == 1
|
|
assert row["session_id"] == "visible-tip"
|
|
assert "_child_sessions" not in row
|
|
assert "_child_session_streaming" not in row
|
|
assert row["_child_session_latest_at"] == 30
|
|
assert "_sidebar_activity_at" not in row
|
|
assert result["timestampMs"] == 10_000
|
|
|
|
|
|
def test_collapsed_lineage_segments_do_not_render_as_child_sessions():
|
|
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);
|
|
}}
|
|
function _isSessionEffectivelyStreaming(session) {{
|
|
return !!(session && (session.is_streaming || session.active_stream_id));
|
|
}}
|
|
function _hasUnreadForSession(session) {{
|
|
return !!(session && session.has_unread);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sessionLineageKey'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_collapseSessionLineageForSidebar'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
|
const root = {{session_id:'root', title:'Root', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'root'}};
|
|
const mid = {{session_id:'mid', title:'Middle', parent_session_id:'root', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'mid'}};
|
|
const tip = {{session_id:'tip', title:'Tip', parent_session_id:'root', updated_at:30, last_message_at:30, _lineage_root_id:'root', _lineage_tip_id:'tip'}};
|
|
const rows = _renderSidebarRowsFromRawSessions([root, mid, tip], [root, mid, tip]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in rows] == ["tip"]
|
|
assert rows[0]["_lineage_collapsed_count"] == 3
|
|
assert [seg["session_id"] for seg in rows[0]["_lineage_segments"]] == ["tip", "mid", "root"]
|
|
assert "_child_sessions" not in rows[0]
|
|
assert "_child_session_count" not in rows[0]
|
|
|
|
|
|
def test_nested_fork_bubbles_parent_attention_state():
|
|
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);
|
|
}}
|
|
function _isSessionEffectivelyStreaming(session) {{
|
|
return !!(session && session.active_stream_id);
|
|
}}
|
|
function _hasUnreadForSession(session) {{
|
|
return !!(session && session.has_unread);
|
|
}}
|
|
eval(extractFunc('_sessionTimestampMs'));
|
|
eval(extractFunc('_isChildSession'));
|
|
eval(extractFunc('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_sessionDisplayTitle'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
|
|
const fork = {{
|
|
session_id:'fork1',
|
|
title:'Fork',
|
|
session_source:'fork',
|
|
parent_session_id:'parent',
|
|
updated_at:20,
|
|
last_message_at:20,
|
|
has_unread:true,
|
|
attention:{{kind:'approval', count:2}},
|
|
active_stream_id:'stream-1'
|
|
}};
|
|
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert rows[0]["_child_session_has_unread"] is True
|
|
assert rows[0]["_child_session_streaming"] is True
|
|
assert rows[0]["_child_session_attention"]["kind"] == "approval"
|
|
|
|
|
|
def test_fork_chain_stays_attached_under_visible_root():
|
|
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('_isForkWithResolvableParent'));
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_sessionDisplayTitle'));
|
|
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
|
const root = {{session_id:'root', title:'Root', updated_at:10, last_message_at:10}};
|
|
const fork1 = {{session_id:'fork1', title:'Fork 1', session_source:'fork', parent_session_id:'root', updated_at:20, last_message_at:20}};
|
|
const fork2 = {{session_id:'fork2', title:'Fork 2', session_source:'fork', parent_session_id:'fork1', updated_at:30, last_message_at:30}};
|
|
const rows = _attachChildSessionsToSidebarRows([fork2, fork1, root], [fork2, fork1, root]);
|
|
console.log(JSON.stringify(rows));
|
|
"""
|
|
rows = json.loads(_run_node(source))
|
|
assert [row["session_id"] for row in rows] == ["root"]
|
|
assert [child["session_id"] for child in rows[0]["_child_sessions"]] == ["fork1", "fork2"]
|
|
assert rows[0]["_child_sessions"][1]["_parent_segment_id"] == "fork1"
|
|
|
|
|
|
def test_sidebar_lineage_key_uses_session_id_for_fork_rows():
|
|
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('_sidebarLineageKeyForRow'));
|
|
const root = {{session_id:'root', parent_session_id:null}};
|
|
const pinnedFork = {{session_id:'fork1', session_source:'fork', parent_session_id:'root', pinned:true}};
|
|
console.log(JSON.stringify({{
|
|
rootKey:_sidebarLineageKeyForRow(root),
|
|
forkKey:_sidebarLineageKeyForRow(pinnedFork),
|
|
}}));
|
|
"""
|
|
result = json.loads(_run_node(source))
|
|
assert result["rootKey"] == "root"
|
|
assert result["forkKey"] == "fork1"
|
|
|
|
|
|
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 needsLineageReport=showLineageMetadata?_lineageReportNeedsFetch(s,lineageKey,segmentCount):false;" in js
|
|
assert "const lineageSegments=showLineageMetadata?_lineageSegmentsForRender(s,lineageKey,needsLineageReport):[];" 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 "_pruneLineageReportCacheToVisibleSessions(_allSessions);" 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 js.count("_fetchLineageReportForRow(s,lineageKey).then(()=>renderSessionListFromCache());") == 2
|
|
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 _openSidebarSession(seg, {skipLineageResolve:true});" in js
|
|
assert "const openChildSession=async(childSession)=>{" in js
|
|
assert "await _openSidebarSession(childSession, {skipLineageResolve:true});" in js
|
|
assert "if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){" 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('_authoritativeLineageTipId'));
|
|
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": True, "fullLocal": False}
|
|
|
|
|
|
def test_lineage_report_cache_identity_uses_tip_and_evicts_segment_count_mismatch():
|
|
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('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_authoritativeLineageTipId'));
|
|
eval(extractFunc('_lineageReportCacheKey'));
|
|
eval(extractFunc('_lineageLocalSegmentCount'));
|
|
eval(extractFunc('_lineageReportNeedsFetch'));
|
|
const oldTip = {{session_id:'old-tip', _lineage_key:'root', _lineage_tip_id:'old-tip', _compression_segment_count:2}};
|
|
const newTip = {{session_id:'new-tip', _lineage_key:'root', _lineage_tip_id:'new-tip', _compression_segment_count:3}};
|
|
const staleSameTip = {{session_id:'new-tip', _lineage_key:'root', _lineage_tip_id:'new-tip', _compression_segment_count:3}};
|
|
const oldKey = _lineageReportCacheKey(oldTip, 'root');
|
|
const newKey = _lineageReportCacheKey(newTip, 'root');
|
|
_lineageReportCache.set(oldKey, {{segments:[{{session_id:'old-tip'}}, {{session_id:'root'}}]}});
|
|
const tipChangedNeedsFetch = _lineageReportNeedsFetch(newTip, 'root', 3);
|
|
_lineageReportCache.set(newKey, {{segments:[{{session_id:'new-tip'}}, {{session_id:'root'}}]}});
|
|
const mismatchNeedsFetch = _lineageReportNeedsFetch(staleSameTip, 'root', 3);
|
|
console.log(JSON.stringify({{
|
|
oldKey,
|
|
newKey,
|
|
tipChangedNeedsFetch,
|
|
mismatchNeedsFetch,
|
|
newCacheRetained:_lineageReportCache.has(newKey),
|
|
}}));
|
|
"""
|
|
assert json.loads(_run_node(source)) == {
|
|
"oldKey": "root::old-tip",
|
|
"newKey": "root::new-tip",
|
|
"tipChangedNeedsFetch": True,
|
|
"mismatchNeedsFetch": True,
|
|
"newCacheRetained": False,
|
|
}
|
|
|
|
|
|
def test_render_path_skips_stale_cached_segments_when_mismatch_forces_refresh():
|
|
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('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_authoritativeLineageTipId'));
|
|
eval(extractFunc('_lineageReportCacheKey'));
|
|
eval(extractFunc('_lineageLocalSegmentCount'));
|
|
eval(extractFunc('_lineageReportNeedsFetch'));
|
|
eval(extractFunc('_lineageSegmentsForRender'));
|
|
const row = {{
|
|
session_id:'tip',
|
|
_lineage_key:'root',
|
|
_lineage_tip_id:'tip',
|
|
_compression_segment_count:3,
|
|
_lineage_segments:[{{session_id:'root'}}],
|
|
}};
|
|
_lineageReportCache.set('root::tip', {{
|
|
segments:[{{session_id:'root'}}, {{session_id:'stale-extra'}}],
|
|
}});
|
|
const needsFetch = _lineageReportNeedsFetch(row, 'root', 3);
|
|
const rendered = _lineageSegmentsForRender(row, 'root', needsFetch).map(seg => seg.session_id);
|
|
console.log(JSON.stringify({{needsFetch, rendered, cacheRetained:_lineageReportCache.has('root::tip')}}));
|
|
"""
|
|
assert json.loads(_run_node(source)) == {
|
|
"needsFetch": True,
|
|
"rendered": ["root"],
|
|
"cacheRetained": 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_lineage_refresh_cache_prune_keeps_visible_keys_and_drops_missing_ones():
|
|
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('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_pruneLineageReportCacheToVisibleSessions'));
|
|
const visibleRequest = Promise.resolve({{found:true}});
|
|
const staleRequest = Promise.resolve({{found:true}});
|
|
_lineageReportCache.set('root', {{segments:[{{session_id:'root'}}]}});
|
|
_lineageReportCache.set('stale', {{segments:[{{session_id:'stale'}}]}});
|
|
_lineageReportInflight.set('root', visibleRequest);
|
|
_lineageReportInflight.set('stale', staleRequest);
|
|
_pruneLineageReportCacheToVisibleSessions([
|
|
{{session_id:'tip', _lineage_key:'root'}},
|
|
{{session_id:'child', parent_session_id:'root'}},
|
|
]);
|
|
console.log(JSON.stringify({{
|
|
cacheKeys:Array.from(_lineageReportCache.keys()),
|
|
inflightKeys:Array.from(_lineageReportInflight.keys()),
|
|
}}));
|
|
"""
|
|
assert json.loads(_run_node(source)) == {
|
|
"cacheKeys": ["root"],
|
|
"inflightKeys": ["root"],
|
|
}
|
|
|
|
|
|
def test_pruned_lineage_inflight_request_cannot_repopulate_cache():
|
|
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;
|
|
let resolveApi;
|
|
function api(path) {{
|
|
return new Promise(resolve => {{
|
|
resolveApi = () => resolve({{found:true, path, segments:[{{session_id:'stale'}}]}});
|
|
}});
|
|
}}
|
|
eval(extractFunc('_sidebarLineageKeyForRow'));
|
|
eval(extractFunc('_lineageReportCacheKey'));
|
|
eval(extractFunc('_pruneLineageReportCacheToVisibleSessions'));
|
|
eval(extractFunc('_fetchLineageReportForRow'));
|
|
(async()=>{{
|
|
const staleRow = {{session_id:'stale-tip', _lineage_key:'stale'}};
|
|
const request = _fetchLineageReportForRow(staleRow, 'stale');
|
|
_pruneLineageReportCacheToVisibleSessions([{{session_id:'tip', _lineage_key:'root'}}]);
|
|
resolveApi();
|
|
await request;
|
|
console.log(JSON.stringify({{
|
|
staleCached:_lineageReportCache.has('stale'),
|
|
staleInflight:_lineageReportInflight.has('stale'),
|
|
visibleCached:_lineageReportCache.has('root'),
|
|
}}));
|
|
}})().catch(err=>{{console.error(err); process.exit(1);}});
|
|
"""
|
|
assert json.loads(_run_node(source)) == {
|
|
"staleCached": False,
|
|
"staleInflight": False,
|
|
"visibleCached": False,
|
|
}
|
|
|
|
|
|
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 "function _sessionSearchDirectAndTitleMatches" in js
|
|
assert "const titleMatches=(sessions||[]).filter(s=>_sessionDisplayTitle(s).toLowerCase().includes(q));" in js
|
|
assert "const directAndTitleMatches=_sessionSearchDirectAndTitleMatches(_allSessions,currentQ);" 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('_isForkWithResolvableParent'));
|
|
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"]}
|
|
|
|
|
|
def test_streaming_state_recorded_from_own_state_not_bubbled_child():
|
|
"""_rememberRenderedStreamingState must receive the parent's own streaming
|
|
state, not the composite own||child value. Otherwise the parent gets
|
|
marked unread/completed when a nested fork stops streaming."""
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
# The pattern we need: ownStreaming used for remember, isStreaming used
|
|
# for rendering (includes child).
|
|
assert "const ownStreaming=_isSessionEffectivelyStreaming(s);" in js
|
|
assert "const isStreaming=ownStreaming||!!s._child_session_streaming;" in js
|
|
assert "_rememberRenderedStreamingState(s, ownStreaming);" in js
|
|
# The old buggy pattern must not exist.
|
|
assert "_rememberRenderedStreamingState(s, isStreaming);" not in js
|
|
|
|
|
|
def test_nested_fork_rows_included_in_visible_sidebar_ids():
|
|
"""Expanded writable fork children must appear in _sessionVisibleSidebarIds
|
|
so they participate in batch-select (select-all / shift-select)."""
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
assert "child.session_source==='fork'" in js
|
|
# The _sessionVisibleSidebarIds builder must push fork children.
|
|
assert "_sessionVisibleSidebarIds.push(child.session_id)" in js
|
|
|
|
|
|
def test_nested_fork_rows_render_select_checkbox():
|
|
"""The session-child-session-fork render path must include a batch-select
|
|
checkbox when _sessionSelectMode is active and the child is writable."""
|
|
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
|
render_marker = "row.className='session-child-session session-child-session-fork'"
|
|
fork_render_start = js.find(render_marker)
|
|
assert fork_render_start > 0
|
|
fork_render_block = js[fork_render_start:fork_render_start + 2000]
|
|
assert "session-select-cb" in fork_render_block
|
|
assert "_sessionSelectMode" in fork_render_block
|