"""Behavioral coverage for #6911 intentional message-shrink generations.""" from __future__ import annotations import json import pytest _VALID_GENERATION_A = "0123456789ab4cde8f0123456789abcd" _VALID_GENERATION_B = "fedcba9876544c21b0fedcba98765432" def _msg(role: str, content: str, ts: float, mid: str) -> dict: return {"id": mid, "role": role, "content": content, "timestamp": ts} def _history() -> list[dict]: return [ _msg("user", "first", 1.0, "u1"), _msg("assistant", "reply first", 2.0, "a1"), _msg("user", "second", 3.0, "u2"), _msg("assistant", "reply second", 4.0, "a2"), ] def _seed_session_dir(monkeypatch, tmp_path): import api.models as models session_dir = tmp_path / "sessions" session_dir.mkdir(parents=True) monkeypatch.setattr(models, "SESSION_DIR", session_dir) monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json") models.SESSIONS.clear() return models, session_dir def _write_json(path, payload): path.write_text(json.dumps(payload), encoding="utf-8") def _live_and_backup(sid: str, live_generation, backup_generation): live = { "session_id": sid, "messages": [_msg("user", "live", 1.0, "u1")], "context_messages": [_msg("user", "live", 1.0, "cu1")], "intentional_shrink_generation": live_generation, } backup = { "session_id": sid, "messages": [ _msg("user", "live", 1.0, "u1"), _msg("assistant", "reply", 2.0, "a1"), ], "context_messages": [ _msg("user", "live", 1.0, "cu1"), _msg("assistant", "reply", 2.0, "ca1"), ], "intentional_shrink_generation": backup_generation, } return live, backup def test_undo_shrink_is_not_revived_by_startup_recovery(monkeypatch, tmp_path): models, session_dir = _seed_session_dir(monkeypatch, tmp_path) from api.session_ops import undo_last from api.session_recovery import recover_all_sessions_on_startup session = models.Session( session_id="issue6911_undo", messages=_history(), context_messages=_history(), ) session.save() undo_last(session.session_id) live = models.Session.load(session.session_id) assert live is not None assert [row["content"] for row in live.messages] == ["first", "reply first"] assert isinstance(live.intentional_shrink_generation, str) assert live.intentional_shrink_generation assert session.path.with_suffix(".json.bak").exists() result = recover_all_sessions_on_startup(session_dir) assert result["restored"] == 0 assert [row["content"] for row in models.Session.load(session.session_id).messages] == [ "first", "reply first", ] def test_retry_shrink_is_not_revived_by_startup_recovery(monkeypatch, tmp_path): models, session_dir = _seed_session_dir(monkeypatch, tmp_path) from api.session_ops import retry_last from api.session_recovery import recover_all_sessions_on_startup session = models.Session( session_id="issue6911_retry", messages=_history(), context_messages=_history(), ) session.save() result = retry_last(session.session_id) assert result["removed_count"] == 2 live = models.Session.load(session.session_id) assert live is not None assert [row["content"] for row in live.messages] == ["first", "reply first"] assert isinstance(live.intentional_shrink_generation, str) assert live.intentional_shrink_generation recovery = recover_all_sessions_on_startup(session_dir) assert recovery["restored"] == 0 assert [row["content"] for row in models.Session.load(session.session_id).messages] == [ "first", "reply first", ] def test_truncate_chokepoint_shrink_is_not_revived_by_startup_recovery(monkeypatch, tmp_path): models, session_dir = _seed_session_dir(monkeypatch, tmp_path) from api.session_ops import truncate_session_at_keep from api.session_recovery import recover_all_sessions_on_startup session = models.Session( session_id="issue6911_truncate", messages=_history(), context_messages=_history(), ) session.save() truncate_session_at_keep(session, 2) session.save() live = models.Session.load(session.session_id) assert live is not None assert len(live.messages) == 2 assert isinstance(live.intentional_shrink_generation, str) assert live.intentional_shrink_generation recovery = recover_all_sessions_on_startup(session_dir) assert recovery["restored"] == 0 assert len(models.Session.load(session.session_id).messages) == 2 def test_generation_save_load_round_trip_is_in_metadata_prefix(monkeypatch, tmp_path): models, _session_dir = _seed_session_dir(monkeypatch, tmp_path) generation = _VALID_GENERATION_A session = models.Session( session_id="issue6911_round_trip", messages=_history(), intentional_shrink_generation=generation, ) session.save() persisted = json.loads(session.path.read_text(encoding="utf-8")) assert persisted["intentional_shrink_generation"] == generation assert list(persisted).index("intentional_shrink_generation") < list(persisted).index("messages") loaded = models.Session.load(session.session_id) metadata = models.Session.load_metadata_only(session.session_id) assert loaded.intentional_shrink_generation == generation assert metadata.intentional_shrink_generation == generation def test_transcript_generation_round_trips_and_advances_only_on_shrink(monkeypatch, tmp_path): models, _session_dir = _seed_session_dir(monkeypatch, tmp_path) from api.session_ops import truncate_session_at_keep from api.webui_session_db import WebUIJsonSessionDB session = models.Session( session_id="issue6911_transcript_generation", messages=_history(), transcript_generation=4, ) session.save() truncate_session_at_keep(session, len(session.messages)) assert session.transcript_generation == 4 truncate_session_at_keep(session, 2) assert session.transcript_generation == 5 assert session.transcript_generation_baseline == 2 session.save() loaded = models.Session.load(session.session_id) metadata = models.Session.load_metadata_only(session.session_id) listed = WebUIJsonSessionDB().list_sessions() assert loaded.transcript_generation == 5 assert loaded.transcript_generation_baseline == 2 assert metadata.transcript_generation == 5 assert metadata.transcript_generation_baseline == 2 assert listed[0]["transcript_generation"] == 5 assert listed[0]["transcript_generation_baseline"] == 2 def test_no_actual_message_shrink_keeps_generation(monkeypatch, tmp_path): models, _session_dir = _seed_session_dir(monkeypatch, tmp_path) from api.session_ops import truncate_session_at_keep session = models.Session( session_id="issue6911_no_shrink", messages=_history(), intentional_shrink_generation=_VALID_GENERATION_A, ) session.save() truncate_session_at_keep(session, len(session.messages)) assert session.intentional_shrink_generation == _VALID_GENERATION_A session.save() assert not session.path.with_suffix(".json.bak").exists() @pytest.mark.parametrize("invalid_generation", [None, "", " ", 0, [], {}]) def test_missing_or_invalid_live_generation_fails_open(tmp_path, invalid_generation): from api.session_recovery import recover_session sid = f"issue6911_invalid_{type(invalid_generation).__name__}" live, backup = _live_and_backup(sid, invalid_generation, _VALID_GENERATION_B) live_path = tmp_path / f"{sid}.json" bak_path = live_path.with_suffix(".json.bak") _write_json(live_path, live) _write_json(bak_path, backup) result = recover_session(live_path) assert result["restored"] is True assert json.loads(live_path.read_text(encoding="utf-8"))["messages"] == backup["messages"] def test_same_generation_backup_from_real_save_remains_recoverable(monkeypatch, tmp_path): """A later non-intentional shrink must keep the generation and recover.""" models, session_dir = _seed_session_dir(monkeypatch, tmp_path) from api.session_recovery import recover_all_sessions_on_startup sid = "issue6911_same_generation" generation = _VALID_GENERATION_A session = models.Session( session_id=sid, messages=_history(), context_messages=_history(), intentional_shrink_generation=generation, ) session.save() # Model an unrelated data-loss shrink that did not come through the # intentional truncate chokepoint: Session.save() must still create a # same-generation rescue backup that remains recoverable. session.messages = session.messages[:2] session.context_messages = session.context_messages[:2] session.save() live_path = session.path bak_path = live_path.with_suffix(".json.bak") assert bak_path.exists() assert json.loads(live_path.read_text(encoding="utf-8"))["intentional_shrink_generation"] == generation assert json.loads(bak_path.read_text(encoding="utf-8"))["intentional_shrink_generation"] == generation result = recover_all_sessions_on_startup(session_dir) assert result["restored"] == 1 assert json.loads(live_path.read_text(encoding="utf-8"))["messages"] == _history() @pytest.mark.parametrize("invalid_generation", ["", " ", 0, [], {}]) def test_invalid_backup_generation_fails_open(tmp_path, invalid_generation): from api.session_recovery import recover_session sid = "issue6911_invalid_backup_generation" live, backup = _live_and_backup(sid, _VALID_GENERATION_A, invalid_generation) live_path = tmp_path / f"{sid}.json" _write_json(live_path, live) _write_json(live_path.with_suffix(".json.bak"), backup) result = recover_session(live_path) assert result["restored"] is True assert json.loads(live_path.read_text(encoding="utf-8"))["messages"] == backup["messages"] def test_different_generation_backup_is_intentional_and_not_restored(tmp_path): from api.session_recovery import inspect_session_recovery_status, recover_session sid = "issue6911_different_generation" live, backup = _live_and_backup(sid, _VALID_GENERATION_A, _VALID_GENERATION_B) live_path = tmp_path / f"{sid}.json" _write_json(live_path, live) _write_json(live_path.with_suffix(".json.bak"), backup) status = inspect_session_recovery_status(live_path) result = recover_session(live_path) assert status["recommend"] == "no_action" assert status["intentional_message_shrink"] is True assert result["restored"] is False assert json.loads(live_path.read_text(encoding="utf-8"))["messages"] == live["messages"] @pytest.mark.parametrize( ("field", "malformed_generation"), [ ("live", "0123456789AB4CDE8F0123456789ABCD"), ("backup", "0123456789AB4CDE8F0123456789ABCD"), ("live", "0123456789ab4cde8f0123456789abcd0"), ("backup", "0123456789ab4cde8f0123456789abcd0"), ("live", "0123456789ab4cde8f0123456789abc"), ("backup", "0123456789ab4cde8f0123456789abc"), ("live", "0123456789ab5cde8f0123456789abcd"), ("backup", "0123456789ab5cde8f0123456789abcd"), ("live", "0123456789ab4cdecf0123456789abcd"), ("backup", "0123456789ab4cdecf0123456789abcd"), ], ) def test_malformed_nonblank_generation_fails_open_to_restore( tmp_path, field, malformed_generation, ): """Malformed nonblank UUID4 markers must not suppress recovery.""" from api.session_recovery import recover_session sid = "issue6911_malformed_nonblank" live_generation = malformed_generation if field == "live" else _VALID_GENERATION_A backup_generation = malformed_generation if field == "backup" else _VALID_GENERATION_B live, backup = _live_and_backup(sid, live_generation, backup_generation) live_path = tmp_path / f"{sid}.json" _write_json(live_path, live) _write_json(live_path.with_suffix(".json.bak"), backup) result = recover_session(live_path) assert result["restored"] is True assert json.loads(live_path.read_text(encoding="utf-8"))["messages"] == backup["messages"]