Files
hermes-webui/tests/test_session_lineage_metadata_api.py
T
webtecnica 2e12075400 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).
2026-08-30 21:37:05 -03:00

1151 lines
41 KiB
Python

"""Regression tests for /api/sessions lineage metadata used by sidebar collapse."""
import json
import sqlite3
import time
import pytest
import api.models as models
import api.routes as routes
from api.models import SESSIONS, STREAMS, Session, all_sessions
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
state_db = tmp_path / "state.db"
index_file.write_text("[]", encoding="utf-8")
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
monkeypatch.setattr(models, "_active_state_db_path", lambda: state_db)
monkeypatch.setattr(models, "_start_session_index_rebuild_thread", lambda: None)
def uncached_persisted_session_ids():
return frozenset(
p.stem
for p in models.SESSION_DIR.glob("*.json")
if not p.name.startswith("_")
)
monkeypatch.setattr(models, "_persisted_session_ids_snapshot", uncached_persisted_session_ids)
SESSIONS.clear()
STREAMS.clear()
yield state_db
SESSIONS.clear()
STREAMS.clear()
def _ensure_state_db(path):
conn = sqlite3.connect(str(path))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT,
model_config TEXT
);
"""
)
return conn
def _ensure_messages_table(conn):
conn.execute(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
timestamp REAL
)
"""
)
def _insert_state_message(conn, sid, *, role, content, timestamp):
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
(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, 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, model_config)
VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?, ?)
""",
(sid, source, session_source, title or sid, started_at or time.time(), parent, ended_at, end_reason, model_config),
)
conn.commit()
def _save_webui_session(sid, *, title, updated_at):
session = Session(
session_id=sid,
title=title,
messages=[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}],
updated_at=updated_at,
)
session.save(touch_updated_at=False)
return session
def test_all_sessions_exposes_state_db_lineage_metadata_for_webui_json_sessions(_isolate):
"""PR #1358 can only collapse rows when /api/sessions exposes lineage keys."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_root", title="Hermes WebUI", updated_at=t0)
_save_webui_session("lineage_api_tip", title="Hermes WebUI #2", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_tip",
parent="lineage_api_root",
started_at=t0 + 6,
)
rows = {row["session_id"]: row for row in all_sessions()}
assert rows["lineage_api_tip"].get("parent_session_id") == "lineage_api_root"
assert rows["lineage_api_tip"].get("_lineage_root_id") == "lineage_api_root"
assert rows["lineage_api_tip"].get("_compression_segment_count") == 2
assert "_lineage_root_id" not in rows["lineage_api_root"]
finally:
conn.close()
def test_all_sessions_keeps_explicit_forks_out_of_state_db_lineage_metadata(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_root", title="Visible root", updated_at=t0)
_save_webui_session("lineage_api_fork", title="Explicit fork", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_fork",
parent="lineage_api_root",
started_at=t0 + 6,
session_source="fork",
)
rows = {row["session_id"]: row for row in all_sessions()}
fork = rows["lineage_api_fork"]
assert fork.get("parent_session_id") == "lineage_api_root"
assert fork.get("relationship_type") == "child_session"
assert fork.get("parent_title") == "lineage_api_root"
assert fork.get("_parent_lineage_root_id") == "lineage_api_root"
assert "_lineage_root_id" not in fork
assert "_compression_segment_count" not in fork
finally:
conn.close()
def test_non_compression_state_db_parent_does_not_create_sidebar_lineage(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_plain_parent", title="Parent", updated_at=t0)
_save_webui_session("lineage_api_plain_child", title="Child", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_plain_parent",
started_at=t0,
ended_at=t0 + 5,
end_reason="user_stop",
)
_insert_state_row(
conn,
"lineage_api_plain_child",
parent="lineage_api_plain_parent",
started_at=t0 + 6,
)
rows = {row["session_id"]: row for row in all_sessions()}
# Non-continuation parents should remain visible child-session links,
# not compression lineage. The frontend must nest them under the parent
# without collapsing sibling child sessions into one lineage row.
child = rows["lineage_api_plain_child"]
assert child.get("parent_session_id") == "lineage_api_plain_parent"
assert child.get("relationship_type") == "child_session"
assert child.get("parent_title") == "lineage_api_plain_parent"
assert child.get("_parent_lineage_root_id") == "lineage_api_plain_parent"
assert "_lineage_root_id" not in child
finally:
conn.close()
def test_child_of_hidden_compression_segment_exposes_parent_lineage_root(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_root", title="Visible root", updated_at=t0)
_save_webui_session("lineage_api_tip", title="Visible tip", updated_at=t0 + 10)
_save_webui_session("lineage_api_subtask", title="Subtask", updated_at=t0 + 20)
_insert_state_row(
conn,
"lineage_api_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_tip",
parent="lineage_api_root",
started_at=t0 + 6,
ended_at=t0 + 15,
end_reason="user_stop",
)
_insert_state_row(
conn,
"lineage_api_subtask",
parent="lineage_api_tip",
started_at=t0 + 12,
)
rows = {row["session_id"]: row for row in all_sessions()}
child = rows["lineage_api_subtask"]
assert child.get("relationship_type") == "child_session"
assert child.get("parent_session_id") == "lineage_api_tip"
assert child.get("_parent_lineage_root_id") == "lineage_api_root"
assert child.get("_parent_lineage_tip_id") == "lineage_api_tip"
serialized = routes._sidebar_session_response_item(child, redact_enabled=False)
assert serialized.get("_parent_lineage_tip_id") == "lineage_api_tip"
assert "_lineage_root_id" not in child
finally:
conn.close()
def test_cli_close_parent_preserves_cross_surface_continuation_lineage(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_cli_parent", title="Hermes WebUI #8", updated_at=t0)
_save_webui_session("lineage_api_webui_child", title="Hermes WebUI #8", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_cli_parent",
started_at=t0,
ended_at=t0 + 5,
end_reason="cli_close",
)
_insert_state_row(
conn,
"lineage_api_webui_child",
parent="lineage_api_cli_parent",
started_at=t0 + 6,
)
rows = {row["session_id"]: row for row in all_sessions()}
assert rows["lineage_api_webui_child"].get("parent_session_id") == "lineage_api_cli_parent"
assert rows["lineage_api_webui_child"].get("_lineage_root_id") == "lineage_api_cli_parent"
finally:
conn.close()
def test_cross_surface_child_session_metadata_marks_orphan_top_level_candidate(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_telegram_parent", title="Telegram parent", updated_at=t0)
_save_webui_session("lineage_api_webui_tip", title="WebUI tip", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_telegram_parent",
source="telegram",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_webui_tip",
source="webui",
parent="lineage_api_telegram_parent",
started_at=t0 + 6,
)
rows = {row["session_id"]: row for row in all_sessions()}
tip = rows["lineage_api_webui_tip"]
assert tip.get("relationship_type") == "child_session"
assert tip.get("parent_source") == "telegram"
assert tip.get("_cross_surface_child_session") is True
finally:
conn.close()
def test_state_db_webui_source_overrides_stale_cli_json_metadata(_isolate):
"""State-db WebUI mirrors should clear stale CLI source fields in sidebar rows."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
session = Session(
session_id="lineage_api_stale_cli_source",
title="WebUI Chatnachrichten verschwinden nach Neustart #9",
messages=[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}],
updated_at=t0,
is_cli_session=True,
source_tag="cli",
raw_source="cli",
session_source="cli",
source_label="CLI",
)
session.save(touch_updated_at=False)
_insert_state_row(
conn,
"lineage_api_stale_cli_source",
source="webui",
started_at=t0,
)
row = {row["session_id"]: row for row in all_sessions()}["lineage_api_stale_cli_source"]
assert row["source_tag"] == "webui"
assert row["raw_source"] == "webui"
assert row["session_source"] == "webui"
assert row["source_label"] == "WebUI"
assert row["is_cli_session"] is False
finally:
conn.close()
def test_sessions_route_keeps_state_db_webui_row_with_stale_cli_json_when_cli_hidden(_isolate, monkeypatch):
"""The hot route must apply state.db source correction before CLI filtering."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
session = Session(
session_id="lineage_api_route_stale_cli_source",
title="WebUI Chatnachrichten verschwinden nach Neustart #9",
messages=[{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}],
updated_at=t0,
is_cli_session=True,
source_tag="cli",
raw_source="cli",
session_source="cli",
source_label="CLI",
)
session.save(touch_updated_at=False)
_insert_state_row(
conn,
"lineage_api_route_stale_cli_source",
source="webui",
started_at=t0,
)
monkeypatch.setattr(routes, "all_sessions", models.all_sessions)
monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", models._enrich_sidebar_lineage_metadata)
monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _sessions: False)
payload = routes._build_session_list_cache_payload(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
)
rows = {row["session_id"]: row for row in payload["sessions"]}
row = rows["lineage_api_route_stale_cli_source"]
assert row["source_tag"] == "webui"
assert row["raw_source"] == "webui"
assert row["session_source"] == "webui"
assert row["source_label"] == "WebUI"
assert row["is_cli_session"] is False
finally:
conn.close()
def test_generic_webui_title_gets_read_only_state_db_display_title(_isolate):
"""Sidebar rows can display the fresher state.db title without mutating JSON."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_stale_title", title="Hermes WebUI #8", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_stale_title",
title="Hermes WebUI #177",
started_at=t0,
)
row = {row["session_id"]: row for row in all_sessions()}["lineage_api_stale_title"]
assert row["title"] == "Hermes WebUI #8"
assert row["display_title"] == "Hermes WebUI #177"
assert row["_state_db_title"] == "Hermes WebUI #177"
finally:
conn.close()
def test_generic_subagent_title_gets_goal_display_title(_isolate):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_goal", title="Subagent Session", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_goal",
title="Subagent Session",
source="subagent",
started_at=t0,
)
_insert_state_message(
conn,
"lineage_api_subagent_goal",
role="user",
content="Find the root cause of the failing sidebar test",
timestamp=t0 + 1,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_goal"]
assert row["title"] == "Subagent Session"
assert row["display_title"] == "Find the root cause of the failing sidebar test"
finally:
conn.close()
def test_custom_subagent_title_stays_authoritative(_isolate):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_custom", title="Investigate auth", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_custom",
title="Investigate auth",
source="subagent",
started_at=t0,
)
_insert_state_message(
conn,
"lineage_api_subagent_custom",
role="user",
content="A different goal",
timestamp=t0 + 1,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_custom"]
assert row["title"] == "Investigate auth"
assert "display_title" not in row
finally:
conn.close()
def test_generic_subagent_title_falls_back_without_first_user_message(_isolate):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_empty", title="Subagent Session", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_empty",
title="Subagent Session",
source="subagent",
started_at=t0,
)
_insert_state_message(
conn,
"lineage_api_subagent_empty",
role="assistant",
content="Only assistant output",
timestamp=t0 + 1,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_empty"]
assert row["title"] == "Subagent Session"
assert "display_title" not in row
finally:
conn.close()
def test_generic_subagent_title_skips_null_first_user_message(_isolate):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_null_first", title="Subagent Session", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_null_first",
title="Subagent Session",
source="subagent",
started_at=t0,
)
_insert_state_message(
conn,
"lineage_api_subagent_null_first",
role="user",
content=None,
timestamp=t0 + 1,
)
_insert_state_message(
conn,
"lineage_api_subagent_null_first",
role="user",
content="Recover the next usable delegated title",
timestamp=t0 + 2,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_null_first"]
assert row["title"] == "Subagent Session"
assert row["display_title"] == "Recover the next usable delegated title"
finally:
conn.close()
def test_generic_subagent_title_respects_sidebar_override_cap(_isolate, monkeypatch):
conn = _ensure_state_db(_isolate)
_ensure_messages_table(conn)
older = time.time() - 200
newer = time.time() - 100
try:
monkeypatch.setenv("HERMES_WEBUI_STATE_DB_OVERRIDE_TOP_N", "1")
_save_webui_session("lineage_api_subagent_old", title="Subagent Session", updated_at=older)
_save_webui_session("lineage_api_subagent_new", title="Subagent Session", updated_at=newer)
_insert_state_row(
conn,
"lineage_api_subagent_old",
title="Subagent Session",
source="subagent",
started_at=older,
)
_insert_state_row(
conn,
"lineage_api_subagent_new",
title="Subagent Session",
source="subagent",
started_at=newer,
)
_insert_state_message(
conn,
"lineage_api_subagent_old",
role="user",
content="Older delegated title",
timestamp=older + 1,
)
_insert_state_message(
conn,
"lineage_api_subagent_new",
role="user",
content="Newest delegated title",
timestamp=newer + 1,
)
rows = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}
assert rows["lineage_api_subagent_new"]["display_title"] == "Newest delegated title"
assert "display_title" not in rows["lineage_api_subagent_old"]
finally:
conn.close()
def test_generic_subagent_title_falls_back_without_messages_table(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_subagent_no_messages", title="Subagent Session", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_subagent_no_messages",
title="Subagent Session",
source="subagent",
started_at=t0,
)
row = {row["session_id"]: row for row in all_sessions(include_lineage_metadata=False)}["lineage_api_subagent_no_messages"]
assert row["title"] == "Subagent Session"
assert "display_title" not in row
finally:
conn.close()
def test_state_db_display_title_does_not_override_custom_json_title(_isolate):
"""Manual/custom JSON titles stay authoritative even when state.db differs."""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_custom_title", title="Customer escalation notes", updated_at=t0)
_insert_state_row(
conn,
"lineage_api_custom_title",
title="Hermes WebUI #177",
started_at=t0,
)
row = {row["session_id"]: row for row in all_sessions()}["lineage_api_custom_title"]
assert row["title"] == "Customer escalation notes"
assert "display_title" not in row
assert "_state_db_title" not in row
finally:
conn.close()
def test_sessions_route_preserves_visible_child_lineage_when_archived_parent_filtered(_isolate, monkeypatch):
"""Default /api/sessions omits archived rows but keeps their lineage metadata.
The route builds the hot sidebar payload with archived rows filtered out by
default. A visible continuation child still needs lineage metadata from its
archived parent so the client can collapse/display the logical conversation
correctly.
"""
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
archived_parent = _save_webui_session(
"lineage_api_archived_parent",
title="Hermes WebUI",
updated_at=t0,
)
archived_parent.archived = True
archived_parent.save(touch_updated_at=False)
_save_webui_session(
"lineage_api_visible_tip",
title="Hermes WebUI #2",
updated_at=t0 + 10,
)
_insert_state_row(
conn,
"lineage_api_archived_parent",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_visible_tip",
parent="lineage_api_archived_parent",
started_at=t0 + 6,
)
monkeypatch.setattr(routes, "all_sessions", models.all_sessions)
monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", models._enrich_sidebar_lineage_metadata)
monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _sessions: False)
default_payload = routes._build_session_list_cache_payload(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=False,
)
assert [row["session_id"] for row in default_payload["sessions"]] == ["lineage_api_visible_tip"]
assert default_payload["archived_count"] == 1
tip = default_payload["sessions"][0]
assert tip.get("parent_session_id") == "lineage_api_archived_parent"
assert tip.get("_lineage_root_id") == "lineage_api_archived_parent"
assert tip.get("_compression_segment_count") == 2
archived_payload = routes._build_session_list_cache_payload(
active_profile="default",
all_profiles=False,
show_cli_sessions=False,
show_previous_messaging_sessions=False,
show_cron_sessions=False,
include_archived=True,
)
assert [row["session_id"] for row in archived_payload["sessions"]] == [
"lineage_api_visible_tip",
"lineage_api_archived_parent",
]
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_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:
_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",
)
# 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 + 10,
)
rows = {row["session_id"]: row for row in all_sessions()}
tip = rows["lineage_title_tip"]
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/#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,
'title': 'Shared conversation',
}
row.update(over)
return row
def make_child(**over):
row = {
'id': 'child-1',
'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: 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')
)
# 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'))
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()