diff --git a/hermes_state.py b/hermes_state.py index 325bd485c0..d67dc076b6 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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. diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 2d72066978..933e395ece 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -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") == [] + +