HERMES-GIELIE/tests/test_core_data_loss_cases.py
2026-06-24 18:08:50 +00:00

291 lines
9.8 KiB
Python

"""Reproduction tests for data-loss cases found in PR review.
Empty-sidecar recovery resurrects deleted pre-edit turns when editing
an older message with ≥2 later turns.
Same-second recovery can silently drop the legitimate post-edit
assistant reply.
Both are regression tests — they should FAIL against the current code
(commit 297c88fed7) and PASS after the fix.
"""
from __future__ import annotations
import api.models as models
import api.webui_session_db as webui_db
def _msg(role: str, content: str, ts: float) -> dict:
return {"role": role, "content": content, "timestamp": ts}
# ─── Resurrected deleted turns ───────────────────────────────────────────────
def test_core_a_empty_sidecar_resurrects_deleted_turns():
"""Editing an older message with ≥2 later turns resurrects
the deleted suffix on empty-sidecar recovery."""
state = [
_msg("user", "original prompt", 100.0),
_msg("assistant", "original reply", 101.0),
_msg("user", "deleted question 1", 200.0),
_msg("assistant", "deleted answer 1", 201.0),
_msg("user", "deleted question 2", 300.0),
_msg("assistant", "deleted answer 2", 302.0),
_msg("user", "edited prompt", 400.0),
_msg("assistant", "post-edit reply", 401.0),
]
merged = models.merge_session_messages_append_only(
[], # empty sidecar (cold reload)
state,
truncation_watermark=400.0,
truncation_boundary=101.0,
)
contents = [m["content"] for m in merged]
assert "original prompt" in contents
assert "original reply" in contents
assert "edited prompt" in contents
assert "post-edit reply" in contents
assert "deleted question 1" not in contents, (
f"Deleted turn @200 resurrected! Contents: {contents}"
)
assert "deleted answer 1" not in contents, (
f"Deleted turn @201 resurrected! Contents: {contents}"
)
assert "deleted question 2" not in contents, (
f"Deleted turn @300 resurrected! Contents: {contents}"
)
assert "deleted answer 2" not in contents, (
f"Deleted turn @302 resurrected! Contents: {contents}"
)
expected = [
"original prompt", "original reply",
"edited prompt", "post-edit reply",
]
assert contents == expected, (
f"Expected {expected}, got {contents}"
)
def test_core_a_single_deleted_turn_still_works():
"""Sanity check: backward-scan handles one deleted turn correctly."""
state = [
_msg("user", "first msg", 50.0),
_msg("assistant", "first reply", 51.0),
_msg("user", "original pre-edit", 100.0),
_msg("assistant", "original reply", 101.0),
_msg("user", "edited/new turn", 200.0),
_msg("assistant", "post-edit reply", 201.0),
]
merged = models.merge_session_messages_append_only(
[], state, truncation_watermark=200.0
)
contents = [m["content"] for m in merged]
expected = [
"first msg", "first reply", "edited/new turn", "post-edit reply"
]
assert contents == expected, (
f"Sanity: expected {expected}, got {contents}"
)
# ─── Same-second assistant reply dropped ──────────────────────────────────────
def test_core_b_same_second_assistant_reply_dropped():
"""Same-second equality guard drops legitimate post-edit
assistant reply when it shares the timestamp with the edited user message."""
T = 100.0
sidecar = [_msg("user", "edited prompt", T)]
state = [
_msg("user", "edited prompt", T),
_msg("assistant", "post-edit reply", T),
]
merged = models.merge_session_messages_append_only(
sidecar, state, truncation_watermark=T
)
contents = [m["content"] for m in merged]
assert "edited prompt" in contents, (
f"Edited user message missing! Contents: {contents}"
)
assert "post-edit reply" in contents, (
f"Same-second assistant reply was DROPPED! Contents: {contents}"
)
def test_core_b_same_second_replaced_user_still_filtered():
"""Sanity check: same-second guard still filters the replaced
pre-edit user message (same timestamp, different content)."""
T = 100.0
sidecar = [_msg("user", "edited prompt", T)]
state = [
_msg("user", "original pre-edit prompt", T),
_msg("user", "edited prompt", T),
_msg("assistant", "post-edit reply", T),
]
merged = models.merge_session_messages_append_only(
sidecar, state, truncation_watermark=T
)
contents = [m["content"] for m in merged]
assert "original pre-edit prompt" not in contents, (
f"Sanity: replaced user message leaked! Contents: {contents}"
)
assert "edited prompt" in contents
assert "post-edit reply" in contents
def test_core_b_same_second_empty_sidecar_assistant_reply():
"""Same-second assistant reply in empty-sidecar
recovery path with truncation_boundary — must survive."""
T = 100.0
state = [
_msg("user", "first msg", 50.0),
_msg("assistant", "first reply", 51.0),
_msg("user", "edited prompt", T),
_msg("assistant", "post-edit reply", T),
]
merged = models.merge_session_messages_append_only(
[], state, truncation_watermark=T,
truncation_boundary=51.0,
)
contents = [m["content"] for m in merged]
assert "first msg" in contents
assert "first reply" in contents
assert "edited prompt" in contents
assert "post-edit reply" in contents, (
f"Same-second assistant reply dropped! Contents: {contents}"
)
# ─── Persistence: save/load round-trip ────────────────────────────────────────
def test_truncation_boundary_survives_save_load(monkeypatch, tmp_path):
"""truncation_boundary must be persisted to JSON and restored on load."""
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
sid = "boundary_save_load"
session = models.Session(
session_id=sid,
messages=[_msg("user", "hello", 100.0)],
truncation_watermark=200.0,
truncation_boundary=101.0,
)
session.save()
with models.LOCK:
models.SESSIONS.pop(sid, None)
loaded = models.Session.load(sid)
assert loaded.truncation_boundary == 101.0, (
f"truncation_boundary lost after save/load: got {loaded.truncation_boundary}"
)
assert loaded.truncation_watermark == 200.0
def test_truncation_boundary_none_survives_save_load(monkeypatch, tmp_path):
"""When truncation_boundary is None, it must survive save/load as None."""
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
sid = "boundary_none_save"
session = models.Session(
session_id=sid,
messages=[_msg("user", "hello", 100.0)],
truncation_watermark=None,
truncation_boundary=None,
)
session.save()
with models.LOCK:
models.SESSIONS.pop(sid, None)
loaded = models.Session.load(sid)
assert getattr(loaded, "truncation_boundary", None) is None
# ─── reconciled_state_db_messages_for_session passes boundary ─────────────────
def test_reconciled_passes_truncation_boundary(monkeypatch, tmp_path):
"""reconciled_state_db_messages_for_session must pass truncation_boundary
to merge_session_messages_append_only so empty-sidecar recovery uses it."""
session_dir = tmp_path / "sessions"
session_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
models.SESSIONS.clear()
sid = "reconciled_boundary"
session = models.Session(
session_id=sid,
messages=[], # empty sidecar (crash recovery)
truncation_watermark=200.0,
truncation_boundary=51.0,
)
session.save()
state_db = [
_msg("user", "kept", 50.0),
_msg("assistant", "kept reply", 51.0),
_msg("user", "deleted 1", 100.0),
_msg("assistant", "deleted answer 1", 101.0),
_msg("user", "deleted 2", 150.0),
_msg("assistant", "deleted answer 2", 151.0),
_msg("user", "new turn", 200.0),
_msg("assistant", "new reply", 201.0),
]
monkeypatch.setattr(
models,
"get_state_db_session_messages",
lambda sid: state_db,
)
reconciled = models.reconciled_state_db_messages_for_session(session)
contents = [m["content"] for m in reconciled]
assert "deleted 1" not in contents, (
f"reconciled leaked deleted turn: {contents}"
)
assert "deleted 2" not in contents, (
f"reconciled leaked deleted turn: {contents}"
)
assert "kept" in contents
assert "kept reply" in contents
assert "new turn" in contents
assert "new reply" in contents
# ─── webui_session_db _METADATA_FIELDS includes boundary ──────────────────────
def test_webui_session_db_metadata_fields_includes_boundary():
"""webui_session_db._METADATA_FIELDS must include truncation_boundary
so it's recognized as a metadata field (not leaked into extra)."""
assert "truncation_boundary" in webui_db._METADATA_FIELDS, (
"webui_session_db._METADATA_FIELDS missing truncation_boundary"
)