supermemory/packages/agent-framework-python/tests/test_middleware.py
Dhravya 03773c4f2e
feat(python-sdks): SDK-level cross-source memory deduplication (#1532)
## 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 -->
2026-09-01 06:10:36 +00:00

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