diff --git a/static/sessions.js b/static/sessions.js index f748f45a..565d0a9a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -7596,7 +7596,34 @@ function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){ (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0); } +// Child sessions (delegated subagents) carry no project_id of their own; for +// project filtering they belong to the nearest ancestor that has one. +function _sidebarProjectIdForRow(s, rowsById){ + if(!s) return null; + if(s.project_id||!_isChildSession(s)||!rowsById) return s.project_id||null; + const seen=new Set([s.session_id]); + let parentSid=s.parent_session_id; + while(parentSid&&!seen.has(parentSid)){ + seen.add(parentSid); + const parent=rowsById.get(parentSid); + if(!parent) break; + if(parent.project_id) return parent.project_id; + parentSid=parent.parent_session_id; + } + return null; +} + +function _sidebarRowsById(rows){ + const byId=new Map(); + for(const list of rows){ + if(!Array.isArray(list)) continue; + for(const s of list) if(s&&s.session_id&&!byId.has(s.session_id)) byId.set(s.session_id,s); + } + return byId; +} + function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ + const rowsById=_sidebarRowsById([allMatched, typeof _sidebarReferenceSessions!=='undefined'?_sidebarReferenceSessions:null]); let cliSessionCount=0; const webuiProfileFiltered=[]; const cliProfileFiltered=[]; @@ -7615,10 +7642,11 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ const referenceRaw=isCli ? cliReferenceRaw : webuiReferenceRaw; const sessionsRaw=isCli ? cliSessionsRaw : webuiSessionsRaw; profileFiltered.push(s); + const projectId=_sidebarProjectIdForRow(s, rowsById); if(_activeProject===NO_PROJECT_FILTER){ - if(s.project_id) continue; + if(projectId) continue; } else if(_activeProject){ - if(s.project_id!==_activeProject) continue; + if(projectId!==_activeProject) continue; } referenceRaw.push(s); if(s.archived){ @@ -7655,13 +7683,15 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ // project + source bucket as the render they feed before using them. function _scopedSidebarReferenceRows(isCli){ if(typeof _sidebarReferenceSessions==='undefined'||!Array.isArray(_sidebarReferenceSessions)||!_sidebarReferenceSessions.length) return []; + const rowsById=_sidebarRowsById([_sidebarReferenceSessions, typeof _allSessions!=='undefined'?_allSessions:null]); return _sidebarReferenceSessions.filter(s=>{ if(!s) return false; // Source scope: only references in the same webui/cli bucket as this render. if(_isCliSession(s)!==!!isCli) return false; // Project scope: mirror _partitionSidebarSessionRows exactly. - if(_activeProject===NO_PROJECT_FILTER){ if(s.project_id) return false; } - else if(_activeProject){ if(s.project_id!==_activeProject) return false; } + const projectId=_sidebarProjectIdForRow(s, rowsById); + if(_activeProject===NO_PROJECT_FILTER){ if(projectId) return false; } + else if(_activeProject){ if(projectId!==_activeProject) return false; } return true; }); } @@ -7841,7 +7871,8 @@ function renderSessionListFromCache(){ } // Project filter bar — show when there are real projects OR there are // unassigned sessions (so the Unassigned chip has something to filter to). - const hasUnprojected=profileFiltered.some(s=>!s.project_id); + const profileRowsById=_sidebarRowsById([profileFiltered]); + const hasUnprojected=profileFiltered.some(s=>!_sidebarProjectIdForRow(s, profileRowsById)); if(_allProjects.length>0||hasUnprojected){ const bar=document.createElement('div'); bar.className='project-bar'; diff --git a/tests/test_5306_subagent_sidebar_flicker.py b/tests/test_5306_subagent_sidebar_flicker.py index 61bb8dbe..9b118bbf 100644 --- a/tests/test_5306_subagent_sidebar_flicker.py +++ b/tests/test_5306_subagent_sidebar_flicker.py @@ -93,6 +93,8 @@ eval(extractFunc('_collapseSessionLineageForSidebar')); eval(extractFunc('_attachChildSessionsToSidebarRows')); eval(extractFunc('_sessionAttentionState')); eval(extractFunc('_sidebarRowHasVisibleMessages')); +eval(extractFunc('_sidebarProjectIdForRow')); +eval(extractFunc('_sidebarRowsById')); eval(extractFunc('_partitionSidebarSessionRows')); eval(extractFunc('_scopedSidebarReferenceRows')); eval(extractFunc('_renderSidebarRowsFromRawSessions')); @@ -216,9 +218,9 @@ console.log(JSON.stringify({ })); """ out = json.loads(_run_node(source)) - # Child survives the visibility/project scope into sessionsRaw (parent does not)... - assert out["sessionsRaw"] == ["subagent_child"] - # ...but is NOT rendered as a top-level orphan. + # The child inherits its parent's project, so the "no project" filter drops both... + assert out["sessionsRaw"] == [] + # ...and it is NOT rendered as a top-level orphan. assert out["topLevel"] == [] assert out["orphans"] == [] diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 2f01abf8..6ff69867 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -925,6 +925,8 @@ eval(extractFunc('_sessionLineageKey')); eval(extractFunc('_sidebarLineageKeyForRow')); eval(extractFunc('_collapseSessionLineageForSidebar')); eval(extractFunc('_attachChildSessionsToSidebarRows')); +eval(extractFunc('_sidebarProjectIdForRow')); +eval(extractFunc('_sidebarRowsById')); 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'}}; @@ -973,6 +975,8 @@ eval(extractFunc('_sessionLineageKey')); eval(extractFunc('_sidebarLineageKeyForRow')); eval(extractFunc('_collapseSessionLineageForSidebar')); eval(extractFunc('_attachChildSessionsToSidebarRows')); +eval(extractFunc('_sidebarProjectIdForRow')); +eval(extractFunc('_sidebarRowsById')); 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'}}; diff --git a/tests/test_sidebar_unassigned_filter.py b/tests/test_sidebar_unassigned_filter.py index 2d19a7ee..33e6895c 100644 --- a/tests/test_sidebar_unassigned_filter.py +++ b/tests/test_sidebar_unassigned_filter.py @@ -61,7 +61,7 @@ def test_unassigned_chip_filter_logic(): assert "if(_activeProject===NO_PROJECT_FILTER){" in js, ( "The Unassigned filter must select sessions without a project_id" ) - assert "if(s.project_id) continue;" in js, ( + assert "if(projectId) continue;" in js, ( "The Unassigned filter must skip sessions with a project_id" ) @@ -76,7 +76,7 @@ def test_unassigned_chip_only_shown_when_relevant(): — though that's vanishingly rare). """ js = _js() - assert "const hasUnprojected=profileFiltered.some(s=>!s.project_id);" in js, ( + assert "const hasUnprojected=profileFiltered.some(s=>!_sidebarProjectIdForRow(s, profileRowsById));" in js, ( "The render function must compute whether unassigned sessions exist" ) assert "if(_allProjects.length>0||hasUnprojected){" in js, ( diff --git a/tests/test_subagent_project_filter.py b/tests/test_subagent_project_filter.py new file mode 100644 index 00000000..b7ab1f8e --- /dev/null +++ b/tests/test_subagent_project_filter.py @@ -0,0 +1,118 @@ +"""Delegated subagent children must follow their parent's project filter. + +Subagent child sessions are created by the agent (state.db) and never carry a +``project_id`` of their own. The sidebar project filter compared each row's own +``project_id``, so selecting a project dropped every subagent child and the +parent rendered with no stacked children, while the Unassigned view kept them. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + +_PREAMBLE = """ +const src = %s; +function extractFunc(name) { + const start = src.search(new RegExp('function\\\\s+' + name + '\\\\s*\\\\(')); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start) + 1, depth = 1; + while (depth > 0 && i < src.length) { if (src[i] === '{') depth++; else if (src[i] === '}') depth--; i++; } + return src.slice(start, i); +} +function _isCliSession(s){ return !!(s && (s.is_cli_session || s.session_source==='cli')); } +function _isExternalSession(s){ return !!(s && (s.is_cli_session || s.session_source === 'messaging')); } +function _isMessagingSession(s){ return !!(s && s.session_source==='messaging'); } +function _hasUnreadForSession(s){ return !!(s && s.has_unread); } +function _sessionDisplayTitle(s){ return s && s.title; } +Object.assign(global, {_isCliSession, _isExternalSession, _isMessagingSession, _hasUnreadForSession, _sessionDisplayTitle}); +global.INFLIGHT = {}; +global.NO_PROJECT_FILTER = '__no_project__'; +global.window = {}; +global._archivedCliCount = 0; global._archivedWebuiCount = 0; +global._sidebarReferenceSessions = []; +global.S = { session: null, busy: false, activeStreamId: null }; +global._showArchived = false; +global._sessionSourceFilter = 'webui'; +for (const fn of ['_isSessionLocallyStreaming','_hasPendingUserMessageSignal','_isSessionEffectivelyStreaming', + '_isChildSession','_isForkWithResolvableParent','_sessionLineageKey','_sidebarLineageKeyForRow', + '_collapseSessionLineageForSidebar','_attachChildSessionsToSidebarRows','_sessionAttentionState', + '_sidebarRowHasVisibleMessages','_sidebarProjectIdForRow','_sidebarRowsById', + '_partitionSidebarSessionRows','_scopedSidebarReferenceRows','_renderSidebarRowsFromRawSessions']) { + eval.call(global, extractFunc(fn)); +} +const parent = { session_id:'proj_parent', title:'Parent', session_source:'webui', raw_source:'webui', source_tag:'webui', message_count:5, project_id:'projX', updated_at:100, last_message_at:100 }; +const child = { session_id:'sub_child', title:'Subagent Session', parent_session_id:'proj_parent', relationship_type:'child_session', raw_source:'subagent', source_tag:'subagent', session_source:'other', _parent_lineage_root_id:'proj_parent', _cross_surface_child_session:true, message_count:3, updated_at:101, last_message_at:101 }; +const grandchild = { session_id:'sub_grandchild', title:'Nested subagent', parent_session_id:'sub_child', relationship_type:'child_session', raw_source:'subagent', source_tag:'subagent', session_source:'other', message_count:2, updated_at:102, last_message_at:102 }; +function render(project, rows) { + global._activeProject = project; + const part = _partitionSidebarSessionRows(rows, null); + const out = _renderSidebarRowsFromRawSessions(part.sessionsRaw, part.webuiReferenceRaw); + const p = out.find(r => r.session_id === 'proj_parent') || {}; + return { raw: part.sessionsRaw.map(s => s.session_id).sort(), top: out.map(r => r.session_id), + children: (p._child_sessions || []).map(c => c.session_id) }; +} +""" + + +def _run(scenario: str) -> dict: + assert NODE is not None + source = (_PREAMBLE % json.dumps(SESSIONS_JS)) + scenario + result = subprocess.run([NODE], input=source, cwd=str(REPO_ROOT), + capture_output=True, text=True, encoding="utf-8", timeout=30) + if result.returncode != 0: + raise RuntimeError(result.stderr) + return json.loads(result.stdout.strip()) + + +def test_subagent_child_is_shown_inside_parent_project(): + out = _run("console.log(JSON.stringify(render('projX', [parent, child])));") + assert out["raw"] == ["proj_parent", "sub_child"] + assert out["top"] == ["proj_parent"] + assert out["children"] == ["sub_child"] + + +def test_nested_subagent_inherits_project_through_child(): + out = _run("console.log(JSON.stringify(render('projX', [parent, child, grandchild])));") + assert out["raw"] == ["proj_parent", "sub_child", "sub_grandchild"] + assert out["top"] == ["proj_parent"] + + +def test_subagent_of_project_parent_is_not_unassigned(): + out = _run("console.log(JSON.stringify(render(NO_PROJECT_FILTER, [parent, child])));") + assert out["raw"] == [] + assert out["top"] == [] + + +def test_subagent_excluded_from_other_project_and_own_project_id_wins(): + out = _run(""" +const other = render('projY', [parent, child]); +const explicit = render('projY', [parent, Object.assign({}, child, {project_id:'projY'})]); +console.log(JSON.stringify({other, explicit})); +""") + assert out["other"]["raw"] == [] + assert out["explicit"]["raw"] == ["sub_child"] + + +def test_parent_resolved_from_reference_rows(): + """Parent only present as a hidden reference row still scopes the child.""" + out = _run(""" +global._sidebarReferenceSessions = [parent]; +const rows = render('projX', [child]); +global._activeProject = 'projX'; +const scoped = _scopedSidebarReferenceRows(false).map(s => s.session_id); +console.log(JSON.stringify({raw: rows.raw, scoped})); +""") + assert out["raw"] == ["sub_child"] + assert out["scoped"] == ["proj_parent"] + + +def test_unassigned_chip_ignores_subagents_of_project_parents(): + assert "const hasUnprojected=profileFiltered.some(s=>!_sidebarProjectIdForRow(s, profileRowsById));" in SESSIONS_JS