deer-flow/backend/tests/test_memory_documentation.py
hataa e163b02bea
fix(provisioner): keep POSIX hostPath values POSIX on Windows hosts (#7905)
join_host_path joined POSIX-styled bases with the running OS's native
path module, so a provisioner running on Windows emitted backslash
hostPath values into pod specs — paths a Linux kubelet cannot resolve.
The same leak existed in _host_base_dir_for_extra_mounts,
_is_path_under_base, extra-mount validation, and PVC subPath
computation, all of which used os.path on paths that are POSIX by
contract. Route every non-Windows-styled path through posixpath and
keep the PureWindowsPath branch only for genuinely Windows bases.

Also re-spell the memory guide probe layout with forward slashes so
backend/tests/test_memory_documentation.py compares against the
documented layout instead of the test host's local separator.
2026-11-06 11:19:13 +08:00

103 lines
4.7 KiB
Python

"""Keep the user-facing memory guides aligned with DeerMem's real storage layout.
The guides once described the pre-abstraction layout (a shared
``{base_dir}/memory.json`` plus one ``agents/{agent_name}/memory.json`` per
agent). DeerMem scopes the memory manifest per user and keeps agent state in a
``facts`` directory beside it, and the host loader moved every DeerMem-private
knob under ``memory.backend_config``. Both guides are prose, so nothing in CI
noticed; this test derives the documented shape from the same functions the
runtime uses.
"""
import re
from pathlib import Path
import pytest
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
from deerflow.agents.memory.backends.deermem.deermem.core.paths import agent_facts_directory, memory_file_path
from deerflow.config.memory_config import MemoryConfig
REPO_ROOT = Path(__file__).resolve().parents[2]
MEMORY_GUIDES = (
Path("frontend/src/content/en/harness/memory.mdx"),
Path("frontend/src/content/zh/harness/memory.mdx"),
)
# A root that never collides with the placeholders the guides spell out.
PROBE_ROOT = "/deer-flow-test-root"
PROBE_USER_ID = "probe-user"
PROBE_AGENT_NAME = "Probe-Agent"
# The pre-abstraction per-agent manifest file. DeerMem writes agent state under
# ``agents/{agent_name}/facts/`` instead, so a guide naming this path is stale.
LEGACY_PER_AGENT_FILE = "agents/{agent_name}/memory.json"
def _guide_text(path: Path) -> str:
return (REPO_ROOT / path).read_text(encoding="utf-8")
def _configuration_block(path: Path) -> str:
"""Return the first yaml example under the guide's Configuration heading."""
lines = _guide_text(path).splitlines()
starts = [index for index, line in enumerate(lines) if re.fullmatch(r"## (Configuration|配置)\s*", line)]
assert len(starts) == 1, f"expected exactly one Configuration heading in {path}, found {len(starts)}"
fences = [index for index in range(starts[0], len(lines)) if lines[index].strip().startswith("```yaml")]
assert fences, f"no yaml example after the Configuration heading in {path}"
end = next(index for index in range(fences[0] + 1, len(lines)) if lines[index].strip() == "```")
return "\n".join(lines[fences[0] + 1 : end])
def _top_level_memory_keys(block: str) -> set[str]:
"""Keys written directly under ``memory:`` (two-space indent) in a yaml example."""
keys = set()
inside = False
for line in block.splitlines():
if line.startswith("memory:"):
inside = True
continue
if not inside:
continue
if line and not line.startswith((" ", " ", "\t", "#")):
break
match = re.fullmatch(r" ([A-Za-z_][A-Za-z0-9_]*):.*", line)
if match:
keys.add(match.group(1))
return keys
@pytest.fixture(scope="module")
def probe_layout() -> tuple[str, str]:
"""The per-user manifest and per-agent facts paths, as the runtime resolves them."""
memory_path = memory_file_path(DeerMemConfig(storage_path=PROBE_ROOT), user_id=PROBE_USER_ID)
facts_path = agent_facts_directory(memory_path, PROBE_AGENT_NAME)
return memory_path.relative_to(PROBE_ROOT).as_posix(), facts_path.relative_to(PROBE_ROOT).as_posix()
@pytest.mark.parametrize("path", MEMORY_GUIDES, ids=str)
def test_guide_documents_the_scoped_manifest_and_facts_paths(path: Path, probe_layout: tuple[str, str]) -> None:
content = _guide_text(path)
memory_rel, facts_rel = probe_layout
# Guides spell the resolved layout with placeholders, so re-spell the real paths
# the same way before comparing. ``agent_facts_directory`` already folded the
# mixed-case probe name, hence the lowercase replacement.
def spelled(rel: str) -> str:
return rel.replace(PROBE_USER_ID, "{user_id}").replace(PROBE_AGENT_NAME.lower(), "{agent_name}")
scoped_manifest = spelled(memory_rel)
agent_facts = spelled(facts_rel)
assert scoped_manifest in content, f"{path} does not document the per-user manifest at {scoped_manifest}"
assert agent_facts in content, f"{path} does not document the per-agent facts directory at {agent_facts}"
assert LEGACY_PER_AGENT_FILE not in content, f"{path} still documents the pre-abstraction per-agent file {LEGACY_PER_AGENT_FILE}"
@pytest.mark.parametrize("path", MEMORY_GUIDES, ids=str)
def test_guide_keeps_backend_private_knobs_out_of_the_memory_top_level(path: Path) -> None:
keys = _top_level_memory_keys(_configuration_block(path))
assert keys, f"no keys parsed from the Configuration example in {path}"
shared = set(MemoryConfig.model_fields)
drift = sorted(keys - shared)
assert not drift, f"{path} documents DeerMem-private keys at the memory top level: {drift} (they belong under backend_config)"