"""Tests for timmy.memory_system — Memory system context injection.""" from unittest.mock import patch import pytest from timmy.memory_system import ( HotMemory, MemorySystem, jot_note, log_decision, reset_memory_system, store_last_reflection, ) @pytest.fixture(autouse=True) def reset_singleton(): """Reset the memory system singleton before each test.""" reset_memory_system() yield reset_memory_system() class TestGetSystemContext: """Tests for get_system_context() soul injection.""" def test_includes_soul_content_when_exists(self, tmp_path): """get_system_context() includes soul content when soul.md exists.""" # Create temp soul.md soul_dir = tmp_path / "memory" / "self" soul_dir.mkdir(parents=True) soul_md = soul_dir / "soul.md" soul_md.write_text("# Soul\n\nI am Timmy. Unique-soul-marker.\n") # Create temp MEMORY.md memory_md = tmp_path / "MEMORY.md" memory_md.write_text("# Timmy Hot Memory\n\n## Current Status\n\nOperational\n") with ( patch("timmy.memory_system.SOUL_PATH", soul_md), patch("timmy.memory_system.HOT_MEMORY_PATH", memory_md), ): memory = MemorySystem() context = memory.get_system_context() assert "## Soul Identity" in context assert "Unique-soul-marker" in context def test_works_without_soul_file(self, tmp_path): """get_system_context() works fine when soul.md is missing.""" # Point to non-existent soul.md nonexistent_soul = tmp_path / "memory" / "self" / "soul.md" # Create temp MEMORY.md memory_md = tmp_path / "MEMORY.md" memory_md.write_text("# Timmy Hot Memory\n\n## Current Status\n\nOperational\n") with ( patch("timmy.memory_system.SOUL_PATH", nonexistent_soul), patch("timmy.memory_system.HOT_MEMORY_PATH", memory_md), ): memory = MemorySystem() context = memory.get_system_context() # Should not contain soul section but should still have hot memory assert "## Soul Identity" not in context assert "## Hot Memory" in context assert "Operational" in context def test_soul_comes_first_in_context(self, tmp_path): """Soul identity should be the first section in context.""" # Create temp soul.md soul_dir = tmp_path / "memory" / "self" soul_dir.mkdir(parents=True) soul_md = soul_dir / "soul.md" soul_md.write_text("# Soul\n\nI am Timmy.\n") # Create temp MEMORY.md memory_md = tmp_path / "MEMORY.md" memory_md.write_text("# Timmy Hot Memory\n\n## Current Status\n\nOperational\n") with ( patch("timmy.memory_system.SOUL_PATH", soul_md), patch("timmy.memory_system.HOT_MEMORY_PATH", memory_md), ): memory = MemorySystem() context = memory.get_system_context() # Soul should appear before hot memory soul_pos = context.find("## Soul Identity") hot_pos = context.find("## Hot Memory") assert soul_pos < hot_pos, "Soul Identity should come before Hot Memory" class TestReadSoul: """Tests for read_soul() method.""" def test_read_soul_returns_content_when_exists(self, tmp_path): """read_soul() returns content when soul.md exists.""" soul_dir = tmp_path / "memory" / "self" soul_dir.mkdir(parents=True) soul_md = soul_dir / "soul.md" soul_md.write_text("# Soul\n\nTest content.\n") with patch("timmy.memory_system.SOUL_PATH", soul_md): memory = MemorySystem() content = memory.read_soul() assert "Test content" in content def test_read_soul_returns_empty_when_missing(self, tmp_path): """read_soul() returns empty string when soul.md doesn't exist.""" nonexistent = tmp_path / "no_such_soul.md" with patch("timmy.memory_system.SOUL_PATH", nonexistent): memory = MemorySystem() content = memory.read_soul() assert content == "" class TestHotMemoryCorruptionGuard: """Tests for MEMORY.md corruption prevention (#252).""" def test_update_section_no_truncation_without_prune_marker(self, tmp_path): """update_section must not corrupt content when *Prune date:* marker is absent. Regression test: rfind("*Prune date:") returning -1 caused full_content[:-1] to chop the last character. """ mem_file = tmp_path / "MEMORY.md" mem_file.write_text("# Timmy Hot Memory\n\n## Known Facts\n\n- fact one\n- fact two\n") hot = HotMemory() hot.path = mem_file # Patch read() to return file content (simulating DB unavailable) with patch.object(hot, "read", return_value=mem_file.read_text()): hot.update_section("Last Reflection", "**Time:** 2026-03-15\n**Seed:** freeform") result = mem_file.read_text() # Must NOT have any truncation — all original content intact assert "- fact one" in result assert "- fact two" in result assert "## Last Reflection" in result assert "freeform" in result # The old bug: last char would get sliced off assert result.count("fact tw") == 1 # "fact two" not "fact tw" def test_update_section_replaces_existing_section(self, tmp_path): """update_section correctly replaces an existing section.""" content = ( "# Timmy Hot Memory\n\n" "## Known Facts\n\n- fact one\n\n" "## Last Reflection\n\nold reflection\n\n" ) mem_file = tmp_path / "MEMORY.md" mem_file.write_text(content) hot = HotMemory() hot.path = mem_file with patch.object(hot, "read", return_value=mem_file.read_text()): hot.update_section("Last Reflection", "new reflection") result = mem_file.read_text() assert "new reflection" in result assert "old reflection" not in result assert "- fact one" in result def test_update_section_rejects_empty_content(self, tmp_path): """update_section refuses to write empty content.""" mem_file = tmp_path / "MEMORY.md" mem_file.write_text("# Timmy Hot Memory\n\noriginal\n") hot = HotMemory() hot.path = mem_file hot.update_section("Test", "") # File should be unchanged assert mem_file.read_text() == "# Timmy Hot Memory\n\noriginal\n" def test_update_section_truncates_oversized_content(self, tmp_path): """update_section caps content at 2000 chars to prevent bloat.""" mem_file = tmp_path / "MEMORY.md" mem_file.write_text("# Timmy Hot Memory\n\n") hot = HotMemory() hot.path = mem_file huge = "x" * 3000 with patch.object(hot, "read", return_value=mem_file.read_text()): hot.update_section("Huge", huge) result = mem_file.read_text() assert "[truncated]" in result class TestStoreLastReflection: """Tests for store_last_reflection / recall_last_reflection (#252).""" def test_store_and_recall_reflection(self, tmp_path): """Reflection round-trips through the database.""" db_path = tmp_path / "test_memory.db" with patch("timmy.memory_system.DB_PATH", db_path): from timmy.memory_system import recall_last_reflection store_last_reflection("**Time:** 2026-03-15\n**Seed:** freeform\n**Thought:** test") result = recall_last_reflection() assert result is not None assert "freeform" in result assert "test" in result def test_store_replaces_previous_reflection(self, tmp_path): """Only the latest reflection is kept — no accumulation.""" db_path = tmp_path / "test_memory.db" with patch("timmy.memory_system.DB_PATH", db_path): from timmy.memory_system import recall_last_reflection store_last_reflection("first reflection") store_last_reflection("second reflection") result = recall_last_reflection() assert "second reflection" in result # DB should only have one reflection row import sqlite3 conn = sqlite3.connect(str(db_path)) count = conn.execute( "SELECT COUNT(*) FROM memories WHERE memory_type = 'reflection'" ).fetchone()[0] conn.close() assert count == 1 def test_store_empty_reflection_is_noop(self, tmp_path): """Empty reflections are silently ignored.""" db_path = tmp_path / "test_memory.db" with patch("timmy.memory_system.DB_PATH", db_path): from timmy.memory_system import recall_last_reflection store_last_reflection("") store_last_reflection(" ") result = recall_last_reflection() assert result is None class TestJotNote: """Tests for jot_note() artifact tool.""" def test_saves_note_file(self, tmp_path): """jot_note creates a markdown file with title and body.""" notes_dir = tmp_path / "notes" with patch("timmy.memory_system.NOTES_DIR", notes_dir): result = jot_note("My Title", "Some body text") assert "Note saved:" in result files = list(notes_dir.glob("*.md")) assert len(files) == 1 content = files[0].read_text() assert "# My Title" in content assert "Some body text" in content assert "Created:" in content def test_slug_in_filename(self, tmp_path): """Filename contains a slug derived from the title.""" notes_dir = tmp_path / "notes" with patch("timmy.memory_system.NOTES_DIR", notes_dir): jot_note("Hello World!", "body") files = list(notes_dir.glob("*.md")) assert "hello-world" in files[0].name def test_rejects_empty_title(self): """jot_note rejects empty title.""" assert "title is empty" in jot_note("", "body") assert "title is empty" in jot_note(" ", "body") def test_rejects_empty_body(self): """jot_note rejects empty body.""" assert "body is empty" in jot_note("title", "") assert "body is empty" in jot_note("title", " ") class TestLogDecision: """Tests for log_decision() artifact tool.""" def test_creates_decision_log(self, tmp_path): """log_decision creates the log file and appends an entry.""" log_file = tmp_path / "decisions.md" with patch("timmy.memory_system.DECISION_LOG", log_file): result = log_decision("Use SQLite for storage") assert "Decision logged:" in result content = log_file.read_text() assert "# Decision Log" in content assert "Use SQLite for storage" in content def test_appends_multiple_decisions(self, tmp_path): """Multiple decisions are appended to the same file.""" log_file = tmp_path / "decisions.md" with patch("timmy.memory_system.DECISION_LOG", log_file): log_decision("First decision") log_decision("Second decision") content = log_file.read_text() assert "First decision" in content assert "Second decision" in content def test_includes_rationale(self, tmp_path): """Rationale is included when provided.""" log_file = tmp_path / "decisions.md" with patch("timmy.memory_system.DECISION_LOG", log_file): log_decision("Use Redis", "Fast in-memory cache") content = log_file.read_text() assert "Use Redis" in content assert "Fast in-memory cache" in content def test_rejects_empty_decision(self): """log_decision rejects empty decision string.""" assert "decision is empty" in log_decision("") assert "decision is empty" in log_decision(" ")