diff --git a/README.md b/README.md index 76429aea..3cce0d14 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup - Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete - Pin/star sessions to the top of the sidebar (gold indicator) - Archive sessions (hide without deleting, toggle to show) -- Session projects -- named groups with colors for organizing sessions +- Session projects -- named groups with colors for organizing sessions; delegated subagent sessions have no project of their own and follow their nearest ancestor's project in the project filter and the Unassigned chip; forks and other child sessions keep their own project, so a fork moved to "No project" stays Unassigned - Session tags -- add #tag to titles for colored chips and click-to-filter - Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups) - Download as Markdown transcript, full JSON export, or import from JSON diff --git a/static/sessions.js b/static/sessions.js index 107767b8..593d660a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -7671,7 +7671,50 @@ function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){ (S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0); } +function _isDelegatedSubagentRow(s){ + if(!_isChildSession(s)) return false; + const role=[s.raw_source,s.source_tag,s.source].map(v=>String(v||'').trim().toLowerCase()).find(Boolean)||''; + return role==='subagent'; +} + +// Delegated subagents without a project_id take their nearest ancestor's project; +// every other row keeps its own. Memoized per render, so each lineage resolves once. +function _sidebarProjectResolver(rowsById){ + const memo=new Map(); + return function projectIdFor(s){ + if(!s) return null; + const path=[]; + const onPath=new Set(); + let cur=s, result=null; + while(cur){ + const sid=cur.session_id; + if(memo.has(sid)){ result=memo.get(sid); break; } + if(cur.project_id||!_isDelegatedSubagentRow(cur)){ result=cur.project_id||null; if(sid) path.push(sid); break; } + if(onPath.has(sid)) break; + onPath.add(sid); path.push(sid); + cur=rowsById?rowsById.get(cur.parent_session_id):null; + } + for(const sid of path) memo.set(sid,result); + return result; + }; +} + +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; +} + +// True when any row resolves to no project, using the filter's own ancestor map. +function _sidebarHasUnprojectedRows(rows, projectIdFor){ + return rows.some(s=>!projectIdFor(s)); +} + function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ + const projectIdFor=_sidebarProjectResolver(_sidebarRowsById([allMatched, typeof _sidebarReferenceSessions!=='undefined'?_sidebarReferenceSessions:null])); let cliSessionCount=0; const webuiProfileFiltered=[]; const cliProfileFiltered=[]; @@ -7690,10 +7733,11 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ const referenceRaw=isCli ? cliReferenceRaw : webuiReferenceRaw; const sessionsRaw=isCli ? cliSessionsRaw : webuiSessionsRaw; profileFiltered.push(s); + const projectId=projectIdFor(s); 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){ @@ -7717,6 +7761,7 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ cliReferenceRaw, webuiSessionsRaw, cliSessionsRaw, + projectIdFor, }; } @@ -7728,15 +7773,18 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){ // suppression context — silently hiding a visible child/fork whose archived // ancestor lives outside the current view. Scope the references to the same // project + source bucket as the render they feed before using them. -function _scopedSidebarReferenceRows(isCli){ +// projectIdFor: the render's shared resolver; standalone callers get one over references + _allSessions. +function _scopedSidebarReferenceRows(isCli, projectIdFor){ if(typeof _sidebarReferenceSessions==='undefined'||!Array.isArray(_sidebarReferenceSessions)||!_sidebarReferenceSessions.length) return []; + const resolve=projectIdFor||_sidebarProjectResolver(_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=resolve(s); + if(_activeProject===NO_PROJECT_FILTER){ if(projectId) return false; } + else if(_activeProject){ if(projectId!==_activeProject) return false; } return true; }); } @@ -7842,19 +7890,20 @@ function renderSessionListFromCache(){ cliReferenceRaw, webuiSessionsRaw, cliSessionsRaw, + projectIdFor, }=_partitionSidebarSessionRows(allMatched, activeSidForSidebar); const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw; const isCliView=_sessionSourceFilter==='cli'; - const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)]); + const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView, projectIdFor)]); // Server-provided source bucket counts are authoritative for the current // payload. When present, skip the expensive cross-bucket render/count pass; // null is a deliberate "not computed" sentinel consumed only by // _sessionSourceTabCount's fallback path below. const renderedWebuiSessionCount=_serverWebuiSessionCount===null - ? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length + ? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length : null; const renderedCliSessionCount=_serverCliSessionCount===null - ? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length + ? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length : null; const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount); const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount); @@ -7916,7 +7965,7 @@ 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 hasUnprojected=_sidebarHasUnprojectedRows(profileFiltered, projectIdFor); 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..ca8c2c18 100644 --- a/tests/test_5306_subagent_sidebar_flicker.py +++ b/tests/test_5306_subagent_sidebar_flicker.py @@ -93,6 +93,9 @@ eval(extractFunc('_collapseSessionLineageForSidebar')); eval(extractFunc('_attachChildSessionsToSidebarRows')); eval(extractFunc('_sessionAttentionState')); eval(extractFunc('_sidebarRowHasVisibleMessages')); +eval(extractFunc('_isDelegatedSubagentRow')); +eval(extractFunc('_sidebarProjectResolver')); +eval(extractFunc('_sidebarRowsById')); eval(extractFunc('_partitionSidebarSessionRows')); eval(extractFunc('_scopedSidebarReferenceRows')); eval(extractFunc('_renderSidebarRowsFromRawSessions')); @@ -216,9 +219,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..9b31896e 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -925,6 +925,9 @@ eval(extractFunc('_sessionLineageKey')); eval(extractFunc('_sidebarLineageKeyForRow')); eval(extractFunc('_collapseSessionLineageForSidebar')); eval(extractFunc('_attachChildSessionsToSidebarRows')); +eval(extractFunc('_isDelegatedSubagentRow')); +eval(extractFunc('_sidebarProjectResolver')); +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 +976,9 @@ eval(extractFunc('_sessionLineageKey')); eval(extractFunc('_sidebarLineageKeyForRow')); eval(extractFunc('_collapseSessionLineageForSidebar')); eval(extractFunc('_attachChildSessionsToSidebarRows')); +eval(extractFunc('_isDelegatedSubagentRow')); +eval(extractFunc('_sidebarProjectResolver')); +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_session_partition.py b/tests/test_sidebar_session_partition.py index 32293bc6..f0e7fcff 100644 --- a/tests/test_sidebar_session_partition.py +++ b/tests/test_sidebar_session_partition.py @@ -31,11 +31,11 @@ def test_render_uses_single_pass_partition_helper(): render_body = _function_block("renderSessionListFromCache") assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body - assert "_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)])" in render_body + assert "_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView, projectIdFor)])" in render_body assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body - assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body - assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body + assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length" in render_body + assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length" in render_body assert ": null;" in render_body assert "null is a deliberate \"not computed\" sentinel" in render_body assert "const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body @@ -72,8 +72,8 @@ def test_partition_helper_keeps_raw_source_counts_while_render_owns_visible_coun assert "cliSessionsRaw," in _partition_block() assert "const renderedWebuiSessionCount=_serverWebuiSessionCount===null" in render_body assert "const renderedCliSessionCount=_serverCliSessionCount===null" in render_body - assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length" in render_body - assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length" in render_body + assert "? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length" in render_body + assert "? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length" in render_body assert "function _countRenderedSidebarRowsFromRawSessions" not in SESSIONS_JS assert "function _renderSidebarRowsFromRawSessions(sessionsRaw, referenceSessionsRaw){" in SESSIONS_JS assert "_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw, referenceRows)" in SESSIONS_JS diff --git a/tests/test_sidebar_unassigned_filter.py b/tests/test_sidebar_unassigned_filter.py index 2d19a7ee..062a7bc5 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=_sidebarHasUnprojectedRows(profileFiltered, projectIdFor);" 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..eb2bfcbb --- /dev/null +++ b/tests/test_subagent_project_filter.py @@ -0,0 +1,188 @@ +"""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','_isDelegatedSubagentRow','_sidebarProjectResolver','_sidebarRowsById','_sidebarHasUnprojectedRows', + '_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(): + out = _run("console.log(JSON.stringify(_sidebarHasUnprojectedRows([parent, child], _sidebarProjectResolver(_sidebarRowsById([[parent, child]])))));") + assert out is False + + +def test_unassigned_chip_uses_reference_parent(): + """A child whose project parent is only a reference row is not unassigned.""" + out = _run(""" +global._sidebarReferenceSessions = [parent]; +global._activeProject = null; +const part = _partitionSidebarSessionRows([child], null); +console.log(JSON.stringify({has: _sidebarHasUnprojectedRows(part.profileFiltered, part.projectIdFor), + unassigned: render(NO_PROJECT_FILTER, [child]).raw})); +""") + assert out["unassigned"] == [] + assert out["has"] is False + + +def test_unassigned_fork_stays_unassigned(): + """A fork moved to "No project" keeps project_id null; only subagents inherit.""" + out = _run(""" +const fork = { session_id:'fork_child', title:'Fork', parent_session_id:'proj_parent', relationship_type:'child_session', session_source:'fork', raw_source:'webui', source_tag:'webui', project_id:null, message_count:2, updated_at:103, last_message_at:103 }; +global._activeProject = null; +const p = _partitionSidebarSessionRows([parent, fork], null); +console.log(JSON.stringify({unassigned: render(NO_PROJECT_FILTER, [parent, fork]).raw, + project: render('projX', [parent, fork]).raw, + chip: _sidebarHasUnprojectedRows(p.profileFiltered, p.projectIdFor)})); +""") + assert out["unassigned"] == ["fork_child"] + assert out["project"] == ["proj_parent"] + assert out["chip"] is True + + +def test_project_resolution_is_linear_in_lineage_depth(): + """Each lineage resolves once: ancestor lookups stay O(n) for a deep chain.""" + out = _run(""" +function chain(n) { + const rows = [parent]; + for (let i = 0; i < n; i++) rows.push(Object.assign({}, grandchild, {session_id:'c'+i, + parent_session_id: i ? 'c'+(i-1) : 'proj_parent', updated_at:200+i, last_message_at:200+i})); + return rows; +} +function lookups(n) { + const rows = chain(n); + let gets = 0; + const realGet = Map.prototype.get; + Map.prototype.get = function(k) { gets++; return realGet.call(this, k); }; + try { + global._activeProject = 'projX'; + global._sidebarReferenceSessions = rows.slice(0, 50); + const part = _partitionSidebarSessionRows(rows, null); + _scopedSidebarReferenceRows(false, part.projectIdFor); + _sidebarHasUnprojectedRows(part.profileFiltered, part.projectIdFor); + return {gets, raw: part.sessionsRaw.length}; + } finally { Map.prototype.get = realGet; } +} +console.log(JSON.stringify({small: lookups(1000), big: lookups(4000)})); +""") + assert out["small"]["raw"] == 1001 and out["big"]["raw"] == 4001 + # 4x the rows must cost ~4x the lookups, not ~16x. + assert out["big"]["gets"] <= 5 * out["small"]["gets"], out + assert out["big"]["gets"] <= 20 * 4001, out + + +def test_project_resolver_is_cycle_safe(): + out = _run(""" +const a = Object.assign({}, grandchild, {session_id:'a', parent_session_id:'b'}); +const b = Object.assign({}, grandchild, {session_id:'b', parent_session_id:'a'}); +const resolve = _sidebarProjectResolver(_sidebarRowsById([[a, b]])); +console.log(JSON.stringify([resolve(a), resolve(b)])); +""") + assert out == [None, None]