diff --git a/static/sessions.js b/static/sessions.js index 1eacb242..bb7c49fb 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -7134,6 +7134,12 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere const childRenderable=!!(child&&child.session_id&&renderableChildIds.has(child.session_id)); if(child&&child.session_id&&visibleBySid.has(child.session_id)) continue; const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned); + const childRawRole=[ + child&&child.raw_source, + child&&child.source_tag, + child&&child.source, + ].map(source=>String(source||'').trim().toLowerCase()).find(Boolean)||''; + const childIsDelegatedSubagent=_isChildSession(child)&&childRawRole==='subagent'; const childLineageKey=child&&(child._lineage_root_id||child.lineage_root_id||child.parent_session_id); const isHiddenLineageReferenceChild=!!(child&&child.archived&&child.parent_session_id&&childLineageKey&&!child.pinned&&!childRenderable); if(!_isChildSession(child)&&!isForkChild&&!isHiddenLineageReferenceChild) continue; @@ -7158,11 +7164,10 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere hiddenArchivedChildTree.add(child.session_id); continue; } - // Cross-surface rows (for example a WebUI continuation from a Telegram - // conversation) should remain top-level when there is no WebUI-owned parent - // row to stack under. But if the parent is visible in this same sidebar - // render, attach normally — delegated subagent rows are also cross-source - // relative to their WebUI parent and should not be forced into orphans. + // Independent cross-surface rows (for example a WebUI continuation from a + // Telegram conversation) remain top-level instead of nesting under an + // external parent. Delegated subagents are also cross-source, but they are + // parent-owned work and should still attach to the visible parent row. const parentSourceMarker=String(parentRow&&( parentRow.session_source||parentRow.raw_source||parentRow.source_tag||parentRow.source )||'').toLowerCase(); @@ -7173,7 +7178,7 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere parentRow.session_source==='messaging'|| (parentSourceMarker&&parentSourceMarker!=='webui'&&parentSourceMarker!=='subagent'&&parentSourceMarker!=='other'&&parentSourceMarker!=='fork') ); - if(parentRow&&child._cross_surface_child_session&&parentIsExternal){ + if(parentRow&&child._cross_surface_child_session&&parentIsExternal&&!childIsDelegatedSubagent){ if(childRenderable) orphans.push({...child,_orphan_child_session:true}); continue; } diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 65b262b3..2f01abf8 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -499,6 +499,181 @@ console.log(JSON.stringify(rows)); 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."""