diff --git a/.claude/pr-sweep/webui-PR-TARGET-5598-PROOF.md b/.claude/pr-sweep/webui-PR-TARGET-5598-PROOF.md new file mode 100644 index 000000000..9b369257d --- /dev/null +++ b/.claude/pr-sweep/webui-PR-TARGET-5598-PROOF.md @@ -0,0 +1,8 @@ +| surface | command run | observed result | base/head | +| --- | --- | --- | --- | +| Mixed-source live refresh keeps one visible lineage representative | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_session_lineage_collapse.py tests/test_session_lineage_metadata_api.py -v --timeout=60"` | `tests/test_session_lineage_collapse.py::test_mixed_source_live_refresh_keeps_authoritative_tip_and_child_set PASSED` | base: base-fails, local timestamp ordering can pick different visible rows across the two refresh snapshots; head: head-passes, the test locks visible SID `tip`, badge kind `children`, and child SID `fork-child`. | +| Lineage-report cache does not survive authoritative tip or segment-count drift | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_session_lineage_collapse.py tests/test_session_lineage_metadata_api.py -v --timeout=60"` | `tests/test_session_lineage_collapse.py::test_lineage_report_cache_identity_uses_tip_and_evicts_segment_count_mismatch PASSED` | base: base-fails, root-only cache identity can reuse stale lineage reports after tip or count drift; head: head-passes, cache keys include tip identity and stale non-error reports are evicted on count mismatch. | +| Backend mixed-source child rows expose stable visible-parent authority | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_session_lineage_collapse.py tests/test_session_lineage_metadata_api.py -v --timeout=60"` | `tests/test_session_lineage_metadata_api.py::test_child_of_hidden_compression_segment_exposes_parent_lineage_root PASSED` | base: base-fails, child metadata exposed `_parent_lineage_root_id` without `_parent_lineage_tip_id`; head: head-passes, the child row exposes root `lineage_api_root` and tip `lineage_api_tip`. | +| Existing delegated-subagent stacking and suppression stay intact | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_5306_subagent_sidebar_flicker.py -v --timeout=60"` | `tests/test_5306_subagent_sidebar_flicker.py::test_5305_visible_parent_still_stacks_subagent_child PASSED` | base: base-passes, delegated-child stacking and suppression already pass; head: head-passes, `7 passed` in the preservation file. | +| Segment-count mismatch boundary value `cached=2 live=3` refreshes stale report | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_session_lineage_collapse.py tests/test_session_lineage_metadata_api.py -v --timeout=60"` | Boundary `cached=2 live=3` covered by `tests/test_session_lineage_collapse.py::test_lineage_report_cache_identity_uses_tip_and_evicts_segment_count_mismatch PASSED` | base: base-fails, boundary `cached=2 live=3` keeps a stale non-error cache entry; head: head-passes, boundary `cached=2 live=3` deletes the stale entry and fetches again. | +| Negative space, external parents still orphan their child rows | `$env:BROWSER='echo'; $env:PYTHONUTF8='1'; conhost --headless cmd /v:on /c "D:\Repos\hermes-agent\venv\Scripts\python.exe -m pytest tests/test_5306_subagent_sidebar_flicker.py -v --timeout=60"` | `tests/test_5306_subagent_sidebar_flicker.py::test_5305_external_parent_child_still_orphans PASSED` | base: base-passes, external-parent orphaning already passes; head: head-passes, the same `parentIsExternal=true` path still orphans child rows. | diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 17511c18e..4821d8915 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -1119,6 +1119,9 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st parent_root = _continuation_root_id(rows, parent_id) if parent_root: entry['_parent_lineage_root_id'] = parent_root + if parent_root not in lineage_tip_cache: + lineage_tip_cache[parent_root] = freshest_continuation_tip(parent_root) + entry['_parent_lineage_tip_id'] = lineage_tip_cache[parent_root][0] continue root_id, segment_count = continuation_root_and_depth(sid) diff --git a/static/sessions.js b/static/sessions.js index 3abfe84e9..474461644 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -5887,6 +5887,11 @@ function _sessionLineageContainsSession(s, sid){ return false; } +function _authoritativeLineageTipId(s){ + if(!s) return null; + return s._lineage_tip_id||s._parent_lineage_tip_id||null; +} + function _resolveSessionIdFromSidebarLineage(sid){ sid=String(sid||'').trim(); if(!sid||!Array.isArray(_allSessions)||!_allSessions.length) return sid||null; @@ -5966,7 +5971,11 @@ function _pruneLineageReportCacheToVisibleSessions(sessions){ } function _lineageReportCacheKey(s,lineageKey){ - return lineageKey||_sidebarLineageKeyForRow(s)||null; + const key=lineageKey||_sidebarLineageKeyForRow(s)||null; + const tip=typeof _authoritativeLineageTipId==='function' + ? _authoritativeLineageTipId(s) + : s&&(s._lineage_tip_id||s._parent_lineage_tip_id)||null; + return key&&tip&&tip!==key?`${key}::${tip}`:key; } function _lineageLocalSegmentCount(s){ @@ -5978,7 +5987,17 @@ function _lineageLocalSegmentCount(s){ function _lineageReportNeedsFetch(s,lineageKey,segmentCount){ const key=_lineageReportCacheKey(s,lineageKey); if(!s||!s.session_id||!key) return false; - if(_lineageReportCache.has(key)||_lineageReportInflight.has(key)) return false; + const cached=_lineageReportCache.get(key); + const expectedCount=Number(segmentCount||0); + if(cached){ + const cachedCount=Array.isArray(cached.segments)?cached.segments.length:0; + if(!cached.error&&expectedCount>0&&cachedCount>0&&cachedCount!==expectedCount){ + _lineageReportCache.delete(key); + } else { + return false; + } + } + if(_lineageReportInflight.has(key)) return false; return Number(segmentCount||0)>_lineageLocalSegmentCount(s); } @@ -6202,6 +6221,9 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere parentRow=resolved.row; parentSegment=resolved.seg; } + if(!parentRow&&child._parent_lineage_tip_id){ + parentRow=visibleBySid.get(child._parent_lineage_tip_id)||null; + } if(!parentRow&&child._parent_lineage_root_id){ parentRow=visibleByLineageKey.get(child._parent_lineage_root_id)||null; } @@ -6306,7 +6328,10 @@ function _collapseSessionLineageForSidebar(sessions){ if(bSnapshot!==aSnapshot) return aSnapshot-bSnapshot; return _sessionTimestampMs(b)-_sessionTimestampMs(a); }); - const chosen=sorted[0]; + const tipIds=new Set(items.map(item=>typeof _authoritativeLineageTipId==='function' + ? _authoritativeLineageTipId(item) + : item&&(item._lineage_tip_id||item._parent_lineage_tip_id)||null).filter(Boolean)); + const chosen=sorted.find(item=>tipIds.has(item&&item.session_id))||sorted[0]; result.push({...chosen,_lineage_key:key,_lineage_collapsed_count:items.length,_lineage_segments:sorted}); } return result; diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 935ee33fe..8dd45efe6 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -364,7 +364,7 @@ eval(extractFunc('_attachChildSessionsToSidebarRows')); const raw = [ {{session_id:'root', title:'Root', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'tip'}}, {{session_id:'tip', title:'Tip', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'tip'}}, - {{session_id:'child', title:'Subtask', parent_session_id:'tip', relationship_type:'child_session', _parent_lineage_root_id:'root', updated_at:30, last_message_at:30}}, + {{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); @@ -376,6 +376,72 @@ console.log(JSON.stringify(attached)); 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.""" @@ -1560,6 +1626,7 @@ function extractFunc(name) {{ 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}}; @@ -1575,7 +1642,57 @@ const afterCache = _lineageReportNeedsFetch(backendOnly, 'root', 25); const fullLocal = _lineageReportNeedsFetch(localFull, 'root', 2); console.log(JSON.stringify({{before, afterCache, fullLocal}})); """ - assert json.loads(_run_node(source)) == {"before": True, "afterCache": False, "fullLocal": False} + 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_cached_lineage_report_segments_merge_with_materialized_segments_without_duplicates_or_children(): diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index cf42a53a4..7f2b752d4 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -217,6 +217,7 @@ def test_child_of_hidden_compression_segment_exposes_parent_lineage_root(_isolat assert child.get("relationship_type") == "child_session" assert child.get("parent_session_id") == "lineage_api_tip" assert child.get("_parent_lineage_root_id") == "lineage_api_root" + assert child.get("_parent_lineage_tip_id") == "lineage_api_tip" assert "_lineage_root_id" not in child finally: conn.close()