forked from Rockachopa/Timmy-time-dashboard
_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.
260 lines
11 KiB
Python
260 lines
11 KiB
Python
"""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()
|