fix(history): isolate branch transcripts from parent updates

(cherry picked from commit 57c51cc401e867e0b315e051ba138d0b8f7a5f27)
This commit is contained in:
DannyFengTianYu
2026-08-02 14:42:30 +08:00
committed by Teknium
parent 101740ed36
commit b066f2b373
2 changed files with 74 additions and 5 deletions
+41 -5
View File
@@ -9676,7 +9676,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
verbatim.
"""
session_ids = [session_id]
if include_ancestors:
if include_ancestors and not self._is_explicit_branch_session(session_id):
session_ids = self._session_lineage_root_to_tip(session_id)
active_clause = "" if include_inactive else " AND active = 1"
@@ -9858,16 +9858,22 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
- ``model_history`` — the tip session's active rows, alternation-repaired
(the live-replay working conversation). Equivalent to
``get_messages_as_conversation(session_id, repair_alternation=True)``.
- ``display_history`` — the full lineage (ancestors → tip), verbatim, with
replayed-user dedup. Equivalent to
``get_messages_as_conversation(session_id, include_ancestors=True)``.
- ``display_history`` — the full compression lineage (ancestors → tip),
verbatim, with replayed-user dedup. Explicit ``/branch`` sessions are
excluded from this lineage because their own rows already contain the
copied transcript; including the live parent's rows would let messages
written to the original after the fork leak into the branch.
The display fetch already reads a superset of the model fetch (the tip
rows are part of the lineage), so serving both from one lineage SELECT
halves the resume's DB work versus two separate calls, with byte-identical
output (see test_get_resume_conversations_matches_separate_reads).
"""
session_ids = self._session_lineage_root_to_tip(session_id)
session_ids = (
[session_id]
if self._is_explicit_branch_session(session_id)
else self._session_lineage_root_to_tip(session_id)
)
with self._read_ctx() as conn:
placeholders = ",".join("?" for _ in session_ids)
rows = conn.execute(
@@ -10003,6 +10009,9 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
returns ONLY the genuine ancestor messages, identified by
``session_id != tip_session_id``. (#65919)
"""
if self._is_explicit_branch_session(session_id):
return []
session_ids = self._session_lineage_root_to_tip(session_id)
if len(session_ids) <= 1:
return []
@@ -10024,6 +10033,33 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
repair_alternation=False,
)
def _is_explicit_branch_session(self, session_id: str) -> bool:
"""Return whether *session_id* is a copied user-facing branch.
Branches and compression continuations both use ``parent_session_id``,
but they have different history semantics: a branch owns a copied
transcript, while a compression continuation needs its ended parent's
archived rows for display. The durable ``_branched_from`` marker is the
existing discriminator written by all branch creation paths.
"""
if not session_id:
return False
with self._read_ctx() as conn:
row = conn.execute(
"SELECT model_config FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
return False
raw_config = row["model_config"] if hasattr(row, "keys") else row[0]
if not raw_config:
return False
try:
config = json.loads(raw_config) if isinstance(raw_config, str) else raw_config
except (json.JSONDecodeError, TypeError):
return False
return isinstance(config, dict) and bool(config.get("_branched_from"))
def get_conversation_root(self, session_id: str) -> str:
"""Return the ROOT id of *session_id*'s lineage chain.
+33
View File
@@ -302,6 +302,39 @@ class TestSessionLifecycle:
assert session["ended_at"] is None
def test_branch_resume_does_not_include_parent_messages_added_after_fork(self, db):
"""A branch owns its copied transcript, not the parent's later turns."""
db.create_session("parent", source="tui")
db.append_message("parent", role="user", content="before branch")
db.append_message("parent", role="assistant", content="initial answer")
db.create_session(
"branch",
source="tui",
parent_session_id="parent",
model_config={"_branched_from": "parent"},
)
db.append_message("branch", role="user", content="before branch")
db.append_message("branch", role="assistant", content="initial answer")
# The original conversation can be resumed after the fork. Those new
# rows must not leak into the already-created branch's transcript.
db.append_message("parent", role="user", content="after branch")
db.append_message("parent", role="assistant", content="later answer")
_, display_history = db.get_resume_conversations("branch")
assert [message["content"] for message in display_history] == [
"before branch",
"initial answer",
]
assert [
message["content"]
for message in db.get_messages_as_conversation("branch", include_ancestors=True)
] == ["before branch", "initial answer"]
assert db.get_ancestor_display_prefix("branch") == []