Files
hermes-webui/tests/test_issue6911_intentional_shrink_backup.py
T

331 lines
12 KiB
Python

"""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"]