deer-flow/backend/tests/test_project_context_injection.py
Zeren Wang a58ab484a6
feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash (#5443)
* feat(projects): Projects MVP Phase 2 — instructions, document shelf, promotion, trash

Implements docs/superpowers/specs/2026-09-12-projects-mvp-phase2-design.md
(issue #5160, tracker #5129) in the slice order of the spec's §16.

Slices:
- A: ProjectsConfig + write-time 422 UTF-8 byte cap; PROJECT_CONTEXT_KEY
  admission pinning (both server-owned sets + worker hoist); latest-only
  request-scoped <project> block via DynamicContextMiddleware
  wrap_model_call/awrap_model_call (idempotent reassembly, reserved ID
  prefix + marker + provenance, never persisted); journal audit
  fingerprints; Instructions tab.
- B: ProjectDocumentRow + migration 0023; ProjectDocumentRepository with
  locked check-and-set; hash-qualified immutable shelf storage with
  Paths helpers; upload/list/content/delete-to-trash routes; project
  delete trashes the shelf in-transaction; request-scoped bounded
  <documents> index with honest count/shown + actionable overflow note;
  list_project_documents/read_project_document tools registered only on
  pinned runs; PAT allowlist + drift guards; blocking-IO anchors.
- C: shared thread-upload ingestion service (uploads router refactored to
  parity); POST from-thread with provenance; attach-to-thread with
  lock-staged copy (archived source allowed); read-only thread-files
  view with per-group truncation reporting.
- D: restore (restored/merged/not_found/no_target/content_missing; no
  file moves), purge (continuous row lock across unlink/delete/commit,
  retryable on FS errors), retention sweep (lazy + startup, 24h orphan
  guard, row-side reconciliation never deletes).
- E: Documents tab (shelf + conversation-files browser, provenance,
  archived banner, content-missing rows), /workspace/trash route,
  sidebar entry, composer attach handoff, i18n (en-US/zh-CN), e2e mocks
  + specs.

Review hardening folded in (10 rounds, all with tests):
- force active shelf content (HTML/XML family) to download; nosniff on
  artifact + content responses; unified unsandboxed-iframe PDF preview
  (fixes the pre-existing Chromium sandbox blank in the artifact viewer)
- scope document trash to the URL project under the document lock
- atomic no-overwrite filename reservation for ALL ingestion (seeded
  claims + os.link commit with suffix retry; same-name re-upload now
  unique-names instead of replacing); hidden staging only, no visible
  placeholders; lease cleanup on setup failure
- serialize conversion under the document lock with post-lock active
  revalidation; drain locked filesystem work on cancellation; preserve
  bytes when an insert's commit state is uncertain (including trashed
  rows)
- original-integrity checks before serving text or cached conversions;
  content_missing surfaced in list responses (UI reads the flag, no
  409-probe); downloads always serve original bytes
- bounded streaming document reads with cached char counts; shelf limits
  declared in middleware release identity
- thread-root confinement for from-thread sources; config fallback
  rejects fractional/infinite values; composer counts staged
  attachments; pending attachments persist until submission or removal;
  in-flight instruction/rename edits survive save refetches; shelf and
  trash pagination; conversation-file and thread-files pages stay
  subscribed to refetches

Docs: README/README_zh, backend API.md/ARCHITECTURE.md, AGENTS.md
contracts, config.example.yaml projects block.

Review follow-ups (head b4807477 → this revision):
- The trash retention sweep is split so repeated lazy triggers stay
  bounded: the indexed expiry purge still runs on every trigger
  (GET /api/trash/documents, POST /api/trash/purge) while the
  O(all rows + all files) reconciliation is throttled to one run per
  user per 15 minutes (process-local, per-user window). The startup
  sweep now runs as a background task instead of blocking gateway
  readiness, and shutdown awaits it (bounded).
- The export scrub (stripInternalMarkers) is fence- and indentation-aware
  like the render path, so a pasted, fenced <project>/<documents> snippet
  survives markdown export while real injected blocks (never fenced) are
  still removed. Fence regexes moved to a dependency-free leaf module to
  avoid the messages↔streamdown import cycle.
- The artifact viewer's PDF iframe no longer carries an added title
  attribute (the upstream e2e contract locates it via :not([title])), and
  the upstream artifact-preview spec now pins the new contract: PDFs
  render unsandboxed, images keep sandbox="".

* fix(projects): round-2 review — cancel an overrun trash sweep, restore the PDF frame title

- Shutdown cancelled only the shield around the background startup sweep,
  so an all-users reconciliation that outlived the 5s budget kept walking
  rows and files while the document repo and DB engine were disposed
  underneath it. The wait now lives in `_shutdown_startup_trash_sweep`,
  which cancels the task and drains it before worker exit: the shield
  keeps the wait bounded, the cancel makes it final (CancelledError lands
  at the sweep's next await, and `_run_startup_trash_sweep` only catches
  `Exception`, so nothing swallows it).
- The browser-preview iframe lost `title={getFileName(filepath)}` in the
  previous fix round, leaving the PDF frame without an accessible name
  while its siblings keep theirs. Restore it (WCAG frame titles), assert
  it in the DOM test, and anchor the e2e on `iframe[title="report.pdf"]`
  instead of `iframe:not([title])`.

* fix(projects): round-3 review — report the sweep's late finish, not a phantom cancel

`Task.cancel()` returns False when the sweep already finished inside the
window between the deadline firing and the cancel, so the shutdown log
claimed a cancellation that never happened. Branch on that outcome: the
warning stays for a real cancel, a late finish is logged at info, and both
paths still reap the task before worker exit.

* fix(projects): round-4 review — make Empty trash delete what it confirms

`POST /api/trash/purge` only ran the retention sweep, and the sweep's
candidate selection is age-gated, so a freshly trashed document survived
"Empty trash" even though the confirmation promises that every listed
document is permanently deleted. With one trashed row the route answered
`{"purged": 0}` and left it in place; `GET /api/trash/documents` sweeps
expired rows before listing, so the visible rows were normally ineligible
for the action by construction.

Empty trash now drives `purge_all_trashed`: the caller's trashed rows
(`list_all_trashed`, no age filter) each go through the same guarded,
row-locked `purge` as the single-document delete — bytes first, then the
row, in one transaction — so a row restored mid-flight is skipped instead of
force-deleted, and an unlink failure rolls that row back and answers 500 with
a retryable message. Retention expiry stays where it was: the sweep's
`purge_candidates` is now the only age-gated selection, and the lazy
retention sweep still runs on the listing and at startup.

Tests: the router suite replaces the retention-gated expectation with the
reviewer's repro (fresh row purged, bytes unlinked, shelf and other users'
trash untouched, a failing unlink stays retryable and 500); a blocking-I/O
anchor drives the new entry point through the offload; the mocked e2e covers
the action end to end; a new real-backend spec performs it against the real
gateway and re-reads `GET /api/trash/documents`. README, API, ARCHITECTURE
and the phase-2 design docs (en+zh) state the age-independent contract.
2026-09-16 18:46:18 +08:00

