Merge branch 'main' into fix/vertexai-py-batch-size
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Python SDK tests (`tests/`)
|
||||
|
||||
pytest suite for the `mem0/` package.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make install_all # optional deps; several tests need them
|
||||
make test # pytest tests/
|
||||
make test-py-3.9 # pin a Python version (3.9 through 3.12)
|
||||
|
||||
pytest tests/llms/test_openai.py::test_generate_response # single test
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Files are named `test_<module>.py`.
|
||||
- Provider tests mirror the source tree: `tests/<category>/<provider_name>/`.
|
||||
- pytest-mock for mocks, pytest-asyncio for the async surface.
|
||||
- Ruff line length **120**, matching `mem0/`. See [`../mem0/AGENTS.md`](../mem0/AGENTS.md).
|
||||
- Mock the provider SDK, never the code under test. A test that asserts the implementation back at itself is worse than no test.
|
||||
- Bug fixes need a regression test that fails without the fix. Write it first and watch it fail.
|
||||
|
||||
Tests for other packages live with those packages: `mem0-ts/` (jest), `cli/python/tests/` (pytest), `cli/node/` (vitest), and each directory under `integrations/`.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -94,7 +94,7 @@ def test_embed_with_huggingface_base_url():
|
||||
embedder = HuggingFaceEmbedding(config)
|
||||
result = embedder.embed("Hello from custom endpoint")
|
||||
|
||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080")
|
||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key=None)
|
||||
mock_client.embeddings.create.assert_called_once_with(
|
||||
input="Hello from custom endpoint",
|
||||
model="my-custom-model",
|
||||
@@ -103,6 +103,18 @@ def test_embed_with_huggingface_base_url():
|
||||
assert result == [0.1, 0.2, 0.3]
|
||||
|
||||
|
||||
def test_embed_with_huggingface_base_url_forwards_api_key():
|
||||
config = BaseEmbedderConfig(
|
||||
huggingface_base_url="http://localhost:8080",
|
||||
model="my-custom-model",
|
||||
api_key="tei-token",
|
||||
)
|
||||
with patch("mem0.embeddings.huggingface.OpenAI") as mock_openai:
|
||||
HuggingFaceEmbedding(config)
|
||||
|
||||
mock_openai.assert_called_once_with(base_url="http://localhost:8080", api_key="tei-token")
|
||||
|
||||
|
||||
def test_embed_batch_sentence_transformer(mock_sentence_transformer):
|
||||
config = BaseEmbedderConfig()
|
||||
embedder = HuggingFaceEmbedding(config)
|
||||
|
||||
@@ -19,14 +19,14 @@ def mock_anthropic_client():
|
||||
|
||||
def test_default_config_omits_top_p(mock_anthropic_client):
|
||||
"""Default AnthropicConfig should not set top_p to avoid conflict with temperature."""
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
|
||||
assert config.top_p is None
|
||||
assert config.temperature == 0.1
|
||||
|
||||
|
||||
def test_generate_response_does_not_send_top_p_by_default(mock_anthropic_client):
|
||||
"""Anthropic API rejects temperature and top_p together; top_p must be omitted by default."""
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
@@ -47,7 +47,7 @@ def test_generate_response_does_not_send_top_p_by_default(mock_anthropic_client)
|
||||
|
||||
def test_generate_response_sends_top_p_alone_when_no_temperature(mock_anthropic_client):
|
||||
"""When user sets only top_p (no temperature), top_p should be sent."""
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key", top_p=0.9, temperature=None)
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key", top_p=0.9, temperature=None)
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
@@ -68,7 +68,7 @@ def test_generate_response_sends_top_p_alone_when_no_temperature(mock_anthropic_
|
||||
|
||||
def test_both_set_prefers_temperature_over_top_p(mock_anthropic_client):
|
||||
"""When both temperature and top_p are set, temperature wins and top_p is dropped."""
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key", top_p=0.9, temperature=0.5)
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key", top_p=0.9, temperature=0.5)
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
@@ -87,7 +87,7 @@ def test_configured_base_url_is_passed_to_client():
|
||||
"""A configured anthropic_base_url must reach the Anthropic client constructor."""
|
||||
with patch("mem0.llms.anthropic.anthropic") as mock_anthropic:
|
||||
config = AnthropicConfig(
|
||||
model="claude-3-5-sonnet-20240620",
|
||||
model="claude-opus-4-6",
|
||||
api_key="test-key",
|
||||
anthropic_base_url="https://proxy.example.com",
|
||||
)
|
||||
@@ -100,7 +100,7 @@ def test_base_url_falls_back_to_env(monkeypatch):
|
||||
"""When no base_url is configured, ANTHROPIC_BASE_URL is used."""
|
||||
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://env.example.com")
|
||||
with patch("mem0.llms.anthropic.anthropic") as mock_anthropic:
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
|
||||
AnthropicLLM(config)
|
||||
|
||||
assert mock_anthropic.Anthropic.call_args[1]["base_url"] == "https://env.example.com"
|
||||
@@ -110,7 +110,7 @@ def test_base_url_omitted_when_unset(monkeypatch):
|
||||
"""With no base_url anywhere, base_url must not be forced to None on the client."""
|
||||
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
|
||||
with patch("mem0.llms.anthropic.anthropic") as mock_anthropic:
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
|
||||
AnthropicLLM(config)
|
||||
|
||||
assert "base_url" not in mock_anthropic.Anthropic.call_args[1]
|
||||
@@ -118,7 +118,7 @@ def test_base_url_omitted_when_unset(monkeypatch):
|
||||
|
||||
def test_base_config_conversion_does_not_send_both(mock_anthropic_client):
|
||||
"""BaseLlmConfig defaults both temperature=0.1 and top_p=0.1; Anthropic must not send both."""
|
||||
base_config = BaseLlmConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
|
||||
base_config = BaseLlmConfig(model="claude-opus-4-6", api_key="test-key")
|
||||
llm = AnthropicLLM(base_config)
|
||||
|
||||
mock_response = Mock()
|
||||
@@ -131,3 +131,107 @@ def test_base_config_conversion_does_not_send_both(mock_anthropic_client):
|
||||
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
|
||||
assert "temperature" in call_kwargs
|
||||
assert "top_p" not in call_kwargs
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected"),
|
||||
[
|
||||
("opus", False),
|
||||
("claude-opus-4-6", True),
|
||||
("claude-opus-4-7", False),
|
||||
("claude-opus-4-8", False),
|
||||
("sonnet", False),
|
||||
("claude-sonnet-4-6", True),
|
||||
("claude-sonnet-5", False),
|
||||
("haiku", True),
|
||||
("claude-haiku-4-5", True),
|
||||
("claude-haiku-4-5-20251001", True),
|
||||
("claude-fable-5", False),
|
||||
("claude-mythos-5", False),
|
||||
("claude-mythos-preview", False),
|
||||
],
|
||||
)
|
||||
def test_enable_sampling_parameters_for_current_models(mock_anthropic_client, model, expected):
|
||||
config = AnthropicConfig(model=model, api_key="test-key", top_p=0.9)
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="Hello!")]
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
llm.generate_response(messages)
|
||||
|
||||
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
|
||||
assert ("temperature" in call_kwargs) is expected
|
||||
|
||||
|
||||
def test_generate_response_returns_text_when_thinking_block_precedes_it(mock_anthropic_client):
|
||||
"""Extended thinking responses put a thinking block before the text block; parsing must skip it."""
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
thinking_block = Mock(spec=["type", "thinking", "signature"])
|
||||
thinking_block.type = "thinking"
|
||||
text_block = Mock(spec=["type", "text"])
|
||||
text_block.type = "text"
|
||||
text_block.text = "The answer"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = [thinking_block, text_block]
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
result = llm.generate_response([{"role": "user", "content": "Hi"}])
|
||||
|
||||
assert result == "The answer"
|
||||
|
||||
|
||||
def test_generate_response_returns_empty_string_on_empty_content(mock_anthropic_client):
|
||||
"""A response with no content blocks must return "" instead of raising IndexError."""
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = []
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
result = llm.generate_response([{"role": "user", "content": "Hi"}])
|
||||
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_generate_response_returns_text_for_plain_text_response(mock_anthropic_client):
|
||||
"""Regular single-text-block responses keep returning the text unchanged."""
|
||||
config = AnthropicConfig(model="claude-opus-4-6", api_key="test-key")
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
text_block = Mock(spec=["type", "text"])
|
||||
text_block.type = "text"
|
||||
text_block.text = "Hello!"
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = [text_block]
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
assert llm.generate_response([{"role": "user", "content": "Hi"}]) == "Hello!"
|
||||
|
||||
|
||||
def test_enable_sampling_parameters_respects_explicit_override(mock_anthropic_client):
|
||||
config = AnthropicConfig(
|
||||
model="claude-opus-4-6",
|
||||
api_key="test-key",
|
||||
top_p=0.9,
|
||||
enable_sampling_parameters=False,
|
||||
)
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="Hello!")]
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
llm.generate_response(messages)
|
||||
|
||||
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
|
||||
assert "temperature" not in call_kwargs
|
||||
assert "top_p" not in call_kwargs
|
||||
|
||||
@@ -6,7 +6,6 @@ from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
|
||||
from mem0.llms.aws_bedrock import AWSBedrockLLM, extract_provider
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -70,6 +69,23 @@ class TestExtractProvider:
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
extract_provider("unknown-vendor.some-model-v1:0")
|
||||
|
||||
def test_application_inference_profile_arn_without_override_raises(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
extract_provider(arn)
|
||||
|
||||
def test_application_inference_profile_arn_with_explicit_provider(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
assert extract_provider(arn, "anthropic") == "anthropic"
|
||||
|
||||
def test_explicit_provider_takes_precedence_over_regex(self):
|
||||
assert extract_provider("anthropic.claude-3-5-sonnet-20240620-v1:0", "amazon") == "amazon"
|
||||
|
||||
def test_explicit_provider_typo_raises(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
with pytest.raises(ValueError, match="Unknown provider_override 'anthorpic'"):
|
||||
extract_provider(arn, "anthorpic")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWSBedrockConfig
|
||||
@@ -114,6 +130,44 @@ class TestAWSBedrockConfig:
|
||||
)
|
||||
assert config.aws_region == "us-east-2"
|
||||
|
||||
def test_provider_override_defaults_to_none(self):
|
||||
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
assert config.provider_override is None
|
||||
|
||||
def test_provider_override_stored(self):
|
||||
arn = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
config = AWSBedrockConfig(model=arn, provider_override="anthropic")
|
||||
assert config.provider_override == "anthropic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWSBedrockLLM with application inference profile ARNs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApplicationInferenceProfileArn:
|
||||
ARN = "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123xyz"
|
||||
|
||||
def test_arn_without_provider_override_raises(self, mock_boto3):
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
_make_llm(self.ARN, mock_boto3)
|
||||
|
||||
def test_arn_with_provider_override_resolves(self, mock_boto3):
|
||||
llm = _make_llm(self.ARN, mock_boto3, provider_override="anthropic")
|
||||
assert llm.provider == "anthropic"
|
||||
assert llm.supports_tools is True
|
||||
|
||||
def test_plain_model_id_unaffected(self, mock_boto3):
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
||||
assert llm.provider == "anthropic"
|
||||
|
||||
def test_cross_region_inference_profile_unaffected(self, mock_boto3):
|
||||
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3)
|
||||
assert llm.provider == "anthropic"
|
||||
|
||||
def test_arn_with_misspelled_provider_override_raises(self, mock_boto3):
|
||||
with pytest.raises(ValueError, match="Unknown provider_override 'anthorpic'"):
|
||||
_make_llm(self.ARN, mock_boto3, provider_override="anthorpic")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LlmFactory
|
||||
|
||||
@@ -49,3 +49,14 @@ def test_regular_model_sends_sampling_params(mock_openai_client):
|
||||
assert "max_tokens" in call_kwargs # standard sampling params still forwarded
|
||||
assert "top_p" in call_kwargs
|
||||
assert call_kwargs["model"] == "gpt-4o"
|
||||
|
||||
|
||||
def test_uses_openai_base_url_environment_variable(monkeypatch):
|
||||
base_url = "https://gateway.example/v1"
|
||||
monkeypatch.setenv("OPENAI_API_BASE", "https://legacy.example/v1")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", base_url)
|
||||
|
||||
with patch("mem0.llms.openai_structured.OpenAI") as mock_openai:
|
||||
OpenAIStructuredLLM(OpenAIConfig(api_key="test-api-key"))
|
||||
|
||||
mock_openai.assert_called_once_with(api_key="test-api-key", base_url=base_url)
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from mem0 import AsyncMemory, Memory
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.vllm import VllmConfig
|
||||
from mem0.llms.vllm import VllmLLM
|
||||
|
||||
|
||||
@@ -15,6 +16,59 @@ def mock_vllm_client():
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_base_url_explicit_arg_wins_over_env(mock_vllm_client, monkeypatch):
|
||||
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
|
||||
config = VllmConfig(vllm_base_url="http://from-arg:8000/v1")
|
||||
VllmLLM(config)
|
||||
|
||||
assert config.vllm_base_url == "http://from-arg:8000/v1"
|
||||
|
||||
|
||||
def test_base_url_env_wins_over_default(mock_vllm_client, monkeypatch):
|
||||
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
|
||||
config = VllmConfig()
|
||||
VllmLLM(config)
|
||||
|
||||
assert config.vllm_base_url == "http://from-env:8000/v1"
|
||||
|
||||
|
||||
def test_base_url_default_when_neither_set(mock_vllm_client, monkeypatch):
|
||||
monkeypatch.delenv("VLLM_BASE_URL", raising=False)
|
||||
config = VllmConfig()
|
||||
VllmLLM(config)
|
||||
|
||||
assert config.vllm_base_url == "http://localhost:8000/v1"
|
||||
|
||||
|
||||
def test_openai_client_receives_explicit_base_url(monkeypatch):
|
||||
monkeypatch.delenv("VLLM_BASE_URL", raising=False)
|
||||
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
|
||||
VllmLLM(VllmConfig(vllm_base_url="http://from-arg:8000/v1"))
|
||||
|
||||
assert mock_openai.call_args.kwargs["base_url"] == "http://from-arg:8000/v1"
|
||||
|
||||
|
||||
def test_openai_client_receives_base_url_from_env(monkeypatch):
|
||||
monkeypatch.setenv("VLLM_BASE_URL", "http://from-env:8000/v1")
|
||||
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
|
||||
VllmLLM(VllmConfig())
|
||||
|
||||
base_url = mock_openai.call_args.kwargs["base_url"]
|
||||
assert base_url == "http://from-env:8000/v1"
|
||||
assert base_url != "http://localhost:8000/v1"
|
||||
|
||||
|
||||
def test_openai_client_receives_base_url_from_env_via_base_llm_config(monkeypatch):
|
||||
"""Original #6256 repro: BaseLlmConfig (not VllmConfig) plus VLLM_BASE_URL only."""
|
||||
monkeypatch.setenv("VLLM_BASE_URL", "http://remote-vllm:8000/v1")
|
||||
with patch("mem0.llms.vllm.OpenAI") as mock_openai:
|
||||
VllmLLM(BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct"))
|
||||
|
||||
base_url = mock_openai.call_args.kwargs["base_url"]
|
||||
assert base_url == "http://remote-vllm:8000/v1"
|
||||
assert base_url != "http://localhost:8000/v1"
|
||||
|
||||
|
||||
def test_generate_response_without_tools(mock_vllm_client):
|
||||
config = BaseLlmConfig(model="Qwen/Qwen2.5-32B-Instruct", temperature=0.7, max_tokens=100, top_p=1.0)
|
||||
llm = VllmLLM(config)
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_sync_delete_failure_does_not_trigger_decay_usage_notice(monkeypatch):
|
||||
def test_sync_delete_all_decay_usage_runs_after_success(monkeypatch):
|
||||
memory = make_sync_memory()
|
||||
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
|
||||
memory.vector_store.list.return_value = (memories, None)
|
||||
memory.vector_store.list.side_effect = [(memories, None), ([], None)]
|
||||
decay_notice = MagicMock()
|
||||
first_run_notice = MagicMock()
|
||||
detect_decay = MagicMock(return_value=("delete_all", "bulk_delete", None, 2))
|
||||
@@ -117,6 +117,21 @@ def test_sync_delete_all_decay_usage_runs_after_success(monkeypatch):
|
||||
first_run_notice.assert_not_called()
|
||||
|
||||
|
||||
def test_sync_delete_all_stops_when_vector_store_repeats_batch(monkeypatch):
|
||||
memory = make_sync_memory()
|
||||
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
|
||||
memory.vector_store.list.side_effect = [(memories, None), (memories, None)]
|
||||
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
|
||||
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", MagicMock(return_value=None))
|
||||
monkeypatch.setattr(memory_main, "display_first_run_notice", MagicMock())
|
||||
|
||||
result = Memory.delete_all(memory, user_id="u1")
|
||||
|
||||
assert result == {"message": "Memories deleted successfully!"}
|
||||
assert memory.vector_store.list.call_count == 2
|
||||
assert memory._delete_memory.call_count == 2
|
||||
|
||||
|
||||
def test_sync_delete_all_zero_deletes_uses_first_run_notice(monkeypatch):
|
||||
memory = make_sync_memory()
|
||||
memory.vector_store.list.return_value = ([], None)
|
||||
@@ -189,7 +204,7 @@ async def test_async_delete_failure_does_not_trigger_decay_usage_notice(monkeypa
|
||||
async def test_async_delete_all_decay_usage_runs_after_success(monkeypatch):
|
||||
memory = make_async_memory()
|
||||
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
|
||||
memory.vector_store.list.return_value = (memories, None)
|
||||
memory.vector_store.list.side_effect = [(memories, None), ([], None)]
|
||||
decay_notice = AsyncMock()
|
||||
first_run_notice = AsyncMock()
|
||||
detect_decay = MagicMock(return_value=("delete_all", "bulk_delete", None, 2))
|
||||
@@ -213,3 +228,23 @@ async def test_async_delete_all_decay_usage_runs_after_success(monkeypatch):
|
||||
2,
|
||||
)
|
||||
first_run_notice.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_delete_all_stops_when_vector_store_repeats_batch(monkeypatch):
|
||||
memory = make_async_memory()
|
||||
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
|
||||
memory.vector_store.list.side_effect = [
|
||||
(memories, None),
|
||||
(memories, None),
|
||||
RuntimeError("delete_all should have stopped before a third list() call"),
|
||||
]
|
||||
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
|
||||
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", MagicMock(return_value=None))
|
||||
monkeypatch.setattr(memory_main, "display_first_run_notice_async", AsyncMock())
|
||||
|
||||
result = await AsyncMemory.delete_all(memory, user_id="u1")
|
||||
|
||||
assert result == {"message": "Memories deleted successfully!"}
|
||||
assert memory.vector_store.list.call_count == 2
|
||||
assert memory._delete_memory.await_count == 2
|
||||
|
||||
+141
-46
@@ -418,6 +418,147 @@ async def test_async_update_memory_uses_utc_timestamps(mocker):
|
||||
assert payload["updated_at"] is not None
|
||||
|
||||
|
||||
_ATTACKER_UPDATE_METADATA = {
|
||||
"user_id": "attacker_tenant",
|
||||
"agent_id": "attacker_agent",
|
||||
"run_id": "attacker_run",
|
||||
"actor_id": "attacker_actor",
|
||||
"category": "sports",
|
||||
}
|
||||
|
||||
# Omits agent_id on purpose, so one payload covers both overwriting and injecting an identity field.
|
||||
_EXISTING_UPDATE_PAYLOAD = {
|
||||
"data": "old memory",
|
||||
"user_id": "tenant_a",
|
||||
"run_id": "run_a",
|
||||
"actor_id": "actor_a",
|
||||
}
|
||||
|
||||
|
||||
def test_update_memory_metadata_cannot_change_identity_fields(mocker, caplog):
|
||||
"""Regression (issues #4490, #6277): update() metadata must not overwrite or inject identity fields."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
memory.vector_store.get.return_value = MagicMock(payload=dict(_EXISTING_UPDATE_PAYLOAD))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mem0.memory.main"):
|
||||
memory._update_memory("memory-id", "new memory", {}, metadata=dict(_ATTACKER_UPDATE_METADATA))
|
||||
|
||||
payload = memory.vector_store.update.call_args.kwargs["payload"]
|
||||
assert payload["user_id"] == "tenant_a"
|
||||
assert payload["run_id"] == "run_a"
|
||||
assert "agent_id" not in payload
|
||||
assert payload["actor_id"] == "actor_a"
|
||||
assert payload["category"] == "sports"
|
||||
assert payload["data"] == "new memory"
|
||||
assert "ignoring metadata['user_id']" in caplog.text
|
||||
|
||||
|
||||
_ATTACKER_ADD_METADATA = {
|
||||
"agent_id": "victim-agent",
|
||||
"run_id": "victim-run",
|
||||
"actor_id": "victim-actor",
|
||||
"category": "sports",
|
||||
}
|
||||
|
||||
|
||||
def _captured_add_metadata(memory, mocker, **add_kwargs):
|
||||
"""Run add() with the pipeline stubbed and return the metadata template it produced."""
|
||||
captured = {}
|
||||
|
||||
def _capture(messages, metadata, filters, infer, **kwargs):
|
||||
captured.update(metadata)
|
||||
return []
|
||||
|
||||
mocker.patch.object(memory, "_add_to_vector_store", side_effect=_capture)
|
||||
memory.add("I like coffee", infer=False, **add_kwargs)
|
||||
return captured
|
||||
|
||||
|
||||
def test_add_metadata_cannot_set_identity_fields(mocker, caplog):
|
||||
"""Regression (issue #6655): add() metadata must not inject identity scope.
|
||||
|
||||
The caller scopes by user_id only, so the agent_id/run_id re-pins in
|
||||
_build_filters_and_metadata never fire and cannot defend the payload.
|
||||
"""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mem0.memory.main"):
|
||||
metadata = _captured_add_metadata(
|
||||
memory, mocker, user_id="attacker", metadata=dict(_ATTACKER_ADD_METADATA)
|
||||
)
|
||||
|
||||
assert metadata["user_id"] == "attacker"
|
||||
for key in ("agent_id", "run_id", "actor_id"):
|
||||
assert key not in metadata, f"{key} was injected through add() metadata"
|
||||
# Non-identity metadata is untouched.
|
||||
assert metadata["category"] == "sports"
|
||||
assert "ignoring metadata['agent_id']" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_add_metadata_cannot_set_identity_fields(mocker):
|
||||
"""Async counterpart of test_add_metadata_cannot_set_identity_fields."""
|
||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||
captured = {}
|
||||
|
||||
async def _capture(messages, metadata, filters, infer, **kwargs):
|
||||
captured.update(metadata)
|
||||
return []
|
||||
|
||||
mocker.patch.object(memory, "_add_to_vector_store", side_effect=_capture)
|
||||
await memory.add(
|
||||
"I like coffee",
|
||||
user_id="attacker",
|
||||
metadata=dict(_ATTACKER_ADD_METADATA),
|
||||
infer=False,
|
||||
)
|
||||
|
||||
assert captured["user_id"] == "attacker"
|
||||
for key in ("agent_id", "run_id", "actor_id"):
|
||||
assert key not in captured, f"{key} was injected through async add() metadata"
|
||||
assert captured["category"] == "sports"
|
||||
|
||||
|
||||
def test_add_entity_params_still_set_scope(mocker):
|
||||
"""The documented top-level params remain the only way to set scope."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
|
||||
metadata = _captured_add_metadata(
|
||||
memory, mocker, user_id="u1", agent_id="a1", run_id="r1", metadata={"category": "sports"}
|
||||
)
|
||||
|
||||
assert metadata["user_id"] == "u1"
|
||||
assert metadata["agent_id"] == "a1"
|
||||
assert metadata["run_id"] == "r1"
|
||||
assert metadata["category"] == "sports"
|
||||
|
||||
|
||||
def test_add_without_metadata_is_unaffected(mocker):
|
||||
"""No metadata argument means no stripping and no behaviour change."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
|
||||
metadata = _captured_add_metadata(memory, mocker, user_id="u1")
|
||||
|
||||
assert metadata == {"user_id": "u1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_update_memory_metadata_cannot_change_identity_fields(mocker):
|
||||
"""Async counterpart of test_update_memory_metadata_cannot_change_identity_fields."""
|
||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||
memory.vector_store.get.return_value = MagicMock(payload=dict(_EXISTING_UPDATE_PAYLOAD))
|
||||
|
||||
await memory._update_memory("memory-id", "new memory", {}, metadata=dict(_ATTACKER_UPDATE_METADATA))
|
||||
|
||||
payload = memory.vector_store.update.call_args.kwargs["payload"]
|
||||
assert payload["user_id"] == "tenant_a"
|
||||
assert payload["run_id"] == "run_a"
|
||||
assert "agent_id" not in payload
|
||||
assert payload["actor_id"] == "actor_a"
|
||||
assert payload["category"] == "sports"
|
||||
assert payload["data"] == "new memory"
|
||||
|
||||
|
||||
def test_create_then_search_and_get_all_return_same_timestamps(mocker):
|
||||
"""Reproduces issue #3720: created_at must be identical in search() and get_all()."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
@@ -714,52 +855,6 @@ class TestMetadataNotMutated:
|
||||
)
|
||||
|
||||
|
||||
def test_update_preserves_actor_id_when_different_actor_updates(mocker):
|
||||
"""actor_id must be preserved from the original memory even when the
|
||||
updating caller passes a different actor_id in metadata (issue #4490)."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
memory.vector_store.get.return_value = MagicMock(
|
||||
payload={
|
||||
"data": "I am player #1",
|
||||
"user_id": "team",
|
||||
"actor_id": "Alice",
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
}
|
||||
)
|
||||
|
||||
memory._update_memory(
|
||||
"mem-id", "Player #1 is a good person",
|
||||
{"Player #1 is a good person": [0.1, 0.2, 0.3]},
|
||||
metadata={"user_id": "team", "actor_id": "Bob"},
|
||||
)
|
||||
|
||||
stored = memory.vector_store.update.call_args.kwargs["payload"]
|
||||
assert stored["actor_id"] == "Alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_update_preserves_actor_id_when_different_actor_updates(mocker):
|
||||
"""Async variant: actor_id must be preserved from the original memory (issue #4490)."""
|
||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||
memory.vector_store.get.return_value = MagicMock(
|
||||
payload={
|
||||
"data": "I am player #1",
|
||||
"user_id": "team",
|
||||
"actor_id": "Alice",
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
}
|
||||
)
|
||||
|
||||
await memory._update_memory(
|
||||
"mem-id", "Player #1 is a good person",
|
||||
{"Player #1 is a good person": [0.1, 0.2, 0.3]},
|
||||
metadata={"user_id": "team", "actor_id": "Bob"},
|
||||
)
|
||||
|
||||
stored = memory.vector_store.update.call_args.kwargs["payload"]
|
||||
assert stored["actor_id"] == "Alice"
|
||||
|
||||
|
||||
def _make_match(score, linked_memory_ids):
|
||||
return SimpleNamespace(score=score, payload={"linked_memory_ids": linked_memory_ids})
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.memory.utils import (
|
||||
parse_messages,
|
||||
parse_vision_messages,
|
||||
process_telemetry_filters,
|
||||
remove_code_blocks,
|
||||
remove_spaces_from_entities,
|
||||
sanitize_relationship_for_cypher,
|
||||
)
|
||||
@@ -114,6 +117,17 @@ class TestParseVisionMessages:
|
||||
parse_vision_messages(messages, llm=mock_llm)
|
||||
mock_llm.generate_response.assert_not_called()
|
||||
|
||||
def test_download_failure_preserves_original_exception(self):
|
||||
mock_llm = Mock()
|
||||
mock_llm.generate_response.side_effect = ValueError("network down")
|
||||
messages = [
|
||||
{"role": "user", "content": {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}}
|
||||
]
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
parse_vision_messages(messages, llm=mock_llm)
|
||||
assert isinstance(exc_info.value.__cause__, ValueError)
|
||||
assert "network down" in str(exc_info.value.__cause__)
|
||||
|
||||
|
||||
class TestRemoveSpacesFromEntities:
|
||||
"""
|
||||
@@ -168,3 +182,13 @@ class TestRemoveSpacesFromEntities:
|
||||
f = remove_spaces_from_entities([dict(base)], sanitize_relationship=False)[0]["relationship"]
|
||||
assert t == sanitize_relationship_for_cypher("a/b")
|
||||
assert f == "a/b"
|
||||
|
||||
|
||||
class TestProcessTelemetryFilters:
|
||||
def test_none_filters_returns_empty_list_and_dict(self):
|
||||
assert process_telemetry_filters(None) == ([], {})
|
||||
|
||||
|
||||
class TestRemoveCodeBlocks:
|
||||
def test_none_content_returns_empty_string(self):
|
||||
assert remove_code_blocks(None) == ""
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.memory.main import Memory, _build_session_scope, _escape_scope_value
|
||||
|
||||
DELIMITER_VALUES = [
|
||||
"u1",
|
||||
"r1",
|
||||
"a1",
|
||||
"%",
|
||||
"&",
|
||||
"=",
|
||||
"%25",
|
||||
"%26",
|
||||
"%3D",
|
||||
"a==",
|
||||
"a1&run_id=r1",
|
||||
"a1&user_id=u1",
|
||||
"r1&user_id=u1",
|
||||
"a1&run_id=r1&user_id=u1",
|
||||
]
|
||||
|
||||
|
||||
class TestBuildSessionScope:
|
||||
"""Tests that _build_session_scope produces a unique key per id combination."""
|
||||
|
||||
def test_ordinary_ids_produce_unchanged_scope_keys(self):
|
||||
"""Ids without delimiter characters keep producing the pre-fix key format."""
|
||||
cases = [
|
||||
({"user_id": "550e8400-e29b-41d4-a716-446655440000"}, "user_id=550e8400-e29b-41d4-a716-446655440000"),
|
||||
({"agent_id": "agent.assistant:v2"}, "agent_id=agent.assistant:v2"),
|
||||
({"run_id": "12345"}, "run_id=12345"),
|
||||
(
|
||||
{"user_id": "user@example.com", "agent_id": "support-bot"},
|
||||
"agent_id=support-bot&user_id=user@example.com",
|
||||
),
|
||||
(
|
||||
{"user_id": "u1", "agent_id": "a1", "run_id": "r1"},
|
||||
"agent_id=a1&run_id=r1&user_id=u1",
|
||||
),
|
||||
]
|
||||
for filters, expected in cases:
|
||||
assert _build_session_scope(filters) == expected
|
||||
|
||||
def test_ids_containing_delimiters_do_not_collide(self):
|
||||
"""A value that embeds the join syntax no longer maps to the same key as the equivalent split filters."""
|
||||
collapsed_run = {"run_id": "proj-x&user_id=u1"}
|
||||
split_run = {"user_id": "u1", "run_id": "proj-x"}
|
||||
assert _build_session_scope(collapsed_run) != _build_session_scope(split_run)
|
||||
assert _build_session_scope(collapsed_run) == "run_id=proj-x%26user_id%3Du1"
|
||||
|
||||
collapsed_agent = {"run_id": "proj-y&agent_id=a1"}
|
||||
split_agent = {"agent_id": "a1", "run_id": "proj-y"}
|
||||
assert _build_session_scope(collapsed_agent) != _build_session_scope(split_agent)
|
||||
|
||||
def test_ids_containing_delimiters_get_a_new_key_format(self):
|
||||
"""Ids holding a delimiter character map to a new key, so their buffer starts empty once after upgrade."""
|
||||
assert _build_session_scope({"user_id": "dXNlcl9pZDE="}) == "user_id=dXNlcl9pZDE%3D"
|
||||
assert _build_session_scope({"agent_id": "x&y"}) == "agent_id=x%26y"
|
||||
assert _build_session_scope({"run_id": "50% off"}) == "run_id=50%25 off"
|
||||
|
||||
def test_scope_keys_are_unique_per_filter_combination(self):
|
||||
"""Every distinct filter dict built from delimiter-heavy id values maps to a distinct scope key."""
|
||||
keys = ["user_id", "agent_id", "run_id"]
|
||||
seen = {}
|
||||
for size in range(1, len(keys) + 1):
|
||||
for key_subset in itertools.combinations(keys, size):
|
||||
for combo in itertools.product(DELIMITER_VALUES, repeat=size):
|
||||
filters = dict(zip(key_subset, combo))
|
||||
scope = _build_session_scope(filters)
|
||||
if scope in seen:
|
||||
assert seen[scope] == filters, f"{seen[scope]} and {filters} both map to {scope!r}"
|
||||
else:
|
||||
seen[scope] = filters
|
||||
|
||||
|
||||
class TestEscapeScopeValue:
|
||||
"""Tests for the low-level per-value escaping helper."""
|
||||
|
||||
def test_non_string_input_is_stringified(self):
|
||||
assert _escape_scope_value(42) == "42"
|
||||
|
||||
def test_percent_is_escaped_before_other_delimiters(self):
|
||||
assert _escape_scope_value("%26") == "%2526"
|
||||
assert _escape_scope_value("%26") != "%26"
|
||||
|
||||
def test_each_delimiter_is_escaped(self):
|
||||
assert _escape_scope_value("%") == "%25"
|
||||
assert _escape_scope_value("&") == "%26"
|
||||
assert _escape_scope_value("=") == "%3D"
|
||||
|
||||
|
||||
class TestSessionScopeWiring:
|
||||
"""Tests that the add pipeline keys the conversation buffer through the builder."""
|
||||
|
||||
@pytest.fixture
|
||||
def memory(self, mocker):
|
||||
mocker.patch("mem0.memory.main.capture_event")
|
||||
mock_embedder = mocker.MagicMock()
|
||||
mock_embedder.return_value.embed.return_value = [0.1, 0.2, 0.3]
|
||||
mocker.patch("mem0.utils.factory.EmbedderFactory.create", mock_embedder)
|
||||
mock_vector_store = mocker.MagicMock()
|
||||
mock_vector_store.return_value.search.return_value = []
|
||||
mocker.patch(
|
||||
"mem0.utils.factory.VectorStoreFactory.create",
|
||||
side_effect=[mock_vector_store.return_value, mocker.MagicMock()],
|
||||
)
|
||||
mocker.patch("mem0.utils.factory.LlmFactory.create", mocker.MagicMock())
|
||||
mocker.patch("mem0.memory.storage.SQLiteManager", mocker.MagicMock())
|
||||
|
||||
memory = Memory()
|
||||
memory.config = mocker.MagicMock()
|
||||
memory.config.custom_instructions = None
|
||||
memory.custom_instructions = None
|
||||
memory.api_version = "v1.1"
|
||||
memory.db.get_last_messages = mocker.MagicMock(return_value=[])
|
||||
memory.db.save_messages = mocker.MagicMock()
|
||||
memory.llm.generate_response.return_value = '{"memory": []}'
|
||||
return memory
|
||||
|
||||
def test_add_pipeline_uses_the_escaped_key(self, memory):
|
||||
"""The pipeline must route through the builder, not assemble the key inline."""
|
||||
memory._add_to_vector_store(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
metadata={},
|
||||
filters={"run_id": "proj-x&user_id=u1"},
|
||||
infer=True,
|
||||
)
|
||||
assert memory.db.get_last_messages.call_args[0][0] == "run_id=proj-x%26user_id%3Du1"
|
||||
assert memory.db.save_messages.call_args[0][1] == "run_id=proj-x%26user_id%3Du1"
|
||||
@@ -1,3 +1,5 @@
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -9,3 +11,33 @@ def mock_llm():
|
||||
mock_llm_instance = MagicMock()
|
||||
mock_factory.create.return_value = mock_llm_instance
|
||||
yield mock_factory, mock_llm_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cohere(monkeypatch):
|
||||
"""Provide a fake ``cohere`` module so CohereReranker imports/constructs."""
|
||||
fake_cohere = ModuleType("cohere")
|
||||
fake_client = MagicMock()
|
||||
fake_cohere.Client = MagicMock(return_value=fake_client)
|
||||
monkeypatch.setitem(sys.modules, "cohere", fake_cohere)
|
||||
|
||||
import mem0.reranker.cohere_reranker as cohere_reranker
|
||||
|
||||
monkeypatch.setattr(cohere_reranker, "cohere", fake_cohere, raising=False)
|
||||
monkeypatch.setattr(cohere_reranker, "COHERE_AVAILABLE", True, raising=False)
|
||||
return cohere_reranker, fake_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_zero_entropy(monkeypatch):
|
||||
"""Provide a fake ``zeroentropy`` module so ZeroEntropyReranker imports."""
|
||||
fake_module = ModuleType("zeroentropy")
|
||||
fake_client = MagicMock()
|
||||
fake_module.ZeroEntropy = MagicMock(return_value=fake_client)
|
||||
monkeypatch.setitem(sys.modules, "zeroentropy", fake_module)
|
||||
|
||||
import mem0.reranker.zero_entropy_reranker as zero_entropy_reranker
|
||||
|
||||
monkeypatch.setattr(zero_entropy_reranker, "ZeroEntropy", fake_module.ZeroEntropy, raising=False)
|
||||
monkeypatch.setattr(zero_entropy_reranker, "ZERO_ENTROPY_AVAILABLE", True, raising=False)
|
||||
return zero_entropy_reranker, fake_client
|
||||
|
||||
@@ -6,7 +6,7 @@ from mem0.reranker.llm_reranker import LLMReranker
|
||||
class TestLLMRerankerConfig:
|
||||
def test_default_config(self):
|
||||
config = LLMRerankerConfig()
|
||||
assert config.model == "gpt-4o-mini"
|
||||
assert config.model == "gpt-5-mini"
|
||||
assert config.provider == "openai"
|
||||
assert config.temperature == 0.0
|
||||
assert config.max_tokens == 100
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Regression tests pinning that the reranker fallback path never mutates caller-owned documents."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from mem0.configs.rerankers.cohere import CohereRerankerConfig
|
||||
from mem0.configs.rerankers.huggingface import HuggingFaceRerankerConfig
|
||||
from mem0.configs.rerankers.sentence_transformer import (
|
||||
SentenceTransformerRerankerConfig,
|
||||
)
|
||||
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
|
||||
from mem0.reranker.huggingface_reranker import HuggingFaceReranker
|
||||
from mem0.reranker.sentence_transformer_reranker import SentenceTransformerReranker
|
||||
|
||||
|
||||
def _docs(n):
|
||||
return [{"memory": f"doc{i}"} for i in range(n)]
|
||||
|
||||
|
||||
class TestCohereFallbackNoMutation:
|
||||
def test_fallback_does_not_mutate_original_documents(self, mock_cohere):
|
||||
module, fake_client = mock_cohere
|
||||
fake_client.rerank.side_effect = RuntimeError("API error")
|
||||
|
||||
reranker = module.CohereReranker(CohereRerankerConfig(api_key="test-key"))
|
||||
documents = _docs(3)
|
||||
result = reranker.rerank("query", documents)
|
||||
|
||||
assert all("rerank_score" not in doc for doc in documents)
|
||||
assert all(doc["rerank_score"] == 0.0 for doc in result)
|
||||
|
||||
|
||||
class TestZeroEntropyFallbackNoMutation:
|
||||
def test_fallback_does_not_mutate_original_documents(self, mock_zero_entropy):
|
||||
module, fake_client = mock_zero_entropy
|
||||
fake_client.models.rerank.side_effect = RuntimeError("API error")
|
||||
|
||||
reranker = module.ZeroEntropyReranker(ZeroEntropyRerankerConfig(api_key="test-key"))
|
||||
documents = _docs(3)
|
||||
result = reranker.rerank("query", documents)
|
||||
|
||||
assert all("rerank_score" not in doc for doc in documents)
|
||||
assert all(doc["rerank_score"] == 0.0 for doc in result)
|
||||
|
||||
|
||||
class TestHuggingFaceFallbackNoMutation:
|
||||
def test_fallback_does_not_mutate_original_documents(self):
|
||||
with (
|
||||
patch("mem0.reranker.huggingface_reranker.AutoTokenizer") as mock_tokenizer_cls,
|
||||
patch("mem0.reranker.huggingface_reranker.AutoModelForSequenceClassification") as mock_model_cls,
|
||||
):
|
||||
mock_tokenizer = MagicMock(side_effect=RuntimeError("tokenizer error"))
|
||||
mock_tokenizer_cls.from_pretrained.return_value = mock_tokenizer
|
||||
mock_model_cls.from_pretrained.return_value = MagicMock()
|
||||
|
||||
reranker = HuggingFaceReranker(HuggingFaceRerankerConfig())
|
||||
documents = _docs(3)
|
||||
result = reranker.rerank("query", documents)
|
||||
|
||||
assert all("rerank_score" not in doc for doc in documents)
|
||||
assert all(doc["rerank_score"] == 0.0 for doc in result)
|
||||
|
||||
|
||||
class TestSentenceTransformerFallbackNoMutation:
|
||||
def test_fallback_does_not_mutate_original_documents(self):
|
||||
with patch("mem0.reranker.sentence_transformer_reranker.CrossEncoder") as mock_cross_encoder_cls:
|
||||
mock_model = MagicMock()
|
||||
mock_model.predict.side_effect = RuntimeError("predict error")
|
||||
mock_cross_encoder_cls.return_value = mock_model
|
||||
|
||||
reranker = SentenceTransformerReranker(SentenceTransformerRerankerConfig())
|
||||
documents = _docs(3)
|
||||
result = reranker.rerank("query", documents)
|
||||
|
||||
assert all("rerank_score" not in doc for doc in documents)
|
||||
assert all(doc["rerank_score"] == 0.0 for doc in result)
|
||||
@@ -1,52 +1,9 @@
|
||||
"""Regression tests for the reranker fallback path honoring ``config.top_k``.
|
||||
|
||||
When the underlying rerank call fails, the reranker falls back to returning the
|
||||
documents in their original order. That fallback must still respect the
|
||||
configured ``top_k`` limit, exactly like the success path does. The HuggingFace
|
||||
and SentenceTransformer rerankers already behave this way; these tests pin the
|
||||
same contract for the Cohere and ZeroEntropy rerankers.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
"""Regression tests pinning that the reranker fallback path still honors ``config.top_k``."""
|
||||
|
||||
from mem0.configs.rerankers.cohere import CohereRerankerConfig
|
||||
from mem0.configs.rerankers.zero_entropy import ZeroEntropyRerankerConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cohere(monkeypatch):
|
||||
"""Provide a fake ``cohere`` module so CohereReranker imports/constructs."""
|
||||
fake_cohere = ModuleType("cohere")
|
||||
fake_client = MagicMock()
|
||||
fake_cohere.Client = MagicMock(return_value=fake_client)
|
||||
monkeypatch.setitem(sys.modules, "cohere", fake_cohere)
|
||||
|
||||
import mem0.reranker.cohere_reranker as cohere_reranker
|
||||
|
||||
monkeypatch.setattr(cohere_reranker, "cohere", fake_cohere, raising=False)
|
||||
monkeypatch.setattr(cohere_reranker, "COHERE_AVAILABLE", True, raising=False)
|
||||
return cohere_reranker, fake_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_zero_entropy(monkeypatch):
|
||||
"""Provide a fake ``zeroentropy`` module so ZeroEntropyReranker imports."""
|
||||
fake_module = ModuleType("zeroentropy")
|
||||
fake_client = MagicMock()
|
||||
fake_module.ZeroEntropy = MagicMock(return_value=fake_client)
|
||||
monkeypatch.setitem(sys.modules, "zeroentropy", fake_module)
|
||||
|
||||
import mem0.reranker.zero_entropy_reranker as zero_entropy_reranker
|
||||
|
||||
monkeypatch.setattr(zero_entropy_reranker, "ZeroEntropy", fake_module.ZeroEntropy, raising=False)
|
||||
monkeypatch.setattr(zero_entropy_reranker, "ZERO_ENTROPY_AVAILABLE", True, raising=False)
|
||||
return zero_entropy_reranker, fake_client
|
||||
|
||||
|
||||
def _docs(n):
|
||||
return [{"memory": f"doc{i}"} for i in range(n)]
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import httpx
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from mem0.client.main import AsyncMemoryClient
|
||||
from mem0.client.types import GetAllMemoryOptions, SearchMemoryOptions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory_client():
|
||||
@@ -130,6 +133,199 @@ class TestGetAllEntityParamRejection:
|
||||
)
|
||||
|
||||
|
||||
class TestSearchTypedOptionsParity:
|
||||
"""MEM-5893: SearchMemoryOptions' new typed fields serialize to their v3 snake_case keys."""
|
||||
|
||||
def test_search_options_pass_reference_date_latest_only_keyword_search(self, mock_memory_client):
|
||||
"""search(options=SearchMemoryOptions(...)) should forward the new fields verbatim."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
options = SearchMemoryOptions(
|
||||
filters={"user_id": "u1"},
|
||||
reference_date="2024-01-01",
|
||||
latest_only=True,
|
||||
keyword_search=True,
|
||||
)
|
||||
mock_memory_client.search("test query", options=options)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/search/",
|
||||
json={
|
||||
"query": "test query",
|
||||
"filters": {"user_id": "u1"},
|
||||
"reference_date": "2024-01-01",
|
||||
"latest_only": True,
|
||||
"keyword_search": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestGetAllTypedOptionsParity:
|
||||
"""MEM-5893: GetAllMemoryOptions.latest_only serializes to its v3 snake_case key."""
|
||||
|
||||
def test_get_all_options_pass_latest_only(self, mock_memory_client):
|
||||
"""get_all(options=GetAllMemoryOptions(...)) should forward latest_only verbatim."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
options = GetAllMemoryOptions(filters={"user_id": "u1"}, latest_only=True)
|
||||
mock_memory_client.get_all(options=options)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}, "latest_only": True},
|
||||
)
|
||||
|
||||
|
||||
class TestGetAllPageSizeQueryParams:
|
||||
"""MEM-6227: page and page_size must reach the API as query params independently."""
|
||||
|
||||
def test_get_all_page_size_alone_lands_in_query_params(self, mock_memory_client):
|
||||
"""get_all(page_size=...) without page should send page_size as a query param."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
mock_memory_client.get_all(filters={"user_id": "u1"}, page_size=50)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page_size": 50},
|
||||
)
|
||||
|
||||
def test_get_all_page_alone_lands_in_query_params(self, mock_memory_client):
|
||||
"""get_all(page=...) without page_size should send page as a query param."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
mock_memory_client.get_all(filters={"user_id": "u1"}, page=2)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page": 2},
|
||||
)
|
||||
|
||||
def test_get_all_page_and_page_size_together(self, mock_memory_client):
|
||||
"""get_all(page=..., page_size=...) should send both as query params."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
mock_memory_client.get_all(filters={"user_id": "u1"}, page=2, page_size=50)
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page": 2, "page_size": 50},
|
||||
)
|
||||
|
||||
def test_get_all_neither_page_nor_page_size_sends_no_query_params(self, mock_memory_client):
|
||||
"""get_all() without page or page_size should not pass a params kwarg."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_memory_client.client.post.return_value = mock_response
|
||||
|
||||
mock_memory_client.get_all(filters={"user_id": "u1"})
|
||||
|
||||
mock_memory_client.client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
)
|
||||
|
||||
def test_async_get_all_page_size_alone_lands_in_query_params(self):
|
||||
asyncio.run(self._assert_async_get_all_page_size_alone())
|
||||
|
||||
async def _assert_async_get_all_page_size_alone(self):
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
client.async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get_all(filters={"user_id": "u1"}, page_size=50)
|
||||
|
||||
client.async_client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page_size": 50},
|
||||
)
|
||||
|
||||
def test_async_get_all_page_alone_lands_in_query_params(self):
|
||||
asyncio.run(self._assert_async_get_all_page_alone())
|
||||
|
||||
async def _assert_async_get_all_page_alone(self):
|
||||
"""get_all(page=0) without page_size should still send page=0 as a query param."""
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
client.async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get_all(filters={"user_id": "u1"}, page=0)
|
||||
|
||||
client.async_client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page": 0},
|
||||
)
|
||||
|
||||
def test_async_get_all_page_and_page_size_together(self):
|
||||
asyncio.run(self._assert_async_get_all_page_and_page_size_together())
|
||||
|
||||
async def _assert_async_get_all_page_and_page_size_together(self):
|
||||
"""get_all(page=0, page_size=0) should send both as query params despite being falsy."""
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
client.async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get_all(filters={"user_id": "u1"}, page=0, page_size=0)
|
||||
|
||||
client.async_client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
params={"page": 0, "page_size": 0},
|
||||
)
|
||||
|
||||
def test_async_get_all_neither_page_nor_page_size_sends_no_query_params(self):
|
||||
asyncio.run(self._assert_async_get_all_neither_page_nor_page_size())
|
||||
|
||||
async def _assert_async_get_all_neither_page_nor_page_size(self):
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"results": []}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
client.async_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get_all(filters={"user_id": "u1"})
|
||||
|
||||
client.async_client.post.assert_called_once_with(
|
||||
"/v3/memories/",
|
||||
json={"filters": {"user_id": "u1"}},
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateExpirationDate:
|
||||
"""Tests for update expiration_date payload handling."""
|
||||
|
||||
@@ -389,3 +585,51 @@ class TestValidateApiKeyHttpError:
|
||||
|
||||
assert not isinstance(exc_info.value, requests.exceptions.JSONDecodeError)
|
||||
assert "Error:" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestAddAgentCustomInstructions:
|
||||
"""Per-request override of the project-level setting, forwarded to the add payload."""
|
||||
|
||||
def _mock_add(self, client):
|
||||
response = MagicMock()
|
||||
response.json.return_value = {"results": []}
|
||||
response.raise_for_status.return_value = None
|
||||
client.client.post.return_value = response
|
||||
return response
|
||||
|
||||
def test_kwarg_reaches_the_add_payload(self, mock_memory_client):
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add(
|
||||
"hello",
|
||||
filters={"agent_id": "a1"},
|
||||
agent_custom_instructions="remember tool failures",
|
||||
)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"]["agent_custom_instructions"] == "remember tool failures"
|
||||
|
||||
def test_typed_option_reaches_the_add_payload(self, mock_memory_client):
|
||||
from mem0.client.types import AddMemoryOptions
|
||||
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add(
|
||||
"hello",
|
||||
AddMemoryOptions(
|
||||
filters={"agent_id": "a1"},
|
||||
agent_custom_instructions="remember tool failures",
|
||||
),
|
||||
)
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert kwargs["json"]["agent_custom_instructions"] == "remember tool failures"
|
||||
|
||||
def test_absent_when_not_passed(self, mock_memory_client):
|
||||
"""Callers that don't use the feature send an unchanged payload."""
|
||||
self._mock_add(mock_memory_client)
|
||||
|
||||
mock_memory_client.add("hello", filters={"user_id": "u1"})
|
||||
|
||||
_, kwargs = mock_memory_client.client.post.call_args
|
||||
assert "agent_custom_instructions" not in kwargs["json"]
|
||||
|
||||
+46
-3
@@ -5,7 +5,7 @@ from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
|
||||
from mem0.configs.base import MemoryConfig
|
||||
from mem0.memory.main import Memory
|
||||
from mem0.memory.main import Memory, _validate_and_trim_entity_id
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -280,19 +280,36 @@ def test_delete(memory_instance):
|
||||
|
||||
def test_delete_all(memory_instance):
|
||||
mock_memories = [Mock(id="1"), Mock(id="2")]
|
||||
memory_instance.vector_store.list = Mock(return_value=(mock_memories, None))
|
||||
memory_instance.vector_store.list = Mock(side_effect=[(mock_memories, None), ([], None)])
|
||||
memory_instance.vector_store.reset = Mock()
|
||||
memory_instance._delete_memory = Mock()
|
||||
|
||||
result = memory_instance.delete_all(user_id="test_user")
|
||||
|
||||
assert memory_instance._delete_memory.call_count == 2
|
||||
memory_instance.vector_store.list.assert_called_with(
|
||||
filters={"user_id": "test_user"}, top_k=1000
|
||||
)
|
||||
# Ensure the collection is NOT dropped — only matched memories should be removed
|
||||
memory_instance.vector_store.reset.assert_not_called()
|
||||
|
||||
assert result["message"] == "Memories deleted successfully!"
|
||||
|
||||
|
||||
def test_delete_all_paginates_beyond_vector_store_page_size(memory_instance):
|
||||
first_batch = [Mock(id=str(index)) for index in range(1000)]
|
||||
second_batch = [Mock(id="1000")]
|
||||
memory_instance.vector_store.list = Mock(
|
||||
side_effect=[(first_batch, None), (second_batch, None), ([], None)]
|
||||
)
|
||||
memory_instance._delete_memory = Mock()
|
||||
|
||||
memory_instance.delete_all(user_id="test_user")
|
||||
|
||||
assert memory_instance._delete_memory.call_count == 1001
|
||||
assert memory_instance.vector_store.list.call_count == 3
|
||||
|
||||
|
||||
def test_get_all(memory_instance):
|
||||
mock_memories = [Mock(id="1", payload={"data": "Memory 1", "user_id": "test_user"})]
|
||||
memory_instance.vector_store.list = Mock(return_value=(mock_memories, None))
|
||||
@@ -431,7 +448,33 @@ class TestEntityIdValidation:
|
||||
|
||||
memory_instance.delete_all(user_id=" alice ")
|
||||
|
||||
memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "alice"})
|
||||
memory_instance.vector_store.list.assert_called_once_with(
|
||||
filters={"user_id": "alice"}, top_k=1000
|
||||
)
|
||||
|
||||
def test_validate_coerces_non_string_entity_id(self):
|
||||
"""Integer (and other non-string) ids are coerced to str, not crashed on."""
|
||||
assert _validate_and_trim_entity_id(42, "user_id") == "42"
|
||||
assert _validate_and_trim_entity_id(0, "user_id") == "0"
|
||||
|
||||
def test_delete_all_coerces_integer_user_id_before_list(self, memory_instance):
|
||||
"""delete_all should accept an integer user_id and scope by its str form."""
|
||||
memory_instance.vector_store.list = Mock(return_value=([], None))
|
||||
|
||||
memory_instance.delete_all(user_id=42)
|
||||
|
||||
memory_instance.vector_store.list.assert_called_once_with(
|
||||
filters={"user_id": "42"}, top_k=1000
|
||||
)
|
||||
|
||||
def test_get_all_coerces_integer_user_id(self, memory_instance):
|
||||
"""get_all should accept an integer user_id in filters and scope by its str form."""
|
||||
memory_instance.vector_store.list = Mock(return_value=([], None))
|
||||
|
||||
memory_instance.get_all(filters={"user_id": 42})
|
||||
|
||||
_, kwargs = memory_instance.vector_store.list.call_args
|
||||
assert kwargs["filters"]["user_id"] == "42"
|
||||
|
||||
|
||||
class TestSearchParamValidation:
|
||||
|
||||
+48
-3
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -982,7 +983,7 @@ async def test_async_delete_all_continues_on_partial_failure(mock_sqlite, mock_l
|
||||
mem3.id = "mem-3"
|
||||
mem3.payload = {"data": "three", "created_at": "2024-01-01T00:00:00+00:00", "actor_id": None, "role": None}
|
||||
|
||||
mock_vector_store.list.return_value = ([mem1, mem2, mem3],)
|
||||
mock_vector_store.list.side_effect = [([mem1, mem2, mem3],), ([],)]
|
||||
|
||||
def _get_side_effect(vector_id):
|
||||
if vector_id == "mem-2":
|
||||
@@ -998,6 +999,10 @@ async def test_async_delete_all_continues_on_partial_failure(mock_sqlite, mock_l
|
||||
|
||||
assert result == {"message": "Memories deleted successfully!"}
|
||||
assert mock_vector_store.delete.call_count == 2
|
||||
assert mock_vector_store.list.call_args_list[0].kwargs == {
|
||||
"filters": {"user_id": "test-user"},
|
||||
"top_k": 1000,
|
||||
}
|
||||
|
||||
|
||||
@patch('mem0.utils.factory.EmbedderFactory.create')
|
||||
@@ -1472,7 +1477,7 @@ class TestAsyncDeleteAllEntityRace:
|
||||
mem_b = MagicMock()
|
||||
mem_b.id = "mem-b"
|
||||
mem_b.payload = {"data": "Alice works at Acme", "user_id": "alice"}
|
||||
mock_vector_store.list.return_value = ([mem_a, mem_b],)
|
||||
mock_vector_store.list.side_effect = [([mem_a, mem_b],), ([],)]
|
||||
mock_vector_store.get.side_effect = lambda vector_id: {"mem-a": mem_a, "mem-b": mem_b}[vector_id]
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
|
||||
@@ -1529,3 +1534,43 @@ async def test_async_procedural_memory_langchain_strips_code_blocks(mock_llm_fac
|
||||
insert_call = memory.vector_store.insert.call_args
|
||||
stored_data = insert_call[1]["payloads"][0]["data"]
|
||||
assert "```" not in stored_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mem0.memory.main.VectorStoreFactory")
|
||||
@patch("mem0.memory.main.EmbedderFactory")
|
||||
@patch("mem0.memory.main.LlmFactory")
|
||||
async def test_async_procedural_memory_default_path_without_langchain(mock_llm_factory, mock_emb, mock_vs):
|
||||
"""Async procedural memory must not require langchain-core on the default
|
||||
(llm=None) path, which uses self.llm and never calls convert_to_messages.
|
||||
The sync path already works without it; this keeps the async path in parity.
|
||||
"""
|
||||
mock_vs.return_value = MagicMock()
|
||||
mock_emb.return_value = MagicMock()
|
||||
mock_emb.return_value.embed.return_value = [0.1] * 1536
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import AsyncMemory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = AsyncMemory(config)
|
||||
memory.vector_store = MagicMock()
|
||||
memory.vector_store.insert = MagicMock()
|
||||
memory.embedding_model.embed = Mock(return_value=[0.1] * 1536)
|
||||
memory.llm.generate_response = Mock(return_value="- deploy with the release script")
|
||||
|
||||
messages = [{"role": "user", "content": "how do we deploy"}]
|
||||
|
||||
# Simulate langchain-core being unavailable; the default path must still work.
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"langchain_core": None,
|
||||
"langchain_core.messages": None,
|
||||
"langchain_core.messages.utils": None,
|
||||
},
|
||||
):
|
||||
result = await memory._create_procedural_memory(messages, metadata={"agent_id": "agent_1"})
|
||||
|
||||
assert result["results"][0]["event"] == "ADD"
|
||||
memory.llm.generate_response.assert_called_once()
|
||||
|
||||
+47
-3
@@ -2,9 +2,9 @@
|
||||
parameter-passthrough surface.
|
||||
|
||||
Verifies the kwarg → JSON payload mapping for every supported field
|
||||
(``custom_instructions``, ``custom_categories``, ``retrieval_criteria``,
|
||||
``multilingual``, ``decay``), the ValueError when no field is
|
||||
provided, and the URL/method shape. The HTTP layer is mocked.
|
||||
(``custom_instructions``, ``custom_categories``, ``multilingual``,
|
||||
``decay``, ``agent_custom_instructions``), the ValueError when no field
|
||||
is provided, and the URL/method shape. The HTTP layer is mocked.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -84,6 +84,50 @@ class TestProjectUpdateDecay:
|
||||
assert args[0] == "/api/v1/orgs/organizations/org1/projects/proj1/"
|
||||
|
||||
|
||||
class TestProjectUpdateAgentCustomInstructions:
|
||||
def test_agent_custom_instructions_sent_in_payload(self, project):
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="remember tool failures")
|
||||
assert _patch_payload(http) == {"agent_custom_instructions": "remember tool failures"}
|
||||
|
||||
def test_agent_custom_instructions_alone_satisfies_the_guard(self, project):
|
||||
"""It is a standalone field, so setting only it must not raise."""
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="remember tool failures")
|
||||
assert http.patch.called
|
||||
|
||||
def test_empty_string_round_trips_to_clear_the_field(self, project):
|
||||
"""An empty string must survive the ``is not None`` filter, not be dropped as falsy."""
|
||||
proj, http = project
|
||||
proj.update(agent_custom_instructions="")
|
||||
assert _patch_payload(http) == {"agent_custom_instructions": ""}
|
||||
|
||||
def test_combined_with_custom_instructions(self, project):
|
||||
"""Both sets in one PATCH: the split-instruction configuration."""
|
||||
proj, http = project
|
||||
proj.update(
|
||||
custom_instructions="remember user preferences",
|
||||
agent_custom_instructions="remember tool failures",
|
||||
)
|
||||
assert _patch_payload(http) == {
|
||||
"custom_instructions": "remember user preferences",
|
||||
"agent_custom_instructions": "remember tool failures",
|
||||
}
|
||||
|
||||
def test_omitted_when_none(self, project):
|
||||
"""Callers that don't pass it must send an unchanged payload."""
|
||||
proj, http = project
|
||||
proj.update(custom_instructions="be concise")
|
||||
payload = _patch_payload(http)
|
||||
assert payload == {"custom_instructions": "be concise"}
|
||||
assert "agent_custom_instructions" not in payload
|
||||
|
||||
def test_no_args_raises_with_agent_instructions_in_message(self, project):
|
||||
proj, _ = project
|
||||
with pytest.raises(ValueError, match=r"agent_custom_instructions"):
|
||||
proj.update()
|
||||
|
||||
|
||||
class TestProjectUpdateBackwardsCompat:
|
||||
def test_multilingual_only_still_works(self, project):
|
||||
"""Pre-decay callers (multilingual only) keep working unchanged."""
|
||||
|
||||
@@ -51,3 +51,12 @@ def test_base_to_provider_without_reasoning_fields_still_builds():
|
||||
|
||||
assert isinstance(built, AnthropicConfig)
|
||||
assert built.model == "claude-3-5-sonnet-20240620"
|
||||
|
||||
|
||||
def test_dict_config_not_mutated_by_kwargs():
|
||||
config = {"model": "gpt-4o-mini"}
|
||||
|
||||
with patch("mem0.utils.factory.load_class", return_value=Mock(return_value=Mock())):
|
||||
LlmFactory.create("openai", config, api_key="secret")
|
||||
|
||||
assert config == {"model": "gpt-4o-mini"}
|
||||
|
||||
@@ -137,6 +137,27 @@ def test_search(mochow_instance, mock_mochow_client):
|
||||
assert results[1].payload == {"name": "vector2"}
|
||||
|
||||
|
||||
def test_search_converts_l2_distance_to_similarity(mochow_instance):
|
||||
# On the L2 metric, Mochow returns raw distances (lower = closer). search()
|
||||
# must convert them to similarity scores (higher = better) to satisfy the
|
||||
# VectorStoreBase contract, mirroring the milvus provider.
|
||||
mochow_instance.metric_type = "L2"
|
||||
|
||||
mock_search_results = Mock()
|
||||
mock_search_results.rows = [
|
||||
{"row": {"id": "id1", "metadata": {"name": "vector1"}}, "score": 0.5},
|
||||
{"row": {"id": "id2", "metadata": {"name": "vector2"}}, "score": 2.0},
|
||||
]
|
||||
mochow_instance._table.vector_search.return_value = mock_search_results
|
||||
|
||||
results = mochow_instance.search(query="test", vectors=[0.1, 0.2, 0.3], top_k=2)
|
||||
|
||||
# 1.0 / (1.0 + distance): the closer memory must score higher than the far one.
|
||||
assert results[0].score == pytest.approx(1.0 / 1.5)
|
||||
assert results[1].score == pytest.approx(1.0 / 3.0)
|
||||
assert results[0].score > results[1].score
|
||||
|
||||
|
||||
def test_search_with_filters(mochow_instance, mock_mochow_client):
|
||||
mochow_instance._table.vector_search.return_value = Mock(rows=[])
|
||||
|
||||
|
||||
@@ -344,3 +344,53 @@ def test_chroma_config_rejects_no_config():
|
||||
"""Test that ChromaDbConfig rejects when no connection config is provided."""
|
||||
with pytest.raises(ValueError):
|
||||
ChromaDbConfig()
|
||||
|
||||
|
||||
def test_generate_where_clause_same_field_range_keeps_both_bounds():
|
||||
"""A same-field range must produce both bounds, each as its own single-operator
|
||||
clause combined with $and (ChromaDB rejects multi-operator field expressions).
|
||||
|
||||
Regression test: each operator previously overwrote the previous one, so
|
||||
{"gte": 18, "lte": 65} silently degraded to {"$lte": 65} and returned rows
|
||||
the caller explicitly excluded.
|
||||
"""
|
||||
result = ChromaDB._generate_where_clause({"age": {"gte": 18, "lte": 65}})
|
||||
assert result == {"$and": [{"age": {"$gte": 18}}, {"age": {"$lte": 65}}]}
|
||||
|
||||
|
||||
def test_generate_where_clause_or_with_same_field_range():
|
||||
"""Same-field ranges inside $or branches must also keep both bounds."""
|
||||
result = ChromaDB._generate_where_clause({"$or": [{"age": {"gte": 18, "lte": 65}}, {"vip": True}]})
|
||||
assert result == {
|
||||
"$or": [
|
||||
{"$and": [{"age": {"$gte": 18}}, {"age": {"$lte": 65}}]},
|
||||
{"vip": {"$eq": True}},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_generate_where_clause_or_with_multi_field_condition():
|
||||
"""Multi-field conditions inside $or must be wrapped in $and — ChromaDB
|
||||
rejects flat dicts with more than one field per level."""
|
||||
result = ChromaDB._generate_where_clause({"$or": [{"age": {"gte": 18}, "vip": True}, {"city": "sh"}]})
|
||||
assert result == {
|
||||
"$or": [
|
||||
{"$and": [{"age": {"$gte": 18}}, {"vip": {"$eq": True}}]},
|
||||
{"city": {"$eq": "sh"}},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_generate_where_clause_not_contains_negates_instead_of_vanishing():
|
||||
"""$not with contains/icontains must produce a negated clause.
|
||||
|
||||
Regression test: operators missing from the negation map were silently
|
||||
dropped, which could erase the whole where clause and return unfiltered
|
||||
results. contains falls back to equality on the positive path, so its
|
||||
negation falls back to inequality.
|
||||
"""
|
||||
result = ChromaDB._generate_where_clause({"$not": [{"title": {"contains": "draft"}}]})
|
||||
assert result == {"title": {"$ne": "draft"}}
|
||||
|
||||
result = ChromaDB._generate_where_clause({"$not": [{"title": {"icontains": "draft"}}]})
|
||||
assert result == {"title": {"$ne": "draft"}}
|
||||
|
||||
@@ -249,9 +249,23 @@ REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
|
||||
|
||||
|
||||
def _redis_search_available(host, port, timeout=2):
|
||||
"""RediSearch (the FT.* command family) is an optional module, not part of vanilla Redis."""
|
||||
if not _tcp_reachable(host, port, timeout=timeout):
|
||||
return False
|
||||
try:
|
||||
import redis
|
||||
|
||||
client = redis.Redis(host=host, port=port, socket_connect_timeout=timeout, socket_timeout=timeout)
|
||||
modules = {m["name"].lower() for m in client.module_list()}
|
||||
return "search" in modules
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _tcp_reachable(REDIS_HOST, REDIS_PORT),
|
||||
reason=f"Redis not reachable at {REDIS_HOST}:{REDIS_PORT}",
|
||||
not _redis_search_available(REDIS_HOST, REDIS_PORT),
|
||||
reason=f"RediSearch module not available at {REDIS_HOST}:{REDIS_PORT} (requires Redis Stack, not vanilla Redis)",
|
||||
)
|
||||
class TestRedisThreshold:
|
||||
def test_threshold_filtering(self):
|
||||
|
||||
@@ -209,6 +209,8 @@ class TestElasticsearchDB(unittest.TestCase):
|
||||
self.assertEqual(body["knn"]["query_vector"], vectors)
|
||||
self.assertEqual(body["knn"]["k"], 5)
|
||||
self.assertEqual(body["knn"]["num_candidates"], 10)
|
||||
# `size` must be set or ES caps the response at its default of 10 hits.
|
||||
self.assertEqual(body["size"], 5)
|
||||
|
||||
# Verify results
|
||||
self.assertEqual(len(results), 1)
|
||||
@@ -216,6 +218,43 @@ class TestElasticsearchDB(unittest.TestCase):
|
||||
self.assertEqual(results[0].score, 0.8)
|
||||
self.assertEqual(results[0].payload, {"key1": "value1"})
|
||||
|
||||
def test_search_sets_size_to_top_k(self):
|
||||
"""Regression for #5909: KNN search must set `size=top_k` or Elasticsearch caps
|
||||
the response at its default of 10 hits regardless of `knn.k`.
|
||||
|
||||
Memory._search_vector_store over-fetches a scoring pool of `max(limit * 4, 60)`
|
||||
candidates for hybrid re-ranking, so even a default search(top_k=20) reaches this
|
||||
layer with top_k=80. Without `size`, that pool is silently truncated to 10.
|
||||
"""
|
||||
self.client_mock.search.return_value = {"hits": {"hits": []}}
|
||||
|
||||
# internal_limit in Memory._search_vector_store for a default search(top_k=20)
|
||||
over_fetch = max(20 * 4, 60)
|
||||
self.es_db.search(query="", vectors=[[0.1] * 1536], top_k=over_fetch)
|
||||
|
||||
body = self.client_mock.search.call_args[1]["body"]
|
||||
self.assertEqual(body["size"], over_fetch)
|
||||
self.assertEqual(body["knn"]["k"], over_fetch)
|
||||
self.assertGreater(body["size"], 10)
|
||||
|
||||
def test_search_with_filters_keeps_size(self):
|
||||
"""Filters must not displace the response size set for KNN search."""
|
||||
self.client_mock.search.return_value = {"hits": {"hits": []}}
|
||||
|
||||
self.es_db.search(
|
||||
query="",
|
||||
vectors=[[0.1] * 1536],
|
||||
top_k=25,
|
||||
filters={"user_id": "u1"},
|
||||
)
|
||||
|
||||
body = self.client_mock.search.call_args[1]["body"]
|
||||
self.assertEqual(body["size"], 25)
|
||||
self.assertEqual(
|
||||
body["knn"]["filter"],
|
||||
{"bool": {"must": [{"term": {"metadata.user_id": "u1"}}]}},
|
||||
)
|
||||
|
||||
def test_custom_search_query(self):
|
||||
# Mock custom search query
|
||||
self.es_db.custom_search_query = Mock()
|
||||
|
||||
@@ -107,6 +107,14 @@ class TestMilvusDB:
|
||||
assert 'metadata["category"] == "work"' in filter_str
|
||||
assert ' and ' in filter_str
|
||||
|
||||
def test_create_filter_wildcard_conditions(self, milvus_db):
|
||||
"""Test filter creation with wildcard conditions."""
|
||||
filters = {"user_id": "alice", "run_id": "*"}
|
||||
filter_str = milvus_db._create_filter(filters)
|
||||
|
||||
assert 'metadata["user_id"] == "alice"' in filter_str
|
||||
assert 'metadata["run_id"] == "*"' not in filter_str
|
||||
|
||||
def test_search_with_filters(self, milvus_db, mock_milvus_client):
|
||||
"""Test search with metadata filters (reproduces user's bug scenario)."""
|
||||
# Setup mock return value
|
||||
@@ -337,10 +345,44 @@ class TestMilvusDB:
|
||||
assert '(metadata["active"] == True)' in result
|
||||
assert '(metadata["deleted"] == False)' in result
|
||||
|
||||
def test_update_omits_text_field_on_pre_v3_collection(self, mock_milvus_client):
|
||||
"""update() must not include 'text' for collections without BM25 schema."""
|
||||
mock_milvus_client.has_collection.return_value = True
|
||||
mock_milvus_client.describe_collection.return_value = {
|
||||
"fields": [
|
||||
{"name": "id"},
|
||||
{"name": "vectors"},
|
||||
{"name": "metadata"},
|
||||
]
|
||||
}
|
||||
db = MilvusDB(
|
||||
url="http://localhost:19530",
|
||||
token="test_token",
|
||||
collection_name="legacy_collection",
|
||||
embedding_model_dims=1536,
|
||||
metric_type=MetricType.COSINE,
|
||||
db_name="test_db",
|
||||
)
|
||||
assert db._has_bm25_schema is False
|
||||
|
||||
db.update(vector_id="id1", vector=[0.1] * 1536, payload={"data": "hello"})
|
||||
|
||||
upserted = mock_milvus_client.upsert.call_args[1]["data"]
|
||||
assert "text" not in upserted
|
||||
|
||||
def test_update_includes_text_field_on_v3_collection(self, milvus_db, mock_milvus_client):
|
||||
"""update() must include 'text' for collections with BM25 schema."""
|
||||
assert milvus_db._has_bm25_schema is True
|
||||
|
||||
milvus_db.update(vector_id="id1", vector=[0.1] * 1536, payload={"data": "hello"})
|
||||
|
||||
upserted = mock_milvus_client.upsert.call_args[1]["data"]
|
||||
assert upserted["text"] == "hello"
|
||||
|
||||
def test_collection_already_exists(self, mock_milvus_client):
|
||||
"""Test that existing collection is not recreated."""
|
||||
mock_milvus_client.has_collection.return_value = True
|
||||
|
||||
|
||||
MilvusDB(
|
||||
url="http://localhost:19530",
|
||||
token="test_token",
|
||||
@@ -349,7 +391,7 @@ class TestMilvusDB:
|
||||
metric_type=MetricType.L2,
|
||||
db_name="test_db"
|
||||
)
|
||||
|
||||
|
||||
# create_collection should not be called
|
||||
mock_milvus_client.create_collection.assert_not_called()
|
||||
|
||||
|
||||
@@ -275,9 +275,116 @@ class TestNeptuneAnalyticsVectorInitValidation:
|
||||
def test_rejects_injection_payload_in_init(self, payload, monkeypatch):
|
||||
from mem0.vector_stores.neptune_analytics import NeptuneAnalyticsVector
|
||||
monkeypatch.setattr("mem0.vector_stores.neptune_analytics.NeptuneAnalyticsGraph", lambda *args, **kwargs: None)
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid collection_name"):
|
||||
NeptuneAnalyticsVector(
|
||||
endpoint="neptune-graph://test",
|
||||
collection_name=payload
|
||||
)
|
||||
|
||||
|
||||
class _FakeNeptuneGraph:
|
||||
"""Minimal stand-in for `NeptuneAnalyticsGraph.query()` so update()'s compensation
|
||||
path can be exercised without a real Neptune Analytics endpoint."""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes = {}
|
||||
self.fail_next_upsert = False
|
||||
self.soft_fail_next_upsert = False
|
||||
self.get_call_count = 0
|
||||
|
||||
def query(self, query_string, params=None):
|
||||
params = params or {}
|
||||
|
||||
if "UNWIND $rows" in query_string:
|
||||
rows = params["rows"]
|
||||
if "CALL neptune.algo.vectors.upsert" in query_string:
|
||||
return [{"success": True} for _ in rows]
|
||||
for row in rows:
|
||||
self.nodes[row["node_id"]] = dict(row["properties"])
|
||||
return []
|
||||
|
||||
if "CALL neptune.algo.vectors.upsert" in query_string:
|
||||
if self.fail_next_upsert:
|
||||
self.fail_next_upsert = False
|
||||
raise RuntimeError("simulated Neptune upsert failure")
|
||||
if self.soft_fail_next_upsert:
|
||||
self.soft_fail_next_upsert = False
|
||||
return [{"success": False}]
|
||||
return [{"success": True}]
|
||||
|
||||
if "SET n = $properties" in query_string:
|
||||
self.nodes[params["vector_id"]] = dict(params["properties"])
|
||||
return []
|
||||
|
||||
if "RETURN n" in query_string and "node_id" in params:
|
||||
self.get_call_count += 1
|
||||
vector_id = params["node_id"]
|
||||
if vector_id not in self.nodes:
|
||||
return []
|
||||
return [{"n": {"~id": vector_id, "~properties": dict(self.nodes[vector_id])}}]
|
||||
|
||||
if "DETACH DELETE n" in query_string:
|
||||
self.nodes.pop(params.get("node_id"), None)
|
||||
return []
|
||||
|
||||
return []
|
||||
|
||||
|
||||
class TestNeptuneAnalyticsUpdateRollback:
|
||||
"""update() must not leave a payload committed against a stale embedding when the
|
||||
vector upsert step fails. See the compensation logic in `NeptuneAnalyticsVector.update()`."""
|
||||
|
||||
def _make_vec(self, monkeypatch):
|
||||
monkeypatch.setattr("mem0.vector_stores.neptune_analytics.NeptuneAnalyticsGraph", lambda *args, **kwargs: None)
|
||||
vec = NeptuneAnalyticsVector(endpoint="neptune-graph://test", collection_name="rollback")
|
||||
vec.graph = _FakeNeptuneGraph()
|
||||
return vec
|
||||
|
||||
def test_restores_prior_payload_when_upsert_fails(self, monkeypatch):
|
||||
vec = self._make_vec(monkeypatch)
|
||||
vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}])
|
||||
|
||||
vec.graph.fail_next_upsert = True
|
||||
with pytest.raises(RuntimeError):
|
||||
vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"})
|
||||
|
||||
restored = vec.get("A")
|
||||
assert restored.payload["data"] == "alpha"
|
||||
assert restored.payload["user_id"] == "u1"
|
||||
|
||||
def test_does_not_snapshot_prior_state_for_a_vector_only_update(self, monkeypatch):
|
||||
"""Only a combined payload+vector update can desync -- a vector-only update has
|
||||
nothing to roll back to, so it must skip the extra get() snapshot entirely."""
|
||||
vec = self._make_vec(monkeypatch)
|
||||
vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}])
|
||||
|
||||
vec.graph.fail_next_upsert = True
|
||||
calls_before = vec.graph.get_call_count
|
||||
with pytest.raises(RuntimeError):
|
||||
vec.update("A", vector=[0.9, 0.9])
|
||||
|
||||
assert vec.graph.get_call_count == calls_before
|
||||
|
||||
def test_succeeds_normally_when_upsert_does_not_fail(self, monkeypatch):
|
||||
vec = self._make_vec(monkeypatch)
|
||||
vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}])
|
||||
|
||||
vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"})
|
||||
|
||||
updated = vec.get("A")
|
||||
assert updated.payload["data"] == "beta"
|
||||
|
||||
def test_rolls_back_on_soft_upsert_failure(self, monkeypatch):
|
||||
"""A soft {"success": False} row desyncs the payload from the embedding just as much as a
|
||||
thrown error, so update() must treat it as a failure and roll the payload back too."""
|
||||
vec = self._make_vec(monkeypatch)
|
||||
vec.insert(vectors=[[0.1, 0.2]], ids=["A"], payloads=[{"data": "alpha", "user_id": "u1"}])
|
||||
|
||||
vec.graph.soft_fail_next_upsert = True
|
||||
with pytest.raises(RuntimeError):
|
||||
vec.update("A", vector=[0.9, 0.9], payload={"data": "beta", "user_id": "u1"})
|
||||
|
||||
restored = vec.get("A")
|
||||
assert restored.payload["data"] == "alpha"
|
||||
assert restored.payload["user_id"] == "u1"
|
||||
|
||||
@@ -424,10 +424,25 @@ class TestOpenSearchDB(unittest.TestCase):
|
||||
|
||||
@patch("mem0.vector_stores.opensearch.logger")
|
||||
def test_search_error_logs_with_exc_info(self, mock_logger):
|
||||
"""Search error logging should include exc_info for full stack trace."""
|
||||
"""Search errors should log with exc_info and re-raise (not swallow as [])."""
|
||||
self.client_mock.search.side_effect = Exception("Search failed")
|
||||
results = self.os_db.search(query="", vectors=[[0.1] * 1536], top_k=5)
|
||||
self.assertEqual(results, [])
|
||||
with self.assertRaises(Exception):
|
||||
self.os_db.search(query="", vectors=[[0.1] * 1536], top_k=5)
|
||||
mock_logger.error.assert_called_once()
|
||||
call_kwargs = mock_logger.error.call_args
|
||||
self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True")
|
||||
|
||||
@patch("mem0.vector_stores.opensearch.logger")
|
||||
def test_keyword_search_error_logs_and_degrades(self, mock_logger):
|
||||
"""Keyword search errors should log with exc_info and degrade to None (not raise).
|
||||
|
||||
keyword_search() is a best-effort augmentation for search(); raising here
|
||||
would crash the whole search() call on a keyword-only failure (regression
|
||||
per maintainer review on #6519).
|
||||
"""
|
||||
self.client_mock.search.side_effect = Exception("Keyword search failed")
|
||||
result = self.os_db.keyword_search(query="test", top_k=5)
|
||||
self.assertIsNone(result)
|
||||
mock_logger.error.assert_called_once()
|
||||
call_kwargs = mock_logger.error.call_args
|
||||
self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True")
|
||||
@@ -594,3 +609,98 @@ class TestOpenSearchFilterValidation(unittest.TestCase):
|
||||
results = self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice"})
|
||||
self.assertEqual(results, [])
|
||||
self.client_mock.search.assert_called_once()
|
||||
|
||||
|
||||
class TestOpenSearchCustomFilters(TestOpenSearchFilterValidation):
|
||||
"""Custom (non-identity) filter keys must be honored, not silently dropped."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client_mock.search.return_value = {"hits": {"hits": []}}
|
||||
|
||||
def _search_body(self):
|
||||
return self.client_mock.search.call_args[1]["body"]
|
||||
|
||||
def test_search_honors_custom_filter_key(self):
|
||||
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "category": "billing"})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertIn({"term": {"payload.user_id.keyword": "alice"}}, clauses)
|
||||
self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses)
|
||||
|
||||
def test_keyword_search_honors_custom_filter_key(self):
|
||||
self.os_db.keyword_search(query="report", filters={"category": "billing"})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses)
|
||||
|
||||
def test_list_honors_custom_filter_key(self):
|
||||
self.os_db.list(filters={"category": "billing"})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertIn({"term": {"payload.category.keyword": "billing"}}, clauses)
|
||||
|
||||
def test_non_string_filter_values_use_plain_field(self):
|
||||
"""Non-string identity/scalar values must match against the plain payload field, not .keyword."""
|
||||
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"age": 30, "archived": False})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertIn({"term": {"payload.age": 30}}, clauses)
|
||||
self.assertIn({"term": {"payload.archived": False}}, clauses)
|
||||
|
||||
def test_custom_filter_keys_still_validated(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"bad key!": "x"})
|
||||
|
||||
def test_search_ignores_or_operator_filter(self):
|
||||
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "$or": [{"a": 1}]})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}])
|
||||
|
||||
def test_search_ignores_operator_shaped_filter_value(self):
|
||||
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "score": {"gte": 5}})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}])
|
||||
|
||||
def test_search_translates_wildcard_filter_value_to_exists(self):
|
||||
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"user_id": "alice", "category": "*"})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertEqual(
|
||||
clauses,
|
||||
[
|
||||
{"term": {"payload.user_id.keyword": "alice"}},
|
||||
{"exists": {"field": "payload.category"}},
|
||||
],
|
||||
)
|
||||
|
||||
def test_list_ignores_or_operator_filter(self):
|
||||
self.os_db.list(filters={"user_id": "alice", "$or": [{"a": 1}]})
|
||||
self.client_mock.search.assert_called_once()
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertEqual(clauses, [{"term": {"payload.user_id.keyword": "alice"}}])
|
||||
|
||||
|
||||
class TestOpenSearchWildcardFilters(TestOpenSearchFilterValidation):
|
||||
"""The "*" wildcard means "any value" (a documented Platform pattern) and
|
||||
must build an exists query — as opensearch.ts does — for every key,
|
||||
including the identity keys, instead of a literal term match on "*"."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client_mock.search.return_value = {"hits": {"hits": []}}
|
||||
|
||||
def _search_body(self):
|
||||
return self.client_mock.search.call_args[1]["body"]
|
||||
|
||||
def test_identity_key_wildcard_builds_exists_query(self):
|
||||
self.os_db.search(query="", vectors=[[0.1] * 1536], filters={"agent_id": "*"})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertIn({"exists": {"field": "payload.agent_id"}}, clauses)
|
||||
self.assertNotIn({"term": {"payload.agent_id.keyword": "*"}}, clauses)
|
||||
|
||||
def test_custom_key_wildcard_builds_exists_query(self):
|
||||
self.os_db.list(filters={"category": "*"})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertIn({"exists": {"field": "payload.category"}}, clauses)
|
||||
|
||||
def test_wildcard_combines_with_term_filters(self):
|
||||
self.os_db.keyword_search(query="q", filters={"user_id": "u1", "topic": "*"})
|
||||
clauses = self._search_body()["query"]["bool"]["filter"]
|
||||
self.assertIn({"term": {"payload.user_id.keyword": "u1"}}, clauses)
|
||||
self.assertIn({"exists": {"field": "payload.topic"}}, clauses)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -711,7 +711,7 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [] # No existing collections
|
||||
self.mock_cursor.fetchone.return_value = (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"})
|
||||
self.mock_cursor.fetchone.return_value = (self.test_ids[0], {"key": "value1"})
|
||||
|
||||
pgvector = PGVector(
|
||||
dbname="test_db",
|
||||
@@ -734,7 +734,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify get query was executed
|
||||
get_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call)]
|
||||
if "SELECT id, payload" in str(call)]
|
||||
self.assertTrue(len(get_calls) > 0)
|
||||
|
||||
# Verify result
|
||||
@@ -756,7 +756,7 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [] # No existing collections
|
||||
self.mock_cursor.fetchone.return_value = (self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"})
|
||||
self.mock_cursor.fetchone.return_value = (self.test_ids[0], {"key": "value1"})
|
||||
|
||||
pgvector = PGVector(
|
||||
dbname="test_db",
|
||||
@@ -779,7 +779,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify get query was executed
|
||||
get_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call)]
|
||||
if "SELECT id, payload" in str(call)]
|
||||
self.assertTrue(len(get_calls) > 0)
|
||||
|
||||
# Verify result
|
||||
@@ -1050,8 +1050,8 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [
|
||||
(self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}),
|
||||
(self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}),
|
||||
(self.test_ids[0], {"key": "value1"}),
|
||||
(self.test_ids[1], {"key": "value2"}),
|
||||
]
|
||||
|
||||
pgvector = PGVector(
|
||||
@@ -1075,7 +1075,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify list query was executed
|
||||
list_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call)]
|
||||
if "SELECT id, payload" in str(call)]
|
||||
self.assertTrue(len(list_calls) > 0)
|
||||
|
||||
# Verify result
|
||||
@@ -1098,8 +1098,8 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [
|
||||
(self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}),
|
||||
(self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}),
|
||||
(self.test_ids[0], {"key": "value1"}),
|
||||
(self.test_ids[1], {"key": "value2"}),
|
||||
]
|
||||
|
||||
pgvector = PGVector(
|
||||
@@ -1123,7 +1123,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify list query was executed
|
||||
list_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call)]
|
||||
if "SELECT id, payload" in str(call)]
|
||||
self.assertTrue(len(list_calls) > 0)
|
||||
|
||||
# Verify result
|
||||
@@ -1440,7 +1440,7 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [
|
||||
(self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice", "agent_id": "agent1"}),
|
||||
(self.test_ids[0], {"user_id": "alice", "agent_id": "agent1"}),
|
||||
]
|
||||
|
||||
pgvector = PGVector(
|
||||
@@ -1465,7 +1465,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify list query was executed with filters
|
||||
list_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)]
|
||||
if "SELECT id, payload" in str(call) and "WHERE" in str(call)]
|
||||
self.assertTrue(len(list_calls) > 0)
|
||||
|
||||
# Verify results
|
||||
@@ -1489,7 +1489,7 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [
|
||||
(self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice", "agent_id": "agent1"}),
|
||||
(self.test_ids[0], {"user_id": "alice", "agent_id": "agent1"}),
|
||||
]
|
||||
|
||||
pgvector = PGVector(
|
||||
@@ -1514,7 +1514,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify list query was executed with filters
|
||||
list_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)]
|
||||
if "SELECT id, payload" in str(call) and "WHERE" in str(call)]
|
||||
self.assertTrue(len(list_calls) > 0)
|
||||
|
||||
# Verify results
|
||||
@@ -1538,7 +1538,7 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [
|
||||
(self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice"}),
|
||||
(self.test_ids[0], {"user_id": "alice"}),
|
||||
]
|
||||
|
||||
pgvector = PGVector(
|
||||
@@ -1563,7 +1563,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify list query was executed with single filter
|
||||
list_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)]
|
||||
if "SELECT id, payload" in str(call) and "WHERE" in str(call)]
|
||||
self.assertTrue(len(list_calls) > 0)
|
||||
|
||||
# Verify results
|
||||
@@ -1586,7 +1586,7 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [
|
||||
(self.test_ids[0], [0.1, 0.2, 0.3], {"user_id": "alice"}),
|
||||
(self.test_ids[0], {"user_id": "alice"}),
|
||||
]
|
||||
|
||||
pgvector = PGVector(
|
||||
@@ -1611,7 +1611,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify list query was executed with single filter
|
||||
list_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call) and "WHERE" in str(call)]
|
||||
if "SELECT id, payload" in str(call) and "WHERE" in str(call)]
|
||||
self.assertTrue(len(list_calls) > 0)
|
||||
|
||||
# Verify results
|
||||
@@ -1634,8 +1634,8 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [
|
||||
(self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}),
|
||||
(self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}),
|
||||
(self.test_ids[0], {"key": "value1"}),
|
||||
(self.test_ids[1], {"key": "value2"}),
|
||||
]
|
||||
|
||||
pgvector = PGVector(
|
||||
@@ -1659,7 +1659,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify list query was executed without WHERE clause
|
||||
list_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call) and "WHERE" not in str(call)]
|
||||
if "SELECT id, payload" in str(call) and "WHERE" not in str(call)]
|
||||
self.assertTrue(len(list_calls) > 0)
|
||||
|
||||
# Verify results
|
||||
@@ -1682,8 +1682,8 @@ class TestPGVector(unittest.TestCase):
|
||||
mock_get_cursor.return_value.__exit__.return_value = None
|
||||
|
||||
self.mock_cursor.fetchall.return_value = [
|
||||
(self.test_ids[0], [0.1, 0.2, 0.3], {"key": "value1"}),
|
||||
(self.test_ids[1], [0.4, 0.5, 0.6], {"key": "value2"}),
|
||||
(self.test_ids[0], {"key": "value1"}),
|
||||
(self.test_ids[1], {"key": "value2"}),
|
||||
]
|
||||
|
||||
pgvector = PGVector(
|
||||
@@ -1707,7 +1707,7 @@ class TestPGVector(unittest.TestCase):
|
||||
|
||||
# Verify list query was executed without WHERE clause
|
||||
list_calls = [call for call in self.mock_cursor.execute.call_args_list
|
||||
if "SELECT id, vector, payload" in str(call) and "WHERE" not in str(call)]
|
||||
if "SELECT id, payload" in str(call) and "WHERE" not in str(call)]
|
||||
self.assertTrue(len(list_calls) > 0)
|
||||
|
||||
# Verify results
|
||||
@@ -2508,3 +2508,20 @@ class TestBuildFilterConditions(unittest.TestCase):
|
||||
def test_numeric_scalar_becomes_string(self):
|
||||
conditions, params = _build_filter_conditions({"priority": 42})
|
||||
self.assertEqual(params, ["priority", "42"])
|
||||
|
||||
def test_in_rejects_string_value(self):
|
||||
"""Passing a string to 'in' would iterate characters and produce a misleading ANY() clause."""
|
||||
with self.assertRaises(ValueError, msg="Expected ValueError for non-list 'in' value"):
|
||||
_build_filter_conditions({"user_id": {"in": "alice"}})
|
||||
|
||||
def test_in_rejects_dict_value(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_build_filter_conditions({"user_id": {"in": {"$gt": 0}}})
|
||||
|
||||
def test_nin_rejects_string_value(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_build_filter_conditions({"user_id": {"nin": "alice"}})
|
||||
|
||||
def test_in_accepts_list_value(self):
|
||||
conditions, params = _build_filter_conditions({"user_id": {"in": ["alice", "bob"]}})
|
||||
self.assertEqual(params, ["user_id", ["alice", "bob"]])
|
||||
|
||||
@@ -139,9 +139,40 @@ def test_list_cols(pinecone_db):
|
||||
pinecone_db.client.list_indexes.assert_called()
|
||||
|
||||
|
||||
def test_delete_col(pinecone_db):
|
||||
@pytest.mark.parametrize(
|
||||
"namespace,drops_whole_index",
|
||||
[(None, True), ("test_namespace", False), ("", False)],
|
||||
)
|
||||
def test_delete_col_scopes_to_namespace(pinecone_db, namespace, drops_whole_index):
|
||||
pinecone_db.namespace = namespace
|
||||
pinecone_db.delete_col()
|
||||
pinecone_db.client.delete_index.assert_called_with("test_index")
|
||||
if drops_whole_index:
|
||||
pinecone_db.client.delete_index.assert_called_with("test_index")
|
||||
else:
|
||||
pinecone_db.index.delete.assert_called_with(delete_all=True, namespace=namespace)
|
||||
pinecone_db.client.delete_index.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_col_namespace_delete_error_is_swallowed(pinecone_db):
|
||||
pinecone_db.index.delete.side_effect = Exception("Namespace not found")
|
||||
pinecone_db.delete_col()
|
||||
|
||||
|
||||
def test_reset_with_namespace_does_not_drop_or_recreate_index(pinecone_db, mock_pinecone_client):
|
||||
mock_pinecone_client.list_indexes.return_value.names.return_value = ["test_index"]
|
||||
mock_pinecone_client.create_index.reset_mock()
|
||||
pinecone_db.reset()
|
||||
pinecone_db.index.delete.assert_called_with(delete_all=True, namespace="test_namespace")
|
||||
mock_pinecone_client.delete_index.assert_not_called()
|
||||
mock_pinecone_client.create_index.assert_not_called()
|
||||
|
||||
|
||||
def test_reset_without_namespace_drops_and_recreates_index(pinecone_db, mock_pinecone_client):
|
||||
pinecone_db.namespace = None
|
||||
mock_pinecone_client.list_indexes.return_value.names.return_value = []
|
||||
pinecone_db.reset()
|
||||
mock_pinecone_client.delete_index.assert_called_with("test_index")
|
||||
mock_pinecone_client.create_index.assert_called()
|
||||
|
||||
|
||||
def test_col_info(pinecone_db):
|
||||
@@ -190,6 +221,17 @@ def test_count_with_none_vector_count(pinecone_db):
|
||||
pinecone_db.index.describe_index_stats.assert_called_once()
|
||||
|
||||
|
||||
def test_count_with_empty_string_namespace(pinecone_db):
|
||||
pinecone_db.namespace = ""
|
||||
stats_mock = MagicMock()
|
||||
stats_mock.namespaces = {"": MagicMock(vector_count=3)}
|
||||
stats_mock.total_vector_count = 99
|
||||
pinecone_db.index.describe_index_stats.return_value = stats_mock
|
||||
|
||||
count = pinecone_db.count()
|
||||
assert count == 3
|
||||
|
||||
|
||||
def test_list_error_returns_list_not_dict(pinecone_db):
|
||||
"""list() error path must return [[]] so callers can do result[0]."""
|
||||
pinecone_db.index.query.side_effect = Exception("connection error")
|
||||
|
||||
@@ -54,6 +54,30 @@ class TestQdrant(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(os.path.isfile(sentinel))
|
||||
|
||||
def test_reset_clears_points_on_local_qdrant(self):
|
||||
"""#6411: reset() must drop points even when the collection dir survives delete_col()."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
store = Qdrant(collection_name="reset_me", embedding_model_dims=4, path=os.path.join(tmp, "qdrant"))
|
||||
try:
|
||||
self.assertTrue(store.is_local)
|
||||
store.insert(
|
||||
vectors=[[0.1, 0.2, 0.3, 0.4]],
|
||||
payloads=[{"data": "remember me"}],
|
||||
ids=[str(uuid.uuid4())],
|
||||
)
|
||||
self.assertEqual(len(store.list(top_k=10)[0]), 1)
|
||||
|
||||
# Simulate Windows/NFS, where rmtree(ignore_errors=True) silently fails
|
||||
# on the open sqlite handle and the collection dir survives delete_col().
|
||||
with patch("qdrant_client.local.qdrant_local.shutil.rmtree"):
|
||||
store.reset()
|
||||
|
||||
self.assertEqual(len(store.list(top_k=10)[0]), 0)
|
||||
sparse = store.client.get_collection("reset_me").config.params.sparse_vectors
|
||||
self.assertIn("bm25", sparse or {})
|
||||
finally:
|
||||
store.client.close()
|
||||
|
||||
def test_create_col(self):
|
||||
self.client_mock.get_collections.return_value = MagicMock(collections=[])
|
||||
|
||||
|
||||
@@ -239,9 +239,23 @@ REDIS_HOST = os.environ.get("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
|
||||
|
||||
|
||||
def _redis_search_available(host, port, timeout=2):
|
||||
"""RediSearch (the FT.* command family) is an optional module, not part of vanilla Redis."""
|
||||
if not _tcp_reachable(host, port, timeout=timeout):
|
||||
return False
|
||||
try:
|
||||
import redis
|
||||
|
||||
client = redis.Redis(host=host, port=port, socket_connect_timeout=timeout, socket_timeout=timeout)
|
||||
modules = {m["name"].lower() for m in client.module_list()}
|
||||
return "search" in modules
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _tcp_reachable(REDIS_HOST, REDIS_PORT),
|
||||
reason=f"Redis not reachable at {REDIS_HOST}:{REDIS_PORT}",
|
||||
not _redis_search_available(REDIS_HOST, REDIS_PORT),
|
||||
reason=f"RediSearch module not available at {REDIS_HOST}:{REDIS_PORT} (requires Redis Stack, not vanilla Redis)",
|
||||
)
|
||||
class TestRedis:
|
||||
"""Redis returns cosine distance [0,2]. Conversion: score = max(0, 1 - dist)."""
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from unittest.mock import Mock, patch
|
||||
import logging
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.configs.vector_stores.supabase import IndexMeasure, IndexMethod
|
||||
from mem0.vector_stores.supabase import Supabase
|
||||
from mem0.vector_stores.supabase import VECS_MAX_QUERY_LIMIT, Supabase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -14,13 +15,12 @@ def mock_vecs_client():
|
||||
|
||||
@pytest.fixture
|
||||
def mock_collection():
|
||||
collection = Mock()
|
||||
collection = MagicMock()
|
||||
collection.name = "test_collection"
|
||||
collection.vectors = 100
|
||||
collection.dimension = 1536
|
||||
collection.index_method = "hnsw"
|
||||
collection.distance_metric = "cosine_distance"
|
||||
collection.describe.return_value = collection
|
||||
collection.index = "ix_vector_cosine_ops_hnsw_m16_efc64_abc1234"
|
||||
collection.is_indexed_for_measure.return_value = True
|
||||
collection.__len__.return_value = 100
|
||||
return collection
|
||||
|
||||
|
||||
@@ -141,10 +141,57 @@ def test_col_info(supabase_instance, mock_collection):
|
||||
"name": "test_collection",
|
||||
"count": 100,
|
||||
"dimension": 1536,
|
||||
"index": {"method": "hnsw", "metric": "cosine_distance"},
|
||||
"index": {
|
||||
"name": "ix_vector_cosine_ops_hnsw_m16_efc64_abc1234",
|
||||
"measure": "cosine_distance",
|
||||
"is_indexed": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_search_clamps_top_k_to_vecs_limit(supabase_instance, mock_collection, caplog):
|
||||
mock_collection.query.return_value = []
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.supabase"):
|
||||
supabase_instance.search(query="", vectors=[[0.1, 0.2, 0.3]], top_k=5000)
|
||||
|
||||
mock_collection.query.assert_called_once_with(
|
||||
data=[[0.1, 0.2, 0.3]], limit=VECS_MAX_QUERY_LIMIT, filters=None, include_metadata=True, include_value=True
|
||||
)
|
||||
assert "5000" in caplog.text
|
||||
assert str(VECS_MAX_QUERY_LIMIT) in caplog.text
|
||||
|
||||
|
||||
def test_search_does_not_clamp_top_k_within_limit(supabase_instance, mock_collection):
|
||||
mock_collection.query.return_value = []
|
||||
|
||||
supabase_instance.search(query="", vectors=[[0.1, 0.2, 0.3]], top_k=5)
|
||||
|
||||
mock_collection.query.assert_called_once_with(
|
||||
data=[[0.1, 0.2, 0.3]], limit=5, filters=None, include_metadata=True, include_value=True
|
||||
)
|
||||
|
||||
|
||||
def test_list_clamps_top_k_to_vecs_limit(supabase_instance, mock_collection, caplog):
|
||||
mock_collection.query.return_value = []
|
||||
mock_collection.fetch.return_value = []
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.supabase"):
|
||||
supabase_instance.list(top_k=5000)
|
||||
|
||||
_, kwargs = mock_collection.query.call_args
|
||||
assert kwargs["limit"] == VECS_MAX_QUERY_LIMIT
|
||||
assert "5000" in caplog.text
|
||||
assert str(VECS_MAX_QUERY_LIMIT) in caplog.text
|
||||
|
||||
|
||||
def test_col_info_does_not_raise(supabase_instance):
|
||||
info = supabase_instance.col_info()
|
||||
|
||||
assert isinstance(info, dict)
|
||||
assert info["name"] == "test_collection"
|
||||
|
||||
|
||||
def test_preprocess_filters(supabase_instance):
|
||||
# Test single filter
|
||||
single_filter = {"category": "test"}
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call, patch
|
||||
import pytest
|
||||
|
||||
from mem0.configs.vector_stores.upstash_vector import UpstashVectorConfig
|
||||
from mem0.vector_stores.upstash_vector import UpstashVector
|
||||
from mem0.vector_stores.upstash_vector import UpstashVector, _validate_filter
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -228,6 +228,57 @@ def test_update_vector_with_embeddings(upstash_instance_with_embeddings):
|
||||
)
|
||||
|
||||
|
||||
def test_filter_rejects_dict_value():
|
||||
with pytest.raises(ValueError):
|
||||
_validate_filter("user_id", {"$ne": ""})
|
||||
|
||||
|
||||
def test_filter_rejects_list_value():
|
||||
with pytest.raises(ValueError):
|
||||
_validate_filter("user_id", ["alice", "bob"])
|
||||
|
||||
|
||||
def test_filter_rejects_invalid_key():
|
||||
with pytest.raises(ValueError):
|
||||
_validate_filter("user_id; DROP", "alice")
|
||||
|
||||
|
||||
def test_filter_accepts_scalars():
|
||||
_validate_filter("user_id", "alice")
|
||||
_validate_filter("count", 42)
|
||||
_validate_filter("score", 0.95)
|
||||
_validate_filter("active", True)
|
||||
|
||||
|
||||
def test_filter_rejects_double_quote_in_value():
|
||||
with pytest.raises(ValueError, match="prohibited characters"):
|
||||
_validate_filter("user_id", 'alice" OR 1=1 --')
|
||||
|
||||
|
||||
def test_filter_rejects_backslash_in_value():
|
||||
with pytest.raises(ValueError, match="prohibited characters"):
|
||||
_validate_filter("user_id", "alice\\bob")
|
||||
|
||||
|
||||
def test_search_rejects_dict_filter(upstash_instance):
|
||||
with pytest.raises(ValueError):
|
||||
upstash_instance.search(
|
||||
query="test", vectors=[[0.1]], filters={"user_id": {"$ne": ""}}
|
||||
)
|
||||
|
||||
|
||||
def test_keyword_search_raises_on_invalid_filter(upstash_instance):
|
||||
with pytest.raises(ValueError):
|
||||
upstash_instance.keyword_search(
|
||||
query="test", filters={"user_id": 'alice" OR 1=1'}
|
||||
)
|
||||
|
||||
|
||||
def test_filter_rejects_key_with_trailing_newline():
|
||||
with pytest.raises(ValueError, match="Invalid filter key"):
|
||||
_validate_filter("user_id\n", "alice")
|
||||
|
||||
|
||||
def test_insert_vectors_with_embeddings_missing_data(upstash_instance_with_embeddings):
|
||||
vectors = [[0.1, 0.2, 0.3]]
|
||||
payloads = [{"name": "vector1"}] # Missing data field
|
||||
|
||||
@@ -220,6 +220,50 @@ class TestWeaviateDB(unittest.TestCase):
|
||||
self.client_mock.collections.delete.assert_called_once_with("test_collection")
|
||||
self.client_mock.collections.create.assert_called_once()
|
||||
|
||||
def test_update_preserves_properties_and_does_not_write_model_fields(self):
|
||||
# Regression: the vector branch of update() previously resent
|
||||
# dict(OutputData) as properties, i.e. the model field names
|
||||
# {"id", "score", "payload"}, corrupting the stored object instead of
|
||||
# preserving its real properties.
|
||||
valid_uuid = str(uuid.uuid4())
|
||||
collection = self.client_mock.collections.get.return_value
|
||||
|
||||
# Mock get() so that, on the buggy code path, the vector branch can run
|
||||
# to completion and actually write its (wrong) properties.
|
||||
mock_response = MagicMock()
|
||||
mock_response.properties = {"data": "existing", "hash": "abc123"}
|
||||
mock_response.uuid = valid_uuid
|
||||
collection.query.fetch_object_by_id.return_value = mock_response
|
||||
|
||||
new_payload = {
|
||||
"data": "updated memory",
|
||||
"hash": "def456",
|
||||
"user_id": "user_123",
|
||||
}
|
||||
|
||||
self.weaviate_db.update(
|
||||
vector_id=valid_uuid,
|
||||
vector=[0.1] * 1536,
|
||||
payload=new_payload,
|
||||
)
|
||||
|
||||
update_calls = collection.data.update.call_args_list
|
||||
# The payload branch updates the real properties.
|
||||
property_updates = [c for c in update_calls if "properties" in c.kwargs]
|
||||
self.assertEqual(len(property_updates), 1)
|
||||
self.assertEqual(property_updates[0].kwargs["properties"], new_payload)
|
||||
|
||||
# No update call may write the OutputData model field names as properties.
|
||||
for call in update_calls:
|
||||
props = call.kwargs.get("properties", {})
|
||||
self.assertNotIn("score", props)
|
||||
self.assertNotIn("payload", props)
|
||||
|
||||
# The vector branch updates the vector without touching properties.
|
||||
vector_updates = [c for c in update_calls if "vector" in c.kwargs]
|
||||
self.assertEqual(len(vector_updates), 1)
|
||||
self.assertNotIn("properties", vector_updates[0].kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user