fix(history): isolate branch transcripts from parent updates
(cherry picked from commit 57c51cc401e867e0b315e051ba138d0b8f7a5f27)
This commit is contained in:
+41
-5
@@ -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.
|
||||
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user