fix: dedup memory consolidation with existing memory search (#105)
Some checks failed
Tests / lint (pull_request) Successful in 3s
Tests / test (pull_request) Failing after 44s

_maybe_consolidate() now checks get_memories(subject=agent_id)
before storing. Skips if a memory of the same type (pattern/anomaly)
was created within the last hour. Prevents duplicate consolidation
entries on repeated task completion/failure events.

Also restructured branching: neutral success rates (0.3-0.8) now
return early instead of falling through.

9 new tests. 1465 total passing.
This commit is contained in:
2026-03-14 20:04:18 -04:00
parent 825f9e6bb4
commit b12e29b92e
2 changed files with 289 additions and 1 deletions

View File

@@ -0,0 +1,259 @@
"""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()