"""Tests for memory consolidation deduplication (issue #105). Verifies that _maybe_consolidate() skips creating duplicate memories when a recent memory of the same type already exists for the agent. """ from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, patch class TestConsolidationDedup: """Test consolidation deduplication behavior.""" def _make_event(self, event_type, agent_id="agent-test"): """Create a mock SparkEvent.""" event = MagicMock() event.event_type = event_type event.agent_id = agent_id return event def _make_memory(self, memory_type, subject, created_at): """Create a mock SparkMemory.""" memory = MagicMock() memory.memory_type = memory_type memory.subject = subject memory.created_at = created_at return memory def _get_enough_events_for_consolidation(self, num_completions=4, num_failures=0): """Return enough events to trigger consolidation (5+ events, 3+ outcomes).""" events = [] # Add some non-outcome events first for _ in range(2): events.append(self._make_event("task_posted")) # Add completion/failure events to trigger pattern (>=0.8 success rate) for _ in range(num_completions): events.append(self._make_event("task_completed")) for _ in range(num_failures): events.append(self._make_event("task_failed")) return events @patch("spark.engine.spark_memory") def test_creates_memory_when_none_exists(self, mock_spark_memory): """Test that consolidation creates a memory when none exists.""" from spark.engine import SparkEngine # Setup: enough events to trigger pattern memory, no existing memories mock_spark_memory.get_events.return_value = self._get_enough_events_for_consolidation( num_completions=4 ) mock_spark_memory.get_memories.return_value = [] # No existing memories engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-test") # Should have called store_memory once mock_spark_memory.store_memory.assert_called_once() call_kwargs = mock_spark_memory.store_memory.call_args.kwargs assert call_kwargs["memory_type"] == "pattern" assert call_kwargs["subject"] == "agent-test" @patch("spark.engine.spark_memory") def test_skips_when_recent_memory_exists(self, mock_spark_memory): """Test that consolidation SKIPS when a recent memory (< 1 hour) exists.""" from spark.engine import SparkEngine # Setup: enough events to trigger pattern memory mock_spark_memory.get_events.return_value = self._get_enough_events_for_consolidation( num_completions=4 ) # Existing recent pattern memory (30 minutes ago) recent_time = (datetime.now(UTC) - timedelta(minutes=30)).isoformat() mock_spark_memory.get_memories.return_value = [ self._make_memory("pattern", "agent-test", recent_time) ] engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-test") # Should NOT have called store_memory mock_spark_memory.store_memory.assert_not_called() @patch("spark.engine.spark_memory") def test_creates_when_existing_memory_is_old(self, mock_spark_memory): """Test that consolidation creates new memory when existing is old (> 1 hour).""" from spark.engine import SparkEngine # Setup: enough events to trigger pattern memory mock_spark_memory.get_events.return_value = self._get_enough_events_for_consolidation( num_completions=4 ) # Existing old pattern memory (2 hours ago) old_time = (datetime.now(UTC) - timedelta(hours=2)).isoformat() mock_spark_memory.get_memories.return_value = [ self._make_memory("pattern", "agent-test", old_time) ] engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-test") # Should have called store_memory (old memory doesn't block) mock_spark_memory.store_memory.assert_called_once() call_kwargs = mock_spark_memory.store_memory.call_args.kwargs assert call_kwargs["memory_type"] == "pattern" @patch("spark.engine.spark_memory") def test_pattern_vs_anomaly_type_distinction(self, mock_spark_memory): """Test that pattern vs anomaly type distinction works correctly.""" from spark.engine import SparkEngine # Setup: events that trigger anomaly (low success rate: 1/4 = 0.25) events = [] events.append(self._make_event("task_posted")) events.append(self._make_event("task_posted")) events.append(self._make_event("task_completed")) # 1 success events.append(self._make_event("task_failed")) events.append(self._make_event("task_failed")) events.append(self._make_event("task_failed")) # 3 failures mock_spark_memory.get_events.return_value = events mock_spark_memory.get_memories.return_value = [] engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-test") # Should create an anomaly memory mock_spark_memory.store_memory.assert_called_once() call_kwargs = mock_spark_memory.store_memory.call_args.kwargs assert call_kwargs["memory_type"] == "anomaly" assert "struggling" in call_kwargs["content"] @patch("spark.engine.spark_memory") def test_anomaly_skips_when_recent_anomaly_exists(self, mock_spark_memory): """Test that anomaly consolidation skips when recent anomaly exists.""" from spark.engine import SparkEngine # Setup: events that trigger anomaly events = [] events.append(self._make_event("task_posted")) events.append(self._make_event("task_posted")) events.append(self._make_event("task_completed")) events.append(self._make_event("task_failed")) events.append(self._make_event("task_failed")) events.append(self._make_event("task_failed")) mock_spark_memory.get_events.return_value = events # Existing recent anomaly memory recent_time = (datetime.now(UTC) - timedelta(minutes=30)).isoformat() mock_spark_memory.get_memories.return_value = [ self._make_memory("anomaly", "agent-test", recent_time) ] engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-test") # Should NOT have called store_memory mock_spark_memory.store_memory.assert_not_called() @patch("spark.engine.spark_memory") def test_pattern_does_not_skip_on_recent_anomaly(self, mock_spark_memory): """Test that pattern consolidation still runs when recent anomaly exists.""" from spark.engine import SparkEngine # Setup: events that trigger pattern (high success rate) mock_spark_memory.get_events.return_value = self._get_enough_events_for_consolidation( num_completions=4 ) # Existing recent anomaly memory (different type) recent_time = (datetime.now(UTC) - timedelta(minutes=30)).isoformat() mock_spark_memory.get_memories.return_value = [ self._make_memory("anomaly", "agent-test", recent_time) ] engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-test") # Should create pattern memory (different type, so no dedup) mock_spark_memory.store_memory.assert_called_once() call_kwargs = mock_spark_memory.store_memory.call_args.kwargs assert call_kwargs["memory_type"] == "pattern" @patch("spark.engine.spark_memory") def test_anomaly_does_not_skip_on_recent_pattern(self, mock_spark_memory): """Test that anomaly consolidation still runs when recent pattern exists.""" from spark.engine import SparkEngine # Setup: events that trigger anomaly events = [] events.append(self._make_event("task_posted")) events.append(self._make_event("task_posted")) events.append(self._make_event("task_completed")) events.append(self._make_event("task_failed")) events.append(self._make_event("task_failed")) events.append(self._make_event("task_failed")) mock_spark_memory.get_events.return_value = events # Existing recent pattern memory (different type) recent_time = (datetime.now(UTC) - timedelta(minutes=30)).isoformat() mock_spark_memory.get_memories.return_value = [ self._make_memory("pattern", "agent-test", recent_time) ] engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-test") # Should create anomaly memory (different type, so no dedup) mock_spark_memory.store_memory.assert_called_once() call_kwargs = mock_spark_memory.store_memory.call_args.kwargs assert call_kwargs["memory_type"] == "anomaly" @patch("spark.engine.spark_memory") def test_no_consolidation_for_neutral_success_rate(self, mock_spark_memory): """Test that neutral success rates (0.3 < rate < 0.8) don't create memories.""" from spark.engine import SparkEngine # Setup: events that result in neutral success rate (2/4 = 0.5) events = [] events.append(self._make_event("task_posted")) events.append(self._make_event("task_posted")) events.append(self._make_event("task_completed")) events.append(self._make_event("task_completed")) events.append(self._make_event("task_failed")) events.append(self._make_event("task_failed")) mock_spark_memory.get_events.return_value = events engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-test") # Should NOT have called store_memory or get_memories mock_spark_memory.get_memories.assert_not_called() mock_spark_memory.store_memory.assert_not_called() @patch("spark.engine.spark_memory") def test_handles_invalid_created_at_gracefully(self, mock_spark_memory): """Test that invalid created_at timestamps don't crash dedup.""" from spark.engine import SparkEngine # Setup: enough events to trigger pattern memory mock_spark_memory.get_events.return_value = self._get_enough_events_for_consolidation( num_completions=4 ) # Existing memory with invalid created_at mock_spark_memory.get_memories.return_value = [ self._make_memory("pattern", "agent-test", "invalid-timestamp") ] engine = SparkEngine(enabled=True) # Should not raise an exception engine._maybe_consolidate("agent-test") # Should still create new memory (invalid timestamp ignored) mock_spark_memory.store_memory.assert_called_once()