"""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: an advanced watermark with the original cutoff persisted as truncation_boundary keeps the prefix + post-edit tail and drops the one deleted turn.""" 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), ] # Original cutoff kept through the first reply (@51); a new turn was then # committed (@200), advancing the watermark. boundary (51) < watermark (200). merged = models.merge_session_messages_append_only( [], state, truncation_watermark=200.0, truncation_boundary=51.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}" ) def test_core_a_not_advanced_watermark_equals_boundary_does_not_resurrect(): """Just-truncated state (boundary == watermark, no new turn committed yet): everything above the watermark is the deleted suffix and must NOT be kept. Reachable via crash/cold-load metadata-vs-sidecar divergence — the exact scenario the empty-sidecar branch serves. Opus gate found that the prior code swept the suffix into `at_or_after` here, resurrecting deleted turns (the same data-loss class this fix exists to kill).""" state = [ _msg("user", "u1", 50.0), _msg("assistant", "a1", 51.0), _msg("user", "deleted-u2", 100.0), _msg("assistant", "deleted-a2", 101.0), _msg("user", "deleted-u3", 150.0), _msg("assistant", "deleted-a3", 151.0), ] merged = models.merge_session_messages_append_only( [], state, truncation_watermark=51.0, truncation_boundary=51.0 ) contents = [m["content"] for m in merged] assert contents == ["u1", "a1"], ( f"watermark==boundary must keep only ts<=watermark, got {contents}" ) def test_core_a_legacy_none_boundary_frozen_watermark_does_not_resurrect(): """Legacy session (truncation_boundary is None) with a positive watermark: in the pre-#4767 model a persisted positive watermark always meant 'frozen at cutoff' (committing a turn cleared it to None), so the safe behavior is the conservative ts<=watermark filter — no resurrection, and the legitimately-kept first turn survives.""" state = [ _msg("user", "u1", 50.0), _msg("assistant", "a1", 51.0), _msg("user", "deleted-u2", 100.0), _msg("assistant", "deleted-a2", 101.0), _msg("user", "deleted-u3", 150.0), _msg("assistant", "deleted-a3", 151.0), ] merged = models.merge_session_messages_append_only( [], state, truncation_watermark=51.0 # boundary defaults to None ) contents = [m["content"] for m in merged] assert contents == ["u1", "a1"], ( f"legacy None-boundary frozen watermark must keep only ts<=watermark " f"(no resurrection, keep u1), got {contents}" ) def test_advanced_sidecar_at_watermark_keeps_state_only_post_edit_reply(): """Non-empty sidecar whose newest row EQUALS the watermark (the post-edit user turn is checkpointed but its assistant reply exists only in state.db) must keep the state-only post-edit reply when the session is genuinely advanced (truncation_boundary < watermark). Opus/Codex gate found that the `sidecar_advanced_past_watermark` guard only treated `max_sidecar_timestamp > watermark` as advanced, so a reply at ts > watermark was dropped whenever the sidecar tail merely EQUALLED the watermark — silently losing the legitimate post-edit assistant reply on restore/full reload. The boundary < watermark signal now also marks the session advanced.""" # boundary @51 (original cutoff), watermark advanced to @200 (new user turn), # sidecar holds the prefix + the committed edited user @200 (== watermark), # state.db additionally holds the post-edit assistant reply @201 (state-only). sidecar = [ _msg("user", "first", 50.0), _msg("assistant", "first reply", 51.0), _msg("user", "edited prompt", 200.0), ] state = [ _msg("user", "first", 50.0), _msg("assistant", "first reply", 51.0), _msg("user", "edited prompt", 200.0), _msg("assistant", "post-edit reply", 201.0), ] merged = models.merge_session_messages_append_only( sidecar, state, truncation_watermark=200.0, truncation_boundary=51.0 ) contents = [m["content"] for m in merged] assert "post-edit reply" in contents, ( f"State-only post-edit assistant reply was DROPPED! Contents: {contents}" ) assert contents == ["first", "first reply", "edited prompt", "post-edit reply"], ( f"Expected full advanced transcript, got {contents}" ) def test_advanced_does_not_resurrect_stale_post_watermark_row_before_checkpoint(): """The boundary watermark that appears in state.db BEFORE the edited checkpoint row. Codex final-gate found that making `sidecar_advanced_past_watermark` globally true (whenever boundary < watermark) let a stale ts>watermark row bypass the skip before state replay reached the sidecar's edited checkpoint, resurrecting deleted turns. The bypass is now gated on the checkpoint having been consumed (state_replay_idx >= len(sidecar_visible_sequence)).""" # sidecar ends at the edited user @200 (== watermark); state.db lists a # deleted future suffix (@300/@301) BEFORE the appended edited turn. sidecar = [ _msg("user", "first", 50.0), _msg("assistant", "first reply", 51.0), _msg("user", "edited prompt", 200.0), ] state = [ _msg("user", "first", 50.0), _msg("assistant", "first reply", 51.0), _msg("user", "deleted future prompt", 300.0), _msg("assistant", "deleted future answer", 301.0), _msg("user", "edited prompt", 200.0), ] merged = models.merge_session_messages_append_only( sidecar, state, truncation_watermark=200.0, truncation_boundary=51.0 ) contents = [m["content"] for m in merged] assert "deleted future prompt" not in contents, ( f"Stale post-watermark row resurrected before checkpoint! {contents}" ) assert "deleted future answer" not in contents, ( f"Stale post-watermark row resurrected before checkpoint! {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" ) # ─── Route-level CORE-A: default /api/session full reload must not resurrect ── def test_core_a_route_full_session_load_does_not_resurrect_deleted_turns(tmp_path): """The default GET /api/session full reload (no msg_limit) must pass the persisted truncation_boundary through to the append-only merge. Regression guard for the call-site gap: merge_session_messages_append_only was fixed to honor truncation_boundary, but the default full-load call site (api/routes.py handle_get '/api/session') still passed only truncation_watermark, so a normal full reload fell back to the old 'drop one turn pair' heuristic and resurrected deleted suffix turns when an older message was edited leaving >=2 later turns. This drives the real endpoint end-to-end so the boundary must reach the merge. """ import json from io import BytesIO from urllib.parse import urlparse import api.routes as routes from api.models import Session 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), ] class _Handler: def __init__(self): self.status = None self.response_headers = [] self.wfile = BytesIO() def send_response(self, status): self.status = status def send_header(self, k, v): self.response_headers.append((k, v)) def end_headers(self): pass def payload(self): return json.loads(self.wfile.getvalue().decode() or "{}") sess_dir = tmp_path / "sessions" sess_dir.mkdir() orig_dir, orig_index = models.SESSION_DIR, models.SESSION_INDEX_FILE models.SESSION_DIR = sess_dir models.SESSION_INDEX_FILE = sess_dir / "_index.json" models.SESSIONS.clear() saved = { "get_state_db_session_messages": getattr(routes, "get_state_db_session_messages", None), "_session_visible_to_active_profile": getattr(routes, "_session_visible_to_active_profile", None), "_clear_stale_stream_state": getattr(routes, "_clear_stale_stream_state", None), "_resolve_effective_session_model_for_display": getattr(routes, "_resolve_effective_session_model_for_display", None), "_resolve_effective_session_model_provider_for_display": getattr(routes, "_resolve_effective_session_model_provider_for_display", None), } try: s = Session( session_id="corea_route", messages=[], truncation_watermark=400.0, truncation_boundary=101.0, ) s.save() routes.get_state_db_session_messages = lambda sid, profile=None, since_timestamp=None, include_inactive=False, limit=None: list(state) routes._session_visible_to_active_profile = lambda profile, handler: True routes._clear_stale_stream_state = lambda s: None routes._resolve_effective_session_model_for_display = lambda s: getattr(s, "model", None) routes._resolve_effective_session_model_provider_for_display = lambda s: getattr(s, "model_provider", None) h = _Handler() routes.handle_get( h, urlparse("/api/session?session_id=corea_route&messages=1&resolve_model=0") ) contents = [ m.get("content") for m in h.payload().get("session", {}).get("messages", []) ] assert h.status == 200, f"unexpected status {h.status}" assert "deleted question 1" not in contents, ( f"Deleted turn resurrected via /api/session full reload: {contents}" ) assert "deleted answer 1" not in contents, ( f"Deleted turn resurrected via /api/session full reload: {contents}" ) assert "edited prompt" in contents and "post-edit reply" in contents, ( f"Post-edit turn missing from full reload: {contents}" ) finally: for name, val in saved.items(): if val is not None: setattr(routes, name, val) models.SESSION_DIR, models.SESSION_INDEX_FILE = orig_dir, orig_index models.SESSIONS.clear()