* fix(sessions): resolve branchy compression lineage to the freshest tip (#3751) Compression parents can have multiple continuation-looking children when a stale segment is resumed after a newer compressed branch already exists. The previous projection followed the newest DIRECT child only, so it could hide the deeper branch with the latest real activity and make a conversation look missing/stale after compaction or session rotation. - compression_tip() now walks ALL reachable continuation descendants (DFS) and selects the freshest importable (messageful) tip by (last_activity, depth). - read_session_lineage_metadata() expands descendants from the materialized ancestors via the parent index (scoped, 20-hop cap), pulls per-session message stats from the messages table, and exposes a canonical _lineage_tip_id so the WebUI sidebar collapse picks the same tip as the projection. Backward-compat hardening (two release-gate Codex findings, both fixed + tested): The new message-stats / tip-scoring code must not raise on older/minimal or non-standard state.db schemas, which previously collapsed the whole projection: - messages table with NO `timestamp` column → MAX(timestamp) raised in SQL → read_session_lineage_metadata returned {} (lost all lineage metadata). - ISO-8601 TEXT messages.timestamp → float()/raw comparison raised TypeError; in read_importable_agent_session_rows that propagated through compression_tip and get_cli_sessions() swallowed it, hiding ALL imported agent rows. Fixes: - PRAGMA table_info(messages) detection: require session_id, only SELECT MAX(timestamp) when present (else NULL + COUNT only); fall back to message_count. - new _as_score() helper (first numerically-coercible value, else next candidate e.g. started_at) used at EVERY tip-scoring / sort site in both compression_tip() and freshest_continuation_tip(), plus the projection sort. - regression tests: lineage metadata survives REAL/absent/TEXT messages.timestamp, and read_importable_agent_session_rows survives a TEXT timestamp (no empty hide). Co-authored-by: ai-ag2026 <[email protected]> * docs(changelog): stamp v0.51.306 — Release JV (stage-a2 #3751) --------- Co-authored-by: nesquena-hermes <[email protected]>
451 lines
17 KiB
Python
451 lines
17 KiB
Python
"""Regression tests for the v0.50.251 pre-release fixes to PR #1370.
|
|
|
|
PR #1370 originally did `SELECT id, parent_session_id, end_reason FROM
|
|
sessions` (full table scan) on every call. At ~1000 rows the indexed
|
|
lookup was 50x faster; the pre-release fix replaces the full scan with
|
|
a parameterized `WHERE id IN (...)` query that hits PRIMARY KEY +
|
|
idx_sessions_parent.
|
|
|
|
Also pins: orphan-parent references (parent_session_id points to a
|
|
row that doesn't exist in state.db) must NOT be exposed in the API
|
|
output, because #1358's frontend `_sessionLineageKey` falls through to
|
|
`parent_session_id` when `_lineage_root_id` is missing — and would
|
|
group orphans by a key no other session shares (cosmetic dead state).
|
|
"""
|
|
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
|
|
import pytest
|
|
|
|
|
|
def _make_db(path):
|
|
conn = sqlite3.connect(str(path))
|
|
conn.executescript("""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
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
|
|
);
|
|
CREATE INDEX idx_sessions_parent ON sessions(parent_session_id);
|
|
""")
|
|
return conn
|
|
|
|
|
|
def _insert(conn, sid, *, parent=None, end_reason=None, started_at=None):
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, title, model, started_at, parent_session_id, end_reason) "
|
|
"VALUES (?, 'webui', ?, 'openai/gpt-5', ?, ?, ?)",
|
|
(sid, sid, started_at or time.time(), parent, end_reason),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def test_does_not_full_scan_sessions_table(tmp_path, monkeypatch):
|
|
"""The perf-critical invariant: must NOT do a `SELECT * FROM sessions`
|
|
or `SELECT id, parent_session_id, end_reason FROM sessions` without a
|
|
WHERE clause. Full scans regress sidebar refresh latency on power users
|
|
with 1000+ session rows.
|
|
"""
|
|
from api import agent_sessions
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = _make_db(db)
|
|
# Insert 500 unrelated rows + 5 we care about
|
|
for i in range(500):
|
|
_insert(conn, f"unrelated_{i:04d}")
|
|
_insert(conn, "wanted_1")
|
|
_insert(conn, "wanted_2", parent="wanted_1", end_reason=None)
|
|
conn.close()
|
|
|
|
# Track every SQL the function issues
|
|
queries = []
|
|
real_connect = sqlite3.connect
|
|
|
|
class _TrackingConn:
|
|
def __init__(self, *args, **kw):
|
|
self._real = real_connect(*args, **kw)
|
|
def cursor(self):
|
|
return _TrackingCursor(self._real.cursor())
|
|
def __enter__(self):
|
|
return self
|
|
def __exit__(self, *a):
|
|
return self._real.__exit__(*a)
|
|
@property
|
|
def row_factory(self):
|
|
return self._real.row_factory
|
|
@row_factory.setter
|
|
def row_factory(self, v):
|
|
self._real.row_factory = v
|
|
|
|
class _TrackingCursor:
|
|
def __init__(self, real):
|
|
self._real = real
|
|
def execute(self, sql, *args):
|
|
queries.append(sql)
|
|
return self._real.execute(sql, *args)
|
|
def fetchall(self):
|
|
return self._real.fetchall()
|
|
def fetchone(self):
|
|
return self._real.fetchone()
|
|
|
|
monkeypatch.setattr(sqlite3, "connect", _TrackingConn)
|
|
agent_sessions.read_session_lineage_metadata(db, ["wanted_1", "wanted_2"])
|
|
|
|
# No query may select from sessions without a WHERE clause
|
|
bad = [q for q in queries
|
|
if "from sessions" in q.lower()
|
|
and "where" not in q.lower()
|
|
and "pragma" not in q.lower()]
|
|
assert not bad, (
|
|
f"read_session_lineage_metadata must scope its SELECTs to specific "
|
|
f"session ids (PR #1370 originally did a full scan). Found unscoped "
|
|
f"queries: {bad}"
|
|
)
|
|
|
|
|
|
def test_orphan_parent_reference_not_exposed_in_metadata(tmp_path):
|
|
"""If a session row references a parent that doesn't exist in state.db
|
|
(orphan), the API output must NOT include `parent_session_id` — because
|
|
#1358's frontend lineage helper would treat it as a sidebar grouping key
|
|
and cluster the orphan into a never-collapsing single-row group.
|
|
"""
|
|
from api.agent_sessions import read_session_lineage_metadata
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = _make_db(db)
|
|
_insert(conn, "child", parent="missing_parent", end_reason=None)
|
|
conn.close()
|
|
|
|
result = read_session_lineage_metadata(db, ["child"])
|
|
assert "child" not in result or "parent_session_id" not in result["child"], (
|
|
f"Orphan parent_session_id leaked into API output: {result}. "
|
|
f"This causes the frontend lineage helper to group the orphan under "
|
|
f"a key no other session shares — cosmetic dead state on the wire."
|
|
)
|
|
|
|
|
|
def test_old_schema_without_source_or_messages_table_keeps_lineage(tmp_path):
|
|
"""Old state.db schemas may lack optional source/message tables but still carry lineage."""
|
|
from api.agent_sessions import read_session_lineage_metadata
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(str(db))
|
|
conn.executescript("""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT,
|
|
started_at REAL NOT NULL,
|
|
parent_session_id TEXT,
|
|
ended_at REAL,
|
|
end_reason TEXT
|
|
);
|
|
CREATE INDEX idx_sessions_parent ON sessions(parent_session_id);
|
|
""")
|
|
t0 = time.time() - 100
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, title, started_at, ended_at, end_reason) VALUES (?, ?, ?, ?, ?)",
|
|
("old_root", "old_root", t0, t0 + 5, "compression"),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, title, started_at, parent_session_id) VALUES (?, ?, ?, ?)",
|
|
("old_tip", "old_tip", t0 + 6, "old_root"),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
result = read_session_lineage_metadata(db, ["old_tip"])
|
|
|
|
assert result["old_tip"]["parent_session_id"] == "old_root"
|
|
assert result["old_tip"]["_lineage_root_id"] == "old_root"
|
|
assert result["old_tip"]["_compression_segment_count"] == 2
|
|
|
|
|
|
def test_cycle_in_parent_chain_terminates(tmp_path):
|
|
"""Pathological data with a parent cycle (A→B→A) must not infinite-loop."""
|
|
from api.agent_sessions import read_session_lineage_metadata
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = _make_db(db)
|
|
_insert(conn, "A", parent="B", end_reason="compression")
|
|
_insert(conn, "B", parent="A", end_reason="compression")
|
|
conn.close()
|
|
|
|
import threading
|
|
done = threading.Event()
|
|
result_box = []
|
|
|
|
def worker():
|
|
result_box.append(read_session_lineage_metadata(db, ["A"]))
|
|
done.set()
|
|
|
|
t = threading.Thread(target=worker, daemon=True)
|
|
t.start()
|
|
finished = done.wait(timeout=2.0)
|
|
assert finished, "read_session_lineage_metadata hung on a parent cycle"
|
|
|
|
result = result_box[0]
|
|
# Cycle should terminate; segment count should be bounded (≤ 2 for A→B→A)
|
|
seg = result.get("A", {}).get("_compression_segment_count", 0)
|
|
assert 1 <= seg <= 5, f"Cycle should produce a small bounded count, got {seg}"
|
|
|
|
|
|
def test_chain_walk_returns_correct_root_for_real_compression_chain(tmp_path):
|
|
"""End-to-end behavioural test: 4-segment compression chain returns the
|
|
correct root and a segment count of 4."""
|
|
from api.agent_sessions import read_session_lineage_metadata
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = _make_db(db)
|
|
_insert(conn, "root", end_reason="compression")
|
|
_insert(conn, "seg2", parent="root", end_reason="compression")
|
|
_insert(conn, "seg3", parent="seg2", end_reason="compression")
|
|
_insert(conn, "tip", parent="seg3", end_reason=None)
|
|
conn.close()
|
|
|
|
result = read_session_lineage_metadata(db, ["tip"])
|
|
assert result["tip"]["_lineage_root_id"] == "root"
|
|
assert result["tip"]["_compression_segment_count"] == 4
|
|
assert result["tip"]["parent_session_id"] == "seg3"
|
|
|
|
|
|
def test_in_clause_chunked_for_large_session_set(tmp_path, monkeypatch):
|
|
"""The first hop must chunk the IN clause into batches of <= 500.
|
|
Without chunking, a power user with 2000+ sessions in the sidebar would
|
|
trigger SQLITE_MAX_VARIABLE_NUMBER on Python 3.9's sqlite 3.31 (default
|
|
limit 999), the OperationalError gets swallowed by the except wrapper,
|
|
and lineage collapse silently disables forever for that user.
|
|
(Opus pre-release review of v0.50.251, SHOULD-FIX 2.)
|
|
"""
|
|
from api import agent_sessions
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = _make_db(db)
|
|
# 1500 unrelated rows
|
|
for i in range(1500):
|
|
_insert(conn, f"session_{i:04d}")
|
|
conn.close()
|
|
|
|
# Track each query's IN clause variable count
|
|
in_counts = []
|
|
real_connect = sqlite3.connect
|
|
|
|
class _TrackingConn:
|
|
def __init__(self, *args, **kw):
|
|
self._real = real_connect(*args, **kw)
|
|
def cursor(self):
|
|
return _TrackingCursor(self._real.cursor())
|
|
def __enter__(self): return self
|
|
def __exit__(self, *a): return self._real.__exit__(*a)
|
|
@property
|
|
def row_factory(self): return self._real.row_factory
|
|
@row_factory.setter
|
|
def row_factory(self, v): self._real.row_factory = v
|
|
|
|
class _TrackingCursor:
|
|
def __init__(self, real): self._real = real
|
|
def execute(self, sql, *args):
|
|
if "IN (" in sql:
|
|
in_counts.append(sql.count("?"))
|
|
return self._real.execute(sql, *args)
|
|
def fetchall(self): return self._real.fetchall()
|
|
def fetchone(self): return self._real.fetchone()
|
|
|
|
monkeypatch.setattr(sqlite3, "connect", _TrackingConn)
|
|
|
|
# Request 1500 ids (the full set we inserted)
|
|
wanted = [f"session_{i:04d}" for i in range(1500)]
|
|
agent_sessions.read_session_lineage_metadata(db, wanted)
|
|
|
|
assert in_counts, "Expected at least one IN-clause query"
|
|
over_limit = [n for n in in_counts if n > 999]
|
|
assert not over_limit, (
|
|
f"IN clause must be chunked to <= 999 vars to stay under SQLite default "
|
|
f"limit (chunked to 500 in the implementation). Found queries with "
|
|
f"{over_limit} variables — would raise OperationalError on older sqlite."
|
|
)
|
|
|
|
|
|
def test_two_children_sharing_non_continuation_parent_not_collapsed(tmp_path):
|
|
"""Two distinct child sessions may share the same non-continuation parent,
|
|
but they must be marked as child sessions so frontend lineage collapse does
|
|
not group them under the parent's id.
|
|
"""
|
|
from api.agent_sessions import read_session_lineage_metadata
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = _make_db(db)
|
|
_insert(conn, "shared_parent", end_reason="user_stop")
|
|
_insert(conn, "child_a", parent="shared_parent", end_reason=None)
|
|
_insert(conn, "child_b", parent="shared_parent", end_reason=None)
|
|
conn.close()
|
|
|
|
result = read_session_lineage_metadata(db, ["child_a", "child_b"])
|
|
for sid in ["child_a", "child_b"]:
|
|
entry = result.get(sid, {})
|
|
assert entry.get("parent_session_id") == "shared_parent"
|
|
assert entry.get("relationship_type") == "child_session"
|
|
assert entry.get("_parent_lineage_root_id") == "shared_parent"
|
|
assert "_lineage_root_id" not in entry
|
|
|
|
|
|
def test_non_compression_parent_does_not_extend_lineage(tmp_path):
|
|
"""If parent's end_reason is something OTHER than 'compression' or
|
|
'cli_close' (e.g. 'user_stop', 'session_reset', 'cron_complete'),
|
|
the chain stops at that boundary. The parent link is surfaced as an
|
|
explicit child-session relationship rather than compression lineage.
|
|
"""
|
|
from api.agent_sessions import read_session_lineage_metadata
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = _make_db(db)
|
|
_insert(conn, "parent", end_reason="user_stop") # NOT compression
|
|
_insert(conn, "child", parent="parent", end_reason=None)
|
|
conn.close()
|
|
|
|
result = read_session_lineage_metadata(db, ["child"])
|
|
entry = result.get("child", {})
|
|
assert entry.get("parent_session_id") == "parent"
|
|
assert entry.get("relationship_type") == "child_session"
|
|
assert entry.get("_parent_lineage_root_id") == "parent"
|
|
# _lineage_root_id should NOT be set — chain doesn't span the boundary
|
|
assert "_lineage_root_id" not in entry
|
|
assert "_compression_segment_count" not in entry
|
|
|
|
|
|
|
|
# ── #3751 backward-compat: messages-table schema variants must not collapse
|
|
# the lineage metadata (regression for the gate finding on stage-a2/v0.51.306) ──
|
|
|
|
def _make_db_with_messages(path, *, timestamp_type):
|
|
"""Build a state.db with a compression lineage and a messages table whose
|
|
timestamp column is REAL, absent, or TEXT (ISO-8601)."""
|
|
conn = sqlite3.connect(str(path))
|
|
conn.executescript("""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
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
|
|
);
|
|
CREATE INDEX idx_sessions_parent ON sessions(parent_session_id);
|
|
""")
|
|
if timestamp_type == "absent":
|
|
conn.execute("CREATE TABLE messages (session_id TEXT, role TEXT, content TEXT)")
|
|
else:
|
|
conn.execute(f"CREATE TABLE messages (session_id TEXT, role TEXT, content TEXT, timestamp {timestamp_type})")
|
|
# Compression chain: root --(compression)--> tip
|
|
now = time.time()
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, title, model, started_at, parent_session_id, ended_at, end_reason) "
|
|
"VALUES ('root', 'webui', 'root', 'openai/gpt-5', ?, NULL, ?, 'compression')",
|
|
(now - 100, now - 50),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, title, model, started_at, parent_session_id, ended_at, end_reason) "
|
|
"VALUES ('tip', 'webui', 'tip', 'openai/gpt-5', ?, 'root', NULL, NULL)",
|
|
(now - 40,),
|
|
)
|
|
if timestamp_type == "absent":
|
|
conn.execute("INSERT INTO messages (session_id, role, content) VALUES ('tip', 'user', 'hi')")
|
|
elif timestamp_type == "TEXT":
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES ('tip', 'user', 'hi', '2026-06-06T12:00:00Z')"
|
|
)
|
|
else: # REAL
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES ('tip', 'user', 'hi', ?)",
|
|
(now - 35,),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
@pytest.mark.parametrize("timestamp_type", ["REAL", "absent", "TEXT"])
|
|
def test_lineage_metadata_survives_messages_timestamp_schema_variants(tmp_path, timestamp_type):
|
|
"""The branchy-lineage tip resolver pulls per-session message stats from the
|
|
messages table. Older/minimal state.db schemas can have a messages table with
|
|
NO timestamp column, or a non-numeric (ISO-8601 text) timestamp. Neither may
|
|
raise out of the DB block and collapse ALL lineage metadata to {} — which is
|
|
a silent regression vs. the prior behavior that returned metadata.
|
|
"""
|
|
from api.agent_sessions import read_session_lineage_metadata
|
|
|
|
db = tmp_path / "state.db"
|
|
_make_db_with_messages(db, timestamp_type=timestamp_type)
|
|
|
|
result = read_session_lineage_metadata(db, ["tip"])
|
|
entry = result.get("tip", {})
|
|
# The compression lineage must still be reported (not silently dropped).
|
|
assert entry.get("_lineage_root_id") == "root", (
|
|
f"lineage metadata collapsed for messages.timestamp={timestamp_type}: {result!r}"
|
|
)
|
|
# And the canonical tip must resolve to the messageful continuation.
|
|
assert entry.get("_lineage_tip_id") == "tip"
|
|
|
|
|
|
def test_importable_rows_survive_text_timestamp_in_messages(tmp_path):
|
|
"""read_importable_agent_session_rows() builds a per-session MAX(timestamp)
|
|
and the compression_tip() DFS scores tips by last_activity. An older/
|
|
non-standard messages.timestamp stored as ISO-8601 TEXT must not raise a
|
|
TypeError out of the projection (get_cli_sessions() would swallow it and
|
|
return [] — silently hiding ALL imported agent rows). Sibling-path guard to
|
|
the read_session_lineage_metadata fix.
|
|
"""
|
|
from api.agent_sessions import read_importable_agent_session_rows
|
|
|
|
db = tmp_path / "state.db"
|
|
conn = sqlite3.connect(str(db))
|
|
conn.executescript("""
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
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
|
|
);
|
|
CREATE INDEX idx_sessions_parent ON sessions(parent_session_id);
|
|
CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, timestamp TEXT);
|
|
""")
|
|
now = time.time()
|
|
# compression chain root --(compression)--> tip, tip has a messageful row
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) "
|
|
"VALUES ('root', 'cli', 'root', 'openai/gpt-5', ?, 0, NULL, ?, 'compression')",
|
|
(now - 100, now - 50),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) "
|
|
"VALUES ('tip', 'cli', 'tip', 'openai/gpt-5', ?, 1, 'root', NULL, NULL)",
|
|
(now - 40,),
|
|
)
|
|
# ISO-8601 TEXT timestamp — the value MAX() would return as a string
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES ('tip', 'user', 'hi', '2026-06-06T12:00:00Z')"
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Must NOT raise; must surface the imported chain (cli source).
|
|
rows = read_importable_agent_session_rows(db, limit=None, exclude_sources=None)
|
|
ids = {r["id"] for r in rows}
|
|
assert ids, "import projection returned no rows on a TEXT messages.timestamp (would hide all CLI sessions)"
|
|
# the chain collapses to its tip
|
|
assert "tip" in ids
|