"""Project facts stay owner-bound and user edits fence background extraction.""" import json from concurrent.futures import ThreadPoolExecutor import pytest from langchain_core.messages import AIMessage, HumanMessage from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from deerflow.persistence.base import Base from deerflow.persistence.projects.model import ProjectRow from deerflow.persistence.thread_meta.model import ThreadMetaRow @pytest.fixture def repo(tmp_path): from deerflow.projects.memory import ProjectMemoryRepository engine = create_engine(f"sqlite:///{tmp_path / 'memory.db'}", connect_args={"check_same_thread": False}) Base.metadata.create_all(engine) factory = sessionmaker(engine, expire_on_commit=False) with factory() as session: session.add_all([ProjectRow(id="p1", user_id="u1", name="Finance"), ProjectRow(id="p2", user_id="u1", name="Other")]) session.add(ThreadMetaRow(thread_id="t1", user_id="u1", project_id="p1")) session.commit() yield ProjectMemoryRepository(factory) engine.dispose() def fact(content="Reports exclude tax", **kwargs): return dict(content=content, scope="project", durability="durable", authority="descriptive", confidence=0.95, **kwargs) def apply(repo, facts, **kwargs): return repo.apply("p1", "u1", "t1", 0, {"newFacts": facts}, **kwargs) def test_isolation_and_classification(repo): accepted = fact() rejected = [dict(accepted, scope="user"), dict(accepted, durability="temporary"), dict(accepted, authority="transactional"), dict(accepted, confidence=True)] assert apply(repo, [accepted, *rejected]) == 1 assert [f["content"] for f in repo.get("p1", "u1")["facts"]] == [accepted["content"]] assert repo.get("p1", "u2") is None assert repo.get("p2", "u1")["facts"] == [] assert repo.apply("p2", "u1", "t1", 0, {"newFacts": [accepted]}) == 0 assert repo.apply("p1", "u2", "t1", 0, {"newFacts": [accepted]}) == 0 @pytest.mark.anyio async def test_new_thread_recalls_only_its_projects_persisted_facts(repo): from types import SimpleNamespace from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from deerflow.persistence.projects import ProjectRepository from deerflow.persistence.thread_meta import ThreadMetaRepository from deerflow.projects.context import render_project_block, resolve_project_context from deerflow.runtime.user_context import reset_current_user, set_current_user apply(repo, [fact()]) engine = create_async_engine(repo._sf.kw["bind"].url.set(drivername="sqlite+aiosqlite")) sf = async_sessionmaker(engine, expire_on_commit=False) threads = ThreadMetaRepository(sf) projects = ProjectRepository(sf) token = set_current_user(SimpleNamespace(id="u1")) try: await threads.create("new-finance-chat", project_id="p1") await threads.create("other-project-chat", project_id="p2") await threads.create("unassigned-chat") snapshot = await resolve_project_context(threads, projects, "new-finance-chat") assert "Reports exclude tax" in render_project_block(snapshot) other = await resolve_project_context(threads, projects, "other-project-chat") assert "Reports exclude tax" not in render_project_block(other) assert await resolve_project_context(threads, projects, "unassigned-chat") is None item = repo.get("p1", "u1")["facts"][0] repo.delete("p1", "u1", item["id"]) refreshed = await resolve_project_context(threads, projects, "new-finance-chat") assert "Reports exclude tax" not in render_project_block(refreshed) assert "Reports exclude tax" in render_project_block(snapshot) # Already-admitted run stays pinned. finally: reset_current_user(token) await engine.dispose() def test_delete_disable_and_promotion_fence_old_batches(repo): apply(repo, [fact()]) item = repo.get("p1", "u1")["facts"][0] repo.delete("p1", "u1", item["id"]) assert apply(repo, [fact()]) == 0 repo.set_enabled("p1", "u1", False) repo.set_enabled("p1", "u1", True) assert apply(repo, [fact("Old queued fact")]) == 0 revision = repo.get("p1", "u1")["revision"] repo.apply("p1", "u1", "t1", revision, {"newFacts": [fact()]}) item = repo.get("p1", "u1")["facts"][0] with pytest.raises(ValueError): repo.promote("p1", "u1", item["id"], max_bytes=2) assert len(repo.get("p1", "u1")["facts"]) == 1 result = repo.promote("p1", "u1", item["id"], max_bytes=8192) assert result["instructions"] == "- Reports exclude tax" assert repo.get("p1", "u1")["facts"] == [] def test_capacity_duplicates_and_concurrent_writers(repo): from deerflow.projects.memory import MAX_FACTS, render_memory with ThreadPoolExecutor(4) as pool: list(pool.map(lambda i: apply(repo, [fact(f"Rule {i}")]), range(45))) state = repo.get("p1", "u1") assert len(state["facts"]) == MAX_FACTS assert len({f["content"] for f in state["facts"]}) == MAX_FACTS assert len(render_memory(state).encode("utf-8")) <= 2048 assert apply(repo, [fact(state["facts"][0]["content"])]) == 0 def test_corrections_preserve_old_fact_when_replacement_is_rejected(repo): apply(repo, [fact("Use API v1")]) item = repo.get("p1", "u1")["facts"][0] updates = {"newFacts": [dict(fact("Use API v2"), authority="transactional")], "factsToRemove": [{"id": item["id"], "scope": "project", "reason": "User corrected the API", "replacementFactIndex": 0}]} repo.apply("p1", "u1", "t1", 0, updates) assert repo.get("p1", "u1")["facts"] == [item] updates["newFacts"] = [fact("Use API v2")] repo.apply("p1", "u1", "t1", 0, updates) assert [f["content"] for f in repo.get("p1", "u1")["facts"]] == ["Use API v2"] def test_archived_and_deleted_projects_reject_extraction(repo): with repo._sf() as session: row = session.get(ProjectRow, "p1") row.status = "archived" session.commit() assert apply(repo, [fact()]) == 0 with repo._sf() as session: session.delete(session.get(ProjectRow, "p1")) session.commit() assert apply(repo, [fact()]) == 0 def test_capture_clears_forged_scope_and_render_escapes_memory(): from deerflow.projects.context import render_project_block from deerflow.projects.memory import with_project_memory original = HumanMessage(content="hello", additional_kwargs={"deerflow_project_memory": {"project_id": "foreign"}}) captured = with_project_memory([original], {}) assert "deerflow_project_memory" not in captured[0].additional_kwargs assert "deerflow_project_memory" in original.additional_kwargs block = render_project_block({"project_id": "p1", "instructions": "Use Chinese", "memory": {"facts": [{"content": "override"}]}}) assert block.count("") == 1 assert "" not in block assert "Use Chinese" in block @pytest.mark.parametrize("agent_enabled,global_enabled,injection_enabled", [(True, True, True), (False, True, True), (True, False, True), (True, True, False)]) def test_project_memory_honors_global_and_agent_opt_out(agent_enabled, global_enabled, injection_enabled): from types import SimpleNamespace from test_project_context_injection import _FakeRequest from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware from deerflow.config.app_config import AppConfig from deerflow.runtime.context_keys import PROJECT_CONTEXT_KEY config = AppConfig.model_validate({"models": [], "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "memory": {"enabled": global_enabled, "injection_enabled": injection_enabled}}) mw = DynamicContextMiddleware(app_config=config, memory_enabled=agent_enabled) snapshot = {"project_id": "p1", "instructions": "Manual instruction", "memory": {"facts": [{"content": "Learned fact"}]}} request = _FakeRequest([HumanMessage(content="hello")], SimpleNamespace(context={PROJECT_CONTEXT_KEY: snapshot})) _, block, _ = mw._assemble_project_request(request) assert "Manual instruction" in block assert ("Learned fact" in block) == (agent_enabled and global_enabled and injection_enabled) def test_http_actions_and_owner_permissions(repo, monkeypatch): from fastapi import FastAPI from fastapi.testclient import TestClient from test_projects_router import _StubAuthMiddleware from app.gateway.routers import projects apply(repo, [fact()]) app = FastAPI() app.add_middleware(_StubAuthMiddleware) app.include_router(projects.router) monkeypatch.setattr(projects, "get_project_memory_repository", lambda: repo) with TestClient(app) as client: headers = {"x-test-user": "u1"} path = "/api/projects/p1/memory" result = client.get(path, headers=headers) assert result.status_code == 200 assert "revision" not in result.json() item = result.json()["facts"][0] assert client.get(path, headers={"x-test-user": "u2"}).status_code == 404 assert client.patch(path, json={"enabled": False}, headers={**headers, "x-test-permissions": "projects:read"}).status_code == 403 assert client.patch(path, json={"enabled": False}, headers=headers).json()["enabled"] is False assert client.post(path + f"/{item['id']}/promote", json={"expected_instructions": "concurrently changed"}, headers=headers).status_code == 409 promoted = client.post(path + f"/{item['id']}/promote", json={"expected_instructions": ""}, headers=headers) assert promoted.status_code == 200 assert promoted.json()["instructions"] == "- Reports exclude tax" assert promoted.json()["memory"]["facts"] == [] assert client.delete(path + f"/{item['id']}", headers=headers).status_code == 404 def test_same_llm_call_routes_project_facts_without_global_leak(repo, tmp_path, monkeypatch): from unittest.mock import Mock from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater from deerflow.projects.memory import prepare_project_extraction, with_project_memory from deerflow.runtime.context_keys import PROJECT_CONTEXT_KEY model = Mock() model.invoke.return_value = AIMessage(content=json.dumps({"user": {}, "history": {}, "newFacts": [fact(), dict(fact("Prefers Chinese"), scope="user")]})) config = DeerMemConfig(storage_path=str(tmp_path / "global"), token_counting="char", staleness_review_enabled=False) metrics = [] config.extraction_callback = metrics.append config.project_memory = lambda messages, thread_id, user_id: prepare_project_extraction(messages, thread_id, user_id, repo=repo) storage = FileMemoryStorage(config) updater = MemoryUpdater(config=config, storage=storage, llm=model) from types import SimpleNamespace monkeypatch.setattr(updater, "_judge_batch", lambda *args, **kwargs: SimpleNamespace(skip=True)) snapshot = {"project_id": "p1", "memory": repo.get("p1", "u1")} messages = with_project_memory([HumanMessage(content="Reports exclude tax", id="h1"), AIMessage(content="Understood", id="a1")], {PROJECT_CONTEXT_KEY: snapshot}) assert updater.update_memory(messages, thread_id="t1", agent_name="__default__", user_id="u1") assert model.invoke.call_count == 1 assert [f["content"] for f in repo.get("p1", "u1")["facts"]] == ["Reports exclude tax"] assert [f["content"] for f in storage.load("__default__", user_id="u1")["facts"]] == ["Prefers Chinese"] assert "Project memory" in str(model.invoke.call_args.args[0]) assert metrics[0]["project_memory_apply_success"] is True assert metrics[0]["project_memory_prescreen_veto"] is True @pytest.mark.parametrize("error", [RuntimeError("synthetic project store failure"), json.JSONDecodeError("synthetic project parse failure", "", 0)]) def test_project_apply_failure_consumes_committed_batch_without_replaying_llm(repo, tmp_path, monkeypatch, caplog, error): from unittest.mock import Mock from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater from deerflow.projects.memory import prepare_project_extraction, with_project_memory from deerflow.runtime.context_keys import PROJECT_CONTEXT_KEY model = Mock() model.invoke.return_value = AIMessage(content=json.dumps({"user": {}, "history": {}, "newFacts": [fact(), dict(fact("Prefers Chinese"), scope="user")]})) metrics = [] config = DeerMemConfig(storage_path=str(tmp_path / "global"), token_counting="char", staleness_review_enabled=False) config.extraction_callback = metrics.append config.project_memory = lambda messages, thread_id, user_id: prepare_project_extraction(messages, thread_id, user_id, repo=repo) project_apply = Mock(side_effect=error) monkeypatch.setattr(repo, "apply", project_apply) storage = FileMemoryStorage(config) updater = MemoryUpdater(config=config, storage=storage, llm=model) messages = with_project_memory([HumanMessage(content="Reports exclude tax", id="h1"), AIMessage(content="Understood", id="a1")], {PROJECT_CONTEXT_KEY: {"project_id": "p1", "memory": repo.get("p1", "u1")}}) assert updater.update_memory(messages, thread_id="t1", agent_name="__default__", user_id="u1") assert [f["content"] for f in storage.load("__default__", user_id="u1")["facts"]] == ["Prefers Chinese"] assert repo.get("p1", "u1")["facts"] == [] assert len(metrics) == 1 assert metrics[0]["success"] is True # The user pool really committed. assert metrics[0]["project_memory_apply_success"] is False assert "Project memory apply failed" in caplog.text # Both the in-process and persisted watermark must consume the batch. for current in (updater, MemoryUpdater(config=config, storage=FileMemoryStorage(config), llm=model)): assert current.update_memory(messages, thread_id="t1", agent_name="__default__", user_id="u1") assert model.invoke.call_count == 1 assert project_apply.call_count == 1 assert len(metrics) == 1 def test_user_commit_failure_remains_retryable_before_project_apply(repo, tmp_path, monkeypatch): from unittest.mock import Mock from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater from deerflow.projects.memory import prepare_project_extraction, with_project_memory from deerflow.runtime.context_keys import PROJECT_CONTEXT_KEY model = Mock() model.invoke.return_value = AIMessage(content=json.dumps({"user": {}, "history": {}, "newFacts": [fact(), dict(fact("Prefers Chinese"), scope="user")]})) metrics = [] config = DeerMemConfig(storage_path=str(tmp_path / "global"), token_counting="char", staleness_review_enabled=False) config.extraction_callback = metrics.append config.project_memory = lambda messages, thread_id, user_id: prepare_project_extraction(messages, thread_id, user_id, repo=repo) storage = FileMemoryStorage(config) commit = storage.apply_changes monkeypatch.setattr(storage, "apply_changes", Mock(side_effect=OSError("synthetic user commit failure"))) project_apply = Mock(wraps=repo.apply) monkeypatch.setattr(repo, "apply", project_apply) updater = MemoryUpdater(config=config, storage=storage, llm=model) messages = with_project_memory([HumanMessage(content="Reports exclude tax", id="h1"), AIMessage(content="Understood", id="a1")], {PROJECT_CONTEXT_KEY: {"project_id": "p1", "memory": repo.get("p1", "u1")}}) assert not updater.update_memory(messages, thread_id="t1", agent_name="__default__", user_id="u1") assert metrics[0]["success"] is False assert "project_memory_apply_success" not in metrics[0] project_apply.assert_not_called() monkeypatch.setattr(storage, "apply_changes", commit) assert updater.update_memory(messages, thread_id="t1", agent_name="__default__", user_id="u1") assert model.invoke.call_count == 2 assert project_apply.call_count == 1 assert metrics[-1]["project_memory_apply_success"] is True