From 52201ac9f7552bd06d67571ec0821f9e7615c0d4 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Fri, 14 Aug 2026 09:43:07 -0300 Subject: [PATCH 1/3] fix(session): recognize compression continuation across sub-second timestamp overlap _is_continuation_session() required float(child.started_at) >= float(parent.ended_at). A compression/cli_close handoff can persist the continuation row a few ms BEFORE the parent's ended_at lands (write-order race), so the strict comparison returned False despite every other piece of evidence (direct parent_session_id link, same source, end_reason=compression/cli_close, non-fork). The backend then labeled the continuation as relationship_type=child_session, causing duplicate same-title sidebar rows and spurious child-session entries (#6931). Allow a small tolerance (2s) for handoff-consistent timing; beyond it, an exact title match on the parent/child rows still confirms the same handoff. Fork, cross-source, and non-compression/cli_close guards are preserved, and materially overlapping children remain separate child sessions. Adds API-level regression tests (sub-second overlap forms one lineage; material overlap stays a child session; same-title evidence beyond tolerance) plus focused unit coverage of the classifier. Closes #6931 --- api/agent_sessions.py | 26 +++- tests/test_session_lineage_metadata_api.py | 159 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index c0a5078f..927f7063 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -53,6 +53,15 @@ MESSAGING_SOURCES = { CLI_MIN_UNTITLED_MESSAGE_COUNT = 6 CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2 +# Sub-second scheduling/write-order races during a compression/cli_close +# handoff can persist the continuation row with ``started_at`` a few +# milliseconds BEFORE the parent's ``ended_at`` lands (#6931). The tolerance +# below lets those rows still classify as the next segment; the fork / +# cross-source / end_reason guards in ``_is_continuation_session`` already +# rule out unrelated rows, so a small window cannot collapse genuinely +# concurrently started children. +CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0 + SOURCE_LABELS = { 'acp': 'ACP', 'api_server': 'API', @@ -312,7 +321,8 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: by ``hermes -c`` also records a new child session; for sidebar projection it should continue the same visible conversation rather than becoming a separate child-session row. Plain parent/child links that started before the - parent's ended boundary remain child sessions. + parent's ended boundary remain child sessions. A small scheduling/write-order + overlap between the handoff boundary timestamps is tolerated (#6931). Do not collapse lineage across raw sources. A WebUI session that continues from a Telegram/CLI/etc. parent must remain visible as its own surface-owned @@ -336,9 +346,21 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: # continuations when no boundary timestamp is available. return True try: - return float(child.get('started_at') or 0) >= float(ended_at) + child_started = float(child.get('started_at') or 0) + parent_ended = float(ended_at) except (TypeError, ValueError): return False + if child_started >= parent_ended - CONTINUATION_STARTED_AT_TOLERANCE_SECONDS: + return True + # Timestamps disagree beyond the tolerance. Independent evidence can still + # identify the same handoff: a compression/cli_close continuation carries + # the parent's conversation title verbatim (the #6931 rows had identical + # titles), while a genuinely new or forked session gets its own + # auto-numbered/user title. Requiring an exact title match keeps unrelated + # same-source children separate even when they overlap the parent. + parent_title = str(parent.get('title') or '').strip() + child_title = str(child.get('title') or '').strip() + return bool(parent_title and child_title and parent_title == child_title) def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None: diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index 22635e57..f79848ce 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -693,3 +693,162 @@ def test_sessions_route_preserves_visible_child_lineage_when_archived_parent_fil ] finally: conn.close() + + +def test_compression_continuation_tolerates_sub_second_timestamp_overlap(_isolate): + """#6931: a compression continuation recorded a few ms BEFORE the parent's + ended_at (write-order race) is still one lineage in /api/sessions.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_race_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_race_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_race_root", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + # child.started_at lands 0.1s BEFORE parent.ended_at — the #6931 race. + _insert_state_row( + conn, + "lineage_race_tip", + parent="lineage_race_root", + started_at=t0 + 5 - 0.1, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_race_tip"] + assert tip.get("parent_session_id") == "lineage_race_root" + assert tip.get("_lineage_root_id") == "lineage_race_root" + assert tip.get("_lineage_tip_id") == "lineage_race_tip" + assert tip.get("_compression_segment_count") == 2 + assert tip.get("relationship_type") != "child_session" + finally: + conn.close() + + +def test_materially_overlapping_compression_child_stays_child_session(_isolate): + """#6931: a child that started long before the compression parent ended + (beyond tolerance, no matching title) must remain a separate child.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_overlap_root", title="Root title", updated_at=t0) + _save_webui_session("lineage_overlap_child", title="Child title", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_overlap_root", + started_at=t0, + ended_at=t0 + 60, + end_reason="compression", + ) + # child started 30s before the parent ended — a genuine concurrent child. + _insert_state_row( + conn, + "lineage_overlap_child", + parent="lineage_overlap_root", + started_at=t0 + 30, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + child = rows["lineage_overlap_child"] + assert child.get("relationship_type") == "child_session" + assert child.get("parent_session_id") == "lineage_overlap_root" + assert "_lineage_root_id" not in child + assert "_compression_segment_count" not in child + finally: + conn.close() + + +def test_same_title_compression_child_recognized_beyond_timestamp_tolerance(_isolate): + """#6931: a direct same-source child with the parent's exact title is still + a continuation even when the boundary timestamps disagree beyond the + tolerance — timestamp is not the only gate.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_title_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_title_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_title_root", + title="Shared conversation", + started_at=t0, + ended_at=t0 + 60, + end_reason="compression", + ) + # child started 30s before the parent ended but carries the exact title. + _insert_state_row( + conn, + "lineage_title_tip", + title="Shared conversation", + parent="lineage_title_root", + started_at=t0 + 30, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_title_tip"] + assert tip.get("_lineage_root_id") == "lineage_title_root" + assert tip.get("_lineage_tip_id") == "lineage_title_tip" + assert tip.get("_compression_segment_count") == 2 + assert tip.get("relationship_type") != "child_session" + finally: + conn.close() + + +def test_continuation_classification_timestamp_tolerance_and_guards(): + """#6931 focused unit coverage of _is_continuation_session: tolerance, + title evidence fallback, and preserved fork/cross-source/end_reason guards.""" + from api.agent_sessions import _is_continuation_session + + def make_parent(**over): + row = { + 'source': 'webui', + 'end_reason': 'compression', + 'ended_at': 1000.0, + 'title': 'Shared conversation', + } + row.update(over) + return row + + def make_child(**over): + row = { + 'source': 'webui', + 'started_at': 1000.05, + 'title': 'Shared conversation', + } + row.update(over) + return row + + # Normal non-overlapping ordering: continuation. + assert _is_continuation_session(make_parent(), make_child()) + # Sub-second overlap (observed -0.06..-0.07s in #6931): continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=999.95)) + # Overlap inside the 2s tolerance: continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=998.5)) + # Overlap beyond tolerance but exact title evidence: continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=950.0)) + # Overlap beyond tolerance with a different title: separate child. + assert not _is_continuation_session( + make_parent(), make_child(started_at=950.0, title='Another conversation') + ) + # Fork guard holds regardless of timing. + assert not _is_continuation_session( + make_parent(), make_child(started_at=999.95, session_source='fork') + ) + # Cross-source guard holds regardless of timing. + assert not _is_continuation_session( + make_parent(), make_child(started_at=999.95, source='telegram') + ) + # Non-compression/cli_close parents never continue. + assert not _is_continuation_session( + make_parent(end_reason='user_stop'), make_child(started_at=999.95) + ) + # Missing/unparsable boundary timestamps degrade to False (no crash). + assert not _is_continuation_session(make_parent(ended_at='not-a-number'), make_child()) + assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number')) From 6f0028cfa1ca565a69d9c5945b1b7bde347e0a56 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Fri, 14 Aug 2026 17:26:05 -0300 Subject: [PATCH 2/3] fix(session): exclude model_config branches before tolerance; drop title fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-gate #7021: two silent sidebar regressions on the #6931 continuation tolerance. 1. MUST-FIX 1 — branch marker in model_config. The session_source=='fork' guard only covers WebUI-created forks (/api/session/branch). Explicit Agent branches/delegates carry no session_source: they are marked in the model_config JSON column (_branched_from / _delegate_from). The caller queries did not select model_config at all, so an Agent branch starting inside the 2s tolerance window of its parent's compression passed the widened check and vanished from the sidebar. Fix: select model_config in the classifier's caller queries (get_cli_sessions, lineage report, lineage metadata) and reject a child whose _branched_from / _delegate_from points at the parent being classified, BEFORE the tolerance is applied. The marker must reference this parent: compression continuations inherit the rotated agent's model_config verbatim (mirrors hermes-agent _NON_CONTINUATION_CHILD_FILTER_SQL). 2. MUST-FIX 2 — unbounded title fallback removed. Titles are user-controlled and non-unique, so parent_title == child_title beyond the tolerance window collapsed independent same-title branches arbitrarily far apart. The fallback is gone entirely; only the bounded 2s early-side tolerance (CONTINUATION_STARTED_AT_TOLERANCE_SECONDS) remains, after the reliable non-continuation guards. Tests: same-title independent child outside tolerance stays visible (was collapsed); _branched_from/_delegate_from children inside the tolerance stay visible (reviewer's adversarial probe); unit coverage for marker parsing (JSON string, dict, unparsable) and marker-points-at-other-session preservation. All 4 new tests are RED on the previous head and GREEN here. Closes #6931 --- api/agent_sessions.py | 77 ++++++-- ...t_issue5455_listing_readonly_connection.py | 71 ++++++++ tests/test_session_lineage_metadata_api.py | 165 +++++++++++++++--- 3 files changed, 279 insertions(+), 34 deletions(-) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 927f7063..76a47411 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -1,4 +1,5 @@ """Shared helpers for reading Hermes Agent sessions from state.db.""" +import json import logging import sqlite3 from contextlib import closing @@ -57,9 +58,9 @@ CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2 # handoff can persist the continuation row with ``started_at`` a few # milliseconds BEFORE the parent's ``ended_at`` lands (#6931). The tolerance # below lets those rows still classify as the next segment; the fork / -# cross-source / end_reason guards in ``_is_continuation_session`` already -# rule out unrelated rows, so a small window cannot collapse genuinely -# concurrently started children. +# model_config branch-marker / cross-source / end_reason guards in +# ``_is_continuation_session`` already rule out unrelated rows, so a small +# window cannot collapse genuinely concurrently started children. CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0 SOURCE_LABELS = { @@ -314,6 +315,30 @@ def is_cli_session_row_visible(row: dict) -> bool: return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT +def _branch_markers(row: dict | None) -> tuple[str | None, str | None]: + """Return ``(_branched_from, _delegate_from)`` from a row's ``model_config``. + + Hermes Agent stamps explicit branches and delegate/subagent runs in the + ``model_config`` JSON column (``_branched_from`` / ``_delegate_from``); + ``session_source='fork'`` alone only covers WebUI-created forks. Missing, + empty, or unparsable ``model_config`` degrades to ``(None, None)``. + """ + if not row: + return None, None + raw = row.get('model_config') + if isinstance(raw, dict): + config = raw + elif isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + parsed = None + config = parsed if isinstance(parsed, dict) else {} + else: + config = {} + return config.get('_branched_from'), config.get('_delegate_from') + + def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: """Return True when ``child`` is the next segment of the same conversation. @@ -328,11 +353,29 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: from a Telegram/CLI/etc. parent must remain visible as its own surface-owned conversation; otherwise the tip inherits the root's title/source metadata and can disappear under messaging/sidebar policies. + + Explicit branches/delegates never continue: a child whose ``model_config`` + ``_branched_from`` / ``_delegate_from`` points at this parent is a fork of + the conversation and must stay visible. Beyond the bounded tolerance window + there is no reliable continuation signal — titles are user-controlled and + non-unique, so they are never used to widen the window (#7021 re-gate). """ if not parent or not child: return False if str(child.get('session_source') or '').strip().lower() == 'fork': return False + # Real Agent branches/delegates are marked in model_config (not + # session_source, which only WebUI-created forks carry). The marker must + # reference THIS parent: compression continuations inherit the rotated + # agent's model_config verbatim, so presence alone (a delegate's + # continuation still carries the delegate's own ``_delegate_from``) would + # misclassify real continuations. + child_branched_from, child_delegate_from = _branch_markers(child) + parent_id = parent.get('id') + if parent_id and ( + child_branched_from == parent_id or child_delegate_from == parent_id + ): + return False parent_source = str(parent.get('source') or '').strip().lower() child_source = str(child.get('source') or '').strip().lower() if parent_source and child_source and parent_source != child_source: @@ -352,15 +395,10 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: return False if child_started >= parent_ended - CONTINUATION_STARTED_AT_TOLERANCE_SECONDS: return True - # Timestamps disagree beyond the tolerance. Independent evidence can still - # identify the same handoff: a compression/cli_close continuation carries - # the parent's conversation title verbatim (the #6931 rows had identical - # titles), while a genuinely new or forked session gets its own - # auto-numbered/user title. Requiring an exact title match keeps unrelated - # same-source children separate even when they overlap the parent. - parent_title = str(parent.get('title') or '').strip() - child_title = str(child.get('title') or '').strip() - return bool(parent_title and child_title and parent_title == child_title) + # Beyond the bounded early-side window the child is a genuine concurrent + # session — an exact title match is not evidence (titles are user-controlled + # and non-unique, so it cannot extend the tolerance). + return False def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None: @@ -594,6 +632,7 @@ def read_importable_agent_session_rows( parent_expr = _optional_col('parent_session_id', session_cols) session_source_expr = _optional_col('session_source', session_cols) + model_config_expr = _optional_col('model_config', session_cols) ended_expr = _optional_col('ended_at', session_cols) end_reason_expr = _optional_col('end_reason', session_cols) user_id_expr = _optional_col('user_id', session_cols) @@ -715,6 +754,7 @@ def read_importable_agent_session_rows( SELECT s.id, s.title, s.model, s.message_count, s.started_at, s.source, {session_source_expr}, + {model_config_expr}, {user_id_expr}, {chat_id_expr}, {chat_type_expr}, @@ -797,6 +837,11 @@ def read_importable_agent_session_rows( params, ) projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()]) + # model_config is selected only so the continuation classifier can + # read the real _branched_from/_delegate_from markers; it is internal + # agent state and must not leak into the /api/sessions response. + for row in projected: + row.pop('model_config', None) projected = [_with_normalized_source(row) for row in projected] projected = [row for row in projected if is_cli_session_row_visible(row)] if limit is None: @@ -899,6 +944,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: source_expr = _optional_col('source', session_cols) session_source_expr = _optional_col('session_source', session_cols) + model_config_expr = _optional_col('model_config', session_cols) title_expr = _optional_col('title', session_cols) started_expr = _optional_col('started_at', session_cols, '0') ended_expr = _optional_col('ended_at', session_cols) @@ -913,6 +959,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: SELECT s.id, {source_expr}, {session_source_expr}, + {model_config_expr}, {title_expr}, {started_expr}, {parent_expr}, @@ -959,6 +1006,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: SELECT s.id, {source_expr}, {session_source_expr}, + {model_config_expr}, {title_expr}, {started_expr}, {parent_expr}, @@ -1036,6 +1084,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st return {} session_source_expr = _optional_col('session_source', session_cols) source_expr = _optional_col('source', session_cols) + model_config_expr = _optional_col('model_config', session_cols) message_count_expr = _optional_col('message_count', session_cols, '0') # Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a # full table scan. The sessions table grows unbounded over time @@ -1072,7 +1121,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st placeholders = ','.join('?' * len(chunk)) cur.execute( f""" - SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} + SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} FROM sessions s WHERE s.id IN ({placeholders}) """, @@ -1101,7 +1150,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st placeholders = ','.join('?' * len(chunk)) cur.execute( f""" - SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} + SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} FROM sessions s WHERE s.parent_session_id IN ({placeholders}) """, diff --git a/tests/test_issue5455_listing_readonly_connection.py b/tests/test_issue5455_listing_readonly_connection.py index fc2cb263..9502fae1 100644 --- a/tests/test_issue5455_listing_readonly_connection.py +++ b/tests/test_issue5455_listing_readonly_connection.py @@ -74,6 +74,77 @@ def test_listing_opens_read_only_and_returns_rows(tmp_path, monkeypatch): assert "mode=ro" in calls[0]["target"] +def test_listing_keeps_model_config_branch_visible_and_never_leaks_it(tmp_path, monkeypatch): + """#7021 re-gate: a CLI/agent branch whose _branched_from marker lives in + model_config must stay visible even when it starts inside the 2s + compression tolerance window, and model_config must not leak into the + returned rows.""" + db = tmp_path / "state.db" + conn = sqlite3.connect(str(db)) + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER, + started_at REAL, source TEXT, session_source TEXT, + parent_session_id TEXT, ended_at REAL, end_reason TEXT, + model_config TEXT + ) + """ + ) + conn.execute( + """ + INSERT INTO sessions + (id, title, model, message_count, started_at, source, session_source, + parent_session_id, ended_at, end_reason, model_config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "cli-parent", "Parent conversation", "gpt", 0, 1000.0, "cli", "cli", + None, 1100.0, "compression", None, + ), + ) + # An explicit Agent branch starting 1.5s BEFORE the parent's compression + # ended_at. No session_source — the fork identity lives in model_config. + conn.execute( + """ + INSERT INTO sessions + (id, title, model, message_count, started_at, source, session_source, + parent_session_id, ended_at, end_reason, model_config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "cli-branch", "Branch conversation", "gpt", 0, 1098.5, "cli", "cli", + "cli-parent", None, None, '{"_branched_from": "cli-parent"}', + ), + ) + conn.execute( + "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, timestamp REAL)" + ) + conn.executemany( + "INSERT INTO messages (session_id, role, timestamp) VALUES (?,?,?)", + [ + ("cli-parent", "user", 1001.0), + ("cli-parent", "assistant", 1002.0), + ("cli-branch", "user", 1099.0), + ("cli-branch", "assistant", 1099.5), + ], + ) + conn.commit() + conn.close() + _record_connects(monkeypatch) + + out = read_importable_agent_session_rows(db, exclude_sources=None) + out_ids = {r["id"] for r in out} + + # The branch survives the tolerance window: it is NOT collapsed into the + # parent lineage. + assert "cli-branch" in out_ids + assert "cli-parent" in out_ids + # model_config is internal agent state — never exposed on returned rows. + for row in out: + assert "model_config" not in row + + def test_listing_read_only_uri_encodes_special_path_chars(tmp_path, monkeypatch): db_dir = tmp_path / "state dir #1" db_dir.mkdir() diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index f79848ce..4d3c9c8c 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -1,5 +1,6 @@ """Regression tests for /api/sessions lineage metadata used by sidebar collapse.""" +import json import sqlite3 import time @@ -51,7 +52,8 @@ def _ensure_state_db(path): message_count INTEGER DEFAULT 0, parent_session_id TEXT, ended_at REAL, - end_reason TEXT + end_reason TEXT, + model_config TEXT ); """ ) @@ -79,14 +81,14 @@ def _insert_state_message(conn, sid, *, role, content, timestamp): conn.commit() -def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None): +def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None, model_config=None): conn.execute( """ INSERT INTO sessions - (id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) - VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?) + (id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason, model_config) + VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?, ?) """, - (sid, source, session_source, title or sid, started_at or time.time(), parent, ended_at, end_reason), + (sid, source, session_source, title or sid, started_at or time.time(), parent, ended_at, end_reason, model_config), ) conn.commit() @@ -764,10 +766,11 @@ def test_materially_overlapping_compression_child_stays_child_session(_isolate): conn.close() -def test_same_title_compression_child_recognized_beyond_timestamp_tolerance(_isolate): - """#6931: a direct same-source child with the parent's exact title is still - a continuation even when the boundary timestamps disagree beyond the - tolerance — timestamp is not the only gate.""" +def test_same_title_independent_child_outside_tolerance_stays_visible(_isolate): + """#7021 re-gate: a same-title independent child started well outside the + tolerance window must NOT collapse on the title match — titles are + user-controlled and non-unique, so the only continuation evidence is the + bounded early-side timestamp window.""" conn = _ensure_state_db(_isolate) t0 = time.time() - 100 try: @@ -781,33 +784,110 @@ def test_same_title_compression_child_recognized_beyond_timestamp_tolerance(_iso ended_at=t0 + 60, end_reason="compression", ) - # child started 30s before the parent ended but carries the exact title. + # Same title, but started 50s before the parent ended — far outside any + # handoff race window. Must remain a visible child session. _insert_state_row( conn, "lineage_title_tip", title="Shared conversation", parent="lineage_title_root", - started_at=t0 + 30, + started_at=t0 + 10, ) rows = {row["session_id"]: row for row in all_sessions()} tip = rows["lineage_title_tip"] - assert tip.get("_lineage_root_id") == "lineage_title_root" - assert tip.get("_lineage_tip_id") == "lineage_title_tip" - assert tip.get("_compression_segment_count") == 2 - assert tip.get("relationship_type") != "child_session" + assert tip.get("relationship_type") == "child_session" + assert tip.get("parent_session_id") == "lineage_title_root" + assert "_lineage_root_id" not in tip + assert "_compression_segment_count" not in tip + finally: + conn.close() + + +def test_model_config_branched_from_child_stays_visible_within_tolerance(_isolate): + """#7021 re-gate: an explicit Agent branch is marked in + model_config._branched_from (not session_source). Even when it starts + inside the 2s tolerance window of the parent's compression, the real + marker must keep it visible instead of collapsing it into the lineage.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_branch_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_branch_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_branch_root", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + # The reviewer's adversarial probe: an Agent branch starting 1.5s + # BEFORE the parent's compression ended_at. No session_source — the + # fork identity lives in model_config._branched_from. + _insert_state_row( + conn, + "lineage_branch_tip", + parent="lineage_branch_root", + started_at=t0 + 5 - 1.5, + model_config=json.dumps({"_branched_from": "lineage_branch_root"}), + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_branch_tip"] + assert tip.get("relationship_type") == "child_session" + assert tip.get("parent_session_id") == "lineage_branch_root" + assert "_lineage_root_id" not in tip + assert "_compression_segment_count" not in tip + finally: + conn.close() + + +def test_model_config_delegate_from_child_stays_visible_within_tolerance(_isolate): + """#7021 re-gate: delegate/subagent runs are marked in + model_config._delegate_from. A delegate child starting inside the + tolerance window of the parent's compression must stay visible.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_delegate_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_delegate_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_delegate_root", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + _insert_state_row( + conn, + "lineage_delegate_tip", + parent="lineage_delegate_root", + started_at=t0 + 5 - 1.0, + model_config=json.dumps({"_delegate_from": "lineage_delegate_root"}), + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_delegate_tip"] + assert tip.get("relationship_type") == "child_session" + assert tip.get("parent_session_id") == "lineage_delegate_root" + assert "_lineage_root_id" not in tip + assert "_compression_segment_count" not in tip finally: conn.close() def test_continuation_classification_timestamp_tolerance_and_guards(): - """#6931 focused unit coverage of _is_continuation_session: tolerance, - title evidence fallback, and preserved fork/cross-source/end_reason guards.""" + """#6931/#7021 focused unit coverage of _is_continuation_session: bounded + tolerance, model_config branch markers, and preserved fork/cross-source/ + end_reason guards.""" from api.agent_sessions import _is_continuation_session def make_parent(**over): row = { + 'id': 'parent-1', 'source': 'webui', 'end_reason': 'compression', 'ended_at': 1000.0, @@ -818,6 +898,7 @@ def test_continuation_classification_timestamp_tolerance_and_guards(): def make_child(**over): row = { + 'id': 'child-1', 'source': 'webui', 'started_at': 1000.05, 'title': 'Shared conversation', @@ -831,12 +912,56 @@ def test_continuation_classification_timestamp_tolerance_and_guards(): assert _is_continuation_session(make_parent(), make_child(started_at=999.95)) # Overlap inside the 2s tolerance: continuation. assert _is_continuation_session(make_parent(), make_child(started_at=998.5)) - # Overlap beyond tolerance but exact title evidence: continuation. - assert _is_continuation_session(make_parent(), make_child(started_at=950.0)) - # Overlap beyond tolerance with a different title: separate child. + # Overlap beyond tolerance: separate child, even with an exact title match + # (titles are user-controlled and non-unique — no title fallback). + assert not _is_continuation_session(make_parent(), make_child(started_at=950.0)) assert not _is_continuation_session( make_parent(), make_child(started_at=950.0, title='Another conversation') ) + # model_config._branched_from pointing at the parent: never a + # continuation, regardless of timing. + assert not _is_continuation_session( + make_parent(), + make_child( + started_at=999.95, + model_config=json.dumps({'_branched_from': 'parent-1'}), + ), + ) + assert not _is_continuation_session( + make_parent(), + make_child( + started_at=998.5, + model_config={'_branched_from': 'parent-1'}, + ), + ) + # model_config._delegate_from pointing at the parent: never a + # continuation, regardless of timing. + assert not _is_continuation_session( + make_parent(), + make_child( + started_at=999.95, + model_config=json.dumps({'_delegate_from': 'parent-1'}), + ), + ) + # A marker pointing at a DIFFERENT session does not disqualify: compression + # continuations inherit the rotated agent's model_config verbatim, so a + # delegate's continuation still carries the delegate's own marker. + assert _is_continuation_session( + make_parent(), + make_child( + started_at=999.95, + model_config=json.dumps({'_delegate_from': 'some-other-session'}), + ), + ) + # Unparsable model_config degrades to no markers (no crash, no match). + assert _is_continuation_session( + make_parent(), + make_child(started_at=999.95, model_config='not-json'), + ) + assert not _is_continuation_session( + make_parent(), + make_child(started_at=950.0, model_config='not-json'), + ) # Fork guard holds regardless of timing. assert not _is_continuation_session( make_parent(), make_child(started_at=999.95, session_source='fork') From 2e12075400dbff9d84ea515f1637d2ecd900ccae Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 17 Aug 2026 10:47:29 -0300 Subject: [PATCH 3/3] fix(session): apply branch-marker guard to transcript-stitcher lineage query (#7021 r2) The open/import transcript stitcher (get_state_db_session_messages with stitch_continuations=True) runs its own lineage SELECT that omitted session_source/model_config, so the shared _is_continuation_session guard added for the listing path never fired there: an explicit branch/delegate child (model_config._branched_from/_delegate_from) starting within the 2s tolerance window got its rows stitched into the parent transcript on open. Select both identity columns when the schema has them (NULL AS fallback for older state.db schemas), so the same guard applies in the stitch path. Tests (tests/test_session_lineage_metadata_api.py): - branched/delegate children inside the tolerance stay out of the parent transcript on open/import (fail before this fix, pass after); - old-schema rows without the identity columns still stitch genuine compression continuations (no crash, no behavior change). --- api/models.py | 25 ++- tests/test_session_lineage_metadata_api.py | 171 +++++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/api/models.py b/api/models.py index 2ad051b2..957eb519 100644 --- a/api/models.py +++ b/api/models.py @@ -8339,9 +8339,26 @@ def get_state_db_session_messages( cur.execute("PRAGMA table_info(sessions)") session_cols = {str(row['name']) for row in cur.fetchall()} if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols): + # session_source/model_config carry the fork/delegate boundary + # identity consumed by _is_continuation_session (#6931/#7021). + # Select them when present so the branch-marker guard applies + # to the stitch path too; older schemas degrade to NULL. + identity_cols = [] + if 'session_source' in session_cols: + identity_cols.append('session_source') + else: + identity_cols.append('NULL AS session_source') + if 'model_config' in session_cols: + identity_cols.append('model_config') + else: + identity_cols.append('NULL AS model_config') + lineage_select = ( + "id, source, started_at, parent_session_id, ended_at, end_reason" + + ", " + ", ".join(identity_cols) + ) cur.execute( - """ - SELECT id, source, started_at, parent_session_id, ended_at, end_reason + f""" + SELECT {lineage_select} FROM sessions WHERE id = ? """, @@ -8359,8 +8376,8 @@ def get_state_db_session_messages( if not parent_id or parent_id in seen: break cur.execute( - """ - SELECT id, source, started_at, parent_session_id, ended_at, end_reason + f""" + SELECT {lineage_select} FROM sessions WHERE id = ? """, diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index 4d3c9c8c..6e35bc1b 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -64,6 +64,7 @@ def _ensure_messages_table(conn): conn.execute( """ CREATE TABLE messages ( + id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, @@ -977,3 +978,173 @@ def test_continuation_classification_timestamp_tolerance_and_guards(): # Missing/unparsable boundary timestamps degrade to False (no crash). assert not _is_continuation_session(make_parent(ended_at='not-a-number'), make_child()) assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number')) + + +def test_state_db_stitch_keeps_branched_child_out_of_parent_transcript(_isolate): + """#7021 r2: the open/import transcript stitcher (get_state_db_session_messages + with stitch_continuations=True) must apply the same model_config branch-marker + guard as the listing classifier. A child whose model_config._branched_from / + _delegate_from points at the parent must NOT have its messages stitched into + the parent transcript even when it starts inside the tolerance window. + """ + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + # Compression parent whose child starts INSIDE the 2s tolerance window. + _insert_state_row( + conn, + "stitch_branch_parent", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + _insert_state_message( + conn, "stitch_branch_parent", role="user", content="parent turn", timestamp=t0 + 1 + ) + # Explicit Agent branch: fork identity lives in model_config, not + # session_source. Starting 1.5s before the parent's ended_at — inside + # the tolerance — it must still stay out of the parent's transcript. + _insert_state_row( + conn, + "stitch_branch_child", + parent="stitch_branch_parent", + started_at=t0 + 5 - 1.5, + model_config=json.dumps({"_branched_from": "stitch_branch_parent"}), + ) + _insert_state_message( + conn, "stitch_branch_child", role="user", content="branch turn", timestamp=t0 + 6 + ) + + msgs = models.get_state_db_session_messages( + "stitch_branch_child", stitch_continuations=True + ) + contents = [m["content"] for m in msgs] + assert "branch turn" in contents + assert "parent turn" not in contents + + # Control: a genuine compression continuation (no branch marker) starting + # inside the same window IS stitched into the parent transcript. + _insert_state_row( + conn, + "stitch_continuation_child", + parent="stitch_branch_parent", + started_at=t0 + 5 - 0.5, + ) + _insert_state_message( + conn, + "stitch_continuation_child", + role="user", + content="continuation turn", + timestamp=t0 + 7, + ) + msgs = models.get_state_db_session_messages( + "stitch_continuation_child", stitch_continuations=True + ) + contents = [m["content"] for m in msgs] + assert "continuation turn" in contents + assert "parent turn" in contents + finally: + conn.close() + + +def test_state_db_stitch_keeps_delegate_child_out_of_parent_transcript(_isolate): + """#7021 r2: same guard for model_config._delegate_from (subagent runs) in + the open/import transcript stitcher.""" + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + _insert_state_row( + conn, + "stitch_delegate_parent", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + _insert_state_message( + conn, "stitch_delegate_parent", role="user", content="parent turn", timestamp=t0 + 1 + ) + _insert_state_row( + conn, + "stitch_delegate_child", + parent="stitch_delegate_parent", + started_at=t0 + 5 - 1.0, + model_config=json.dumps({"_delegate_from": "stitch_delegate_parent"}), + ) + _insert_state_message( + conn, "stitch_delegate_child", role="user", content="delegate turn", timestamp=t0 + 6 + ) + + msgs = models.get_state_db_session_messages( + "stitch_delegate_child", stitch_continuations=True + ) + contents = [m["content"] for m in msgs] + assert "delegate turn" in contents + assert "parent turn" not in contents + finally: + conn.close() + + +def test_state_db_stitch_old_schema_without_identity_columns_still_stitches(_isolate): + """#7021 r2: older state.db schemas lacking session_source/model_config degrade + to NULL and keep the pre-fix behavior — a genuine compression continuation + within the tolerance is still stitched, without crashing.""" + conn = sqlite3.connect(str(_isolate)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + title TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + """ + ) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + conn.execute( + """ + INSERT INTO sessions + (id, source, title, started_at, message_count, parent_session_id, ended_at, end_reason) + VALUES (?, ?, ?, ?, 2, ?, ?, ?) + """, + ( + "stitch_old_parent", + "webui", + "stitch_old_parent", + t0, + None, + t0 + 5, + "compression", + ), + ) + conn.execute( + """ + INSERT INTO sessions + (id, source, title, started_at, message_count, parent_session_id, ended_at, end_reason) + VALUES (?, ?, ?, ?, 2, ?, NULL, NULL) + """, + ("stitch_old_child", "webui", "stitch_old_child", t0 + 4.5, "stitch_old_parent"), + ) + conn.commit() + _insert_state_message( + conn, "stitch_old_parent", role="user", content="parent turn", timestamp=t0 + 1 + ) + _insert_state_message( + conn, "stitch_old_child", role="user", content="old child turn", timestamp=t0 + 6 + ) + + msgs = models.get_state_db_session_messages( + "stitch_old_child", stitch_continuations=True + ) + contents = [m["content"] for m in msgs] + assert "old child turn" in contents + assert "parent turn" in contents + finally: + conn.close()