deer-flow/backend/tests/test_memory_normalize.py
tomyan007 45411cee69
feat(memory): add cognitiveStyle for collaboration preferences (#3182)
* feat(memory): add cognitiveStyle for collaboration preferences

Extend cross-session memory with user.cognitiveStyle and cognitive facts,
backward-compatible normalization, prompt injection as Thinking Style, and
settings UI. Add design notes and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(memory): normalize legacy memory imports on frontend

Add normalizeMemoryPayload aligned with backend normalize_memory_data,
use it for Settings import and API reads, and add unit/regression tests.
Document schema-evolution steps and sync FACT_EXTRACTION_PROMPT cognitive category.

* fix(memory): harden legacy memory normalization

* fix(memory): keep import envelope validation strict

* fix(memory): keep unknown fields through frontend import normalization

normalizeMemoryPayload rebuilt the memory document from a section whitelist,
so every key it did not name was dropped. Because readMemoryResponse routes
all memory API responses through it, the loss showed up on read, export, and
import alike: revision vanished, and any future backend field would silently
break lossless export/import.

The Gateway models are extra="allow" and deermem normalizes additively, which
left the frontend as the only lossy hop in the chain. Spread before overriding
at each nesting level so normalization only validates and defaults
contract-owned fields, leaving the envelope check as strict as before.

Covered by a unit test on the strict import path, two backend tests for
extension preservation and caller immutability, and an E2E round-trip that
asserts on the captured import request body.

* docs(memory): state the normalization invariant that actually holds

The docstring cited `display` as a backend-driven field alongside
`revision`, but #4546 was closed unmerged and no such field exists in the
contract. It also promised lossless export/import, which the Gateway's
`extra="ignore"` models preclude for any undeclared field.

Replace both with the invariant the code is really defending: the frontend
must not be narrower than the Gateway contract, using the declared
top-level `revision` as the concrete example.

* fix(memory): preserve legacy migration and reject lossy imports

* fix(memory): align legacy fact normalization defaults

* test(memory): use authenticated app for import regression

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-23 18:29:12 +08:00

107 lines
3.7 KiB
Python

"""Tests for memory schema normalization."""
import copy
import pytest
from deerflow.agents.memory.backends.deermem.deermem.core.storage import create_empty_memory, normalize_memory_data
def test_normalize_memory_data_adds_cognitive_style() -> None:
legacy = {
"version": "1.0",
"lastUpdated": "",
"user": {
"workContext": {"summary": "work", "updatedAt": "2026-01-01T00:00:00Z"},
"personalContext": {"summary": "", "updatedAt": ""},
"topOfMind": {"summary": "", "updatedAt": ""},
},
"history": {
"recentMonths": {"summary": "", "updatedAt": ""},
"earlierContext": {"summary": "", "updatedAt": ""},
"longTermBackground": {"summary": "", "updatedAt": ""},
},
"facts": [],
}
result = normalize_memory_data(legacy)
assert "cognitiveStyle" in result["user"]
assert result["user"]["cognitiveStyle"]["summary"] == ""
assert result["user"]["cognitiveStyle"]["updatedAt"] == ""
def test_create_empty_memory_includes_cognitive_style() -> None:
empty = create_empty_memory()
assert empty["user"]["cognitiveStyle"] == {"summary": "", "updatedAt": ""}
def test_normalize_memory_data_preserves_unknown_fields() -> None:
payload = {
"version": "1.0",
"revision": 7,
"lastUpdated": "2026-01-01T00:00:00Z",
"display": {"title": "Memory export"},
"data": {"future": True},
"user": {
"workContext": {
"summary": "work",
"updatedAt": "2026-01-01T00:00:00Z",
"confidence": 0.8,
},
"providerState": {"loaded": True},
},
"history": {
"timeline": {"entries": ["2026-01"]},
},
"facts": [
{
"content": "User prefers conclusions first.",
"category": "cognitive",
"topics": ["communication"],
}
],
}
result = normalize_memory_data(payload)
assert result["revision"] == 7
assert result["display"] == {"title": "Memory export"}
assert result["data"] == {"future": True}
assert result["user"]["providerState"] == {"loaded": True}
assert result["user"]["workContext"]["confidence"] == 0.8
assert result["user"]["workContext"]["summary"] == "work"
assert result["history"]["timeline"] == {"entries": ["2026-01"]}
assert result["facts"][0]["topics"] == ["communication"]
assert result["user"]["cognitiveStyle"] == {"summary": "", "updatedAt": ""}
def test_normalize_memory_data_does_not_mutate_caller() -> None:
payload = {
"version": "1.0",
"lastUpdated": "",
"user": {"workContext": {"summary": "work"}},
"history": {},
"facts": [{"content": "kept"}],
}
snapshot = copy.deepcopy(payload)
result = normalize_memory_data(payload)
assert result is not payload
assert payload == snapshot
@pytest.mark.parametrize("confidence,expected", [(None, 0.5), (True, 0.5), ("invalid", 0.5), (float("nan"), 0.5), (float("inf"), 0.5), (0, 0), (-1, 0), (2, 1), ("0.8", 0.8)])
def test_normalize_legacy_fact_metadata(confidence, expected):
fact = {"id": "legacy", "content": " Keep conclusions first. ", "confidence": confidence, "source": " "}
result = normalize_memory_data({"facts": [fact]})["facts"][0]
assert result["confidence"] == expected
assert result["content"] == "Keep conclusions first."
assert result["source"] == "unknown"
def test_normalize_missing_fact_confidence_uses_neutral_default():
result = normalize_memory_data({"facts": [{"content": "Legacy preference"}]})
assert result["facts"][0]["confidence"] == 0.5