From dbc2345fead90c43c6ec212c8a5bc24baf209358 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 5 Jul 2026 08:07:38 +0000 Subject: [PATCH] fix(#5571): keep fork children out of compression stitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _webui_sidecar_lineage_messages_for_display stitches a session's pre-compression snapshot parents for full-transcript display. A cleared fork child could resurrect its ORIGINAL (non-fork) parent's transcript when that parent was later compressed. Now, when the root session is itself a fork (session_source=='fork'), the stitch walk allows fork-sourced snapshot parents but stops at the first non-fork ancestor — so a compressed fork continuation still shows its own pre-compression turns, while an ordinary fork child stays isolated from the original parent. Additive guard (one extra break condition); non-fork lineage display unchanged. Contributor stage; gate-pass. Co-authored-by: rodboev --- api/routes.py | 5 + tests/test_session_lineage_full_transcript.py | 116 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/api/routes.py b/api/routes.py index 3403e210..5a941658 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8052,6 +8052,8 @@ def _webui_sidecar_lineage_messages_for_display(session, *, max_hops: int = 20) segments = [] current = session session_messages = list(getattr(session, "messages", []) or []) + source = str(getattr(session, "session_source", "") or "").strip().lower() + root_is_fork = source == "fork" seen = {str(getattr(session, "session_id", "") or "")} for _ in range(max(0, int(max_hops))): parent_id = str(getattr(current, "parent_session_id", "") or "").strip() @@ -8060,6 +8062,9 @@ def _webui_sidecar_lineage_messages_for_display(session, *, max_hops: int = 20) parent = Session.load(parent_id) if not parent or not getattr(parent, "pre_compression_snapshot", False): break + parent_source = str(getattr(parent, "session_source", "") or "").strip().lower() + if root_is_fork and parent_source != "fork": + break if not segments and _messages_start_with_visible_prefix( session_messages, getattr(parent, "messages", []) or [], diff --git a/tests/test_session_lineage_full_transcript.py b/tests/test_session_lineage_full_transcript.py index e0812888..c83a0af2 100644 --- a/tests/test_session_lineage_full_transcript.py +++ b/tests/test_session_lineage_full_transcript.py @@ -276,6 +276,122 @@ def test_webui_fork_session_does_not_stitch_non_snapshot_parent(monkeypatch): ] +def test_webui_fork_session_does_not_stitch_snapshot_parent(monkeypatch): + """A fork child must stay isolated even if its parent later becomes a snapshot.""" + parent = SimpleNamespace( + session_id="parent-snapshot-fork", + parent_session_id=None, + session_source="webui", + pre_compression_snapshot=True, + truncation_watermark=None, + messages=[{"role": "user", "content": "parent should stay separate", "timestamp": 1.0}], + ) + child = SimpleNamespace( + session_id="child-snapshot-fork", + parent_session_id="parent-snapshot-fork", + session_source="fork", + pre_compression_snapshot=False, + truncation_watermark=None, + messages=[{"role": "user", "content": "fork child only", "timestamp": 2.0}], + ) + + monkeypatch.setattr(routes.Session, "load", lambda sid: parent if sid == "parent-snapshot-fork" else None) + + assert [m["content"] for m in routes._webui_sidecar_lineage_messages_for_display(child)] == [ + "fork child only", + ] + + +def test_webui_compressed_fork_stitches_fork_snapshots_only(monkeypatch): + original_parent = SimpleNamespace( + session_id="original-fork-parent", + parent_session_id=None, + session_source="webui", + pre_compression_snapshot=True, + truncation_watermark=None, + messages=[{"role": "user", "content": "original parent should stay separate", "timestamp": 0.0}], + ) + first_snapshot = SimpleNamespace( + session_id="fork-compression-snapshot-1", + parent_session_id="original-fork-parent", + session_source="fork", + pre_compression_snapshot=True, + truncation_watermark=None, + messages=[{"role": "user", "content": "before first fork compression", "timestamp": 1.0}], + ) + second_snapshot = SimpleNamespace( + session_id="fork-compression-snapshot-2", + parent_session_id="fork-compression-snapshot-1", + session_source="fork", + pre_compression_snapshot=True, + truncation_watermark=None, + messages=[{"role": "assistant", "content": "before second fork compression", "timestamp": 2.0}], + ) + child = SimpleNamespace( + session_id="fork-compression-child", + parent_session_id="fork-compression-snapshot-2", + session_source="fork", + pre_compression_snapshot=False, + truncation_watermark=None, + messages=[{"role": "assistant", "content": "after fork compression", "timestamp": 3.0}], + ) + by_id = { + "original-fork-parent": original_parent, + "fork-compression-snapshot-1": first_snapshot, + "fork-compression-snapshot-2": second_snapshot, + } + + monkeypatch.setattr(routes.Session, "load", lambda sid: by_id.get(sid)) + + assert [m["content"] for m in routes._webui_sidecar_lineage_messages_for_display(child)] == [ + "before first fork compression", + "before second fork compression", + "after fork compression", + ] + + +def test_webui_merged_lineage_keeps_session_source_fork_isolated(monkeypatch): + parent = SimpleNamespace( + session_id="merged-parent-fork", + messages=[{"role": "user", "content": "parent only", "timestamp": 1.0}], + ) + fork = SimpleNamespace( + session_id="merged-fork", + parent_session_id="merged-parent-fork", + session_source="fork", + relationship_type="", + messages=[{"role": "user", "content": "fork starts here", "timestamp": 2.0}], + truncation_watermark=None, + ) + + monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: parent) + + merged = routes._merged_webui_lineage_messages_for_display(fork, fork.messages) + + assert [m["content"] for m in merged] == ["fork starts here"] + + +def test_webui_merged_lineage_keeps_child_relationship_isolated(monkeypatch): + parent = SimpleNamespace( + session_id="merged-parent-child", + messages=[{"role": "user", "content": "parent only", "timestamp": 1.0}], + ) + child = SimpleNamespace( + session_id="merged-child", + parent_session_id="merged-parent-child", + session_source="", + relationship_type="child_session", + messages=[{"role": "user", "content": "child starts here", "timestamp": 2.0}], + truncation_watermark=None, + ) + + monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: parent) + + merged = routes._merged_webui_lineage_messages_for_display(child, child.messages) + + assert [m["content"] for m in merged] == ["child starts here"] + + def test_webui_lineage_display_keeps_child_tail_after_snapshot_watermark(monkeypatch): """A child sidecar watermark must not delete the child's persisted continuation tail.""" parent = SimpleNamespace(