"""v0.3 PR #3 — Auto-memory tests. Covers: 1. Bootstrap creates an empty MEMORY.md index on first call (idempotent). 2. `add_memory_entry` writes a `.md` file + appends an index pointer line. 3. Auto-slug derivation + collision handling (`-2`, `-3`, … suffix). 4. `remove_memory_entry` deletes file + prunes index line (refuses to delete the index itself). 5. `list_memory_paths` puts MEMORY.md first (deepagents reads in order, so the index becomes the table-of-contents header in the system prompt). 6. `memory_entries_summary` skips the index, returns (name, size) pairs. 7. ``project_memory_dir`` rejects empty project_key. 8. Different `project_key`s isolate memory between repos. 9. End-to-end via deepagents MemoryMiddleware: `build_agent(..., memory_paths_override=[...])` constructs an agent whose middleware stack contains a MemoryMiddleware with the right sources. """ from __future__ import annotations from pathlib import Path from typing import Any import pytest from my_deepagent.config import load_config from my_deepagent.memory import ( INDEX_FILENAME, _slugify, add_memory_entry, ensure_memory_initialized, list_memory_paths, memory_entries_summary, project_memory_dir, remove_memory_entry, ) @pytest.fixture def memory_dir(tmp_path: Path) -> Path: return tmp_path / "memory" # --------------------------------------------------------------------------- # Bootstrap # --------------------------------------------------------------------------- def test_ensure_memory_initialized_creates_index(memory_dir: Path) -> None: assert not memory_dir.exists() index = ensure_memory_initialized(memory_dir) assert index.is_file() assert index.name == INDEX_FILENAME body = index.read_text(encoding="utf-8") assert "Auto-memory" in body assert "## Entries" in body def test_ensure_memory_initialized_is_idempotent(memory_dir: Path) -> None: ensure_memory_initialized(memory_dir) index = memory_dir / INDEX_FILENAME original_content = index.read_text(encoding="utf-8") # Re-running should not overwrite an existing index. ensure_memory_initialized(memory_dir) assert index.read_text(encoding="utf-8") == original_content # --------------------------------------------------------------------------- # add_memory_entry # --------------------------------------------------------------------------- def test_add_memory_entry_writes_file_and_updates_index(memory_dir: Path) -> None: result = add_memory_entry(memory_dir, "프로젝트 핵심: 위크닥 CLI MVP") assert result.path.is_file() body = result.path.read_text(encoding="utf-8") assert "프로젝트 핵심" in body assert body.startswith("---\nname: ") assert "type:" in body assert result.scrubbed is False index = (memory_dir / INDEX_FILENAME).read_text(encoding="utf-8") assert result.path.name in index assert "프로젝트 핵심" in index def test_add_memory_entry_handles_slug_collision(memory_dir: Path) -> None: r1 = add_memory_entry(memory_dir, "Same first line") r2 = add_memory_entry(memory_dir, "Same first line\nsecond entry body") r3 = add_memory_entry(memory_dir, "Same first line\nthird entry body") p1, p2, p3 = r1.path, r2.path, r3.path assert p1.name != p2.name != p3.name stems = sorted([p1.stem, p2.stem, p3.stem]) assert stems[0] == "same-first-line" assert stems[1] == "same-first-line-2" assert stems[2] == "same-first-line-3" def test_add_memory_entry_rejects_empty_content(memory_dir: Path) -> None: with pytest.raises(ValueError, match="non-empty"): add_memory_entry(memory_dir, " \n \t ") def test_add_memory_entry_explicit_name_override(memory_dir: Path) -> None: r = add_memory_entry(memory_dir, "Random body text", name="My Custom Slug!!") assert r.path.stem == "my-custom-slug" def test_add_memory_entry_scrubs_openrouter_key(memory_dir: Path) -> None: r = add_memory_entry( memory_dir, "save this for me: sk-or-v1-abcdefghijklmnop1234567890", ) body = r.path.read_text(encoding="utf-8") assert "sk-or-v1-abcdefghijklmnop" not in body assert "" in body assert r.scrubbed is True def test_add_memory_entry_infers_user_type(memory_dir: Path) -> None: r = add_memory_entry(memory_dir, "I prefer fish shell over bash") assert r.memory_type == "user" def test_add_memory_entry_infers_feedback_type(memory_dir: Path) -> None: r = add_memory_entry(memory_dir, "don't mock the database in integration tests") assert r.memory_type == "feedback" def test_add_memory_entry_explicit_type_overrides_heuristic(memory_dir: Path) -> None: r = add_memory_entry(memory_dir, "I prefer fish shell", memory_type="reference") assert r.memory_type == "reference" # --------------------------------------------------------------------------- # remove_memory_entry # --------------------------------------------------------------------------- def test_remove_memory_entry_by_slug(memory_dir: Path) -> None: r = add_memory_entry(memory_dir, "to be forgotten") assert remove_memory_entry(memory_dir, r.path.stem) is True assert not r.path.exists() index_body = (memory_dir / INDEX_FILENAME).read_text(encoding="utf-8") assert r.path.name not in index_body def test_remove_memory_entry_by_filename(memory_dir: Path) -> None: r = add_memory_entry(memory_dir, "to be forgotten by full filename") assert remove_memory_entry(memory_dir, r.path.name) is True assert not r.path.exists() def test_remove_memory_entry_missing_returns_false(memory_dir: Path) -> None: ensure_memory_initialized(memory_dir) assert remove_memory_entry(memory_dir, "no-such-slug") is False def test_remove_memory_entry_refuses_to_delete_index(memory_dir: Path) -> None: ensure_memory_initialized(memory_dir) assert remove_memory_entry(memory_dir, INDEX_FILENAME) is False assert (memory_dir / INDEX_FILENAME).is_file() def test_remove_memory_entry_empty_input(memory_dir: Path) -> None: assert remove_memory_entry(memory_dir, "") is False # --------------------------------------------------------------------------- # list_memory_paths # --------------------------------------------------------------------------- def test_list_memory_paths_puts_index_first(memory_dir: Path) -> None: ensure_memory_initialized(memory_dir) add_memory_entry(memory_dir, "alpha entry") add_memory_entry(memory_dir, "beta entry") paths = list_memory_paths(memory_dir) assert len(paths) == 3 assert Path(paths[0]).name == INDEX_FILENAME # Remaining ordered lexicographically. rest = [Path(p).name for p in paths[1:]] assert rest == sorted(rest) def test_list_memory_paths_missing_dir_returns_empty(tmp_path: Path) -> None: assert list_memory_paths(tmp_path / "no-such-dir") == [] # --------------------------------------------------------------------------- # memory_entries_summary # --------------------------------------------------------------------------- def test_memory_entries_summary_skips_index(memory_dir: Path) -> None: ensure_memory_initialized(memory_dir) add_memory_entry(memory_dir, "first") add_memory_entry(memory_dir, "second") summary = memory_entries_summary(memory_dir) names = [n for (n, _sz) in summary] assert INDEX_FILENAME not in names assert len(summary) == 2 for _name, size in summary: assert size > 0 def test_memory_entries_summary_missing_dir(tmp_path: Path) -> None: assert memory_entries_summary(tmp_path / "no-such") == [] # --------------------------------------------------------------------------- # project_memory_dir # --------------------------------------------------------------------------- def test_project_memory_dir_uses_project_key(tmp_path: Path) -> None: cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") d1 = project_memory_dir(cfg, "abcdef0123456789") d2 = project_memory_dir(cfg, "fedcba9876543210") assert d1 != d2 assert d1.parent.name == "abcdef0123456789" assert d2.parent.name == "fedcba9876543210" def test_project_memory_dir_rejects_empty_key(tmp_path: Path) -> None: cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") with pytest.raises(ValueError, match="non-empty"): project_memory_dir(cfg, "") # --------------------------------------------------------------------------- # Slugify # --------------------------------------------------------------------------- def test_slugify_basic() -> None: assert _slugify("Hello World!") == "hello-world" def test_slugify_unicode_falls_back() -> None: # Korean text becomes purely separator characters → fallback "entry". assert _slugify("프로젝트 설정") == "entry" def test_slugify_truncates_to_max_len() -> None: long = "a" * 100 s = _slugify(long, max_len=20) assert len(s) <= 20 # --------------------------------------------------------------------------- # Integration: build_agent threads memory paths through to deepagents # --------------------------------------------------------------------------- def test_build_agent_passes_memory_paths_to_deepagents( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Ensure `build_agent(..., memory_paths_override=[...])` causes `create_deep_agent` to receive the combined memory list. """ from my_deepagent import session as session_mod from my_deepagent.persona import Persona captured: dict[str, Any] = {} def fake_create_deep_agent(**kwargs: Any) -> Any: captured.update(kwargs) return object() # opaque sentinel monkeypatch.setattr(session_mod, "create_deep_agent", fake_create_deep_agent) # Persona doubles as MemoryMiddleware source when populated; here we leave # persona.memory_files empty and rely entirely on the override. persona = Persona( name="test-persona", version=1, backend="openrouter", model="openrouter:deepseek/deepseek-chat", provider_origin="CN/DeepSeek", capabilities=("code_edit",), max_risk_level="high", system_prompt="System prompt for test persona (must be ≥10 chars)", deepagents_backend="state", ) cfg = load_config( workspace_root=tmp_path, data_dir=tmp_path / "data", openrouter_api_key="test-key", ) # Build memory paths under tmp_path so the test is hermetic. memdir = tmp_path / "mem" ensure_memory_initialized(memdir) add_memory_entry(memdir, "do not commit secrets") paths = list_memory_paths(memdir) _agent = session_mod.build_agent( persona, cfg, root_dir=tmp_path, memory_paths_override=paths, ) assert "memory" in captured assert captured["memory"] == paths def test_build_agent_omits_memory_kwarg_when_no_paths( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Empty memory list → no `memory=` kwarg passed to deepagents (so the MemoryMiddleware is not constructed).""" from my_deepagent import session as session_mod from my_deepagent.persona import Persona captured: dict[str, Any] = {} def fake_create_deep_agent(**kwargs: Any) -> Any: captured.update(kwargs) return object() monkeypatch.setattr(session_mod, "create_deep_agent", fake_create_deep_agent) persona = Persona( name="test-persona", version=1, backend="openrouter", model="openrouter:deepseek/deepseek-chat", provider_origin="CN/DeepSeek", capabilities=("code_edit",), max_risk_level="high", system_prompt="System prompt for test persona (must be ≥10 chars)", deepagents_backend="state", ) cfg = load_config( workspace_root=tmp_path, data_dir=tmp_path / "data", openrouter_api_key="test-key", ) _agent = session_mod.build_agent(persona, cfg, root_dir=tmp_path) assert "memory" not in captured