deer-flow/backend/tests/test_memory_storage_markdown.py
RongfuShuiping 4bf028d048
feat(memory): add incremental agent-scoped Markdown fact storage (#4279)
* feat(memory): add Markdown fact storage repository

* docs(memory): explain storage rewrite for beginners

* docs(memory): fix plan markdown formatting

* refactor(memory): separate global summaries from agent facts

* fix(memory): make Markdown fact updates incremental and safe

* Update STORAGE_REWRITE_CHANGES.md

* Delete docs/plans/STORAGE_REWRITE_PLAN.md

* Delete docs/plans/STORAGE_REWRITE_CHANGES.md

* fix(memory): address Markdown storage review feedback

* fix(memory): complete review follow-ups

* fix(memory): resolve storage review findings

* feat(memory): add proactive Markdown migration CLI

* fix(memory): harden Markdown storage concurrency

* fix(memory): harden markdown storage migration

* fix(memory): close migration review gaps

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: qin-chenghan <qinchenghan@Huawei.com>
2026-07-22 07:46:03 +08:00

1190 lines
52 KiB
Python

"""File/JSON + single-fact Markdown storage contract tests."""
import copy
import gc
import hashlib
import json
import os
import shutil
import weakref
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from deerflow.agents.memory import MemoryCorruptionError
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
from deerflow.agents.memory.backends.deermem.deermem.core import storage as storage_module
from deerflow.agents.memory.backends.deermem.deermem.core.paths import fact_file_path
from deerflow.agents.memory.backends.deermem.deermem.core.storage import (
FileMemoryStorage,
MemoryFactRevisionConflict,
MemoryManifestRevisionConflict,
MemoryRevisionConflict,
MemoryStorageCorruption,
create_empty_memory,
)
@pytest.fixture
def storage(tmp_path: Path) -> FileMemoryStorage:
return FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)))
def _memory_with_fact(content: str = "Project uses Python 3.12") -> dict:
memory = create_empty_memory()
memory["facts"] = [
{
"id": "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ",
"content": content,
"category": "constraint",
"topics": ["python", "runtime"],
"confidence": 0.95,
"createdAt": "2026-07-17T00:00:00Z",
"source": {"type": "manual", "threadId": "thread-1"},
"revision": 1,
}
]
return memory
def test_agent_scope_uses_fact_directories_but_one_user_memory_file(storage: FileMemoryStorage, tmp_path: Path) -> None:
assert storage.save(_memory_with_fact("A"), "agent-a", user_id="alice")
assert storage.save(_memory_with_fact("B"), "agent-b", user_id="alice")
assert storage.load("agent-a", user_id="alice")["facts"][0]["content"] == "A"
assert storage.load("agent-b", user_id="alice")["facts"][0]["content"] == "B"
assert (tmp_path / "users" / "alice" / "memory.json").exists()
assert not (tmp_path / "users" / "alice" / "agents" / "agent-a" / "memory.json").exists()
assert list((tmp_path / "users" / "alice" / "agents" / "agent-a" / "facts").glob("**/*.md"))
assert list((tmp_path / "users" / "alice" / "agents" / "agent-b" / "facts").glob("**/*.md"))
def test_thread_id_is_source_only_not_storage_bucket(storage: FileMemoryStorage) -> None:
fact = _memory_with_fact()["facts"][0]
assert fact["source"]["threadId"] == "thread-1"
assert storage.save(_memory_with_fact(), "agent-a", user_id="alice")
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
assert "thread-1" not in str(memory_path)
def test_memory_json_contains_only_global_summaries_and_agent_fact_is_markdown(storage: FileMemoryStorage) -> None:
memory = _memory_with_fact()
memory["user"]["workContext"] = {"summary": "global profile", "updatedAt": "now"}
assert storage.save(memory, "agent-a", user_id="alice")
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
persisted = json.loads(memory_path.read_text(encoding="utf-8"))
assert persisted["version"] == "2.0"
assert "facts" not in persisted
assert set(persisted) == {"version", "revision", "lastUpdated", "user", "history"}
fact_path = fact_file_path(memory_path, "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", agent_name="agent-a")
text = fact_path.read_text(encoding="utf-8")
assert text.startswith("---\n")
assert "user_id: alice" in text
assert "agent_name: agent-a" in text
assert "# Project uses Python 3.12" in text
def test_load_keeps_frontend_shape_but_only_agent_load_returns_facts(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact(), "agent-a", user_id="alice")
global_memory = storage.load(user_id="alice")
agent_memory = storage.load("agent-a", user_id="alice")
assert global_memory["facts"] == []
assert agent_memory["facts"][0]["topics"] == ["python", "runtime"]
assert agent_memory["facts"][0]["scope"] == {"userId": "alice", "agentName": "agent-a"}
def test_agent_save_does_not_overwrite_global_summaries(storage: FileMemoryStorage) -> None:
global_memory = create_empty_memory()
global_memory["user"]["workContext"] = {"summary": "works remotely", "updatedAt": "global"}
assert storage.save(global_memory, user_id="alice")
agent_memory = storage.load("agent-a", user_id="alice")
agent_memory["user"]["workContext"] = {"summary": "project secret", "updatedAt": "agent"}
agent_memory["facts"] = _memory_with_fact()["facts"]
assert storage.save(agent_memory, "agent-a", user_id="alice", expected_revision=1)
assert storage.load(user_id="alice")["user"]["workContext"]["summary"] == "works remotely"
def test_removed_fact_is_physically_deleted(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact(), "agent-a", user_id="alice")
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
fact_path = fact_file_path(memory_path, "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", agent_name="agent-a")
assert fact_path.exists()
empty = create_empty_memory()
assert storage.save(empty, "agent-a", user_id="alice")
assert not fact_path.exists()
def test_cached_document_is_not_mutable_by_caller(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact(), "agent-a", user_id="alice")
first = storage.load("agent-a", user_id="alice")
first["facts"][0]["content"] = "mutated outside storage"
second = storage.load("agent-a", user_id="alice")
assert second["facts"][0]["content"] == "Project uses Python 3.12"
def test_cached_load_does_not_scan_all_fact_files(storage: FileMemoryStorage, monkeypatch: pytest.MonkeyPatch) -> None:
assert storage.save(_memory_with_fact(), "agent-a", user_id="alice")
storage.load("agent-a", user_id="alice")
def fail_fact_scan(*args, **kwargs):
raise AssertionError("cached load scanned the agent fact directory")
monkeypatch.setattr(storage_module, "agent_facts_directory", fail_fact_scan)
assert storage.load("agent-a", user_id="alice")["facts"][0]["content"] == "Project uses Python 3.12"
def test_shared_json_signature_invalidates_cache_after_other_storage_writes(tmp_path: Path) -> None:
config = DeerMemConfig(storage_path=str(tmp_path))
first = FileMemoryStorage(config)
second = FileMemoryStorage(config)
assert first.save(_memory_with_fact(), "agent-a", user_id="alice")
cached = first.load("agent-a", user_id="alice")
changed = copy.deepcopy(cached["facts"][0])
changed["content"] = "changed by another storage instance"
second.apply_changes(
{"upserts": [changed], "upsertRevisions": {changed["id"]: changed["revision"]}},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=cached["revision"],
allow_manifest_rebase=True,
)
assert first.load("agent-a", user_id="alice")["facts"][0]["content"] == "changed by another storage instance"
def test_revision_invalidates_cache_when_file_metadata_signature_collides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A coarse filesystem may report the same mtime/size for two writes."""
monkeypatch.setattr(storage_module, "_file_signature", lambda path: (1, 1))
config = DeerMemConfig(storage_path=str(tmp_path))
first = FileMemoryStorage(config)
second = FileMemoryStorage(config)
assert first.save(_memory_with_fact(), "agent-a", user_id="alice")
cached = first.load("agent-a", user_id="alice")
changed = copy.deepcopy(cached["facts"][0])
changed["content"] = "same metadata, newer repository revision"
second.apply_changes(
{"upserts": [changed], "upsertRevisions": {changed["id"]: changed["revision"]}},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=cached["revision"],
allow_manifest_rebase=True,
)
assert first.load("agent-a", user_id="alice")["facts"][0]["content"] == "same metadata, newer repository revision"
def test_fact_paths_shard_by_sha256_id_digest(tmp_path: Path) -> None:
memory_path = tmp_path / "users" / "alice" / "memory.json"
fact_ids = ["fact_00000000", "fact_11111111", "fact_22222222", "external-123"]
paths = [fact_file_path(memory_path, fact_id, agent_name="agent-a") for fact_id in fact_ids]
for fact_id, path in zip(fact_ids, paths, strict=True):
assert path.parent.name == hashlib.sha256(fact_id.encode("utf-8")).hexdigest()[:2]
assert len({path.parent.name for path in paths}) > 1
def test_corrupt_manifest_raises_and_is_not_treated_as_empty(storage: FileMemoryStorage) -> None:
path = storage._get_memory_file_path(user_id="alice")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{broken", encoding="utf-8")
with pytest.raises(MemoryStorageCorruption):
storage.load(user_id="alice")
assert path.read_text(encoding="utf-8") == "{broken"
def test_manifest_revision_conflict_rejects_stale_write(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact(), "agent-a", user_id="alice")
current = storage.load("agent-a", user_id="alice")
assert current["revision"] == 1
assert storage.save(_memory_with_fact("new"), "agent-a", user_id="alice", expected_revision=1)
with pytest.raises(MemoryRevisionConflict):
storage.save(_memory_with_fact("stale"), "agent-a", user_id="alice", expected_revision=1)
def test_partial_summary_patch_preserves_omitted_sibling_sections(storage: FileMemoryStorage) -> None:
memory = create_empty_memory()
memory["user"]["workContext"] = {"summary": "old work", "updatedAt": "old"}
memory["user"]["personalContext"] = {"summary": "keep personal", "updatedAt": "old"}
memory["user"]["topOfMind"] = {"summary": "keep focus", "updatedAt": "old"}
assert storage.save(memory, user_id="alice")
storage.apply_changes(
{"summaries": {"user": {"workContext": {"summary": "new work", "updatedAt": "new"}}}},
user_id="alice",
expected_manifest_revision=1,
)
updated = storage.load(user_id="alice")
assert updated["user"]["workContext"] == {"summary": "new work", "updatedAt": "new"}
assert updated["user"]["personalContext"] == {"summary": "keep personal", "updatedAt": "old"}
assert updated["user"]["topOfMind"] == {"summary": "keep focus", "updatedAt": "old"}
def test_storage_delegates_index_lifecycle_and_search_to_retrieval(tmp_path: Path) -> None:
class FakeRetrieval:
def __init__(self) -> None:
self.upserts = []
self.removes = []
def upsert(self, fact, *, scope, path):
self.upserts.append((fact["id"], scope, path))
def remove(self, fact_id, *, scope):
self.removes.append((fact_id, scope))
def search(self, query, *, scopes, top_k, mode, filters):
return [{"id": "fact-result", "score": 0.9, "query": query}]
retrieval = FakeRetrieval()
scoped = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=retrieval)
assert scoped.save(_memory_with_fact(), "agent-a", user_id="alice")
assert retrieval.upserts[0][1] == {"userId": "alice", "agentName": "agent-a"}
assert scoped.search_facts("python", scopes=[{"userId": "alice", "agentName": "agent-a"}])[0]["score"] == 0.9
assert scoped.save(create_empty_memory(), "agent-a", user_id="alice")
assert retrieval.removes == [("fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", {"userId": "alice", "agentName": "agent-a"})]
def test_prepared_journal_restores_previous_manifest_and_fact(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact("original"), "agent-a", user_id="alice")
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
memory = json.loads(memory_path.read_text(encoding="utf-8"))
fact_id = "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ"
fact_path = fact_file_path(memory_path, fact_id, agent_name="agent-a")
operation_id = "op-recovery-test"
recovery = memory_path.parent / ".recovery" / operation_id
recovery.mkdir(parents=True)
shutil.copy2(memory_path, recovery / "memory.json")
shutil.copy2(fact_path, recovery / f"{fact_id}.md")
relative_fact_path = fact_path.relative_to(memory_path.parent).as_posix()
journal = {
"operationId": operation_id,
"state": "prepared",
"agentName": "agent-a",
"expectedRevision": memory["revision"],
"nextRevision": memory["revision"] + 1,
"factIds": [fact_id],
"oldEntries": {fact_id: {"path": relative_fact_path}},
}
(memory_path.parent / ".memory.journal.json").write_text(json.dumps(journal), encoding="utf-8")
fact_path.write_text("corrupt in-progress content", encoding="utf-8")
loaded = storage.load("agent-a", user_id="alice")
assert loaded["facts"][0]["content"] == "original"
assert not (memory_path.parent / ".memory.journal.json").exists()
def test_fact_repository_applies_upsert_and_physical_delete(storage: FileMemoryStorage) -> None:
first = storage.upsert_fact(
_memory_with_fact()["facts"][0],
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=0,
expected_fact_revision=None,
)
assert first["revision"] == 1
assert first["complete"] is False
assert storage.get_fact("fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", user_id="alice", agent_name="agent-a")["content"] == "Project uses Python 3.12"
updated = copy.deepcopy(first["upsertedFacts"][0])
updated["content"] = "Project uses Python 3.13"
second = storage.apply_changes(
{"upserts": [updated]},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=1,
)
assert second["upsertedFacts"][0]["content"] == "Project uses Python 3.13"
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
fact_path = fact_file_path(memory_path, updated["id"], agent_name="agent-a")
assert fact_path.exists()
third = storage.delete_fact(
updated["id"],
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=2,
expected_fact_revision=second["upsertedFacts"][0]["revision"],
)
assert third["deletedFactIds"] == [updated["id"]]
assert not fact_path.exists()
def test_upsert_fact_uses_separate_manifest_and_fact_revisions(storage: FileMemoryStorage) -> None:
created = storage.upsert_fact(
_memory_with_fact("first")["facts"][0],
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=0,
expected_fact_revision=None,
)
changed = copy.deepcopy(created["upsertedFacts"][0])
changed["content"] = "updated"
updated = storage.upsert_fact(
changed,
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=created["revision"],
expected_fact_revision=changed["revision"],
)
assert updated["upsertedFacts"][0]["content"] == "updated"
def test_revision_conflicts_have_stable_manifest_and_fact_subtypes(storage: FileMemoryStorage) -> None:
storage.upsert_fact(
_memory_with_fact("first")["facts"][0],
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=0,
expected_fact_revision=None,
)
with pytest.raises(MemoryManifestRevisionConflict):
storage.apply_changes(
{"summaries": {"user": {}, "history": {}}},
user_id="alice",
expected_manifest_revision=0,
)
with pytest.raises(MemoryFactRevisionConflict):
storage.upsert_fact(
_memory_with_fact("duplicate")["facts"][0],
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=1,
expected_fact_revision=None,
)
def test_search_facts_declares_and_uses_substring_fallback(storage: FileMemoryStorage) -> None:
storage.upsert_fact(_memory_with_fact()["facts"][0], user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
results = storage.search_facts(
"python",
scopes=[{"userId": "alice", "agentName": "agent-a"}],
)
assert results[0]["fact"]["content"] == "Project uses Python 3.12"
assert results[0]["matchType"] == "substring"
assert storage.retrieval_status()["mode"] == "substring_fallback"
assert "substring-fallback" in storage.capabilities()
def test_strict_scope_and_custom_manifest_filename(tmp_path: Path) -> None:
strict = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path), strict_user_scope=True, manifest_filename="index.json"))
with pytest.raises(ValueError, match="user_id"):
strict.load()
strict.upsert_fact(_memory_with_fact()["facts"][0], user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
assert (tmp_path / "users" / "alice" / "index.json").exists()
def test_explicit_migrate_converts_legacy_json(storage: FileMemoryStorage) -> None:
path = storage._get_memory_file_path(user_id="alice")
path.parent.mkdir(parents=True)
path.write_text(json.dumps(_memory_with_fact()), encoding="utf-8")
original = path.read_bytes()
report = storage.migrate(user_id="alice", agent_name="agent-a")
assert report["migrated"] is True
assert report["fromVersion"] == "1.0"
assert report["toVersion"] == "2.0"
assert "facts" not in json.loads(path.read_text(encoding="utf-8"))
assert fact_file_path(path, "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", agent_name="agent-a").exists()
assert path.with_name("memory.json.v1.bak").read_bytes() == original
def test_explicit_migration_notifies_retrieval_adapter(tmp_path: Path) -> None:
class RecordingRetrieval:
def __init__(self) -> None:
self.upserts: list[tuple[str, dict, str]] = []
def upsert(self, fact, *, scope, path):
self.upserts.append((fact["id"], scope, path))
def remove(self, fact_id, *, scope):
pass
def search(self, query, *, scopes, top_k, mode, filters):
return []
retrieval = RecordingRetrieval()
scoped = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=retrieval)
path = scoped._get_memory_file_path(user_id="alice")
path.parent.mkdir(parents=True)
path.write_text(json.dumps(_memory_with_fact("legacy indexed fact")), encoding="utf-8")
scoped.migrate(user_id="alice", agent_name="agent-a")
scoped.migrate(user_id="alice", agent_name="agent-a")
assert [(fact_id, scope) for fact_id, scope, _ in retrieval.upserts] == [("fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", {"userId": "alice", "agentName": "agent-a"})]
assert retrieval.upserts[0][2].endswith("fact_01HZZZZZZZZZZZZZZZZZZZZZZZ.md")
def test_first_agent_load_removes_legacy_per_agent_memory_json(storage: FileMemoryStorage) -> None:
user_memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
legacy_path = user_memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.parent.mkdir(parents=True)
legacy_path.write_text(json.dumps(_memory_with_fact("legacy agent fact")), encoding="utf-8")
original = legacy_path.read_bytes()
loaded = storage.load("agent-a", user_id="alice")
assert loaded["facts"][0]["content"] == "legacy agent fact"
assert not legacy_path.exists()
assert legacy_path.with_name("memory.json.v1.bak").read_bytes() == original
assert user_memory_path.exists()
assert "facts" not in json.loads(user_memory_path.read_text(encoding="utf-8"))
def test_lazy_agent_migration_notifies_retrieval_adapter(tmp_path: Path) -> None:
class RecordingRetrieval:
def __init__(self) -> None:
self.upserts: list[str] = []
def upsert(self, fact, *, scope, path):
self.upserts.append(fact["id"])
def remove(self, fact_id, *, scope):
pass
def search(self, query, *, scopes, top_k, mode, filters):
return []
retrieval = RecordingRetrieval()
scoped = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=retrieval)
memory_path = scoped._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.parent.mkdir(parents=True)
legacy_path.write_text(json.dumps(_memory_with_fact("lazy indexed fact")), encoding="utf-8")
assert scoped.load("agent-a", user_id="alice")["facts"][0]["content"] == "lazy indexed fact"
assert retrieval.upserts == ["fact_01HZZZZZZZZZZZZZZZZZZZZZZZ"]
def test_clear_all_migrates_and_then_removes_unread_legacy_agent_facts(storage: FileMemoryStorage) -> None:
global_memory = create_empty_memory()
global_memory["user"]["workContext"] = {"summary": "canonical summary", "updatedAt": "now"}
assert storage.save(global_memory, user_id="alice")
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.parent.mkdir(parents=True)
legacy = _memory_with_fact("legacy fact must stay cleared")
legacy["user"]["workContext"] = {"summary": "conflicting legacy summary", "updatedAt": "then"}
legacy_path.write_text(json.dumps(legacy), encoding="utf-8")
config_path = legacy_path.parent / "config.yaml"
config_path.write_text("name: agent-a\n", encoding="utf-8")
cleared = storage.clear_all(user_id="alice")
assert cleared["facts"] == []
assert not legacy_path.exists()
assert legacy_path.with_name("memory.json.v1.bak").exists()
assert storage.load("agent-a", user_id="alice")["facts"] == []
assert config_path.read_text(encoding="utf-8") == "name: agent-a\n"
def test_clear_all_legacy_migration_leaves_retrieval_fact_removed(tmp_path: Path) -> None:
class RecordingRetrieval:
def __init__(self) -> None:
self.events: list[tuple[str, str]] = []
def upsert(self, fact, *, scope, path):
self.events.append(("upsert", fact["id"]))
def remove(self, fact_id, *, scope):
self.events.append(("remove", fact_id))
def search(self, query, *, scopes, top_k, mode, filters):
return []
retrieval = RecordingRetrieval()
scoped = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=retrieval)
memory_path = scoped._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.parent.mkdir(parents=True)
legacy_path.write_text(json.dumps(_memory_with_fact("legacy indexed then cleared")), encoding="utf-8")
scoped.clear_all(user_id="alice")
assert retrieval.events == [
("upsert", "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ"),
("remove", "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ"),
]
def test_migration_rejects_a_different_existing_v1_backup(storage: FileMemoryStorage) -> None:
path = storage._get_memory_file_path(user_id="alice")
path.parent.mkdir(parents=True)
path.write_text(json.dumps(_memory_with_fact("source v1")), encoding="utf-8")
original = path.read_bytes()
path.with_name("memory.json.v1.bak").write_text(json.dumps(_memory_with_fact("older different v1")), encoding="utf-8")
with pytest.raises(MemoryStorageCorruption, match="migration backup"):
storage.migrate(user_id="alice", agent_name="agent-a")
assert path.read_bytes() == original
assert not fact_file_path(path, "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", agent_name="agent-a").exists()
def test_migration_does_not_modify_v1_source_when_backup_write_fails(
storage: FileMemoryStorage,
monkeypatch: pytest.MonkeyPatch,
) -> None:
path = storage._get_memory_file_path(user_id="alice")
path.parent.mkdir(parents=True)
path.write_text(json.dumps(_memory_with_fact("source v1")), encoding="utf-8")
original = path.read_bytes()
real_atomic_write = storage_module._atomic_write
def fail_backup(path_to_write: Path, raw: bytes) -> None:
if path_to_write.name.endswith(".v1.bak"):
raise OSError("backup disk unavailable")
real_atomic_write(path_to_write, raw)
monkeypatch.setattr(storage_module, "_atomic_write", fail_backup)
with pytest.raises(OSError, match="backup disk unavailable"):
storage.migrate(user_id="alice", agent_name="agent-a")
assert path.read_bytes() == original
assert not fact_file_path(path, "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", agent_name="agent-a").exists()
def test_fact_repository_requires_agent_name(storage: FileMemoryStorage) -> None:
with pytest.raises(ValueError, match="agent_name"):
storage.upsert_fact(_memory_with_fact()["facts"][0], user_id="alice", expected_manifest_revision=0)
def test_full_save_rejects_non_object_facts_without_deleting_existing(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact("keep me"), "agent-a", user_id="alice")
invalid = storage.load("agent-a", user_id="alice")
invalid["facts"] = [None]
assert storage.save(invalid, "agent-a", user_id="alice", expected_revision=1) is False
assert storage.load("agent-a", user_id="alice")["facts"][0]["content"] == "keep me"
def test_agent_full_save_requires_facts_field_without_deleting_existing(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact("keep me"), "agent-a", user_id="alice")
assert storage.save({"user": {}, "history": {}}, "agent-a", user_id="alice", expected_revision=1) is False
assert storage.load("agent-a", user_id="alice")["facts"][0]["content"] == "keep me"
def test_legacy_agent_migration_merges_existing_canonical_facts(storage: FileMemoryStorage) -> None:
canonical = _memory_with_fact("canonical")
canonical["facts"][0]["id"] = "fact_canonical"
assert storage.save(canonical, "agent-a", user_id="alice")
legacy = _memory_with_fact("legacy")
legacy["facts"][0]["id"] = "fact_legacy"
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.write_text(json.dumps(legacy), encoding="utf-8")
loaded = storage.load("agent-a", user_id="alice")
assert {fact["content"] for fact in loaded["facts"]} == {"canonical", "legacy"}
def test_legacy_agent_migration_rejects_same_id_content_conflict(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact("canonical"), "agent-a", user_id="alice")
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.write_text(json.dumps(_memory_with_fact("conflicting legacy")), encoding="utf-8")
with pytest.raises(MemoryStorageCorruption, match="migration conflict"):
storage.load("agent-a", user_id="alice")
legacy_path.unlink()
assert storage.reload("agent-a", user_id="alice")["facts"][0]["content"] == "canonical"
def test_concurrent_legacy_agent_migration_is_idempotent(tmp_path: Path) -> None:
config = DeerMemConfig(storage_path=str(tmp_path))
first = FileMemoryStorage(config)
second = FileMemoryStorage(config)
memory_path = first._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.parent.mkdir(parents=True)
legacy_path.write_text(json.dumps(_memory_with_fact("legacy")), encoding="utf-8")
with ThreadPoolExecutor(max_workers=2) as pool:
loaded = list(pool.map(lambda store: store.load("agent-a", user_id="alice"), (first, second)))
assert all([fact["content"] for fact in document["facts"]] == ["legacy"] for document in loaded)
assert not legacy_path.exists()
def test_default_manager_read_auto_migrates_global_legacy_facts(storage: FileMemoryStorage, tmp_path: Path) -> None:
path = storage._get_memory_file_path(user_id="alice")
path.parent.mkdir(parents=True)
legacy = _memory_with_fact("old global fact")
legacy["user"]["workContext"] = {"summary": "keep global profile", "updatedAt": "2026-01-01T00:00:00Z"}
path.write_text(json.dumps(legacy), encoding="utf-8")
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
loaded = manager.get_memory(user_id="alice")
loaded_again = manager.reload_memory(user_id="alice")
assert [fact["content"] for fact in loaded["facts"]] == ["old global fact"]
assert [fact["content"] for fact in loaded_again["facts"]] == ["old global fact"]
persisted = json.loads(path.read_text(encoding="utf-8"))
assert "facts" not in persisted
assert persisted["user"]["workContext"]["summary"] == "keep global profile"
assert fact_file_path(path, "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", agent_name="__default__").exists()
@pytest.mark.parametrize("custom_first", [True, False])
def test_default_bucket_is_isolated_from_custom_lead_agent(tmp_path: Path, custom_first: bool) -> None:
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
custom_dir = tmp_path / "users" / "alice" / "agents" / "lead-agent"
custom_dir.mkdir(parents=True)
(custom_dir / "config.yaml").write_text("name: lead-agent\n", encoding="utf-8")
if custom_first:
manager.create_fact("custom fact", agent_name="lead-agent", user_id="alice")
manager.create_fact("default fact", user_id="alice")
else:
manager.create_fact("default fact", user_id="alice")
manager.create_fact("custom fact", agent_name="lead-agent", user_id="alice")
assert [fact["content"] for fact in manager.get_memory(user_id="alice")["facts"]] == ["default fact"]
assert [fact["content"] for fact in manager.get_memory(agent_name="lead-agent", user_id="alice")["facts"]] == ["custom fact"]
assert list((tmp_path / "users" / "alice" / "agents" / "__default__" / "facts").glob("**/*.md"))
assert list((tmp_path / "users" / "alice" / "agents" / "lead-agent" / "facts").glob("**/*.md"))
def test_deermem_canonicalizes_agent_names_to_lowercase(tmp_path: Path) -> None:
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
manager.create_fact("case-insensitive fact", agent_name="Lead-Agent", user_id="alice")
lower = manager.get_memory(agent_name="lead-agent", user_id="alice")
upper = manager.get_memory(agent_name="LEAD-AGENT", user_id="alice")
stored = manager._storage.load("lead-agent", user_id="alice")["facts"][0]
assert [fact["id"] for fact in lower["facts"]] == [fact["id"] for fact in upper["facts"]]
assert stored["scope"]["agentName"] == "lead-agent"
def test_old_implicit_lead_agent_bucket_moves_to_reserved_default(tmp_path: Path) -> None:
config = DeerMemConfig(storage_path=str(tmp_path))
old_storage = FileMemoryStorage(config)
old_storage.upsert_fact(
_memory_with_fact("fact written by the previous PR version")["facts"][0],
user_id="alice",
agent_name="lead-agent",
expected_manifest_revision=0,
)
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
loaded = manager.get_memory(user_id="alice")
assert [fact["content"] for fact in loaded["facts"]] == ["fact written by the previous PR version"]
assert not (tmp_path / "users" / "alice" / "agents" / "lead-agent").exists()
assert list((tmp_path / "users" / "alice" / "agents" / "__default__" / "facts").glob("**/*.md"))
def test_old_implicit_bucket_with_unknown_files_is_preserved(tmp_path: Path) -> None:
legacy_dir = tmp_path / "users" / "alice" / "agents" / "lead-agent"
legacy_dir.mkdir(parents=True)
unknown = legacy_dir / "SOUL.md"
unknown.write_text("possibly a partially created custom agent", encoding="utf-8")
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
with pytest.raises(MemoryCorruptionError, match="unexpected entries"):
manager.get_memory(user_id="alice")
assert unknown.read_text(encoding="utf-8") == "possibly a partially created custom agent"
def test_create_returns_fresh_full_view_after_disjoint_rebase(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
competing_storage = FileMemoryStorage(manager._config)
real_apply_changes = manager._storage.apply_changes
injected = False
def apply_with_competing_create(change_set, **scope):
nonlocal injected
if not injected:
injected = True
competing_fact = _memory_with_fact("competing fact")["facts"][0]
competing_fact["id"] = "fact_competing"
competing_storage.apply_changes(
{"upserts": [competing_fact], "upsertRevisions": {"fact_competing": None}},
agent_name=scope["agent_name"],
user_id=scope["user_id"],
expected_manifest_revision=scope["expected_manifest_revision"],
)
return real_apply_changes(change_set, **scope)
monkeypatch.setattr(manager._storage, "apply_changes", apply_with_competing_create)
returned, created_id = manager.create_fact("requested fact", user_id="alice")
fresh = manager.reload_memory(user_id="alice")
assert created_id is not None
assert {fact["id"] for fact in returned["facts"]} == {fact["id"] for fact in fresh["facts"]}
assert {fact["content"] for fact in returned["facts"]} == {"competing fact", "requested fact"}
def test_scoped_clear_recomputes_after_competing_create(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
manager.create_fact("existing fact", agent_name="agent-a", user_id="alice")
competing_storage = FileMemoryStorage(manager._config)
real_apply_changes = manager._storage.apply_changes
injected = False
def apply_with_competing_create(change_set, **scope):
nonlocal injected
if not injected:
injected = True
competing_fact = _memory_with_fact("competing fact")["facts"][0]
competing_fact.update({"id": "fact_competing", "revision": 1})
competing_storage.apply_changes(
{"upserts": [competing_fact], "upsertRevisions": {"fact_competing": None}},
agent_name=scope["agent_name"],
user_id=scope["user_id"],
expected_manifest_revision=scope["expected_manifest_revision"],
)
return real_apply_changes(change_set, **scope)
monkeypatch.setattr(manager._storage, "apply_changes", apply_with_competing_create)
returned = manager.clear_memory(agent_name="agent-a", user_id="alice")
fresh = manager.reload_memory(agent_name="agent-a", user_id="alice")
assert returned["facts"] == []
assert fresh["facts"] == []
def test_max_facts_is_recomputed_after_competing_create(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
manager = DeerMem(backend_config={"storage_path": str(tmp_path), "max_facts": 10})
initial = create_empty_memory()
initial["facts"] = []
for index in range(9):
fact = copy.deepcopy(_memory_with_fact(f"existing {index}")["facts"][0])
fact.update({"id": f"fact_existing_{index}", "confidence": 0.9})
initial["facts"].append(fact)
assert manager._storage.save(initial, "agent-a", user_id="alice")
competing_storage = FileMemoryStorage(manager._config)
real_apply_changes = manager._storage.apply_changes
injected = False
def apply_with_competing_create(change_set, **scope):
nonlocal injected
if not injected:
injected = True
competing_fact = copy.deepcopy(_memory_with_fact("competing fact")["facts"][0])
competing_fact.update({"id": "fact_competing", "confidence": 0.9})
competing_storage.apply_changes(
{"upserts": [competing_fact], "upsertRevisions": {"fact_competing": None}},
agent_name=scope["agent_name"],
user_id=scope["user_id"],
expected_manifest_revision=scope["expected_manifest_revision"],
)
return real_apply_changes(change_set, **scope)
monkeypatch.setattr(manager._storage, "apply_changes", apply_with_competing_create)
returned, created_id = manager.create_fact(
"requested fact",
confidence=0.95,
agent_name="agent-a",
user_id="alice",
)
fresh = manager.reload_memory(agent_name="agent-a", user_id="alice")
assert created_id is not None
assert len(returned["facts"]) <= 10
assert len(fresh["facts"]) <= 10
assert {fact["id"] for fact in returned["facts"]} == {fact["id"] for fact in fresh["facts"]}
def test_agent_fact_scope_must_match_requested_directory(storage: FileMemoryStorage) -> None:
assert storage.save(_memory_with_fact(), "agent-a", user_id="alice")
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
fact_path = fact_file_path(memory_path, "fact_01HZZZZZZZZZZZZZZZZZZZZZZZ", agent_name="agent-a")
fact_path.write_text(fact_path.read_text(encoding="utf-8").replace("agent_name: agent-a", "agent_name: agent-b"), encoding="utf-8")
with pytest.raises(MemoryStorageCorruption, match="scope mismatch"):
storage.reload("agent-a", user_id="alice")
def test_legacy_fact_path_must_remain_below_user_directory(storage: FileMemoryStorage, tmp_path: Path) -> None:
outside = tmp_path / "outside.md"
outside.write_text("do not read", encoding="utf-8")
path = storage._get_memory_file_path(user_id="alice")
path.parent.mkdir(parents=True)
path.write_text(
json.dumps(
{
"version": "2.0",
"revision": 0,
"user": {},
"history": {},
"facts": {"fact_escape": {"path": "../../outside.md"}},
}
),
encoding="utf-8",
)
with pytest.raises(MemoryStorageCorruption, match="escapes"):
storage.migrate(user_id="alice", agent_name="agent-a")
def test_fact_schema_rejects_invalid_collection_and_revision_types(storage: FileMemoryStorage) -> None:
invalid_topics = _memory_with_fact()["facts"][0]
invalid_topics["topics"] = "python"
with pytest.raises(ValueError, match="topics"):
storage.upsert_fact(invalid_topics, user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
invalid_revision = _memory_with_fact()["facts"][0]
invalid_revision["revision"] = []
with pytest.raises(ValueError, match="revision"):
storage.upsert_fact(invalid_revision, user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
def test_changed_fact_increments_revision_and_updated_at(storage: FileMemoryStorage) -> None:
created = storage.upsert_fact(_memory_with_fact()["facts"][0], user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
original = created["upsertedFacts"][0]
updated = copy.deepcopy(original)
updated["content"] = "Project uses Python 3.13"
result = storage.apply_changes({"upserts": [updated]}, user_id="alice", agent_name="agent-a", expected_manifest_revision=1)
changed = result["upsertedFacts"][0]
assert changed["revision"] == original["revision"] + 1
assert changed["updatedAt"] > original["updatedAt"]
assert changed["createdAt"] == original["createdAt"]
def test_single_fact_change_writes_and_notifies_only_that_fact(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
class RecordingRetrieval:
def __init__(self) -> None:
self.upserts: list[str] = []
def upsert(self, fact, *, scope, path):
self.upserts.append(fact["id"])
def remove(self, fact_id, *, scope):
pass
def search(self, query, *, scopes, top_k, mode, filters):
return []
retrieval = RecordingRetrieval()
scoped = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=retrieval)
memory = create_empty_memory()
first = _memory_with_fact("first")["facts"][0]
second = copy.deepcopy(first)
second.update({"id": "fact_second", "content": "second"})
memory["facts"] = [first, second]
assert scoped.save(memory, "agent-a", user_id="alice")
retrieval.upserts.clear()
markdown_writes: list[Path] = []
real_atomic_write = storage_module._atomic_write
def record_atomic_write(path: Path, raw: bytes) -> None:
if path.suffix == ".md":
markdown_writes.append(path)
real_atomic_write(path, raw)
monkeypatch.setattr(storage_module, "_atomic_write", record_atomic_write)
loaded = scoped.load("agent-a", user_id="alice")
changed = copy.deepcopy(next(fact for fact in loaded["facts"] if fact["id"] == first["id"]))
changed["content"] = "first changed"
scoped.apply_changes({"upserts": [changed]}, user_id="alice", agent_name="agent-a", expected_manifest_revision=1)
assert [path.stem for path in markdown_writes] == [first["id"]]
assert retrieval.upserts == [first["id"]]
def test_incremental_change_does_not_scan_all_fact_files(storage: FileMemoryStorage, monkeypatch: pytest.MonkeyPatch) -> None:
memory = create_empty_memory()
first = _memory_with_fact("first")["facts"][0]
second = copy.deepcopy(first)
second.update({"id": "fact_second", "content": "second"})
memory["facts"] = [first, second]
assert storage.save(memory, "agent-a", user_id="alice")
changed = copy.deepcopy(storage.get_fact(first["id"], user_id="alice", agent_name="agent-a"))
changed["content"] = "first changed"
def fail_full_scan(*args, **kwargs):
raise AssertionError("incremental change attempted to scan all facts")
monkeypatch.setattr(storage, "_load_agent_facts", fail_full_scan)
result = storage.apply_changes(
{"upserts": [changed]},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=1,
)
assert result["complete"] is False
assert [fact["id"] for fact in result["upsertedFacts"]] == [first["id"]]
assert result["upsertedFacts"][0]["content"] == "first changed"
def test_fresh_storage_incremental_result_does_not_hide_untouched_sibling(tmp_path: Path) -> None:
config = DeerMemConfig(storage_path=str(tmp_path))
writer = FileMemoryStorage(config)
memory = create_empty_memory()
first = _memory_with_fact("first")["facts"][0]
second = copy.deepcopy(first)
second.update({"id": "fact_second", "content": "second"})
memory["facts"] = [first, second]
assert writer.save(memory, "agent-a", user_id="alice")
fresh = FileMemoryStorage(config)
changed = copy.deepcopy(fresh.get_fact(first["id"], user_id="alice", agent_name="agent-a"))
changed["content"] = "first changed"
result = fresh.apply_changes(
{"upserts": [changed]},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=1,
)
assert result["complete"] is False
assert [fact["id"] for fact in result["upsertedFacts"]] == [first["id"]]
reloaded = fresh.load("agent-a", user_id="alice")
assert {fact["content"] for fact in reloaded["facts"]} == {"first changed", "second"}
def test_stale_user_revision_rebases_disjoint_fact_change_but_not_same_fact(storage: FileMemoryStorage) -> None:
memory = create_empty_memory()
first = _memory_with_fact("first")["facts"][0]
second = copy.deepcopy(first)
second.update({"id": "fact_second", "content": "second"})
memory["facts"] = [first, second]
assert storage.save(memory, "agent-a", user_id="alice")
snapshot = storage.load("agent-a", user_id="alice")
first_update = copy.deepcopy(next(fact for fact in snapshot["facts"] if fact["id"] == first["id"]))
second_update = copy.deepcopy(next(fact for fact in snapshot["facts"] if fact["id"] == second["id"]))
first_update["content"] = "first changed"
second_update["content"] = "second changed"
storage.apply_changes({"upserts": [first_update]}, user_id="alice", agent_name="agent-a", expected_manifest_revision=1)
rebased = storage.apply_changes(
{"upserts": [second_update]},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=1,
allow_manifest_rebase=True,
)
assert [fact["content"] for fact in rebased["upsertedFacts"]] == ["second changed"]
assert {fact["content"] for fact in storage.load("agent-a", user_id="alice")["facts"]} == {"first changed", "second changed"}
first_update["content"] = "stale overwrite"
with pytest.raises(MemoryRevisionConflict):
storage.apply_changes(
{"upserts": [first_update]},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=1,
allow_manifest_rebase=True,
)
def test_rebuild_index_continues_after_adapter_exception(tmp_path: Path) -> None:
class FlakyRetrieval:
def upsert(self, fact, *, scope, path):
if fact["id"] == "fact_bad":
raise RuntimeError("index unavailable")
def remove(self, fact_id, *, scope):
pass
def search(self, query, *, scopes, top_k, mode, filters):
return []
scoped = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=FlakyRetrieval())
memory = create_empty_memory()
good = _memory_with_fact("good")["facts"][0]
bad = copy.deepcopy(good)
bad.update({"id": "fact_bad", "content": "bad"})
memory["facts"] = [good, bad]
assert scoped.save(memory, "agent-a", user_id="alice")
result = scoped.rebuild_index([{"userId": "alice", "agentName": "agent-a"}])
assert result == {"supported": True, "indexed": 1, "failed": 1}
def test_full_rebuild_index_accepts_original_email_user_scope(tmp_path: Path) -> None:
class RecordingRetrieval:
def __init__(self) -> None:
self.fact_ids: list[str] = []
def upsert(self, fact, *, scope, path):
self.fact_ids.append(fact["id"])
def remove(self, fact_id, *, scope):
pass
def search(self, query, *, scopes, top_k, mode, filters):
return []
retrieval = RecordingRetrieval()
scoped = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=retrieval)
scoped.upsert_fact(
_memory_with_fact("email user fact")["facts"][0],
user_id="test@example.com",
agent_name="agent-a",
expected_manifest_revision=0,
expected_fact_revision=None,
)
retrieval.fact_ids.clear()
result = scoped.rebuild_index()
assert result == {"supported": True, "indexed": 1, "failed": 0}
assert retrieval.fact_ids == ["fact_01HZZZZZZZZZZZZZZZZZZZZZZZ"]
def test_scope_lock_cache_releases_unused_entries(storage: FileMemoryStorage) -> None:
key = storage._cache_key("agent-a", user_id="alice")
lock = storage._scope_lock(key)
lock_ref = weakref.ref(lock)
del lock
gc.collect()
assert lock_ref() is None
assert key not in storage._scope_locks
def test_atomic_write_syncs_parent_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
synced: list[Path] = []
monkeypatch.setattr(storage_module, "_fsync_parent_directory", lambda path: synced.append(path))
target = tmp_path / "nested" / "memory.json"
storage_module._atomic_write(target, b"{}")
assert synced == [target.parent]
def test_migrate_reports_legacy_agent_file(storage: FileMemoryStorage) -> None:
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.parent.mkdir(parents=True)
legacy_path.write_text(json.dumps(_memory_with_fact("legacy")), encoding="utf-8")
report = storage.migrate(user_id="alice", agent_name="agent-a")
assert report["migrated"] is True
assert not legacy_path.exists()
def test_migrate_preserves_legacy_summaries_before_deleting_agent_file(storage: FileMemoryStorage) -> None:
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.parent.mkdir(parents=True)
legacy = _memory_with_fact("legacy")
legacy["user"]["workContext"] = {"summary": "legacy profile", "updatedAt": "2026-01-01T00:00:00Z"}
legacy["history"]["recentMonths"] = {"summary": "legacy history", "updatedAt": "2026-01-01T00:00:00Z"}
legacy_path.write_text(json.dumps(legacy), encoding="utf-8")
storage.migrate(user_id="alice", agent_name="agent-a")
global_memory = storage.load(user_id="alice")
assert global_memory["user"]["workContext"]["summary"] == "legacy profile"
assert global_memory["history"]["recentMonths"]["summary"] == "legacy history"
assert not legacy_path.exists()
def test_migrate_keeps_legacy_file_when_summary_conflicts(storage: FileMemoryStorage) -> None:
global_memory = create_empty_memory()
global_memory["user"]["workContext"] = {"summary": "canonical profile", "updatedAt": "now"}
assert storage.save(global_memory, user_id="alice")
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
legacy_path = memory_path.parent / "agents" / "agent-a" / "memory.json"
legacy_path.parent.mkdir(parents=True)
legacy = _memory_with_fact("legacy")
legacy["user"]["workContext"] = {"summary": "different profile", "updatedAt": "then"}
legacy_path.write_text(json.dumps(legacy), encoding="utf-8")
with pytest.raises(MemoryStorageCorruption, match="summary migration conflict"):
storage.migrate(user_id="alice", agent_name="agent-a")
assert legacy_path.exists()
assert not fact_file_path(memory_path, legacy["facts"][0]["id"], agent_name="agent-a").exists()
def test_two_storage_instances_cannot_create_the_same_fact_id(tmp_path: Path) -> None:
config = DeerMemConfig(storage_path=str(tmp_path))
first_storage = FileMemoryStorage(config)
second_storage = FileMemoryStorage(config)
new_fact = _memory_with_fact("first writer")["facts"][0]
new_fact.pop("revision")
first_storage.apply_changes(
{"upserts": [copy.deepcopy(new_fact)]},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=0,
)
competing = copy.deepcopy(new_fact)
competing["content"] = "second writer"
with pytest.raises(MemoryRevisionConflict, match="must not already exist"):
second_storage.apply_changes(
{"upserts": [competing]},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=0,
allow_manifest_rebase=True,
)
stored = first_storage.get_fact(new_fact["id"], user_id="alice", agent_name="agent-a")
assert stored["content"] == "first writer"
def test_stale_summary_change_is_not_rebased_over_newer_summary(storage: FileMemoryStorage) -> None:
newer = create_empty_memory()
newer["user"]["workContext"] = {"summary": "newer", "updatedAt": "now"}
assert storage.save(newer, user_id="alice", expected_revision=0)
stale = create_empty_memory()
stale["user"]["workContext"] = {"summary": "stale", "updatedAt": "before"}
fact = _memory_with_fact("should not be committed")["facts"][0]
fact.pop("revision")
with pytest.raises(MemoryRevisionConflict):
storage.apply_changes(
{"upserts": [fact], "upsertRevisions": {fact["id"]: None}, "summaries": {"user": stale["user"], "history": stale["history"]}},
user_id="alice",
agent_name="agent-a",
expected_manifest_revision=0,
)
assert storage.load(user_id="alice")["user"]["workContext"]["summary"] == "newer"
assert not fact_file_path(storage._get_memory_file_path("agent-a", user_id="alice"), fact["id"], agent_name="agent-a").exists()
@pytest.mark.skipif(os.name != "nt", reason="Windows append-mode lock behavior")
def test_windows_lock_file_does_not_grow_per_acquisition(storage: FileMemoryStorage) -> None:
for _ in range(5):
assert storage.save(create_empty_memory(), user_id="alice")
lock_path = storage._get_memory_file_path(user_id="alice").parent / ".memory.lock"
assert lock_path.stat().st_size == 1