From dbc2345fead90c43c6ec212c8a5bc24baf209358 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 5 Jul 2026 08:07:38 +0000 Subject: [PATCH 1/2] 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 3403e2102fb..5a9416585a8 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 e0812888dcf..c83a0af2260 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( From 4f0b6576b5e1d38ab8cb4f7dd5f2fca72737dd7e Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 5 Jul 2026 08:16:30 +0000 Subject: [PATCH 2/2] release #5582: keep fork children out of compression stitch + CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c83bb24bd82..9c997d0b1cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed +- **Clearing a forked conversation no longer resurrects the original conversation's messages if the original is later compressed.** When viewing a fork's full transcript, the display could stitch in the original (pre-fork) conversation's history after that original got compressed — so a cleared fork child appeared to bring back messages it shouldn't. Fork transcripts now include only their own fork-sourced history and stop at the first non-fork ancestor. Ordinary (non-fork) conversation history display is unchanged. Thanks @rodboev. (#5582, #5571) - **Steering a response now keeps your attachments.** If you had files staged in the composer and submitted them mid-response (steer), the attachments were silently dropped. They're now uploaded and included with the steer so the agent can read them, the file chips stay put if the steer is rejected (nothing lost), and retrying a rejected steer reuses the already-uploaded files instead of uploading duplicates. Files you stage while the steer is uploading are preserved. Thanks @ruizanthony. (#5459) - **Mobile sidebar header: the close (✕) and new-conversation (+) buttons are now a consistent, aligned pair.** The close button rendered a larger icon sitting slightly lower than the adjacent + button; both now use the same icon size on the same baseline, with the tap target kept large enough for comfortable mobile use. Desktop is unaffected. (mobile UI polish) - **iOS PWA: tapping a conversation now closes the sidebar immediately and reliably opens it.** On the mobile PWA, tapping an older (large) conversation left the sidebar drawer open for several seconds with only a tiny spinner — and a background refresh could cancel the switch mid-flight so the conversation never opened and the highlight jumped back. The sidebar now closes the instant you tap (matching how ChatGPT/Claude mobile dismiss the drawer on select, with a "Loading conversation…" placeholder in the chat area), and an in-flight session switch is protected from being cancelled by a concurrent background refresh. Desktop is unaffected. Thanks @luperrypf. (#5602)