Files
agentscope-ai--agentscope/tests/mem0_agentscope_adapter_test.py
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

463 lines
17 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=wrong-import-order
"""Unit tests for the AgentScope ↔ mem0 adapters.
Verifies that ``AgentScopeLLM`` / ``AgentScopeEmbedding`` correctly
translate between mem0's sync OpenAI-style contract and AgentScope's
async ``Msg`` / ``ContentBlock`` / ``EmbeddingResponse`` shapes, and
that ``register_with_mem0`` plugs them into mem0's factories.
"""
import asyncio
import json
import unittest
from typing import Any
from agentscope.credential import DashScopeCredential
from agentscope.embedding import EmbeddingModelBase, EmbeddingResponse
from agentscope.message import (
Msg,
TextBlock,
ThinkingBlock,
ToolCallBlock,
)
from agentscope.middleware._longterm_memory._mem0._agentscope_adapter import (
AgentScopeEmbedding,
AgentScopeLLM,
_convert_messages_to_agentscope,
_parse_chat_response,
build_mem0_config,
)
from agentscope.model import ChatResponse
from utils import MockModel
# ----------------------------------------------------------------------
# Pure helpers
# ----------------------------------------------------------------------
class TestConvertMessages(unittest.TestCase):
"""Tests for converting mem0 dict messages into AgentScope messages."""
def test_three_roles_map_to_correct_role(self) -> None:
"""System, user, and assistant roles should be preserved."""
msgs = _convert_messages_to_agentscope(
[
{"role": "system", "content": "you are helpful"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
],
)
self.assertEqual(
[m.role for m in msgs],
["system", "user", "assistant"],
)
self.assertTrue(all(isinstance(m, Msg) for m in msgs))
self.assertEqual(msgs[1].get_text_content(), "hi")
def test_unknown_role_dropped(self) -> None:
"""Unsupported message roles should be skipped."""
msgs = _convert_messages_to_agentscope(
[
{"role": "tool", "content": "noise"},
{"role": "user", "content": "real"},
],
)
self.assertEqual(len(msgs), 1)
self.assertEqual(msgs[0].role, "user")
class TestParseChatResponse(unittest.TestCase):
"""Tests for converting AgentScope chat responses into mem0 output."""
def _resp(self, blocks: list) -> ChatResponse:
"""Build a final chat response from content blocks."""
return ChatResponse(content=blocks, is_last=True)
def test_text_only_returns_string(self) -> None:
"""Plain text responses should become plain strings."""
resp = self._resp([TextBlock(type="text", text="answer")])
self.assertEqual(_parse_chat_response(resp, has_tool=False), "answer")
def test_thinking_prefixed_to_text(self) -> None:
"""Thinking blocks should be preserved before visible text."""
resp = self._resp(
[
ThinkingBlock(type="thinking", thinking="hmm"),
TextBlock(type="text", text="final"),
],
)
out = _parse_chat_response(resp, has_tool=False)
self.assertIn("hmm", out)
self.assertIn("final", out)
# thinking comes first to mirror v1 order
self.assertLess(out.index("hmm"), out.index("final"))
def test_tool_call_with_json_string_input(self) -> None:
"""v2 stores ToolCallBlock.input as a JSON string — adapter
must parse it back to a dict for mem0."""
resp = self._resp(
[
TextBlock(type="text", text="calling tool"),
ToolCallBlock(
type="tool_call",
id="call_1",
name="lookup",
input=json.dumps({"q": "alice"}),
),
],
)
out = _parse_chat_response(resp, has_tool=True)
self.assertEqual(out["content"], "calling tool")
self.assertEqual(
out["tool_calls"],
[{"name": "lookup", "arguments": {"q": "alice"}}],
)
def test_tool_call_with_malformed_input_keeps_raw(self) -> None:
"""Malformed JSON tool inputs should remain as raw strings."""
resp = self._resp(
[
ToolCallBlock(
type="tool_call",
id="call_2",
name="lookup",
input="not json",
),
],
)
out = _parse_chat_response(resp, has_tool=True)
self.assertEqual(out["tool_calls"][0]["arguments"], "not json")
def test_empty_content(self) -> None:
"""Empty responses should convert to the empty mem0 shapes."""
resp = self._resp([])
self.assertEqual(_parse_chat_response(resp, has_tool=False), "")
self.assertEqual(
_parse_chat_response(resp, has_tool=True),
{"content": "", "tool_calls": []},
)
# ----------------------------------------------------------------------
# AgentScopeLLM end-to-end (fake AgentScope model on caller event loop)
# ----------------------------------------------------------------------
class _CurrentEventLoopTestCase(unittest.TestCase):
"""Provides a current event loop for the adapter's sync bridge."""
_event_loop: asyncio.AbstractEventLoop
_previous_event_loop: asyncio.AbstractEventLoop | None
def setUp(self) -> None:
"""Install a fresh event loop for each sync-bridge test."""
super().setUp()
try:
self._previous_event_loop = asyncio.get_event_loop()
except RuntimeError:
self._previous_event_loop = None
self._event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._event_loop)
def tearDown(self) -> None:
"""Restore the previous event loop after each test."""
asyncio.set_event_loop(self._previous_event_loop)
self._event_loop.close()
super().tearDown()
class _RecordingMockChatModel(MockModel):
"""Captures the ``messages`` arg so we can assert the v2 Msg
objects mem0's dict messages were converted into."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the recording model."""
super().__init__(*args, **kwargs)
self.received_messages: list[list[Msg]] = []
async def _call_api(self, *args: Any, **kwargs: Any) -> Any:
"""Record delivered messages before delegating to MockModel."""
self.received_messages.append(list(kwargs.get("messages") or []))
return await super()._call_api(*args, **kwargs)
class TestAgentScopeLLM(_CurrentEventLoopTestCase):
"""End-to-end tests for the mem0 LLM adapter."""
def test_constructor_rejects_non_chatmodel(self) -> None:
"""The LLM adapter should reject non-AgentScope chat models."""
with self.assertRaises(TypeError):
AgentScopeLLM(config={"model": object()})
def test_constructor_requires_model(self) -> None:
"""The LLM adapter should require a model config entry."""
with self.assertRaises(ValueError):
AgentScopeLLM(config={})
def test_generate_response_routes_through_agentscope_model(self) -> None:
"""mem0 generation should call the wrapped AgentScope model."""
model = _RecordingMockChatModel()
model.set_responses(
[
ChatResponse(
content=[TextBlock(type="text", text="from agentscope")],
is_last=True,
),
],
)
llm = AgentScopeLLM(config={"model": model})
result = llm.generate_response(
[
{"role": "system", "content": "sys"},
{"role": "user", "content": "hello"},
],
)
self.assertEqual(result, "from agentscope")
# The dict messages were converted to Msg objects with the
# correct roles preserved.
delivered = model.received_messages[0]
self.assertEqual([m.role for m in delivered], ["system", "user"])
def test_generate_response_with_tools(self) -> None:
"""Tool-call responses should be converted to mem0's tool shape."""
model = _RecordingMockChatModel()
model.set_responses(
[
ChatResponse(
content=[
ToolCallBlock(
type="tool_call",
id="call_3",
name="search",
input=json.dumps({"q": "x"}),
),
],
is_last=True,
),
],
)
llm = AgentScopeLLM(config={"model": model})
result = llm.generate_response(
[{"role": "user", "content": "find x"}],
tools=[{"name": "search"}],
)
self.assertIsInstance(result, dict)
self.assertEqual(
result["tool_calls"],
[{"name": "search", "arguments": {"q": "x"}}],
)
def test_streaming_response_drained_to_last_chunk(self) -> None:
"""When the AgentScope model streams, the adapter must
consume all chunks and use the last (which carries the
complete content per AgentScope's streaming contract)."""
model = _RecordingMockChatModel()
# MockModel.set_responses with a list-of-list triggers stream mode
model.set_responses(
[
[
ChatResponse(
content=[TextBlock(type="text", text="part 1")],
is_last=False,
),
ChatResponse(
content=[TextBlock(type="text", text="final")],
is_last=True,
),
],
],
)
llm = AgentScopeLLM(config={"model": model})
result = llm.generate_response(
[{"role": "user", "content": "stream"}],
)
self.assertEqual(result, "final")
def test_empty_messages_raises(self) -> None:
"""Dropping all unsupported messages should raise ValueError."""
llm = AgentScopeLLM(config={"model": _RecordingMockChatModel()})
with self.assertRaises(ValueError):
llm.generate_response([{"role": "tool", "content": "ignored"}])
# ----------------------------------------------------------------------
# AgentScopeEmbedding
# ----------------------------------------------------------------------
class _FakeEmbeddingModel(EmbeddingModelBase):
"""Minimal embedding model that records requested texts."""
def __init__(self) -> None:
"""Initialize the fake embedding model."""
super().__init__(
credential=DashScopeCredential(api_key="fake"),
model="fake-embed",
parameters=self.Parameters(),
context_size=8192,
batch_size=10,
max_retries=0,
retry_delay=0.0,
dimensions=3,
)
self.received: list[list[str]] = []
async def _call_api(
self,
inputs: list[str],
**kwargs: Any,
) -> EmbeddingResponse:
"""Return a fixed vector for every input text."""
self.received.append(list(inputs))
return EmbeddingResponse(
embeddings=[[0.1, 0.2, 0.3] for _ in inputs],
source="api",
)
class TestAgentScopeEmbedding(_CurrentEventLoopTestCase):
"""Tests for the mem0 embedding adapter."""
def test_constructor_validation(self) -> None:
"""The embedding adapter should validate model config."""
with self.assertRaises(ValueError):
AgentScopeEmbedding(config={})
with self.assertRaises(TypeError):
AgentScopeEmbedding(config={"model": object()})
def test_embed_single_string(self) -> None:
"""Single-string inputs should be wrapped before model calls."""
model = _FakeEmbeddingModel()
emb = AgentScopeEmbedding(config={"model": model})
result = emb.embed("hello")
self.assertEqual(result, [0.1, 0.2, 0.3])
# The string was wrapped into a list before reaching the model.
self.assertEqual(model.received, [["hello"]])
def test_embed_list_of_strings_returns_first(self) -> None:
"""mem0's contract is that ``embed`` returns ONE vector. We
return the first."""
model = _FakeEmbeddingModel()
emb = AgentScopeEmbedding(config={"model": model})
result = emb.embed(["a", "b"])
self.assertEqual(result, [0.1, 0.2, 0.3])
self.assertEqual(model.received, [["a", "b"]])
# ----------------------------------------------------------------------
# Factory registration
# ----------------------------------------------------------------------
class TestBuildMem0Config(unittest.TestCase):
"""``build_mem0_config`` must bypass mem0's hardcoded provider
whitelist and emit a config that names the AgentScope adapter."""
def test_models_only_produces_fresh_config(self) -> None:
"""Models-only construction should create AgentScope providers."""
chat = MockModel()
emb = _FakeEmbeddingModel()
cfg = build_mem0_config(chat_model=chat, embedding_model=emb)
self.assertEqual(cfg.llm.provider, "agentscope")
self.assertEqual(cfg.embedder.provider, "agentscope")
self.assertIs(cfg.llm.config["model"], chat)
self.assertIs(cfg.embedder.config["model"], emb)
def test_models_only_requires_both(self) -> None:
"""Models-only construction should require chat and embedding."""
with self.assertRaises(ValueError):
build_mem0_config(chat_model=MockModel())
with self.assertRaises(ValueError):
build_mem0_config(embedding_model=_FakeEmbeddingModel())
with self.assertRaises(ValueError):
build_mem0_config()
def test_base_config_with_both_models_preserves_other_fields(
self,
) -> None:
"""When a ``mem0_config`` base is given, vector_store /
history_db / etc. should survive — only .llm and .embedder
are rewired to the AgentScope adapters."""
from mem0.configs.base import MemoryConfig
base = MemoryConfig(history_db_path="/tmp/custom_history.db")
original_vs = base.vector_store
chat = MockModel()
emb = _FakeEmbeddingModel()
cfg = build_mem0_config(
chat_model=chat,
embedding_model=emb,
mem0_config=base,
)
self.assertEqual(cfg.llm.provider, "agentscope")
self.assertIs(cfg.llm.config["model"], chat)
self.assertEqual(cfg.embedder.provider, "agentscope")
self.assertIs(cfg.embedder.config["model"], emb)
# Non-llm/embedder fields preserved.
self.assertEqual(cfg.history_db_path, "/tmp/custom_history.db")
self.assertIs(cfg.vector_store, original_vs)
def test_base_config_with_only_chat_model_partial_override(
self,
) -> None:
"""Partial override: chat_model alone replaces .llm but
leaves .embedder untouched (whatever the base config had)."""
from mem0.configs.base import MemoryConfig
base = MemoryConfig() # base has the default openai embedder
original_embedder = base.embedder
cfg = build_mem0_config(
chat_model=MockModel(),
mem0_config=base,
)
self.assertEqual(cfg.llm.provider, "agentscope")
# Embedder unchanged from base — still mem0's openai default.
self.assertIs(cfg.embedder, original_embedder)
def test_base_config_alone_is_pass_through(self) -> None:
"""``mem0_config=base`` with no models is just a pass-through —
registration still happens (cheap) but no fields change."""
from mem0.configs.base import MemoryConfig
base = MemoryConfig()
cfg = build_mem0_config(mem0_config=base)
self.assertIs(cfg, base)
def test_registers_adapter_in_factory(self) -> None:
"""The helper side-effect: mem0's factory dicts now know how
to resolve provider='agentscope'."""
from mem0.utils.factory import EmbedderFactory, LlmFactory
build_mem0_config(
chat_model=MockModel(),
embedding_model=_FakeEmbeddingModel(),
)
self.assertIn("agentscope", LlmFactory.provider_to_class)
self.assertIn("agentscope", EmbedderFactory.provider_to_class)
def test_naive_from_config_path_still_rejected(self) -> None:
"""Sanity check: confirm WHY the helper exists — calling
``MemoryConfig`` with ``provider='agentscope'`` directly DOES
raise, which is the failure ``build_mem0_config`` works around."""
from mem0.configs.base import MemoryConfig
from pydantic import ValidationError
with self.assertRaises(ValidationError):
MemoryConfig(
llm={
"provider": "agentscope",
"config": {"model": MockModel()},
},
embedder={
"provider": "agentscope",
"config": {"model": _FakeEmbeddingModel()},
},
)