mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
## Stack Context
Part 2 of a 3-PR stack moving memory deduplication into the SDKs. See `sdk-dedup/tools-ts` (parent) for the full context and the TypeScript implementation this mirrors.
## What?
Port the normalized, priority-ordered (`static > dynamic > search`) profile deduplication into the Python SDKs.
- Each request injects one **owned memory block that replaces** the prior block rather than accumulating.
- Dedup is **request-local** (no shared state), so it stays correct under concurrency.
Covers OpenAI, Agent Framework (middleware + context provider), Cartesia, and Pipecat.
## Why?
Keeps the Python SDKs at behavioral parity with the TypeScript SDK so all integrations deduplicate memory the same way.
## Testing
- OpenAI: 31 passed, 11 skipped (live)
- Agent Framework: 59 passed
- Cartesia: 8 passed
- Pipecat: 8 passed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Changes memory formatting and system-prompt injection across multiple SDK integrations; incorrect dedup or replacement could alter LLM context, but there is no auth or data-store risk.
>
> **Overview**
> Ports **normalized cross-source memory deduplication** and **replace-not-append injection** into the Python OpenAI, Agent Framework, Cartesia, and Pipecat packages so they match the TypeScript SDK behavior.
>
> **Deduplication** uses request-local keys: strip optional `[YYYY-MM-DD]` prefixes, normalize whitespace, and compare with `casefold`, with priority **static → dynamic → search**. In **`query` mode**, profile static/dynamic are excluded from dedup input so facts that only appear in search (or overlap profile) are not dropped before formatting.
>
> **Injection** no longer appends memory text every turn. OpenAI and Agent Framework middleware **strip prior owned `<supermemory context="user-memories" readonly>` blocks** and **replace** them once per request while keeping the caller’s system instructions; extra system messages lose stale blocks only. New helpers (`strip`/`replace`/`wrap`) live in each package’s utils.
>
> Tests cover normalized fact variants, query-mode search retention, and stale block replacement.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 42f308b224. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
148 lines
4.7 KiB
Python
148 lines
4.7 KiB
Python
"""Tests for Supermemory context provider."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from supermemory_agent_framework import AgentSupermemory, SupermemoryContextProvider
|
|
|
|
|
|
def _make_conn(**kwargs):
|
|
kwargs.setdefault("api_key", "test-key")
|
|
kwargs.setdefault("container_tag", "user-123")
|
|
return AgentSupermemory(**kwargs)
|
|
|
|
|
|
class TestContextProviderConfiguration:
|
|
def test_accepts_connection(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
assert provider._container_tag == "user-123"
|
|
assert provider.source_id == "supermemory"
|
|
|
|
def test_uses_connection_client(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
assert provider._client is conn.client
|
|
|
|
def test_custom_source_id(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(
|
|
conn, source_id="custom-source"
|
|
)
|
|
assert provider.source_id == "custom-source"
|
|
|
|
def test_default_mode(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
assert provider._mode == "full"
|
|
|
|
def test_custom_mode(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn, mode="profile")
|
|
assert provider._mode == "profile"
|
|
|
|
def test_store_conversations_default(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
assert provider._store_conversations is False
|
|
|
|
def test_conversation_id_from_connection(self) -> None:
|
|
conn = _make_conn(conversation_id="conv-xyz")
|
|
provider = SupermemoryContextProvider(conn)
|
|
assert provider._connection.conversation_id == "conv-xyz"
|
|
assert provider._connection.custom_id == "conversation_conv-xyz"
|
|
|
|
def test_entity_context_from_connection(self) -> None:
|
|
conn = _make_conn(entity_context="User prefers TypeScript")
|
|
provider = SupermemoryContextProvider(conn)
|
|
assert provider._connection.entity_context == "User prefers TypeScript"
|
|
|
|
|
|
class TestExtractQuery:
|
|
def test_dict_messages(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
|
|
class MockContext:
|
|
input_messages = [
|
|
{"role": "user", "content": "Hello!"},
|
|
{"role": "assistant", "content": "Hi!"},
|
|
{"role": "user", "content": "How are you?"},
|
|
]
|
|
|
|
result = provider._extract_query_from_context(MockContext())
|
|
assert result == "How are you?"
|
|
|
|
def test_empty_messages(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
|
|
class MockContext:
|
|
input_messages = []
|
|
|
|
result = provider._extract_query_from_context(MockContext())
|
|
assert result == ""
|
|
|
|
def test_no_messages_attr(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
|
|
class MockContext:
|
|
pass
|
|
|
|
result = provider._extract_query_from_context(MockContext())
|
|
assert result == ""
|
|
|
|
|
|
class TestExtractConversation:
|
|
def test_basic_conversation(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
|
|
class MockContext:
|
|
input_messages = [
|
|
{"role": "user", "content": "Hello!"},
|
|
]
|
|
response = None
|
|
|
|
result = provider._extract_conversation_from_context(MockContext())
|
|
assert "User: Hello!" in result
|
|
|
|
def test_with_response(self) -> None:
|
|
conn = _make_conn()
|
|
provider = SupermemoryContextProvider(conn)
|
|
|
|
class MockResponse:
|
|
text = "Hi there!"
|
|
|
|
class MockContext:
|
|
input_messages = [
|
|
{"role": "user", "content": "Hello!"},
|
|
]
|
|
response = MockResponse()
|
|
|
|
result = provider._extract_conversation_from_context(MockContext())
|
|
assert "User: Hello!" in result
|
|
assert "Assistant: Hi there!" in result
|
|
|
|
|
|
class TestMemoryRetrieval:
|
|
@pytest.mark.asyncio
|
|
async def test_query_mode_keeps_search_fact_also_present_in_profile(self) -> None:
|
|
fact = "User likes machine learning projects"
|
|
conn = _make_conn()
|
|
conn.client.profile = AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
profile=SimpleNamespace(static=[fact], dynamic=[]),
|
|
search_results=SimpleNamespace(
|
|
results=[SimpleNamespace(memory=fact)]
|
|
),
|
|
)
|
|
)
|
|
provider = SupermemoryContextProvider(conn, mode="query")
|
|
|
|
memories = await provider._fetch_memories("machine learning")
|
|
|
|
assert fact in memories
|