deer-flow/backend/tests/test_memory_cancel_by_agent.py
SPEC 822c7bca4b
fix(memory): cancel buffered extraction when agent is deleted or cleared (#5123)
* fix(memory): cancel buffered extraction when agent is deleted or cleared

* fix(memory): cancel buffered work before agent delete

Address review: cancel before/after delete to close the rmtree race,
scope user_id=None cancels to the legacy root only, and import memory
helpers at module scope.

Signed-off-by: SPEC <zt1y17@soton.ac.uk>

* fix(memory): close remaining cancel races from review

Post-clear cancel, legacy-only all_agents scope, always cancel even when
memory is disabled, and fold cancel+delete into one offloaded thread.

Signed-off-by: SPEC <zt1y17@soton.ac.uk>

* docs(memory): align cancel_by_agent None-scope with legacy root

Document that user_id=None cancels only the legacy no-user bucket, matching
clear/storage semantics, not the whole process-local queue.

Signed-off-by: SPEC <zt1y17@soton.ac.uk>

* test(memory): fix cancel_by_agent docstring regression assertion

Signed-off-by: SPEC <zt1y17@soton.ac.uk>

* fix(memory): address final cancel review nits

Type the delete helper with AgentStore, replace docstring pinning with a
kwargs mapping test, and document scoped cancel + residual window in AGENTS.md.

Signed-off-by: SPEC <zt1y17@soton.ac.uk>

* fix(memory): resolve agent store inside delete worker thread

get_agent_store() does blocking config/FS work; keep it off the event
loop so test_delete_agent_does_not_block_event_loop and backend-blocking-io CI pass.

Signed-off-by: SPEC <zt1y17@soton.ac.uk>

---------

Signed-off-by: SPEC <zt1y17@soton.ac.uk>
2026-09-03 08:00:25 +08:00

210 lines
8.6 KiB
Python

"""Regression for #5037: scoped cancellation of buffered memory extraction."""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
from deerflow.agents.memory.backends.deermem.deermem.core.queue import ConversationContext
from deerflow.agents.memory.manager import MemoryManager, get_memory_manager, reset_memory_manager
from deerflow.config.memory_config import MemoryConfig, get_memory_config, set_memory_config
def test_deermem_cancel_by_agent_uses_canonical_bucket(tmp_path) -> None:
mem = DeerMem(backend_config={"storage_path": str(tmp_path)})
with patch.object(mem._queue, "_schedule_timer"):
mem._queue.add(thread_id="t1", messages=["m"], agent_name="research-agent", user_id="u1")
mem._queue.add(thread_id="t2", messages=["m"], agent_name="other", user_id="u1")
removed = mem.cancel_by_agent("Research-Agent", user_id="u1")
assert removed == 1
assert mem._queue.pending_count == 1
assert mem._queue._items[0].agent_name == "other"
def test_deermem_clear_memory_cancels_before_and_after_clear(tmp_path) -> None:
mem = DeerMem(backend_config={"storage_path": str(tmp_path)})
mem._queue._items = [
ConversationContext(thread_id="t1", messages=["m"], agent_name="research-agent", user_id="u1"),
ConversationContext(thread_id="t2", messages=["m"], agent_name="other", user_id="u1"),
]
mem._updater = MagicMock()
def _clear(**kwargs):
mem._queue._items.append(ConversationContext(thread_id="t-mid", messages=["m"], agent_name="research-agent", user_id="u1"))
return {"facts": []}
mem._updater.clear_memory_data.side_effect = _clear
mem.clear_memory(agent_name="research-agent", user_id="u1")
assert [c.agent_name for c in mem._queue._items] == ["other"]
mem._updater.clear_memory_data.assert_called_once_with(agent_name="research-agent", user_id="u1")
def test_deermem_clear_all_cancels_all_pending_for_user(tmp_path) -> None:
mem = DeerMem(backend_config={"storage_path": str(tmp_path)})
mem._queue._items = [
ConversationContext(thread_id="t1", messages=["m"], agent_name="a", user_id="u1"),
ConversationContext(thread_id="t2", messages=["m"], agent_name="b", user_id="u1"),
ConversationContext(thread_id="t3", messages=["m"], agent_name="a", user_id="u2"),
]
mem._updater = MagicMock()
mem._updater.clear_all_memory_data.return_value = {"facts": []}
mem.clear_memory(user_id="u1")
assert mem._queue.pending_count == 1
assert mem._queue._items[0].user_id == "u2"
mem._updater.clear_all_memory_data.assert_called_once_with(user_id="u1")
def test_base_memory_manager_cancel_by_agent_defaults_to_zero() -> None:
class _Bare(MemoryManager):
def add(self, thread_id, messages, *, agent_name=None, user_id=None, trace_id=None) -> None:
return None
def get_context(self, user_id, *, agent_name=None, thread_id=None) -> str:
return ""
@classmethod
def from_config(cls, backend_config, *, mode="middleware", **host_hooks):
return cls(backend_config=backend_config or {}, mode=mode)
assert _Bare().cancel_by_agent("x", user_id="u") == 0
def test_deermem_cancel_by_agent_forwards_scoped_queue_kwargs(tmp_path) -> None:
"""Manager mapping: None agent → all_agents; named agent → canonical bucket."""
mem = DeerMem(backend_config={"storage_path": str(tmp_path)})
queue = MagicMock()
queue.cancel_by_agent.return_value = 0
mem._queue = queue
mem.cancel_by_agent(None, user_id=None)
queue.cancel_by_agent.assert_called_with(user_id=None, all_agents=True)
mem.cancel_by_agent("Research-Agent", user_id="u1")
queue.cancel_by_agent.assert_called_with("research-agent", user_id="u1", all_agents=False)
def test_delete_agent_cancels_before_and_after_successful_delete(tmp_path) -> None:
"""Cancel must run before store.delete so a timer cannot resurrect mid-rmtree."""
from app.gateway.routers import agents as agents_router
orig = get_memory_config()
reset_memory_manager()
set_memory_config(
MemoryConfig(
enabled=True,
manager_class="deermem",
backend_config={"storage_path": str(tmp_path / "memory")},
)
)
try:
manager = get_memory_manager()
with patch.object(manager._queue, "_schedule_timer"):
manager._queue.add(thread_id="t1", messages=["m"], agent_name="gone", user_id="user-1")
manager._queue.add(thread_id="t2", messages=["m"], agent_name="keep", user_id="user-1")
store = MagicMock()
order: list[str] = []
def _delete(name, *, user_id=None):
order.append("delete")
manager._queue._items.append(ConversationContext(thread_id="t-mid", messages=["m"], agent_name="gone", user_id="user-1"))
return "deleted"
store.delete.side_effect = _delete
real_cancel = agents_router._cancel_pending_memory_for_agent
def tracked_cancel(name, user_id):
order.append("cancel")
return real_cancel(name, user_id)
with (
patch.object(agents_router, "_require_agents_api_enabled"),
patch.object(agents_router, "_validate_agent_name"),
patch.object(agents_router, "_normalize_agent_name", side_effect=lambda n: n.lower()),
patch.object(agents_router, "get_effective_user_id", return_value="user-1"),
patch.object(agents_router, "get_agent_store", return_value=store),
patch.object(agents_router, "_cancel_pending_memory_for_agent", side_effect=tracked_cancel),
):
asyncio.run(agents_router.delete_agent("Gone"))
assert order == ["cancel", "delete", "cancel"]
assert manager._queue.pending_count == 1
assert manager._queue._items[0].agent_name == "keep"
store.delete.assert_called_once_with("gone", user_id="user-1")
finally:
set_memory_config(orig)
reset_memory_manager()
def test_delete_agent_still_cancels_when_memory_disabled(tmp_path) -> None:
"""Disabling memory must not skip cancel of an already-live queue."""
from app.gateway.routers import agents as agents_router
orig = get_memory_config()
reset_memory_manager()
set_memory_config(
MemoryConfig(
enabled=True,
manager_class="deermem",
backend_config={"storage_path": str(tmp_path / "memory")},
)
)
try:
manager = get_memory_manager()
with patch.object(manager._queue, "_schedule_timer"):
manager._queue.add(thread_id="t1", messages=["m"], agent_name="gone", user_id="user-1")
# Hot-disable after work was queued; delete must still cancel.
set_memory_config(MemoryConfig(enabled=False, manager_class="deermem"))
store = MagicMock()
store.delete.return_value = "deleted"
with (
patch.object(agents_router, "_require_agents_api_enabled"),
patch.object(agents_router, "_validate_agent_name"),
patch.object(agents_router, "_normalize_agent_name", side_effect=lambda n: n.lower()),
patch.object(agents_router, "get_effective_user_id", return_value="user-1"),
patch.object(agents_router, "get_agent_store", return_value=store),
):
asyncio.run(agents_router.delete_agent("Gone"))
assert manager._queue.pending_count == 0
finally:
set_memory_config(orig)
reset_memory_manager()
def test_delete_agent_still_cancels_before_rejected_delete() -> None:
"""Pre-delete cancel is intentional even when delete later 404s."""
from app.gateway.routers import agents as agents_router
store = MagicMock()
store.delete.return_value = "missing"
manager = MagicMock()
with (
patch.object(agents_router, "_require_agents_api_enabled"),
patch.object(agents_router, "_validate_agent_name"),
patch.object(agents_router, "_normalize_agent_name", side_effect=lambda n: n.lower()),
patch.object(agents_router, "get_effective_user_id", return_value="user-1"),
patch.object(agents_router, "get_agent_store", return_value=store),
patch.object(agents_router, "get_memory_manager", return_value=manager),
):
try:
asyncio.run(agents_router.delete_agent("ghost"))
raise AssertionError("expected 404")
except HTTPException as exc:
assert exc.status_code == 404
manager.cancel_by_agent.assert_called_once_with("ghost", user_id="user-1")