Files
turnstone/tests/test_db.py
T
Patrick Buckley 723cad24bb feat: structured memory system — typed/scoped memories with BM25 rele… (#53)
* feat: structured memory system — typed/scoped memories with BM25 relevance and metacognitive prompting

Replace flat key-value memories table with structured_memories (migration 014).
Four memory types (user/project/feedback/reference), three scopes
(global/workstream/user). Consolidate remember/recall/forget into two tools:
memory (action-based: save/search/delete/list) and recall (conversation
history only).

BM25 relevance scoring (extracted to turnstone/core/bm25.py) selects top-5
memories for system message injection based on conversation context.
Metacognitive prompting injects ephemeral nudges after corrections, tool
denials, workstream resume, and completion signals.

Scope isolation enforced: system message injection and nudge counts filtered
to visible memories only (global + current workstream + authenticated user).
User scope requires authentication. Content capped at 32KB. ILIKE/LIKE
metacharacters escaped in both backends.

113 new tests (2053 total).

* fix: CI failure + copilot review feedback

- Fix time.monotonic() cooldown: use None sentinel instead of 0.0
  default (monotonic clock starts at boot, not epoch — fresh CI
  runners have uptime < 300s so cooldown check always triggered)
- Catch sa.exc.IntegrityError specifically in upsert instead of
  broad Exception (copilot review)
- Preserve existing description/type on upsert when caller doesn't
  explicitly set them (copilot review)
- Add last_accessed + access_count columns to schema/migration for
  future LRU/LFU eviction support
2026-03-13 21:21:09 -07:00

81 lines
2.5 KiB
Python

"""Tests for turnstone.core.memory — database operations."""
import sqlalchemy as sa
from turnstone.core.memory import (
normalize_key,
save_message,
search_history,
search_history_recent,
)
from turnstone.core.storage import get_storage
class TestSchemaCreation:
def test_creates_tables(self, tmp_db):
engine = get_storage()._engine # noqa: SLF001
with engine.connect() as conn:
rows = conn.execute(
sa.text(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='structured_memories'"
)
).fetchall()
assert len(rows) == 1
rows = conn.execute(
sa.text(
"SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'"
)
).fetchall()
assert len(rows) == 1
class TestSaveAndSearchHistory:
def test_save_and_search_roundtrip(self, tmp_db):
save_message("sess1", "user", "hello world test message")
results = search_history("hello")
assert len(results) >= 1
found = any(r[3] == "hello world test message" for r in results)
assert found
def test_search_empty_query_returns_empty(self, tmp_db):
save_message("sess1", "user", "something")
assert search_history("") == []
assert search_history(" ") == []
def test_search_no_match(self, tmp_db):
save_message("sess1", "user", "hello world")
results = search_history("zzzznotfound")
assert results == []
class TestSearchHistoryRecent:
def test_returns_recent_messages(self, tmp_db):
save_message("sess1", "user", "first message")
save_message("sess1", "assistant", "second message")
results = search_history_recent(limit=10)
assert len(results) == 2
def test_respects_limit(self, tmp_db):
for i in range(5):
save_message("sess1", "user", f"message {i}")
results = search_history_recent(limit=3)
assert len(results) == 3
class TestNormalizeKey:
def test_lowercase(self):
assert normalize_key("Hello") == "hello"
def test_hyphens_to_underscores(self):
assert normalize_key("my-key") == "my_key"
def test_spaces_to_underscores(self):
assert normalize_key("my key") == "my_key"
def test_combined(self):
assert normalize_key("My-Key Name") == "my_key_name"
def test_already_normalized(self):
assert normalize_key("my_key") == "my_key"