diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 1bdaf48b..7d65bc57 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -527,6 +527,10 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st entry['relationship_type'] = 'child_session' entry['parent_title'] = parent_row.get('title') entry['parent_source'] = parent_row.get('source') + parent_source = str(parent_row.get('source') or '').strip().lower() + child_source = str(row.get('source') or '').strip().lower() + if parent_source and child_source and parent_source != child_source: + entry['_cross_surface_child_session'] = True parent_root = _continuation_root_id(rows, parent_id) if parent_root: entry['_parent_lineage_root_id'] = parent_root diff --git a/static/sessions.js b/static/sessions.js index 7ee8214e..2bfaeaff 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1870,6 +1870,10 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions){ const orphans=[]; for(const child of rawSessions||[]){ if(!_isChildSession(child)) continue; + if(child._cross_surface_child_session){ + orphans.push({...child,_orphan_child_session:true}); + continue; + } const parentSid=child.parent_session_id; let parentRow=visibleBySid.get(parentSid); let parentSegment=null; diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index f68b3779..868c416b 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -126,6 +126,8 @@ console.log(JSON.stringify({{sid: collapsed[0].session_id, containsRoot: _sessio assert '"containsRoot":true' in result + + def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" @@ -162,3 +164,47 @@ console.log(JSON.stringify(attached)); 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_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('_sidebarLineageKeyForRow')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +const collapsed = [{{session_id:'telegram_parent', title:'Telegram parent', 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] diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index fbd3381b..f070bd7e 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -45,14 +45,14 @@ def _ensure_state_db(path): return conn -def _insert_state_row(conn, sid, *, parent=None, ended_at=None, end_reason=None, started_at=None): +def _insert_state_row(conn, sid, *, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui'): conn.execute( """ INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) - VALUES (?, 'webui', ?, 'openai/gpt-5', ?, 2, ?, ?, ?) + VALUES (?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?) """, - (sid, sid, started_at or time.time(), parent, ended_at, end_reason), + (sid, source, sid, started_at or time.time(), parent, ended_at, end_reason), ) conn.commit() @@ -202,3 +202,35 @@ def test_cli_close_parent_preserves_cross_surface_continuation_lineage(_isolate) assert rows["lineage_api_webui_child"].get("_lineage_root_id") == "lineage_api_cli_parent" finally: conn.close() + + +def test_cross_surface_child_session_metadata_marks_orphan_top_level_candidate(_isolate): + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_api_telegram_parent", title="Telegram parent", updated_at=t0) + _save_webui_session("lineage_api_webui_tip", title="WebUI tip", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_api_telegram_parent", + source="telegram", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + _insert_state_row( + conn, + "lineage_api_webui_tip", + source="webui", + parent="lineage_api_telegram_parent", + started_at=t0 + 6, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + tip = rows["lineage_api_webui_tip"] + + assert tip.get("relationship_type") == "child_session" + assert tip.get("parent_source") == "telegram" + assert tip.get("_cross_surface_child_session") is True + finally: + conn.close()