feat(memory): add working memory implementation (Issue #89)

Implements session-scoped ephemeral memory with:

Storage Backends:
- InMemoryStorage: Thread-safe fallback with TTL support and capacity limits
- RedisStorage: Primary storage with connection pooling and JSON serialization
- Auto-fallback from Redis to in-memory when unavailable

WorkingMemory Class:
- Key-value storage with TTL and reserved key protection
- Task state tracking with progress updates
- Scratchpad for reasoning steps with timestamps
- Checkpoint/snapshot support for recovery
- Factory methods for auto-configured storage

Tests:
- 55 unit tests covering all functionality
- Tests for basic ops, TTL, capacity, concurrency
- Tests for task state, scratchpad, checkpoints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-05 01:51:03 +01:00
parent c9d8c0835c
commit 4974233169
7 changed files with 1673 additions and 4 deletions

View File

@@ -0,0 +1,2 @@
# tests/unit/services/memory/working/__init__.py
"""Unit tests for working memory implementation."""

View File

@@ -0,0 +1,391 @@
# tests/unit/services/memory/working/test_memory.py
"""Unit tests for WorkingMemory class."""
import pytest
import pytest_asyncio
from app.services.memory.exceptions import MemoryNotFoundError
from app.services.memory.types import ScopeContext, ScopeLevel, TaskState
from app.services.memory.working.memory import WorkingMemory
from app.services.memory.working.storage import InMemoryStorage
@pytest.fixture
def scope() -> ScopeContext:
"""Create a test scope."""
return ScopeContext(
scope_type=ScopeLevel.SESSION,
scope_id="test-session-123",
)
@pytest.fixture
def storage() -> InMemoryStorage:
"""Create a test storage backend."""
return InMemoryStorage(max_keys=1000)
@pytest_asyncio.fixture
async def memory(scope: ScopeContext, storage: InMemoryStorage) -> WorkingMemory:
"""Create a WorkingMemory instance for testing."""
wm = WorkingMemory(scope=scope, storage=storage)
await wm._initialize()
return wm
class TestWorkingMemoryBasicOperations:
"""Tests for basic key-value operations."""
@pytest.mark.asyncio
async def test_set_and_get(self, memory: WorkingMemory) -> None:
"""Test basic set and get."""
await memory.set("key1", "value1")
result = await memory.get("key1")
assert result == "value1"
@pytest.mark.asyncio
async def test_get_with_default(self, memory: WorkingMemory) -> None:
"""Test get with default value."""
result = await memory.get("nonexistent", default="fallback")
assert result == "fallback"
@pytest.mark.asyncio
async def test_delete(self, memory: WorkingMemory) -> None:
"""Test delete operation."""
await memory.set("key1", "value1")
result = await memory.delete("key1")
assert result is True
assert await memory.exists("key1") is False
@pytest.mark.asyncio
async def test_exists(self, memory: WorkingMemory) -> None:
"""Test exists check."""
await memory.set("key1", "value1")
assert await memory.exists("key1") is True
assert await memory.exists("nonexistent") is False
@pytest.mark.asyncio
async def test_reserved_key_prefix(self, memory: WorkingMemory) -> None:
"""Test that keys starting with _ are rejected."""
with pytest.raises(ValueError, match="reserved"):
await memory.set("_internal", "value")
@pytest.mark.asyncio
async def test_cannot_delete_internal_keys(self, memory: WorkingMemory) -> None:
"""Test that internal keys cannot be deleted directly."""
with pytest.raises(ValueError, match="internal"):
await memory.delete("_task_state")
class TestWorkingMemoryListAndClear:
"""Tests for list and clear operations."""
@pytest.mark.asyncio
async def test_list_keys(self, memory: WorkingMemory) -> None:
"""Test listing keys."""
await memory.set("key1", "value1")
await memory.set("key2", "value2")
keys = await memory.list_keys()
assert set(keys) == {"key1", "key2"}
@pytest.mark.asyncio
async def test_list_keys_excludes_internal(self, memory: WorkingMemory) -> None:
"""Test that list_keys excludes internal keys."""
await memory.set("user_key", "value")
# Internal keys exist from initialization
keys = await memory.list_keys()
assert all(not k.startswith("_") for k in keys)
@pytest.mark.asyncio
async def test_list_keys_with_pattern(self, memory: WorkingMemory) -> None:
"""Test listing keys with pattern."""
await memory.set("prefix_a", "value1")
await memory.set("prefix_b", "value2")
await memory.set("other", "value3")
keys = await memory.list_keys("prefix_*")
assert set(keys) == {"prefix_a", "prefix_b"}
@pytest.mark.asyncio
async def test_get_all(self, memory: WorkingMemory) -> None:
"""Test getting all key-value pairs."""
await memory.set("key1", "value1")
await memory.set("key2", "value2")
result = await memory.get_all()
assert result == {"key1": "value1", "key2": "value2"}
@pytest.mark.asyncio
async def test_clear_preserves_internal_state(self, memory: WorkingMemory) -> None:
"""Test that clear preserves internal state."""
# Set some user data
await memory.set("user_key", "value")
# Set task state
state = TaskState(
task_id="task-1",
task_type="test",
description="Test task",
)
await memory.set_task_state(state)
# Clear
await memory.clear()
# User data should be gone
assert await memory.exists("user_key") is False
# Task state should be preserved
restored_state = await memory.get_task_state()
assert restored_state is not None
assert restored_state.task_id == "task-1"
class TestWorkingMemoryTaskState:
"""Tests for task state operations."""
@pytest.mark.asyncio
async def test_set_and_get_task_state(self, memory: WorkingMemory) -> None:
"""Test setting and getting task state."""
state = TaskState(
task_id="task-123",
task_type="code_review",
description="Review pull request",
status="in_progress",
current_step=2,
total_steps=5,
progress_percent=40.0,
context={"pr_id": 456},
)
await memory.set_task_state(state)
result = await memory.get_task_state()
assert result is not None
assert result.task_id == "task-123"
assert result.task_type == "code_review"
assert result.status == "in_progress"
assert result.current_step == 2
assert result.progress_percent == 40.0
assert result.context == {"pr_id": 456}
@pytest.mark.asyncio
async def test_get_task_state_none_when_not_set(
self, memory: WorkingMemory
) -> None:
"""Test that get_task_state returns None when not set."""
result = await memory.get_task_state()
assert result is None
@pytest.mark.asyncio
async def test_update_task_progress(self, memory: WorkingMemory) -> None:
"""Test updating task progress."""
state = TaskState(
task_id="task-123",
task_type="test",
description="Test",
current_step=1,
progress_percent=10.0,
status="running",
)
await memory.set_task_state(state)
updated = await memory.update_task_progress(
current_step=3,
progress_percent=60.0,
status="processing",
)
assert updated is not None
assert updated.current_step == 3
assert updated.progress_percent == 60.0
assert updated.status == "processing"
@pytest.mark.asyncio
async def test_update_task_progress_clamps_percent(
self, memory: WorkingMemory
) -> None:
"""Test that progress percent is clamped to 0-100."""
state = TaskState(
task_id="task-123",
task_type="test",
description="Test",
)
await memory.set_task_state(state)
updated = await memory.update_task_progress(progress_percent=150.0)
assert updated is not None
assert updated.progress_percent == 100.0
updated = await memory.update_task_progress(progress_percent=-10.0)
assert updated is not None
assert updated.progress_percent == 0.0
class TestWorkingMemoryScratchpad:
"""Tests for scratchpad operations."""
@pytest.mark.asyncio
async def test_append_and_get_scratchpad(self, memory: WorkingMemory) -> None:
"""Test appending to and getting scratchpad."""
await memory.append_scratchpad("First note")
await memory.append_scratchpad("Second note")
entries = await memory.get_scratchpad()
assert entries == ["First note", "Second note"]
@pytest.mark.asyncio
async def test_get_scratchpad_empty(self, memory: WorkingMemory) -> None:
"""Test getting empty scratchpad."""
entries = await memory.get_scratchpad()
assert entries == []
@pytest.mark.asyncio
async def test_get_scratchpad_with_timestamps(self, memory: WorkingMemory) -> None:
"""Test getting scratchpad with timestamps."""
await memory.append_scratchpad("Test note")
entries = await memory.get_scratchpad_with_timestamps()
assert len(entries) == 1
assert entries[0]["content"] == "Test note"
assert "timestamp" in entries[0]
@pytest.mark.asyncio
async def test_clear_scratchpad(self, memory: WorkingMemory) -> None:
"""Test clearing scratchpad."""
await memory.append_scratchpad("Note 1")
await memory.append_scratchpad("Note 2")
count = await memory.clear_scratchpad()
assert count == 2
entries = await memory.get_scratchpad()
assert entries == []
class TestWorkingMemoryCheckpoints:
"""Tests for checkpoint operations."""
@pytest.mark.asyncio
async def test_create_checkpoint(self, memory: WorkingMemory) -> None:
"""Test creating a checkpoint."""
await memory.set("key1", "value1")
await memory.set("key2", "value2")
checkpoint_id = await memory.create_checkpoint("Test checkpoint")
assert checkpoint_id is not None
assert len(checkpoint_id) == 8 # UUID prefix
@pytest.mark.asyncio
async def test_restore_checkpoint(self, memory: WorkingMemory) -> None:
"""Test restoring from a checkpoint."""
await memory.set("key1", "original")
checkpoint_id = await memory.create_checkpoint()
# Modify state
await memory.set("key1", "modified")
await memory.set("key2", "new")
# Restore
await memory.restore_checkpoint(checkpoint_id)
# Check restoration
assert await memory.get("key1") == "original"
# key2 didn't exist in checkpoint, so it should be gone
# But due to checkpoint being restored with clear, it's gone
@pytest.mark.asyncio
async def test_restore_nonexistent_checkpoint(self, memory: WorkingMemory) -> None:
"""Test restoring from nonexistent checkpoint raises error."""
with pytest.raises(MemoryNotFoundError):
await memory.restore_checkpoint("nonexistent")
@pytest.mark.asyncio
async def test_list_checkpoints(self, memory: WorkingMemory) -> None:
"""Test listing checkpoints."""
cp1 = await memory.create_checkpoint("First")
cp2 = await memory.create_checkpoint("Second")
checkpoints = await memory.list_checkpoints()
assert len(checkpoints) == 2
ids = [cp["id"] for cp in checkpoints]
assert cp1 in ids
assert cp2 in ids
@pytest.mark.asyncio
async def test_delete_checkpoint(self, memory: WorkingMemory) -> None:
"""Test deleting a checkpoint."""
checkpoint_id = await memory.create_checkpoint()
result = await memory.delete_checkpoint(checkpoint_id)
assert result is True
checkpoints = await memory.list_checkpoints()
assert len(checkpoints) == 0
class TestWorkingMemoryScope:
"""Tests for scope handling."""
@pytest.mark.asyncio
async def test_scope_property(
self, memory: WorkingMemory, scope: ScopeContext
) -> None:
"""Test scope property."""
assert memory.scope == scope
@pytest.mark.asyncio
async def test_for_session_factory(self) -> None:
"""Test for_session factory method."""
# This would normally try Redis and fall back to in-memory
# In tests, Redis won't be available, so it uses fallback
wm = await WorkingMemory.for_session(
session_id="session-abc",
project_id="project-123",
agent_instance_id="agent-456",
)
assert wm.scope.scope_type == ScopeLevel.SESSION
assert wm.scope.scope_id == "session-abc"
assert wm.scope.parent is not None
assert wm.scope.parent.scope_type == ScopeLevel.AGENT_INSTANCE
class TestWorkingMemoryHealth:
"""Tests for health and lifecycle."""
@pytest.mark.asyncio
async def test_is_healthy(self, memory: WorkingMemory) -> None:
"""Test health check."""
assert await memory.is_healthy() is True
@pytest.mark.asyncio
async def test_get_stats(self, memory: WorkingMemory) -> None:
"""Test getting stats."""
await memory.set("key1", "value1")
await memory.append_scratchpad("Note")
state = TaskState(task_id="t1", task_type="test", description="Test")
await memory.set_task_state(state)
stats = await memory.get_stats()
assert stats["scope_type"] == "session"
assert stats["scope_id"] == "test-session-123"
assert stats["user_keys"] == 1
assert stats["scratchpad_entries"] == 1
assert stats["has_task_state"] is True
@pytest.mark.asyncio
async def test_is_using_fallback(self, memory: WorkingMemory) -> None:
"""Test fallback detection."""
# In-memory storage is always fallback
assert memory.is_using_fallback is False # Not set in fixture
@pytest.mark.asyncio
async def test_close(self, memory: WorkingMemory) -> None:
"""Test close doesn't error."""
await memory.close() # Should not raise

View File

@@ -0,0 +1,303 @@
# tests/unit/services/memory/working/test_storage.py
"""Unit tests for working memory storage backends."""
import asyncio
import pytest
from app.services.memory.exceptions import MemoryStorageError
from app.services.memory.working.storage import InMemoryStorage
class TestInMemoryStorageBasicOperations:
"""Tests for basic InMemoryStorage operations."""
@pytest.fixture
def storage(self) -> InMemoryStorage:
"""Create a fresh storage instance."""
return InMemoryStorage(max_keys=100)
@pytest.mark.asyncio
async def test_set_and_get(self, storage: InMemoryStorage) -> None:
"""Test basic set and get."""
await storage.set("key1", "value1")
result = await storage.get("key1")
assert result == "value1"
@pytest.mark.asyncio
async def test_get_nonexistent_key(self, storage: InMemoryStorage) -> None:
"""Test getting a key that doesn't exist."""
result = await storage.get("nonexistent")
assert result is None
@pytest.mark.asyncio
async def test_set_overwrites_existing(self, storage: InMemoryStorage) -> None:
"""Test that set overwrites existing values."""
await storage.set("key1", "original")
await storage.set("key1", "updated")
result = await storage.get("key1")
assert result == "updated"
@pytest.mark.asyncio
async def test_delete_existing_key(self, storage: InMemoryStorage) -> None:
"""Test deleting an existing key."""
await storage.set("key1", "value1")
result = await storage.delete("key1")
assert result is True
assert await storage.get("key1") is None
@pytest.mark.asyncio
async def test_delete_nonexistent_key(self, storage: InMemoryStorage) -> None:
"""Test deleting a key that doesn't exist."""
result = await storage.delete("nonexistent")
assert result is False
@pytest.mark.asyncio
async def test_exists(self, storage: InMemoryStorage) -> None:
"""Test exists check."""
await storage.set("key1", "value1")
assert await storage.exists("key1") is True
assert await storage.exists("nonexistent") is False
class TestInMemoryStorageTTL:
"""Tests for TTL functionality."""
@pytest.fixture
def storage(self) -> InMemoryStorage:
"""Create a fresh storage instance."""
return InMemoryStorage(max_keys=100)
@pytest.mark.asyncio
async def test_set_with_ttl(self, storage: InMemoryStorage) -> None:
"""Test that TTL is stored correctly."""
await storage.set("key1", "value1", ttl_seconds=10)
# Key should exist immediately
assert await storage.exists("key1") is True
@pytest.mark.asyncio
async def test_ttl_expiration(self, storage: InMemoryStorage) -> None:
"""Test that expired keys return None."""
await storage.set("key1", "value1", ttl_seconds=1)
# Key exists initially
assert await storage.get("key1") == "value1"
# Wait for expiration
await asyncio.sleep(1.1)
# Key should be expired
assert await storage.get("key1") is None
assert await storage.exists("key1") is False
@pytest.mark.asyncio
async def test_remove_ttl_on_update(self, storage: InMemoryStorage) -> None:
"""Test that updating without TTL removes expiration."""
await storage.set("key1", "value1", ttl_seconds=1)
await storage.set("key1", "value2") # No TTL
await asyncio.sleep(1.1)
# Key should still exist (TTL removed)
assert await storage.get("key1") == "value2"
class TestInMemoryStorageListAndClear:
"""Tests for list and clear operations."""
@pytest.fixture
def storage(self) -> InMemoryStorage:
"""Create a fresh storage instance."""
return InMemoryStorage(max_keys=100)
@pytest.mark.asyncio
async def test_list_keys_all(self, storage: InMemoryStorage) -> None:
"""Test listing all keys."""
await storage.set("key1", "value1")
await storage.set("key2", "value2")
await storage.set("other", "value3")
keys = await storage.list_keys()
assert set(keys) == {"key1", "key2", "other"}
@pytest.mark.asyncio
async def test_list_keys_with_pattern(self, storage: InMemoryStorage) -> None:
"""Test listing keys with pattern."""
await storage.set("key1", "value1")
await storage.set("key2", "value2")
await storage.set("other", "value3")
keys = await storage.list_keys("key*")
assert set(keys) == {"key1", "key2"}
@pytest.mark.asyncio
async def test_get_all(self, storage: InMemoryStorage) -> None:
"""Test getting all key-value pairs."""
await storage.set("key1", "value1")
await storage.set("key2", "value2")
result = await storage.get_all()
assert result == {"key1": "value1", "key2": "value2"}
@pytest.mark.asyncio
async def test_clear(self, storage: InMemoryStorage) -> None:
"""Test clearing all keys."""
await storage.set("key1", "value1")
await storage.set("key2", "value2")
count = await storage.clear()
assert count == 2
assert await storage.get_all() == {}
class TestInMemoryStorageCapacity:
"""Tests for capacity limits."""
@pytest.mark.asyncio
async def test_capacity_limit_exceeded(self) -> None:
"""Test that exceeding capacity raises error."""
storage = InMemoryStorage(max_keys=2)
await storage.set("key1", "value1")
await storage.set("key2", "value2")
with pytest.raises(MemoryStorageError, match="capacity exceeded"):
await storage.set("key3", "value3")
@pytest.mark.asyncio
async def test_update_existing_key_within_capacity(self) -> None:
"""Test that updating existing key doesn't count against capacity."""
storage = InMemoryStorage(max_keys=2)
await storage.set("key1", "value1")
await storage.set("key2", "value2")
await storage.set("key1", "updated") # Should succeed
assert await storage.get("key1") == "updated"
@pytest.mark.asyncio
async def test_expired_keys_freed_for_capacity(self) -> None:
"""Test that expired keys are cleaned up for capacity."""
storage = InMemoryStorage(max_keys=2)
await storage.set("key1", "value1", ttl_seconds=1)
await storage.set("key2", "value2")
await asyncio.sleep(1.1)
# Should succeed because key1 is expired and will be cleaned
await storage.set("key3", "value3")
assert await storage.get("key3") == "value3"
class TestInMemoryStorageDataTypes:
"""Tests for different data types."""
@pytest.fixture
def storage(self) -> InMemoryStorage:
"""Create a fresh storage instance."""
return InMemoryStorage(max_keys=100)
@pytest.mark.asyncio
async def test_store_dict(self, storage: InMemoryStorage) -> None:
"""Test storing dict values."""
data = {"nested": {"key": "value"}, "list": [1, 2, 3]}
await storage.set("dict_key", data)
result = await storage.get("dict_key")
assert result == data
@pytest.mark.asyncio
async def test_store_list(self, storage: InMemoryStorage) -> None:
"""Test storing list values."""
data = [1, 2, {"nested": "dict"}]
await storage.set("list_key", data)
result = await storage.get("list_key")
assert result == data
@pytest.mark.asyncio
async def test_store_numbers(self, storage: InMemoryStorage) -> None:
"""Test storing numeric values."""
await storage.set("int_key", 42)
await storage.set("float_key", 3.14)
assert await storage.get("int_key") == 42
assert await storage.get("float_key") == 3.14
@pytest.mark.asyncio
async def test_store_boolean(self, storage: InMemoryStorage) -> None:
"""Test storing boolean values."""
await storage.set("true_key", True)
await storage.set("false_key", False)
assert await storage.get("true_key") is True
assert await storage.get("false_key") is False
@pytest.mark.asyncio
async def test_store_none(self, storage: InMemoryStorage) -> None:
"""Test storing None value."""
await storage.set("none_key", None)
# Note: None is stored, but get returns None for both missing and None values
# Use exists to distinguish
assert await storage.exists("none_key") is True
class TestInMemoryStorageHealth:
"""Tests for health and lifecycle."""
@pytest.mark.asyncio
async def test_is_healthy(self) -> None:
"""Test health check."""
storage = InMemoryStorage()
assert await storage.is_healthy() is True
@pytest.mark.asyncio
async def test_close(self) -> None:
"""Test close is no-op but doesn't error."""
storage = InMemoryStorage()
await storage.close() # Should not raise
class TestInMemoryStorageConcurrency:
"""Tests for concurrent access."""
@pytest.mark.asyncio
async def test_concurrent_writes(self) -> None:
"""Test concurrent write operations don't corrupt data."""
storage = InMemoryStorage(max_keys=1000)
async def write_batch(prefix: str, count: int) -> None:
for i in range(count):
await storage.set(f"{prefix}_{i}", f"value_{i}")
# Run concurrent writes
await asyncio.gather(
write_batch("a", 100),
write_batch("b", 100),
write_batch("c", 100),
)
# Verify all writes succeeded
keys = await storage.list_keys()
assert len(keys) == 300
@pytest.mark.asyncio
async def test_concurrent_read_write(self) -> None:
"""Test concurrent read and write operations."""
storage = InMemoryStorage()
await storage.set("key", 0)
async def increment() -> None:
for _ in range(100):
val = await storage.get("key") or 0
await storage.set("key", val + 1)
# Run concurrent increments
await asyncio.gather(
increment(),
increment(),
)
# Final value depends on interleaving
# Just verify we don't crash and value is positive
result = await storage.get("key")
assert result > 0