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 -->
167 lines
5.5 KiB
Python
167 lines
5.5 KiB
Python
"""Tests for Supermemory middleware."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from supermemory_agent_framework import (
|
|
AgentSupermemory,
|
|
SupermemoryChatMiddleware,
|
|
SupermemoryMiddlewareOptions,
|
|
)
|
|
from supermemory_agent_framework.middleware import (
|
|
_get_last_user_message,
|
|
_get_conversation_content,
|
|
_build_memories_text,
|
|
_inject_memories,
|
|
)
|
|
|
|
|
|
def _make_conn(**kwargs):
|
|
kwargs.setdefault("api_key", "test-key")
|
|
kwargs.setdefault("container_tag", "user-123")
|
|
return AgentSupermemory(**kwargs)
|
|
|
|
|
|
class TestGetLastUserMessage:
|
|
def test_dict_messages(self) -> None:
|
|
messages = [
|
|
{"role": "system", "content": "You are helpful."},
|
|
{"role": "user", "content": "Hello!"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
{"role": "user", "content": "How are you?"},
|
|
]
|
|
assert _get_last_user_message(messages) == "How are you?"
|
|
|
|
def test_no_user_message(self) -> None:
|
|
messages = [
|
|
{"role": "system", "content": "You are helpful."},
|
|
{"role": "assistant", "content": "Hi!"},
|
|
]
|
|
assert _get_last_user_message(messages) == ""
|
|
|
|
def test_empty_messages(self) -> None:
|
|
assert _get_last_user_message([]) == ""
|
|
assert _get_last_user_message(None) == ""
|
|
|
|
def test_content_parts(self) -> None:
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "Hello"},
|
|
{"type": "text", "text": "world"},
|
|
],
|
|
}
|
|
]
|
|
assert _get_last_user_message(messages) == "Hello world"
|
|
|
|
|
|
class TestGetConversationContent:
|
|
def test_basic_conversation(self) -> None:
|
|
messages = [
|
|
{"role": "user", "content": "Hello!"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
{"role": "user", "content": "How are you?"},
|
|
]
|
|
result = _get_conversation_content(messages)
|
|
assert "User: Hello!" in result
|
|
assert "Assistant: Hi there!" in result
|
|
assert "User: How are you?" in result
|
|
|
|
|
|
class TestMiddlewareOptions:
|
|
def test_defaults(self) -> None:
|
|
options = SupermemoryMiddlewareOptions()
|
|
assert options.verbose is False
|
|
assert options.mode == "profile"
|
|
assert options.add_memory == "never"
|
|
|
|
def test_custom_options(self) -> None:
|
|
options = SupermemoryMiddlewareOptions(
|
|
verbose=True,
|
|
mode="full",
|
|
add_memory="always",
|
|
)
|
|
assert options.verbose is True
|
|
assert options.mode == "full"
|
|
assert options.add_memory == "always"
|
|
|
|
|
|
class TestMiddlewareConfiguration:
|
|
def test_accepts_connection(self) -> None:
|
|
conn = _make_conn()
|
|
middleware = SupermemoryChatMiddleware(conn)
|
|
assert middleware._container_tag == "user-123"
|
|
|
|
def test_uses_connection_client(self) -> None:
|
|
conn = _make_conn()
|
|
middleware = SupermemoryChatMiddleware(conn)
|
|
assert middleware._supermemory_client is conn.client
|
|
|
|
def test_conversation_id_from_connection(self) -> None:
|
|
conn = _make_conn(conversation_id="conv-abc")
|
|
middleware = SupermemoryChatMiddleware(conn)
|
|
assert middleware._connection.conversation_id == "conv-abc"
|
|
assert middleware._connection.custom_id == "conversation_conv-abc"
|
|
|
|
def test_auto_generated_conversation_id(self) -> None:
|
|
conn = _make_conn()
|
|
middleware = SupermemoryChatMiddleware(conn)
|
|
assert middleware._connection.conversation_id is not None
|
|
assert len(middleware._connection.conversation_id) > 0
|
|
|
|
def test_entity_context_from_connection(self) -> None:
|
|
conn = _make_conn(entity_context="User is a Python developer")
|
|
middleware = SupermemoryChatMiddleware(conn)
|
|
assert middleware._connection.entity_context == "User is a Python developer"
|
|
|
|
|
|
class TestMemoryInjection:
|
|
def test_replaces_prior_sdk_context(self) -> None:
|
|
context = SimpleNamespace(
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": (
|
|
"Be helpful.\n\n"
|
|
'<supermemory context="user-memories" readonly>\n'
|
|
"Stale profile fact\n"
|
|
"</supermemory>"
|
|
),
|
|
},
|
|
{"role": "user", "content": "What do you remember?"},
|
|
]
|
|
)
|
|
|
|
_inject_memories(context, "Fresh profile fact")
|
|
|
|
content = context.messages[0]["content"]
|
|
assert "Be helpful." in content
|
|
assert "Fresh profile fact" in content
|
|
assert "Stale profile fact" not in content
|
|
assert content.count(
|
|
'<supermemory context="user-memories" readonly>'
|
|
) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_mode_keeps_search_fact_also_present_in_profile(self) -> None:
|
|
fact = "User likes machine learning projects"
|
|
client = SimpleNamespace(
|
|
profile=AsyncMock(
|
|
return_value=SimpleNamespace(
|
|
profile=SimpleNamespace(static=[fact], dynamic=[]),
|
|
search_results=SimpleNamespace(
|
|
results=[SimpleNamespace(memory=fact)]
|
|
),
|
|
)
|
|
)
|
|
)
|
|
logger = Mock()
|
|
|
|
memories = await _build_memories_text(
|
|
"user-123", logger, "query", client, "machine learning"
|
|
)
|
|
|
|
assert fact in memories
|