Keep the two tests that are red on unchanged main: previous_content comes from the locked native-store result (single + batch replace/remove, batch ordering) and never from caller arguments or build_metadata. Drop the uncommitted-batch cases (they pass on main -- the no-notification gate on a failed write predates this change) and the memory/user target axis (same store code path). Salvage of #118903 by @ehz0ah.
176 lines
5.8 KiB
Python
176 lines
5.8 KiB
Python
"""Behavior tests for the built-in memory → external provider bridge.
|
|
|
|
The bridge lives behind the MemoryManager interface
|
|
(``MemoryManager.notify_memory_tool_write``): the agent loop hands over the raw
|
|
built-in memory tool result + args, and the manager decides whether/what to
|
|
mirror to external providers. These tests drive that method with a fake
|
|
external provider and assert which ``on_memory_write`` calls land.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from agent.memory_manager import MemoryManager
|
|
from agent.memory_provider import MemoryProvider
|
|
|
|
|
|
class _RecordingProvider(MemoryProvider):
|
|
"""Minimal external provider that records on_memory_write calls."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls = []
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "recording"
|
|
|
|
def is_available(self) -> bool:
|
|
return True
|
|
|
|
def initialize(self, session_id: str, **kwargs) -> None:
|
|
pass
|
|
|
|
def get_tool_schemas(self):
|
|
return []
|
|
|
|
def shutdown(self) -> None:
|
|
pass
|
|
|
|
def on_memory_write(self, action, target, content, metadata=None):
|
|
self.calls.append({
|
|
"action": action,
|
|
"target": target,
|
|
"content": content,
|
|
"metadata": dict(metadata or {}),
|
|
})
|
|
|
|
|
|
def _manager_with_provider():
|
|
mgr = MemoryManager()
|
|
provider = _RecordingProvider()
|
|
mgr.add_provider(provider)
|
|
return mgr, provider
|
|
|
|
|
|
def test_notifies_remove_with_old_text_after_success():
|
|
mgr, provider = _manager_with_provider()
|
|
mgr.notify_memory_tool_write(
|
|
json.dumps({"success": True}),
|
|
{"action": "remove", "target": "memory", "old_text": "stale preference entry"},
|
|
)
|
|
assert provider.calls == [
|
|
{
|
|
"action": "remove",
|
|
"target": "memory",
|
|
"content": "",
|
|
"metadata": {"old_text": "stale preference entry"},
|
|
}
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("tool_result", [None, [], object(), "not-json"])
|
|
def test_skips_unrecognized_tool_result_shape(tool_result):
|
|
mgr, provider = _manager_with_provider()
|
|
mgr.notify_memory_tool_write(
|
|
tool_result,
|
|
{"action": "add", "target": "memory", "content": "new fact"},
|
|
)
|
|
assert provider.calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_metadata_callback_is_merged_per_op():
|
|
mgr, provider = _manager_with_provider()
|
|
mgr.notify_memory_tool_write(
|
|
json.dumps({"success": True}),
|
|
{"action": "add", "target": "memory", "content": "fact"},
|
|
build_metadata=lambda: {"session_id": "s1", "tool_name": "memory"},
|
|
)
|
|
assert provider.calls == [
|
|
{
|
|
"action": "add",
|
|
"target": "memory",
|
|
"content": "fact",
|
|
"metadata": {"session_id": "s1", "tool_name": "memory"},
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize('batch,operations,previous', [
|
|
(False, [{'action': 'remove', 'old_text': 'Prefers tea'}], ['Prefers tea']),
|
|
(False, [{'action': 'replace', 'old_text': 'Prefers tea', 'content': 'Prefers coffee'}], ['Prefers tea']),
|
|
(True, [{'action': 'remove', 'old_text': 'Prefers tea'}], ['Prefers tea']),
|
|
(True, [{'action': 'replace', 'old_text': 'Prefers tea', 'new_text': 'Prefers coffee'}], ['Prefers tea']),
|
|
(True, [
|
|
{'action': 'add', 'content': 'Uses the blue notebook'},
|
|
{'action': 'replace', 'old_text': 'blue notebook', 'new_text': 'Uses the green notebook'},
|
|
{'action': 'remove', 'old_text': 'green notebook'},
|
|
], [None, 'Uses the blue notebook', 'Uses the green notebook']),
|
|
])
|
|
def test_committed_entry_identity_comes_from_locked_store(
|
|
tmp_path, monkeypatch, batch, operations, previous
|
|
):
|
|
target = 'memory'
|
|
from contextlib import contextmanager
|
|
from tools import memory_tool_store
|
|
from tools.memory_tool import MemoryStore, memory_tool
|
|
|
|
monkeypatch.setattr('tools.memory_tool.get_memory_dir', lambda: tmp_path)
|
|
store = MemoryStore()
|
|
store.load_from_disk()
|
|
store.add(target, 'Prefers tea')
|
|
store.add(target, 'Prefers tea with milk')
|
|
manager, provider = _manager_with_provider()
|
|
locked = False
|
|
lock = store._file_lock
|
|
match = memory_tool_store._find_unique_match
|
|
|
|
@contextmanager
|
|
def checked_lock(path):
|
|
nonlocal locked
|
|
with lock(path):
|
|
locked = True
|
|
try:
|
|
yield
|
|
finally:
|
|
locked = False
|
|
|
|
def checked_match(entries, old_text):
|
|
assert locked
|
|
assert provider.calls == []
|
|
return match(entries, old_text)
|
|
|
|
monkeypatch.setattr(store, '_file_lock', checked_lock)
|
|
monkeypatch.setattr(memory_tool_store, '_find_unique_match', checked_match)
|
|
args = {'target': target, **({'operations': operations} if batch else operations[0])}
|
|
result = memory_tool(store=store, **args)
|
|
assert json.loads(result)['success'] is True
|
|
assert not locked
|
|
assert provider.calls == []
|
|
reloaded = MemoryStore()
|
|
reloaded.load_from_disk()
|
|
assert reloaded._entries_for(target) == store._entries_for(target)
|
|
|
|
manager.notify_memory_tool_write(result, args, build_metadata=lambda: {'session_id': 'test'})
|
|
assert [call['action'] for call in provider.calls] == [op['action'] for op in operations]
|
|
assert [call['metadata'].get('previous_content') for call in provider.calls] == previous
|
|
assert all(call['metadata']['session_id'] == 'test' for call in provider.calls)
|
|
assert 'Prefers tea with milk' in store._entries_for(target)
|
|
|
|
|
|
def test_previous_content_cannot_come_from_uncommitted_arguments():
|
|
manager, provider = _manager_with_provider()
|
|
args = {'action': 'remove', 'old_text': 'partial', 'previous_content': 'Untrusted argument'}
|
|
manager.notify_memory_tool_write(
|
|
{'success': True}, args, build_metadata=lambda: {'previous_content': 'Uncommitted metadata'}
|
|
)
|
|
assert provider.calls[0]['metadata'] == {'old_text': 'partial'}
|