From 8b34a79f022f8c7df2521cfad8d59358dd1f464a Mon Sep 17 00:00:00 2001 From: ai-ag2026 Date: Tue, 5 May 2026 22:32:19 +0200 Subject: [PATCH] fix: preserve imported session lineage visibility --- api/models.py | 9 +- api/routes.py | 23 +++- tests/test_import_cli_session_lineage.py | 34 ++++++ .../test_session_import_cli_fallback_model.py | 112 ++++++++++++++++++ 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 tests/test_import_cli_session_lineage.py diff --git a/api/models.py b/api/models.py index 85e8bd82..7d2cccdc 100644 --- a/api/models.py +++ b/api/models.py @@ -1229,9 +1229,13 @@ def import_cli_session( profile=None, created_at=None, updated_at=None, + parent_session_id=None, ): - """Create a new WebUI session populated with CLI messages. - Returns the Session object. + """Create a new WebUI session populated with CLI/agent messages. + + Preserve parent_session_id from state.db so imported continuation segments + keep their lineage in the WebUI store and sidebar instead of reappearing as + detached orphan chats. """ s = Session( session_id=session_id, @@ -1242,6 +1246,7 @@ def import_cli_session( profile=profile, created_at=created_at, updated_at=updated_at, + parent_session_id=parent_session_id, ) s.save(touch_updated_at=False) return s diff --git a/api/routes.py b/api/routes.py index e2139a0c..9a642028 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1270,9 +1270,15 @@ def _merge_cli_sidebar_metadata(ui_session: dict, cli_meta: dict) -> dict: if cli_meta.get("last_message_at") is not None: merged["last_message_at"] = cli_meta["last_message_at"] if cli_meta.get("message_count") is not None: - merged["message_count"] = cli_meta["message_count"] + merged["message_count"] = max( + _numeric_count(merged.get("message_count")), + _numeric_count(cli_meta.get("message_count")), + ) elif cli_meta.get("actual_message_count") is not None: - merged["message_count"] = cli_meta["actual_message_count"] + merged["message_count"] = max( + _numeric_count(merged.get("message_count")), + _numeric_count(cli_meta.get("actual_message_count")), + ) if cli_meta.get("title"): current_title = merged.get("title") @@ -2622,7 +2628,13 @@ def handle_get(handler, parsed) -> bool: _t3 = _time.monotonic() if load_messages: if is_messaging_session and cli_messages: - _all_msgs = cli_messages + sidecar_messages = getattr(s, "messages", []) or [] + # Recovery/aggregate sidecars can intentionally contain a + # longer visible conversation than the single state.db + # segment for this messaging session id. Prefer the longer + # sidecar so repaired WebUI history is not hidden behind the + # canonical per-segment transcript. + _all_msgs = sidecar_messages if len(sidecar_messages) > len(cli_messages) else cli_messages else: _all_msgs = s.messages else: @@ -7661,6 +7673,7 @@ def _handle_session_import_cli(handler, body): "raw_source": existing.raw_source or cli_meta.get("raw_source") or cli_meta.get("source_tag"), "session_source": existing.session_source or cli_meta.get("session_source"), "source_label": existing.source_label or cli_meta.get("source_label"), + "parent_session_id": existing.parent_session_id or cli_meta.get("parent_session_id"), } for attr, value in updates.items(): if getattr(existing, attr, None) != value: @@ -7702,6 +7715,7 @@ def _handle_session_import_cli(handler, body): cli_thread_id = None cli_session_key = None cli_platform = None + cli_parent_session_id = None cli_read_only = False for cs in get_cli_sessions(): if cs["session_id"] == sid: @@ -7720,6 +7734,7 @@ def _handle_session_import_cli(handler, body): cli_thread_id = cs.get("thread_id") cli_session_key = cs.get("session_key") cli_platform = cs.get("platform") + cli_parent_session_id = cs.get("parent_session_id") cli_read_only = bool(cs.get("read_only")) break @@ -7750,6 +7765,7 @@ def _handle_session_import_cli(handler, body): "raw_source": cli_raw_source or cli_source_tag, "session_source": cli_session_source, "source_label": cli_source_label, + "parent_session_id": cli_parent_session_id, "read_only": True, "messages": msgs, "tool_calls": [], @@ -7764,6 +7780,7 @@ def _handle_session_import_cli(handler, body): profile=profile, created_at=created_at, updated_at=updated_at, + parent_session_id=cli_parent_session_id, ) if cron_project_id: s.project_id = cron_project_id diff --git a/tests/test_import_cli_session_lineage.py b/tests/test_import_cli_session_lineage.py new file mode 100644 index 00000000..e9165edc --- /dev/null +++ b/tests/test_import_cli_session_lineage.py @@ -0,0 +1,34 @@ +import json + + +def test_import_cli_session_preserves_parent_session_id(): + from api.models import import_cli_session, SESSION_DIR, Session + + parent_id = 'parent_lineage_001' + child_id = 'child_lineage_001' + + # Ensure clean fixture state for direct model-level import. + for sid in (parent_id, child_id): + try: + (SESSION_DIR / f'{sid}.json').unlink(missing_ok=True) + except Exception: + pass + + session = import_cli_session( + child_id, + 'Child Session', + [{'role': 'user', 'content': 'hello', 'timestamp': 1.0}], + model='test-model', + parent_session_id=parent_id, + created_at=1.0, + updated_at=2.0, + ) + + assert session.parent_session_id == parent_id + + payload = json.loads((SESSION_DIR / f'{child_id}.json').read_text(encoding='utf-8')) + assert payload['parent_session_id'] == parent_id + + loaded = Session.load(child_id) + assert loaded.parent_session_id == parent_id + assert loaded.compact()['parent_session_id'] == parent_id diff --git a/tests/test_session_import_cli_fallback_model.py b/tests/test_session_import_cli_fallback_model.py index c8399033..f47adeb9 100644 --- a/tests/test_session_import_cli_fallback_model.py +++ b/tests/test_session_import_cli_fallback_model.py @@ -90,6 +90,7 @@ def test_session_import_cli_refresh_matches_messages_despite_timestamp_type_diff self.raw_source = "weixin" self.session_source = "messaging" self.source_label = "WeChat" + self.parent_session_id = None def compact(self): return {"session_id": session_id, "title": "Imported"} @@ -141,6 +142,7 @@ def test_session_import_cli_refresh_rejects_prefix_if_non_timing_content_diverge self.session_source = "messaging" self.source_label = "Telegram" self.is_cli_session = True + self.parent_session_id = None def compact(self): return {"session_id": session_id, "title": "Imported"} @@ -169,3 +171,113 @@ def test_session_import_cli_refresh_rejects_prefix_if_non_timing_content_diverge assert response["session"]["messages"] == existing.messages assert existing.messages[0]["content"] == "old-prefix" assert save_calls == [] + + +def test_session_import_cli_preserves_parent_metadata_on_existing_import(monkeypatch): + """Refreshing an already-imported CLI session must persist lineage metadata.""" + import api.routes as routes + + session_id = "existing_parent_lineage_001" + parent_id = "root_parent_lineage_001" + + class FakeSession: + def __init__(self): + self.messages = [{"role": "user", "content": "hello", "timestamp": 1.0}] + self.source_tag = "telegram" + self.raw_source = "telegram" + self.session_source = "messaging" + self.source_label = "Telegram" + self.parent_session_id = None + self.is_cli_session = True + + def compact(self): + return {"session_id": session_id, "title": "Imported", "parent_session_id": self.parent_session_id} + + def save(self, touch_updated_at=False): + save_calls.append(touch_updated_at) + + save_calls = [] + existing = FakeSession() + + monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, sid: existing if sid == session_id else None)) + monkeypatch.setattr(routes, "require", lambda body, *keys: None) + monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload) + monkeypatch.setattr(routes, "get_cli_session_messages", lambda sid: existing.messages if sid == session_id else []) + monkeypatch.setattr( + routes, + "get_cli_sessions", + lambda: [{ + "session_id": session_id, + "source_tag": "telegram", + "raw_source": "telegram", + "session_source": "messaging", + "source_label": "Telegram", + "parent_session_id": parent_id, + }], + ) + + response = routes._handle_session_import_cli(object(), {"session_id": session_id}) + + assert response["imported"] is False + assert existing.parent_session_id == parent_id + assert response["session"]["parent_session_id"] == parent_id + assert save_calls == [False] + + +def test_read_only_import_payload_includes_parent_session_id(monkeypatch): + """Read-only CLI/session imports should also expose lineage in the payload.""" + import api.routes as routes + + session_id = "readonly_parent_lineage_001" + parent_id = "readonly_root_lineage_001" + messages = [{"role": "user", "content": "hello", "timestamp": 1.0}] + + monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, sid: None)) + monkeypatch.setattr(routes, "require", lambda body, *keys: None) + monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status}) + monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload) + monkeypatch.setattr(routes, "get_cli_session_messages", lambda sid: messages if sid == session_id else []) + monkeypatch.setattr( + routes, + "get_cli_sessions", + lambda: [{ + "session_id": session_id, + "title": "Read-only child", + "model": "test-model", + "created_at": 1.0, + "updated_at": 2.0, + "source_tag": "discord", + "raw_source": "discord", + "session_source": "messaging", + "source_label": "Discord", + "parent_session_id": parent_id, + "read_only": True, + }], + ) + + response = routes._handle_session_import_cli(object(), {"session_id": session_id}) + + assert response["imported"] is False + assert response["session"]["parent_session_id"] == parent_id + assert response["session"]["messages"] == messages + + +def test_merge_cli_sidebar_metadata_keeps_larger_sidecar_message_count(): + """Sidebar metadata merge should not shrink repaired aggregate sidecar counts.""" + import api.routes as routes + + merged = routes._merge_cli_sidebar_metadata( + {"session_id": "sid", "message_count": 535, "title": "Recovered"}, + {"session_id": "sid", "message_count": 407, "source_tag": "discord"}, + ) + + assert merged["message_count"] == 535 + + +def test_messaging_session_loader_prefers_longer_sidecar_transcript(): + """Pin the /api/session invariant that repaired sidecars can be longer than state.db segments.""" + handler = _extract_handler("handle_get") + old = "if is_messaging_session and cli_messages:\n _all_msgs = cli_messages" + assert old not in handler + assert "sidecar_messages = getattr(s, \"messages\", []) or []" in handler + assert "len(sidecar_messages) > len(cli_messages)" in handler