fix(#5571): keep fork children out of compression stitch

_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 <rodboev@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-07-05 08:07:38 +00:00
parent f1f4514799
commit dbc2345fea
2 changed files with 121 additions and 0 deletions

View file

@ -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 [],

View file

@ -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(