From f3545da5721f10d42c0206e3625e00878dec84cd Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Thu, 24 Sep 2026 11:53:47 +0000 Subject: [PATCH] fix(lineage): keep tool children separate across readers --- ARCHITECTURE.md | 20 +++++++++ api/agent_sessions.py | 4 ++ tests/test_session_lineage_metadata_api.py | 52 ++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d03f6eb1..38530f25 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -278,6 +278,26 @@ visibility stage decides whether recovered background rows are shown. In are merged; cross-profile scoping, visibility, deduplication, and final route limits remain downstream responsibilities. +#### Compression lineage and session-list invalidation + +`api.agent_sessions._is_continuation_session()` is the shared classifier for +sidebar projection, lineage metadata/reporting, and `state.db` transcript +stitching. It uses the direct parent link, no conflicting non-empty source, +`compression` or `cli_close` parent end reason, and the existing two-second +`started_at` overlap allowance. A `source="tool"` child is always a separate +conversation, even when its parent is also a tool session or has no source. +A direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the +child's `model_config` also makes a boundary; inherited ancestor markers do +not. Malformed or unverifiable marker evidence fails closed as a boundary. +The overlap allowance is the existing master policy, not a new window set by +this change. + +The gateway watcher's cheap database fingerprint includes `model_config`, so +a marker-only update causes a fresh projection. Its published-payload hash +covers every emitted session field, not just ID, activity time and message +count; a changed projected title or lineage field can therefore emit +`sessions_changed` even without message-row churn. + ### 4.3 SSE Streaming Engine This is the most architecturally interesting part. Two endpoints cooperate: diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 6f067aa5..5b696222 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -462,6 +462,10 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: return False parent_source = str(parent.get('source') or '').strip().lower() child_source = str(child.get('source') or '').strip().lower() + # Agent lineage excludes tool children even when the parent has the same + # source (or no source); a timestamp overlap cannot override that boundary. + if child_source == 'tool': + return False if parent_source and child_source and parent_source != child_source: return False if parent.get('end_reason') not in {'compression', 'cli_close'}: diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index b9cc92c9..7ef10d54 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -981,6 +981,58 @@ def test_continuation_classification_timestamp_tolerance_and_guards(): assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number')) +@pytest.mark.parametrize("parent_source", ["tool", ""]) +def test_tool_child_is_not_stitched_into_parent_lineage(_isolate, parent_source): + """A marker-less tool child stays independent even with a matching/empty source.""" + from api.agent_sessions import ( + read_importable_agent_session_rows, + read_session_lineage_metadata, + read_session_lineage_report, + ) + + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + parent_id, child_id = "tool_boundary_parent", "tool_boundary_child" + try: + _insert_state_row( + conn, parent_id, source=parent_source, title="Parent conversation", + started_at=t0, ended_at=t0 + 5, end_reason="compression", + ) + _insert_state_row( + conn, child_id, source="tool", title="Tool conversation", + parent=parent_id, started_at=t0 + 4.5, + ) + _insert_state_message( + conn, parent_id, role="user", content="parent request", timestamp=t0 + 1, + ) + _insert_state_message( + conn, child_id, role="user", content="tool request", timestamp=t0 + 6, + ) + + projected = read_importable_agent_session_rows( + _isolate, limit=None, exclude_sources=None, + ) + rows = {row["id"]: row for row in projected} + assert set(rows) == {parent_id, child_id} + assert rows[child_id]["relationship_type"] == "child_session" + assert "_lineage_root_id" not in rows[child_id] + assert "_lineage_tip_id" not in rows[parent_id] + + metadata = read_session_lineage_metadata(_isolate, {parent_id, child_id}) + assert metadata[child_id]["relationship_type"] == "child_session" + assert "_lineage_root_id" not in metadata[child_id] + assert read_session_lineage_report(_isolate, parent_id)["total_segments"] == 1 + assert read_session_lineage_report(_isolate, child_id)["total_segments"] == 1 + + messages = models.get_state_db_session_messages( + child_id, stitch_continuations=True, + ) + assert [message["content"] for message in messages] == ["tool request"] + finally: + conn.close() + + 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