"""Tests for memory safety guards: empty-list wipe prevention and ID stability.""" from langchain_core.messages import HumanMessage from lilith_agent.memory import MemoryStore, MIN_MESSAGES_FOR_EXTRACTION class _FakeEpisodeModel: def invoke(self, prompt): class Response: content = "Test lesson" return Response() class TestSaveMemoriesEmptyGuard: """save_memories([]) must not wipe existing facts.""" def test_refuses_empty_list_when_facts_exist(self, tmp_path): store = MemoryStore(tmp_path / "test.sqlite") store.save_memories([ {"id": "fact-1", "content": "User prefers dark theme"}, {"id": "fact-2", "content": "User name is Yujing"}, ]) assert len(store.get_all_memories()) == 2 # Empty list should NOT wipe store.save_memories([]) facts = store.get_all_memories() assert len(facts) == 2, "Empty list wiped all facts!" def test_empty_list_on_empty_store_is_noop(self, tmp_path): store = MemoryStore(tmp_path / "test.sqlite") assert len(store.get_all_memories()) == 0 # Empty save on empty store is fine — no error, still empty store.save_memories([]) assert len(store.get_all_memories()) == 0 class TestExtractDoesNotWipeOnEmptyResult: """extract_and_compress_facts must not wipe store when LangMem returns [].""" def test_langmem_empty_result_preserves_existing_facts(self, tmp_path, monkeypatch): from lilith_agent import memory import langmem class FakeManager: def invoke(self, payload): return [] # LangMem found nothing new store = MemoryStore(tmp_path / "test.sqlite") store.save_memories([ {"id": "fact-1", "content": "Important fact"}, ]) monkeypatch.setattr(memory, "_store", store) monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager()) memory.extract_and_compress_facts( [HumanMessage(content="Hello"), HumanMessage(content="How are you?")], _FakeEpisodeModel(), ) facts = store.get_all_memories() assert len(facts) == 1, "LangMem empty result wiped existing facts!" assert facts[0]["content"] == "Important fact" class TestMemoryIdStability: def test_passes_existing_memories_with_stable_ids_to_langmem(self, tmp_path, monkeypatch): from lilith_agent import memory import langmem captured = {} class FakeManager: def invoke(self, payload): captured["existing"] = payload["existing"] return [ {"id": "memory-1", "content": "Existing fact"}, {"content": "New fact"}, ] store = MemoryStore(tmp_path / "test.sqlite") store.save_memories([ {"id": "memory-1", "content": "Existing fact"}, ]) original = store.get_all_memories()[0] monkeypatch.setattr(memory, "_store", store) monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager()) memory.extract_and_compress_facts( [HumanMessage(content="Remember a new fact"), HumanMessage(content="New fact")], _FakeEpisodeModel(), ) facts_by_content = {fact["content"]: fact for fact in store.get_all_memories()} existing_payload = captured["existing"][0] assert existing_payload[0] == "memory-1" assert getattr(existing_payload[1], "content") == "Existing fact" assert facts_by_content["Existing fact"]["id"] == "memory-1" assert facts_by_content["Existing fact"]["created_at"] == original["created_at"] assert facts_by_content["New fact"]["id"] != "memory-1" def test_langmem_remove_doc_deletes_fact_instead_of_persisting_marker(self, tmp_path, monkeypatch): from lilith_agent import memory import langmem class RemoveDocLike: def __repr_name__(self): return "RemoveDoc" class FakeExtractedMemory: def __init__(self, id, content): self.id = id self.content = content class FakeMemory: content = "Keep fact" class FakeManager: def invoke(self, payload): return [ FakeExtractedMemory("fact-1", RemoveDocLike()), FakeExtractedMemory("fact-2", FakeMemory()), ] store = MemoryStore(tmp_path / "test.sqlite") store.save_memories([ {"id": "fact-1", "content": "Delete fact"}, {"id": "fact-2", "content": "Keep fact"}, ]) monkeypatch.setattr(memory, "_store", store) monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager()) memory.extract_and_compress_facts( [HumanMessage(content="Forget delete fact"), HumanMessage(content="Keep fact")], _FakeEpisodeModel(), ) facts_by_id = {fact["id"]: fact for fact in store.get_all_memories()} assert "fact-1" not in facts_by_id assert facts_by_id["fact-2"]["content"] == "Keep fact" def test_successful_fact_extraction_also_records_episode(self, tmp_path, monkeypatch): from lilith_agent import memory import langmem class FakeManager: def invoke(self, payload): return [{"content": "New fact"}] class FakeModel: def invoke(self, prompt): class Response: content = "Successful lesson" return Response() store = MemoryStore(tmp_path / "test.sqlite") monkeypatch.setattr(memory, "_store", store) monkeypatch.setattr(langmem, "create_memory_manager", lambda model, enable_deletes: FakeManager()) memory.extract_and_compress_facts( [HumanMessage(content="Remember useful fact"), HumanMessage(content="New fact")], FakeModel(), ) episodes = store.get_recent_episodes() assert episodes[0]["summary"] == "Successful lesson" def test_one_turn_exchange_is_eligible_for_memory_extraction(self): assert MIN_MESSAGES_FOR_EXTRACTION <= 2 class TestForgetSafety: def test_delete_memory_prefix_does_not_treat_sql_wildcards_as_patterns(self, tmp_path): store = MemoryStore(tmp_path / "test.sqlite") store.save_memories([ {"id": "abc-1", "content": "First fact"}, {"id": "def-1", "content": "Second fact"}, ]) assert store.delete_memory_prefix("%") == 0 assert len(store.get_all_memories()) == 2 assert store.delete_memory_prefix("abc") == 1 assert [fact["id"] for fact in store.get_all_memories()] == ["def-1"] class TestSearchMemoryLimits: def test_max_results_applies_to_combined_facts_and_episodes(self, tmp_path, monkeypatch): from lilith_agent import memory store = MemoryStore(tmp_path / "test.sqlite") store.save_memories([ {"id": "fact-1", "content": "alpha fact one"}, {"id": "fact-2", "content": "alpha fact two"}, ]) store.add_episode("alpha task one", "alpha summary one", "success") store.add_episode("alpha task two", "alpha summary two", "success") monkeypatch.setattr(memory, "_store", store) result = memory.search_memory_store("alpha", max_results=3) result_count = result.count("[fact]") + result.count("[episode]") assert result_count == 3