582 lines
24 KiB
Python

"""Tests for request-scoped project context injection (Projects Phase 2, spec §7.2).
The pinned admission snapshot is rendered by pure helpers in
``deerflow/projects/context.py`` and delivered through
``DynamicContextMiddleware.wrap_model_call`` as at most one transient,
request-only HumanMessage — never persisted, never a state update, never a
correction chain.
"""
import hashlib
from types import SimpleNamespace
from unittest import mock
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from deerflow.agents.middlewares.dynamic_context_middleware import (
_DYNAMIC_CONTEXT_REMINDER_KEY,
DynamicContextMiddleware,
)
from deerflow.projects.context import (
PROJECT_CONTEXT_MESSAGE_ID_PREFIX,
PROJECT_CONTEXT_MESSAGE_MARKER,
build_project_context_message,
is_project_context_message,
project_context_insertion_index,
render_project_block,
)
from deerflow.runtime.context_keys import CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY, PROJECT_CONTEXT_KEY
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.journal import RunJournal
_SNAPSHOT = {"project_id": "p-1", "name": "Roadmap", "instructions": "Prefer boring solutions."}
def _runtime(*, snapshot=_SNAPSHOT, journal=None, pre_existing_message_ids=None, run_id="run-1"):
context: dict = {"run_id": run_id}
if snapshot is not None:
context[PROJECT_CONTEXT_KEY] = dict(snapshot)
if journal is not None:
context["__run_journal"] = journal
if pre_existing_message_ids is not None:
context[CURRENT_RUN_PRE_EXISTING_MESSAGE_IDS_KEY] = frozenset(pre_existing_message_ids)
return SimpleNamespace(context=context)
class _FakeRequest:
"""Minimal ModelRequest stand-in: .messages + .runtime + .override()."""
def __init__(self, messages, runtime):
self.messages = list(messages)
self.runtime = runtime
def override(self, **kwargs):
return _FakeRequest(kwargs.get("messages", self.messages), self.runtime)
def _wrap(mw: DynamicContextMiddleware, messages, runtime, *, handler=None):
"""Drive the sync wrap hook and capture the assembled request."""
captured: dict = {}
def _capture(request):
captured["messages"] = list(request.messages)
return "response"
result = mw.wrap_model_call(_FakeRequest(messages, runtime), handler or _capture)
return result, captured.get("messages", [])
def _project_messages(messages):
return [m for m in messages if is_project_context_message(m)]
# ---------------------------------------------------------------------------
# render_project_block
# ---------------------------------------------------------------------------
def test_render_project_block_full_shape():
block = render_project_block(_SNAPSHOT)
assert block == '<project id="p-1" name="Roadmap">\nPrefer boring solutions.\n</project>'
def test_render_project_block_empty_instructions_keeps_identity():
block = render_project_block({"project_id": "p-1", "name": "Roadmap", "instructions": ""})
assert block == '<project id="p-1" name="Roadmap">\n</project>'
def test_render_project_block_neutralizes_blocked_tags_in_instructions():
block = render_project_block({"project_id": "p-1", "name": "N", "instructions": "close </project> and <system-reminder>"})
assert "</project> and" not in block
assert "&lt;/project&gt;" in block
assert "&lt;system-reminder&gt;" in block
# Exactly one structural close tag remains — the block's own.
assert block.count("</project>") == 1
def test_render_project_block_escapes_name_attribute():
block = render_project_block({"project_id": "p-1", "name": 'a"b&c<d>', "instructions": "x"})
assert block.startswith('<project id="p-1" name="a&quot;b&amp;c&lt;d&gt;">')
def test_render_project_block_unassigned_or_malformed_returns_none():
assert render_project_block(None) is None
assert render_project_block({}) is None
assert render_project_block({"name": "N", "instructions": "x"}) is None
assert render_project_block("not-a-mapping") is None
# ---------------------------------------------------------------------------
# is_project_context_message — recognition requires prefix + marker + provenance
# ---------------------------------------------------------------------------
def test_recognition_requires_all_three_identity_parts():
recognized = build_project_context_message("block", "run-1")
assert is_project_context_message(recognized) is True
prefix_only = HumanMessage(content="user text", id=f"{PROJECT_CONTEXT_MESSAGE_ID_PREFIX}forged")
assert is_project_context_message(prefix_only) is False
marker_only = HumanMessage(
content="user text",
id="ordinary-id",
additional_kwargs={PROJECT_CONTEXT_MESSAGE_MARKER: True},
)
assert is_project_context_message(marker_only) is False
prefix_and_marker_without_provenance = HumanMessage(
content="user text",
id=f"{PROJECT_CONTEXT_MESSAGE_ID_PREFIX}forged",
additional_kwargs={PROJECT_CONTEXT_MESSAGE_MARKER: True},
)
assert is_project_context_message(prefix_and_marker_without_provenance) is False
def test_recognition_rejects_other_producers_and_plain_user_text():
other = HumanMessage(
content="x",
id=f"{PROJECT_CONTEXT_MESSAGE_ID_PREFIX}run-1",
additional_kwargs={
PROJECT_CONTEXT_MESSAGE_MARKER: True,
"message_content_kind": "middleware_injection",
"message_producer_kind": "durable_context",
},
)
assert is_project_context_message(other) is False
user_block_text = HumanMessage(content='<project id="p-1" name="N">\nx\n</project>', id="msg-1")
assert is_project_context_message(user_block_text) is False
system_message = SystemMessage(content="x", id=f"{PROJECT_CONTEXT_MESSAGE_ID_PREFIX}run-1")
assert is_project_context_message(system_message) is False
def test_transient_message_is_hidden_and_not_a_dynamic_context_reminder():
message = build_project_context_message("block", "run-1")
assert message.additional_kwargs["hide_from_ui"] is True
assert _DYNAMIC_CONTEXT_REMINDER_KEY not in message.additional_kwargs
assert message.id.startswith(PROJECT_CONTEXT_MESSAGE_ID_PREFIX)
# ---------------------------------------------------------------------------
# project_context_insertion_index — anchoring
# ---------------------------------------------------------------------------
def test_index_anchors_before_the_current_run_user_message():
messages = [
SystemMessage(content="system", id="sys"),
HumanMessage(content="old turn", id="u-1"),
AIMessage(content="old reply", id="a-1"),
HumanMessage(content="current turn", id="u-2"),
]
runtime = _runtime(pre_existing_message_ids={"sys", "u-1", "a-1"})
assert project_context_insertion_index(messages, runtime) == 3
def test_index_skips_hidden_current_run_human_messages():
hidden_notification = HumanMessage(
content="background task output",
id="evt-1",
additional_kwargs={"hide_from_ui": True},
)
messages = [
SystemMessage(content="system", id="sys"),
HumanMessage(content="current turn", id="u-2"),
hidden_notification,
]
runtime = _runtime(pre_existing_message_ids={"sys"})
assert project_context_insertion_index(messages, runtime) == 1
def test_index_is_stable_across_the_tool_loop():
messages = [
SystemMessage(content="system", id="sys"),
HumanMessage(content="current turn", id="u-2"),
AIMessage(content="calling a tool", id="a-2", tool_calls=[{"name": "bash", "args": {}, "id": "call-1"}]),
ToolMessage(content="tool result", tool_call_id="call-1", id="t-1"),
]
runtime = _runtime(pre_existing_message_ids={"sys"})
index = project_context_insertion_index(messages, runtime)
# Before the user turn: never between the tool call and its result, never
# appended after the tool result.
assert index == 1
def test_index_falls_back_after_leading_system_messages_for_resumed_runs():
messages = [
SystemMessage(content="system", id="sys"),
SystemMessage(content="date reminder", id="msg-1"),
HumanMessage(content="old turn", id="u-1"),
]
runtime = _runtime(pre_existing_message_ids={"sys", "msg-1", "u-1"})
assert project_context_insertion_index(messages, runtime) == 2
def test_index_without_server_identity_uses_last_genuine_user_message():
messages = [
SystemMessage(content="system", id="sys"),
HumanMessage(content="first", id="u-1"),
AIMessage(content="reply", id="a-1"),
HumanMessage(content="second", id="u-2"),
]
runtime = SimpleNamespace(context={})
assert project_context_insertion_index(messages, runtime) == 3
def test_index_recomputes_from_the_current_request_after_compaction():
summary = HumanMessage(content="summary of earlier turns", id="sum-1", name="summary")
messages = [
SystemMessage(content="system", id="sys"),
summary,
HumanMessage(content="current turn", id="u-9"),
AIMessage(content="calling a tool", id="a-9", tool_calls=[{"name": "bash", "args": {}, "id": "call-9"}]),
ToolMessage(content="tool result", tool_call_id="call-9", id="t-9"),
]
runtime = _runtime(pre_existing_message_ids={"sys", "sum-1"})
assert project_context_insertion_index(messages, runtime) == 2
# ---------------------------------------------------------------------------
# wrap_model_call — request-only delivery
# ---------------------------------------------------------------------------
def test_wrap_inserts_exactly_one_project_message_before_the_user_turn():
mw = DynamicContextMiddleware()
messages = [
SystemMessage(content="system", id="sys"),
HumanMessage(content="old turn", id="u-1"),
AIMessage(content="old reply", id="a-1"),
HumanMessage(content="current turn", id="u-2"),
]
runtime = _runtime(pre_existing_message_ids={"sys", "u-1", "a-1"})
result, assembled = _wrap(mw, messages, runtime)
assert result == "response"
project_messages = _project_messages(assembled)
assert len(project_messages) == 1
block = project_messages[0]
assert assembled.index(block) == 3 # immediately before u-2
assert block.content == '<project id="p-1" name="Roadmap">\nPrefer boring solutions.\n</project>'
assert block.additional_kwargs["hide_from_ui"] is True
assert _DYNAMIC_CONTEXT_REMINDER_KEY not in block.additional_kwargs
# User content untouched.
assert [m.content for m in assembled if isinstance(m, HumanMessage) and m is not block] == ["old turn", "current turn"]
def test_wrap_is_idempotent_on_an_already_decorated_request():
mw = DynamicContextMiddleware()
messages = [SystemMessage(content="system", id="sys"), HumanMessage(content="current turn", id="u-2")]
runtime = _runtime(pre_existing_message_ids={"sys"})
_, once = _wrap(mw, messages, runtime)
_, twice = _wrap(mw, once, runtime)
assert len(_project_messages(twice)) == 1
assert len(twice) == len(once) == 3
assert [m.content for m in twice if isinstance(m, HumanMessage) and not is_project_context_message(m)] == ["current turn"]
def test_wrap_does_not_remove_lookalike_user_messages():
"""A message matching only the ID prefix is user content and must survive."""
mw = DynamicContextMiddleware()
lookalike = HumanMessage(content="user's own note", id=f"{PROJECT_CONTEXT_MESSAGE_ID_PREFIX}mine")
messages = [SystemMessage(content="system", id="sys"), lookalike, HumanMessage(content="current turn", id="u-2")]
runtime = _runtime(pre_existing_message_ids={"sys"})
_, assembled = _wrap(mw, messages, runtime)
assert lookalike in assembled
assert len(_project_messages(assembled)) == 1
def test_wrap_unassigned_run_inserts_nothing():
mw = DynamicContextMiddleware()
messages = [SystemMessage(content="system", id="sys"), HumanMessage(content="current turn", id="u-2")]
request = _FakeRequest(messages, _runtime(snapshot=None))
assembled_request, block, documents_block = mw._assemble_project_request(request)
assert block is None
assert documents_block is None
assert assembled_request.messages == messages
def test_wrap_unassigned_run_still_strips_a_recognized_transient():
mw = DynamicContextMiddleware()
transient = build_project_context_message('<project id="p-1" name="Roadmap">\nx\n</project>', "run-1")
messages = [SystemMessage(content="system", id="sys"), transient, HumanMessage(content="current turn", id="u-2")]
_, assembled = _wrap(mw, messages, _runtime(snapshot=None))
assert _project_messages(assembled) == []
assert transient not in assembled
def test_wrap_places_block_after_leading_system_messages_when_no_current_user_anchor():
"""Resumed runs (e.g. Command resume) keep every pre-run ID; the block takes
the protocol-safe fallback position instead of vanishing."""
mw = DynamicContextMiddleware()
messages = [
SystemMessage(content="system", id="sys"),
HumanMessage(content="old turn", id="u-1"),
AIMessage(content="old reply", id="a-1"),
]
runtime = _runtime(pre_existing_message_ids={"sys", "u-1", "a-1"})
_, assembled = _wrap(mw, messages, runtime)
project_messages = _project_messages(assembled)
assert len(project_messages) == 1
assert assembled.index(project_messages[0]) == 1 # after the leading SystemMessage
# ---------------------------------------------------------------------------
# Latest-only semantics — the next run renders the next pinned snapshot only
# ---------------------------------------------------------------------------
def _run_once(snapshot):
"""One run's assembled request for a fresh admission snapshot."""
mw = DynamicContextMiddleware()
messages = [
SystemMessage(content="system", id="sys"),
HumanMessage(content="old turn", id="u-1"),
AIMessage(content="old reply", id="a-1"),
HumanMessage(content="current turn", id="u-2"),
]
runtime = _runtime(snapshot=snapshot, pre_existing_message_ids={"sys", "u-1", "a-1"})
return _wrap(mw, messages, runtime)[1]
def test_rename_replaces_the_next_runs_block():
first = _project_messages(_run_once(_SNAPSHOT))[0]
renamed = _project_messages(_run_once({**_SNAPSHOT, "name": "Q3 Plan"}))[0]
assert 'name="Roadmap"' in first.content
assert 'name="Q3 Plan"' in renamed.content
assert "Roadmap" not in renamed.content
def test_instructions_edit_replaces_the_next_runs_block():
edited = _project_messages(_run_once({**_SNAPSHOT, "instructions": "Ship the skeleton first."}))[0]
assert "Ship the skeleton first." in edited.content
assert "Prefer boring solutions." not in edited.content
def test_cleared_instructions_keep_project_identity_without_body():
cleared = _project_messages(_run_once({**_SNAPSHOT, "instructions": ""}))[0]
assert cleared.content == '<project id="p-1" name="Roadmap">\n</project>'
def test_move_in_adds_the_block_and_move_out_removes_it():
moved_in = _project_messages(_run_once(_SNAPSHOT))
assert len(moved_in) == 1
moved_out = _project_messages(_run_once(None))
assert moved_out == []
def test_repeated_edits_across_runs_never_accumulate_blocks():
assembled = None
for edit_number in range(40):
snapshot = {**_SNAPSHOT, "instructions": f"revision {edit_number}"}
assembled = _run_once(snapshot)
assert len(_project_messages(assembled)) == 1
assert "revision 39" in _project_messages(assembled)[0].content
assert "revision 0" not in assembled[-1].content
def test_no_correction_or_update_messages_appear_anywhere():
for snapshot in (_SNAPSHOT, {**_SNAPSHOT, "name": "Renamed"}, None):
assembled = _run_once(snapshot)
for message in assembled:
content = message.content if isinstance(message.content, str) else ""
assert "<project_update>" not in content
assert "supersedes earlier project context" not in content
# ---------------------------------------------------------------------------
# Memory/date injection is untouched by the pinned snapshot
# ---------------------------------------------------------------------------
def test_before_agent_update_is_identical_with_and_without_pinned_snapshot():
state = {"messages": [HumanMessage(content="Hi", id="msg-1")]}
with (
mock.patch("deerflow.agents.lead_agent.prompt._get_memory_context", return_value="<memory>\nPrefs.\n</memory>"),
mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt,
):
mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday"
with_snapshot = DynamicContextMiddleware().before_agent(state, _runtime())
without_snapshot = DynamicContextMiddleware().before_agent(state, _runtime(snapshot=None))
def shape(update):
return [(type(m).__name__, m.id, m.content, m.additional_kwargs) for m in update["messages"]]
assert shape(with_snapshot) == shape(without_snapshot)
assert all(not is_project_context_message(m) for m in with_snapshot["messages"])
def test_midnight_update_is_identical_with_and_without_pinned_snapshot():
def state():
return {
"messages": [
SystemMessage(
content="<system-reminder>\n<current_date>2026-05-08, Friday</current_date>\n</system-reminder>",
id="msg-1",
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True, "reminder_date": "2026-05-08, Friday"},
),
HumanMessage(content="Hello", id="msg-1__user"),
HumanMessage(content="Good morning", id="msg-2"),
]
}
with mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt:
mock_dt.now.return_value.strftime.return_value = "2026-05-09, Saturday"
with_snapshot = DynamicContextMiddleware().before_agent(state(), _runtime())
without_snapshot = DynamicContextMiddleware().before_agent(state(), _runtime(snapshot=None))
def shape(update):
return [(type(m).__name__, m.id, m.content, m.additional_kwargs) for m in update["messages"]]
assert shape(with_snapshot) == shape(without_snapshot)
# ---------------------------------------------------------------------------
# Journal fingerprints — one context:memory event at first successful assembly
# ---------------------------------------------------------------------------
def test_journal_project_only_run_records_null_memory_hash():
journal = mock.MagicMock()
mw = DynamicContextMiddleware()
messages = [SystemMessage(content="system", id="sys"), HumanMessage(content="current turn", id="u-2")]
runtime = _runtime(journal=journal, pre_existing_message_ids={"sys"})
_wrap(mw, messages, runtime)
expected_block = '<project id="p-1" name="Roadmap">\nPrefer boring solutions.\n</project>'
journal.record_memory_context.assert_called_once_with(
content_sha256=None,
project_context_revision=hashlib.sha256(expected_block.encode("utf-8")).hexdigest(),
project_shelf_revision=None,
)
def test_journal_memory_only_run_records_null_project_revision():
journal = mock.MagicMock()
mw = DynamicContextMiddleware()
memory = "<memory>\nPrefs.\n</memory>"
state = {"messages": [HumanMessage(content="Hi", id="msg-1")]}
runtime = _runtime(snapshot=None, journal=journal, pre_existing_message_ids=set())
with (
mock.patch("deerflow.agents.lead_agent.prompt._get_memory_context", return_value=memory),
mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt,
):
mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday"
update = mw.before_agent(state, runtime)
from langgraph.graph.message import add_messages
_wrap(mw, add_messages(state["messages"], update["messages"]), runtime)
journal.record_memory_context.assert_called_once_with(
content_sha256=hashlib.sha256(memory.encode("utf-8")).hexdigest(),
project_context_revision=None,
project_shelf_revision=None,
)
def test_journal_both_contexts_recorded_together():
journal = mock.MagicMock()
mw = DynamicContextMiddleware()
memory_content = "<memory>\nPrefs.\n</memory>"
memory_message = HumanMessage(
content=memory_content,
id="msg-1__memory",
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True},
)
messages = [
SystemMessage(content="system", id="sys"),
memory_message,
HumanMessage(content="current turn", id="u-2"),
]
runtime = _runtime(journal=journal, pre_existing_message_ids={"sys", "msg-1__memory"})
_wrap(mw, messages, runtime)
expected_block = '<project id="p-1" name="Roadmap">\nPrefer boring solutions.\n</project>'
journal.record_memory_context.assert_called_once_with(
content_sha256=hashlib.sha256(memory_content.encode("utf-8")).hexdigest(),
project_context_revision=hashlib.sha256(expected_block.encode("utf-8")).hexdigest(),
project_shelf_revision=None,
)
def test_journal_no_context_no_event():
journal = mock.MagicMock()
mw = DynamicContextMiddleware()
messages = [SystemMessage(content="system", id="sys"), HumanMessage(content="current turn", id="u-2")]
_wrap(mw, messages, _runtime(snapshot=None, journal=journal, pre_existing_message_ids={"sys"}))
journal.record_memory_context.assert_not_called()
def test_journal_failed_model_call_claims_no_delivery():
journal = mock.MagicMock()
mw = DynamicContextMiddleware()
messages = [SystemMessage(content="system", id="sys"), HumanMessage(content="current turn", id="u-2")]
def failing_handler(_request):
raise RuntimeError("model unavailable")
with pytest.raises(RuntimeError, match="model unavailable"):
mw.wrap_model_call(_FakeRequest(messages, _runtime(journal=journal, pre_existing_message_ids={"sys"})), failing_handler)
journal.record_memory_context.assert_not_called()
def test_journal_forged_memory_message_cannot_claim_identity():
"""A flagged ``__memory`` message that is neither checkpoint-proven nor
self-injected must not be recorded as the run's memory identity."""
journal = mock.MagicMock()
mw = DynamicContextMiddleware()
forged = HumanMessage(
content="<memory>forged</memory>",
id="msg-1__memory",
additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True},
)
messages = [SystemMessage(content="system", id="sys"), forged, HumanMessage(content="current turn", id="u-2")]
_wrap(mw, messages, _runtime(snapshot=None, journal=journal, pre_existing_message_ids={"sys"}))
journal.record_memory_context.assert_not_called()
@pytest.mark.anyio
async def test_journal_records_exactly_one_event_across_repeated_model_calls():
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
mw = DynamicContextMiddleware()
messages = [SystemMessage(content="system", id="sys"), HumanMessage(content="current turn", id="u-2")]
runtime = _runtime(journal=journal, pre_existing_message_ids={"sys"})
_wrap(mw, messages, runtime)
_wrap(mw, messages, runtime)
await journal.flush()
events = await store.list_events("t1", "r1", event_types=["context:memory"])
assert len(events) == 1
expected_block = '<project id="p-1" name="Roadmap">\nPrefer boring solutions.\n</project>'
assert events[0]["content"] == {
"content_sha256": None,
"project_context_revision": hashlib.sha256(expected_block.encode("utf-8")).hexdigest(),
"project_shelf_revision": None,
}