deer-flow/backend/tests/test_run_event_store.py
rayhpeng cd35363a05
fix(history): early user messages vanish or jump mid-run when pagination and context compaction overlap (#4696)
* fix(history): stop dropping user messages that fall outside the loaded page window

Two independent paths made a user's own message disappear from a long thread
(#4666, #4508, #4363). Both are reproduced by a real two-round run: once the
thread passes the 50-row `/messages/page` window AND context compaction fires,
the two sources of truth stop overlapping at the head.

1. Middleware-answered tool results never reached the event store. A middleware
   that short-circuits a tool call (e.g. ReadBeforeWriteMiddleware's blocked
   write) returns a user-visible ToolMessage, but LangChain never emits
   `on_tool_end`, so RunJournal never persisted it — the user saw it during the
   run and it vanished on reload. RunJournal already reconciles final-output
   tool messages, but only for an `ask_clarification` allowlist. The allowlist
   is removed; scope stays bounded by the three conditions that actually matter
   (visible, this run's lead agent, not already persisted), so subagent results
   still stay in their own step feed.

2. mergeMessages discarded the checkpoint prefix before the first shared anchor.
   #4065 correctly established that a summarization-rescued early message must
   not be appended to the tail, and suppressed it instead. That suppression is
   what deletes the message when the first history page no longer reaches back
   to it. It is now woven in before the first shared anchor — the one position
   both the checkpoint and seq-sorted history agree on — so #4065's invariant
   (never the tail) still holds. A collapsed unloaded gap is recoverable by
   paging; a dropped message is not.

Verified against real captured payloads from the reproducing run: the first user
message returns to the transcript. Its exact position is still approximate —
after compaction the live window carries too few anchors to place it precisely,
which only seq-based ordering can close.

Backend: 10809 passed (baseline 10808; same 15 pre-existing failures in
browser/crawler community tools). Frontend: 986 passed, typecheck + eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(events): look up a persisted message's seq by identity

Groundwork for placing checkpoint messages in the seq-ordered thread feed
(#4666). A checkpoint carries no seq of its own and loses messages to
summarization, so once the feed's 50-row page window no longer reaches back to a
surviving old message, a client has nothing to place it by. The seq already
exists in run_events keyed by the message id — this exposes it without paging
the whole feed.

`message_identity` is the backend half of the identity rule the frontend applies
in `hooks.ts::messageIdentity`: a ToolMessage is keyed by `tool_call_id`, and
DynamicContextMiddleware's `X` / `X__user` human copies collapse to one identity.
The two halves must stay in sync — a mismatch is silent, degrading placement
rather than raising.

`get_message_seqs` is implemented for all three stores. Misses are absent from
the result rather than an error, so callers degrade to their own placement rule;
the earliest seq wins when one identity resolves to several rows, so a
re-persisted message keeps the position it first occupied. The DB store decodes
rows in Python because `content` is a TEXT column holding a JSON string, not a
JSON column — the identity fields cannot be projected in SQL.

Nothing consumes this yet; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(runtime): carry each persisted message's feed seq on values frames

Attaches `additional_kwargs.deerflow_seq` to messages in a root `values` frame
that the thread feed already holds, so a client can place a message the
checkpoint kept but its loaded history page window no longer reaches (#4666).
Nothing is written back to the checkpoint: the seq is added when the frame is
serialized and belongs to that frame only.

Cost is bounded to frames introducing identities the run has not resolved yet.
Messages this run produces are not in the feed while streaming, so they are
looked up once, recorded as misses, and never retried — in a real run the only
frame that pays for a query is the one where compaction brings older messages
back into view. Measured on a reproducing two-round run: 1 lookup across 25
values frames.

The stamper is built once per run rather than per `_stream_once`, or a goal
continuation would discard the resolved seqs. Subgraph frames are not stamped:
a subagent's snapshot is not part of this thread's feed ordering. A lookup
failure logs and leaves the frame unstamped rather than failing it — placement
is an enhancement and clients fall back to their own ordering rule.

Frontend does not read the field yet; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gateway): strip the server-owned message seq from untrusted input

`deerflow_seq` is display metadata the Gateway attaches when it serializes a
values frame. A client replaying messages (regenerate / edit-and-rerun) would
otherwise write it into the checkpoint, where it becomes wrong the moment the
thread is forked — a branch re-seeds its feed and reassigns seq (#4380).

Joins the existing server-owned key set, so it follows the same trusted-internal
rule as the dynamic-context and view-image markers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): place a checkpoint message by its feed seq, not its nearest anchor

Completes #4666. Weaving a compaction-rescued message before the first shared
anchor keeps it in the transcript, but not in the right place: after compaction
the live window carries too few anchors, and the nearest one can sit deep inside
the loaded page window — measured at row 25 of 50 on a reproducing run, which is
why the first user turn rendered mid-transcript instead of at the head.

Both sides now carry the backend's thread-global seq. `buildVisibleHistoryMessages`
copies each row's `seq` onto the message (same shape as the existing `run_id`),
and the Gateway stamps it onto `values` frame messages it has already persisted.
A live message whose seq is below the loaded window's lower bound is placed ahead
of everything on screen rather than before the nearest anchor. A message with no
seq — still streaming, so not in the feed yet — keeps the weaving path, since the
tail is already its correct position.

Verified against the captured payloads of the reproducing run: the first user
message goes from absent, to #13 (behind the second question), to #0.

Frontend: 988 passed, typecheck + eslint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(frontend): place a pre-window checkpoint message even when no anchor is shared

Also #4666. Placing a compaction-rescued message by its feed seq was gated on
reaching a shared anchor, because the split ran inside the anchor walk. When the
loaded page and the live checkpoint share no identity at all, that walk never
runs and the message fell through to `[...canonical, ...live]` — appended after
the entire window, the one arrangement #4065 proved wrong, with its seq known
the whole time.

That is not a corner case. Open an old, already-summarized conversation and send
a message: the page on screen is the newest rows from before that turn, while
the checkpoint holds the rescued first user turn plus steps of the new run that
are not in the feed yet. On a reproducing run the two sides shared zero anchors
and the user's own first question rendered at row 50 of 50 — the reported
"first message jumps to the bottom".

Split `beforeWindow` out of `live` before walking anchors, walk `liveInWindow`,
and use it for the no-anchor branch as well, so a message routed ahead of the
window is not re-appended at the tail by dedup.

Measured on captured payloads of a reproducing run (real gateway, real
compaction), first user message position:

  no shared anchor:  row 50 -> row 0, seq order monotonic again
  shared anchors:    row 0 -> row 0 (unchanged)
  paged to the top:  row 0 -> row 0 (unchanged)

Regression test verified red-green: reverting the fix fails it with the message
rendered after the window.

Frontend: 989 passed, eslint + tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gateway): stamp the message feed seq on checkpoint reads, not only on stream frames

Completes #4666. `_MessageSeqStamper` sits on the streaming publish path, so a
client that joins a live run learns where a summarization-rescued turn belongs
while a client that merely opens the conversation does not — and opening is the
common case. `GET /threads/{id}/state` and `POST /threads/{id}/history` returned
the checkpoint with no seq at all, so the merge fell back to the nearest shared
anchor, which after summarization sits deep inside the loaded page.

Reproduced in a browser against a real gateway, on a thread that had already
compacted: the user's first question rendered at row 320 of 389, behind the
newest question instead of at the head. Both reads showed 0 of 13 messages
carrying a seq. That is the reported symptom, still present after the streaming
fix.

Add `stamp_messages_with_seq`, the request-scoped counterpart of the stamper:
everything a checkpoint still holds is already persisted, so one batched lookup
resolves the whole list and there is nothing to retry later. Resolve the store
through `_optional_run_event_store` rather than `get_run_event_store`, because
seq is placement metadata — a deployment without a feed must still be able to
read a thread.

After the fix, on the same thread in the same browser: 13 of 13 messages carry a
seq and the first question renders at the head, ahead of the newest one.

Backend: ruff clean, 326 passed across the touched suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(harness): move the injected-user-id suffix helpers to utils.messages to break an import cycle

message_identity imported strip_injected_user_message_id_suffix from the
dynamic-context middleware, closing a cycle (middleware -> deerflow.runtime
-> worker -> events -> middleware) that only stayed hidden while an earlier
import happened to break it. Define INJECTED_USER_MESSAGE_ID_SUFFIX and the
strip helper in deerflow.utils.messages and re-export them from the
middleware so existing importers keep working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docs): improve formatting and clarity in AGENTS.md and message-merge.test.ts

* perf(events): stop the seq scan once every wanted identity is resolved

Rows past the last wanted seq can only be re-persisted copies that
already lose the earliest-seq-wins tiebreak, so all three stores now
break out of the scan (and the db store out of its per-row JSON
decoding) once found covers wanted. Matters most for /state and
/history reads of long threads, where this lookup runs with no run
cache and a typically tiny wanted set.

Raised by review on #4696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(events): share the seq-stamping expression between the two stampers

The walrus-plus-merge expression was duplicated verbatim between
stamp_messages_with_seq and _MessageSeqStamper.stamp — two counterparts
of one rule where silent divergence is the likely failure mode if only
one side is edited. Both now call attach_message_seq next to
MESSAGE_SEQ_KEY in message_identity.py. The trailing
isinstance(message, Mapping) guard was unreachable (a non-Mapping entry
already got identity = None) and is gone with the extraction.

Raised by review on #4696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(events): seq stamping survives launch paths without user context

The db store's get_message_seqs defaults to user_id=AUTO, which raises
when no user is in the contextvar — the first strict-AUTO read ever
called from the worker context. On a launch path that never inherits
the auth context (e.g. a null-owner scheduled task), stamp()'s except
clause swallowed that into a per-frame warning and silently disabled
seq stamping for exactly the background runs that need it.

The stamper now soft-resolves the user id once at build time — the
same rule as the worker's write paths beside it (unset -> no filter)
— and passes it explicitly. jsonl/memory stores gain the same
user_id kwarg the base list_messages contract already carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(events): SQL-prefilter the message seq lookup's candidate rows

get_message_seqs scanned and JSON-decoded every message row of the
thread: the early exit never fires when a wanted identity is absent
from the feed (a message still streaming, or checkpoint-only), and
/state / /history reads want the newest messages, so the ascending
scan traversed essentially the whole feed — with the content column
carrying full tool outputs, that is heavy I/O plus N JSON parses on
exactly the long threads this lookup exists for.

A LIKE prefilter now keeps that cost in SQL: only rows containing a
wanted raw id as a substring are fetched and decoded. False positives
are re-checked by message_identity; LIKE wildcards are escaped; an id
json.dumps would escape (breaking the verbatim-substring guarantee)
falls the whole set back to the full scan rather than silently
missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(agents): sink runtime mechanism docs below the gateway guidance budget

Merging main pushed backend/app/gateway/AGENTS.md past its 40KB soft
budget (main had left 81 bytes of headroom). Per the nearest-file rule,
move the mechanism detail of the message-seq stamping and run-delivery
receipt sections — both owned by runtime/ code — into
packages/harness/deerflow/runtime/AGENTS.md, leaving the gateway file
the REST-surface summary and a pointer. The seq section also documents
the stamper's build-time soft user-id resolution and the db store's SQL
prefilter from the review follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(agents): sink durable-MCP task detail below the backend guidance budget

Merging main pushed backend/AGENTS.md past its 24KB module soft budget
(main itself is at 24762 after #4848 — this branch adds zero net bytes
to the file). Per the nearest-file rule, move the two durable-MCP task
runtime bullets' mechanism detail into
packages/harness/deerflow/mcp/AGENTS.md, leaving summaries and
pointers; this also restores ~2KB of headroom so the next merge does
not trip the same wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(events): re-ask a message-seq miss once the feed advances

The run-scoped stamper cached lookup misses for the whole run. A message
this run produces reaches a values frame before RunJournal flushes it, so
its first lookup legitimately misses — and the journal persists it moments
later, giving it a feed seq the stamper never asks for again. A long run
that afterwards rolls past the history page and compacts then carries that
message unstamped, back to the approximate anchor placement this stamper
exists to replace (#4666). A transient store error had the same permanent
effect, since the except clause degrades to an empty result.

A miss is now provisional while a hit stays final: RunJournal counts its
successful event-store writes as `feed_generation`, and the stamper re-asks
a missed identity only once that counter moves. Retrying is therefore
bounded by feed writes rather than by frames — the per-frame query the
run-scoped cache was built to avoid — and a failed lookup costs one
generation instead of the run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:04:17 +08:00

1545 lines
62 KiB
Python

"""Tests for RunEventStore contract across all backends.
Uses a helper to create the store for each backend type.
Memory tests run directly; DB and JSONL tests create stores inside each test.
"""
from unittest.mock import AsyncMock, patch
import pytest
from deerflow.runtime.events.store.memory import MemoryRunEventStore
@pytest.fixture
def store():
return MemoryRunEventStore()
async def _assert_find_latest_ai_message_run_ids_contract(store, *, allow_empty_run_id: bool) -> None:
assert await store.find_latest_ai_message_run_ids("t1", set()) == {}
await store.put(
thread_id="t1",
run_id="trace-run",
event_type="llm.ai.response",
category="trace",
content={"type": "ai", "id": "target"},
)
await store.put(
thread_id="t1",
run_id="string-content-run",
event_type="llm.ai.response",
category="message",
content="target",
)
await store.put(
thread_id="t1",
run_id="human-run",
event_type="human_message",
category="message",
content={"type": "human", "id": "target"},
)
await store.put(
thread_id="t1",
run_id="old-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "target"},
)
await store.put(
thread_id="t1",
run_id="other-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "other"},
)
await store.put(
thread_id="t1",
run_id="decoy-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "decoy", "note": "target"},
)
await store.put(
thread_id="t1",
run_id="new-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "target"},
)
if allow_empty_run_id:
await store.put(
thread_id="t1",
run_id="",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "target"},
)
assert await store.find_latest_ai_message_run_ids("t1", {"target", "other", "missing"}, user_id=None) == {
"target": "new-run",
"other": "other-run",
}
# -- Basic write and query --
class TestPutAndSeq:
@pytest.mark.anyio
async def test_put_returns_dict_with_seq(self, store):
record = await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", content="hello")
assert "seq" in record
assert record["seq"] == 1
assert record["thread_id"] == "t1"
assert record["run_id"] == "r1"
assert record["event_type"] == "human_message"
assert record["category"] == "message"
assert record["content"] == "hello"
assert "created_at" in record
@pytest.mark.anyio
async def test_seq_strictly_increasing_same_thread(self, store):
r1 = await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
r2 = await store.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message")
r3 = await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace")
assert r1["seq"] == 1
assert r2["seq"] == 2
assert r3["seq"] == 3
@pytest.mark.anyio
async def test_seq_independent_across_threads(self, store):
r1 = await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
r2 = await store.put(thread_id="t2", run_id="r2", event_type="human_message", category="message")
assert r1["seq"] == 1
assert r2["seq"] == 1
@pytest.mark.anyio
async def test_put_respects_provided_created_at(self, store):
ts = "2024-06-01T12:00:00+00:00"
record = await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", created_at=ts)
assert record["created_at"] == ts
@pytest.mark.anyio
async def test_put_metadata_preserved(self, store):
meta = {"model": "gpt-4", "tokens": 100}
record = await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace", metadata=meta)
assert record["metadata"] == meta
@pytest.mark.anyio
async def test_put_if_absent_preserves_first_run_scoped_event(self, store):
first, created = await store.put_if_absent(
thread_id="t1",
run_id="r1",
event_type="run.delivery",
category="outputs",
content={"presented": 1},
)
duplicate, duplicate_created = await store.put_if_absent(
thread_id="t1",
run_id="r1",
event_type="run.delivery",
category="outputs",
content={"presented": 0},
)
assert created is True
assert duplicate_created is False
assert duplicate == first
assert [event["content"] for event in await store.list_events("t1", "r1") if event["event_type"] == "run.delivery"] == [{"presented": 1}]
# -- list_messages --
class TestListMessages:
@pytest.mark.anyio
async def test_only_returns_message_category(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace")
await store.put(thread_id="t1", run_id="r1", event_type="run_start", category="lifecycle")
messages = await store.list_messages("t1")
assert len(messages) == 1
assert messages[0]["category"] == "message"
@pytest.mark.anyio
async def test_ascending_seq_order(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", content="first")
await store.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message", content="second")
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", content="third")
messages = await store.list_messages("t1")
seqs = [m["seq"] for m in messages]
assert seqs == sorted(seqs)
@pytest.mark.anyio
async def test_before_seq_pagination(self, store):
for i in range(10):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", content=str(i))
messages = await store.list_messages("t1", before_seq=6, limit=3)
assert len(messages) == 3
assert [m["seq"] for m in messages] == [3, 4, 5]
@pytest.mark.anyio
async def test_after_seq_pagination(self, store):
for i in range(10):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", content=str(i))
messages = await store.list_messages("t1", after_seq=7, limit=3)
assert len(messages) == 3
assert [m["seq"] for m in messages] == [8, 9, 10]
@pytest.mark.anyio
async def test_limit_restricts_count(self, store):
for _ in range(20):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
messages = await store.list_messages("t1", limit=5)
assert len(messages) == 5
@pytest.mark.anyio
async def test_cross_run_unified_ordering(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message")
await store.put(thread_id="t1", run_id="r2", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r2", event_type="ai_message", category="message")
messages = await store.list_messages("t1")
assert [m["seq"] for m in messages] == [1, 2, 3, 4]
assert messages[0]["run_id"] == "r1"
assert messages[2]["run_id"] == "r2"
@pytest.mark.anyio
async def test_default_returns_latest(self, store):
for _ in range(10):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
messages = await store.list_messages("t1", limit=3)
assert [m["seq"] for m in messages] == [8, 9, 10]
@pytest.mark.anyio
async def test_pagination_with_interleaved_trace_events(self, store):
# Messages and non-message events interleave, so message seqs are
# non-contiguous (1, 3, 5, 7, 9). Seq-window pagination must still be
# correct over the messages-only projection, including when the cursor
# lands in a gap or exactly on a message seq (exclusive bound).
for i in range(10):
category = "message" if i % 2 == 0 else "trace"
await store.put(thread_id="t1", run_id="r1", event_type="e", category=category, content=str(i))
assert [m["seq"] for m in await store.list_messages("t1")] == [1, 3, 5, 7, 9]
# before_seq in a gap: seq < 6 -> [1, 3, 5], last 2
assert [m["seq"] for m in await store.list_messages("t1", before_seq=6, limit=2)] == [3, 5]
# before_seq on a message seq is exclusive: seq < 5 -> [1, 3]
assert [m["seq"] for m in await store.list_messages("t1", before_seq=5, limit=5)] == [1, 3]
# after_seq in a gap: seq > 4 -> [5, 7, 9], first 2
assert [m["seq"] for m in await store.list_messages("t1", after_seq=4, limit=2)] == [5, 7]
# after_seq on a message seq is exclusive: seq > 5 -> [7, 9]
assert [m["seq"] for m in await store.list_messages("t1", after_seq=5, limit=5)] == [7, 9]
class TestFindLatestAiMessageRunIds:
@pytest.mark.anyio
async def test_memory_contract(self, store):
await _assert_find_latest_ai_message_run_ids_contract(store, allow_empty_run_id=True)
@pytest.mark.anyio
async def test_memory_stops_after_all_targets_are_found(self, store):
from deerflow.runtime.events.store import base as event_store_base
await store.put(
thread_id="t1",
run_id="old-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "old"},
)
await store.put(
thread_id="t1",
run_id="new-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "target"},
)
store.list_messages = AsyncMock(wraps=store.list_messages)
with patch.object(event_store_base, "match_ai_message_run_id", wraps=event_store_base.match_ai_message_run_id) as match_event:
assert await store.find_latest_ai_message_run_ids("t1", {"target"}, user_id=None) == {"target": "new-run"}
assert match_event.call_count == 1
store.list_messages.assert_awaited_once_with("t1", limit=1000, before_seq=None, user_id=None)
@pytest.mark.anyio
async def test_memory_pages_in_bounded_windows_and_keeps_initial_high_watermark(self, store):
await store.put(
thread_id="t1",
run_id="old-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "target"},
)
await store.put_batch(
[
{
"thread_id": "t1",
"run_id": "noise-run",
"event_type": "llm.ai.response",
"category": "message",
"content": {"type": "ai", "id": f"noise-{index}"},
}
for index in range(1000)
]
)
original_list_messages = store.list_messages
calls: list[dict] = []
async def list_messages(*args, **kwargs):
page = await original_list_messages(*args, **kwargs)
calls.append(kwargs)
if len(calls) == 1:
# This duplicate is newer than the first page's snapshot. A
# backward cursor must not let it replace the older answer
# while resolving the rest of that same lookup.
await store.put(
thread_id="t1",
run_id="concurrent-new-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "target"},
)
return page
store.list_messages = AsyncMock(side_effect=list_messages)
assert await store.find_latest_ai_message_run_ids("t1", {"target"}, user_id=None) == {"target": "old-run"}
assert len(calls) == 2
assert all(call["limit"] == 1000 for call in calls)
assert calls[0].get("before_seq") is None
assert calls[1]["before_seq"] == 2
@pytest.mark.anyio
@pytest.mark.parametrize("malformed_page", ["missing-seq", "non-progressing-seq"])
async def test_default_lookup_raises_instead_of_looping_on_unsafe_cursor(self, store, malformed_page):
from deerflow.runtime.events.store.base import RunEventStore
calls = 0
async def list_messages(*_args, **_kwargs):
nonlocal calls
calls += 1
if malformed_page == "missing-seq":
return [
{
"category": "message",
"content": {"type": "ai", "id": f"noise-{index}"},
"run_id": "noise-run",
}
for index in range(1000)
]
return [
{
"category": "message",
"content": {"type": "ai", "id": f"noise-{index}"},
"run_id": "noise-run",
"seq": index + 1,
}
for index in range(1000)
]
store.list_messages = AsyncMock(side_effect=list_messages)
with pytest.raises(RuntimeError, match="safe backward cursor"):
await RunEventStore.find_latest_ai_message_run_ids(store, "t1", {"missing"}, user_id=None)
assert calls == (1 if malformed_page == "missing-seq" else 2)
# -- list_events --
class TestListEvents:
@pytest.mark.anyio
async def test_returns_all_categories_for_run(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace")
await store.put(thread_id="t1", run_id="r1", event_type="run_start", category="lifecycle")
events = await store.list_events("t1", "r1")
assert len(events) == 3
@pytest.mark.anyio
async def test_event_types_filter(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="llm_start", category="trace")
await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace")
await store.put(thread_id="t1", run_id="r1", event_type="tool_start", category="trace")
events = await store.list_events("t1", "r1", event_types=["llm_end"])
assert len(events) == 1
assert events[0]["event_type"] == "llm_end"
@pytest.mark.anyio
async def test_only_returns_specified_run(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace")
await store.put(thread_id="t1", run_id="r2", event_type="llm_end", category="trace")
events = await store.list_events("t1", "r1")
assert len(events) == 1
assert events[0]["run_id"] == "r1"
# -- list_messages_by_run --
class TestListMessagesByRun:
@pytest.mark.anyio
async def test_only_messages_for_specified_run(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace")
await store.put(thread_id="t1", run_id="r2", event_type="human_message", category="message")
messages = await store.list_messages_by_run("t1", "r1")
assert len(messages) == 1
assert messages[0]["run_id"] == "r1"
assert messages[0]["category"] == "message"
# -- count_messages --
class TestCountMessages:
@pytest.mark.anyio
async def test_counts_only_message_category(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message")
await store.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace")
assert await store.count_messages("t1") == 2
# -- put_batch --
class TestPutBatch:
@pytest.mark.anyio
async def test_batch_assigns_seq(self, store):
events = [
{"thread_id": "t1", "run_id": "r1", "event_type": "human_message", "category": "message", "content": "a"},
{"thread_id": "t1", "run_id": "r1", "event_type": "ai_message", "category": "message", "content": "b"},
{"thread_id": "t1", "run_id": "r1", "event_type": "llm_end", "category": "trace"},
]
results = await store.put_batch(events)
assert len(results) == 3
assert all("seq" in r for r in results)
@pytest.mark.anyio
async def test_batch_seq_strictly_increasing(self, store):
events = [
{"thread_id": "t1", "run_id": "r1", "event_type": "human_message", "category": "message"},
{"thread_id": "t1", "run_id": "r1", "event_type": "ai_message", "category": "message"},
]
results = await store.put_batch(events)
assert results[0]["seq"] == 1
assert results[1]["seq"] == 2
# -- delete --
class TestDelete:
@pytest.mark.anyio
async def test_delete_by_thread(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message")
await store.put(thread_id="t1", run_id="r2", event_type="llm_end", category="trace")
count = await store.delete_by_thread("t1")
assert count == 3
assert await store.list_messages("t1") == []
assert await store.count_messages("t1") == 0
@pytest.mark.anyio
async def test_delete_by_run(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r2", event_type="human_message", category="message")
await store.put(thread_id="t1", run_id="r2", event_type="llm_end", category="trace")
count = await store.delete_by_run("t1", "r2")
assert count == 2
messages = await store.list_messages("t1")
assert len(messages) == 1
assert messages[0]["run_id"] == "r1"
@pytest.mark.anyio
async def test_delete_nonexistent_thread_returns_zero(self, store):
assert await store.delete_by_thread("nope") == 0
@pytest.mark.anyio
async def test_delete_nonexistent_run_returns_zero(self, store):
await store.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
assert await store.delete_by_run("t1", "nope") == 0
@pytest.mark.anyio
async def test_delete_nonexistent_thread_for_run_returns_zero(self, store):
assert await store.delete_by_run("nope", "r1") == 0
# -- Edge cases --
class TestEdgeCases:
@pytest.mark.anyio
async def test_empty_thread_list_messages(self, store):
assert await store.list_messages("empty") == []
@pytest.mark.anyio
async def test_empty_run_list_events(self, store):
assert await store.list_events("empty", "r1") == []
@pytest.mark.anyio
async def test_empty_thread_count_messages(self, store):
assert await store.count_messages("empty") == 0
# -- DB-specific tests --
class TestDbRunEventStore:
"""Tests for DbRunEventStore with temp SQLite."""
@pytest.mark.anyio
async def test_postgres_max_seq_uses_advisory_lock_without_for_update(self):
from sqlalchemy.dialects import postgresql
from deerflow.runtime.events.store.db import DbRunEventStore
class FakeSession:
def __init__(self):
self.dialect = postgresql.dialect()
self.execute_calls = []
self.scalar_stmt = None
def get_bind(self):
return self
async def execute(self, stmt, params=None):
self.execute_calls.append((stmt, params))
async def scalar(self, stmt):
self.scalar_stmt = stmt
return 41
session = FakeSession()
max_seq = await DbRunEventStore._max_seq_for_thread(session, "thread-1")
assert max_seq == 41
assert session.execute_calls
assert session.execute_calls[0][1] == {"thread_id": "thread-1"}
assert "pg_advisory_xact_lock" in str(session.execute_calls[0][0])
compiled = str(session.scalar_stmt.compile(dialect=postgresql.dialect()))
assert "FOR UPDATE" not in compiled
@pytest.mark.anyio
async def test_basic_crud(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
r = await s.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", content="hi")
assert r["seq"] == 1
r2 = await s.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message", content="hello")
assert r2["seq"] == 2
messages = await s.list_messages("t1")
assert len(messages) == 2
count = await s.count_messages("t1")
assert count == 2
await close_engine()
@pytest.mark.anyio
async def test_find_latest_ai_message_run_ids_contract_and_owner_filter(self, tmp_path):
from types import SimpleNamespace
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.user_context import reset_current_user, set_current_user
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
store = DbRunEventStore(get_session_factory())
await _assert_find_latest_ai_message_run_ids_contract(store, allow_empty_run_id=True)
owner_a_token = set_current_user(SimpleNamespace(id="owner-a"))
try:
await store.put(
thread_id="owned-thread",
run_id="owner-a-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "shared-id"},
)
finally:
reset_current_user(owner_a_token)
owner_b_token = set_current_user(SimpleNamespace(id="owner-b"))
try:
await store.put(
thread_id="owned-thread",
run_id="owner-b-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "shared-id"},
)
finally:
reset_current_user(owner_b_token)
assert await store.find_latest_ai_message_run_ids("owned-thread", {"shared-id"}, user_id="owner-a") == {"shared-id": "owner-a-run"}
assert await store.find_latest_ai_message_run_ids("owned-thread", {"shared-id"}, user_id="owner-b") == {"shared-id": "owner-b-run"}
finally:
await close_engine()
@pytest.mark.anyio
async def test_find_latest_ai_message_run_ids_handles_large_target_sets_and_special_ids(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
store = DbRunEventStore(get_session_factory())
target_ids = {f"id-{index:03d}" for index in range(201)}
special_id = 'message-%_/"-雪'
target_ids.add(special_id)
await store.put_batch(
[
{
"thread_id": "t1",
"run_id": "first-chunk-run",
"event_type": "llm.ai.response",
"category": "message",
"content": {"type": "ai", "id": "id-000"},
},
{
"thread_id": "t1",
"run_id": "last-chunk-run",
"event_type": "llm.ai.response",
"category": "message",
"content": {"type": "ai", "id": "id-200"},
},
{
"thread_id": "t1",
"run_id": "special-run",
"event_type": "llm.ai.response",
"category": "message",
"content": {"type": "ai", "id": special_id},
},
]
)
assert await store.find_latest_ai_message_run_ids("t1", target_ids, user_id=None) == {
"id-000": "first-chunk-run",
"id-200": "last-chunk-run",
special_id: "special-run",
}
finally:
await close_engine()
@pytest.mark.anyio
async def test_find_latest_ai_message_run_ids_pages_db_with_owner_scoped_high_watermark(self, tmp_path):
from types import SimpleNamespace
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.user_context import reset_current_user, set_current_user
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
owner_token = set_current_user(SimpleNamespace(id="owner-a"))
try:
store = DbRunEventStore(get_session_factory())
await store.put(
thread_id="t1",
run_id="old-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "target"},
)
await store.put_batch(
[
{
"thread_id": "t1",
"run_id": "noise-run",
"event_type": "llm.ai.response",
"category": "message",
"content": {"type": "ai", "id": f"noise-{index}"},
}
for index in range(1000)
]
)
original_list_messages = store.list_messages
calls: list[dict] = []
async def list_messages(*args, **kwargs):
page = await original_list_messages(*args, **kwargs)
calls.append(kwargs)
if len(calls) == 1:
await store.put(
thread_id="t1",
run_id="concurrent-new-run",
event_type="llm.ai.response",
category="message",
content={"type": "ai", "id": "target"},
)
return page
store.list_messages = AsyncMock(side_effect=list_messages)
assert await store.find_latest_ai_message_run_ids("t1", {"target"}, user_id="owner-a") == {"target": "old-run"}
assert len(calls) == 2
assert all(call["limit"] == 1000 and call["user_id"] == "owner-a" for call in calls)
assert calls[0].get("before_seq") is None
assert calls[1]["before_seq"] == 2
finally:
reset_current_user(owner_token)
await close_engine()
@pytest.mark.anyio
async def test_put_if_absent_is_idempotent(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
first, created = await s.put_if_absent(thread_id="t1", run_id="r1", event_type="run.delivery", category="outputs", content={"presented": 2})
duplicate, duplicate_created = await s.put_if_absent(thread_id="t1", run_id="r1", event_type="run.delivery", category="outputs", content={"presented": 0})
assert created is True
assert duplicate_created is False
assert duplicate["seq"] == first["seq"]
assert duplicate["content"] == {"presented": 2}
await close_engine()
@pytest.mark.anyio
async def test_trace_content_truncation(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory(), max_trace_content=100)
long = "x" * 200
r = await s.put(thread_id="t1", run_id="r1", event_type="llm_end", category="trace", content=long)
assert len(r["content"]) == 100
assert r["metadata"].get("content_truncated") is True
# message content NOT truncated
m = await s.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message", content=long)
assert len(m["content"]) == 200
await close_engine()
@pytest.mark.anyio
async def test_structured_content_round_trips(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
content = [{"type": "text", "text": "hello"}, {"type": "image_url", "image_url": {"url": "https://example.test/a.png"}}]
record = await s.put(thread_id="t1", run_id="r1", event_type="ai_message", category="message", content=content)
assert record["content"] == content
assert record["metadata"]["content_is_json"] is True
assert "content_is_dict" not in record["metadata"]
messages = await s.list_messages("t1")
assert messages[0]["content"] == content
assert messages[0]["metadata"]["content_is_json"] is True
await close_engine()
@pytest.mark.anyio
async def test_pagination(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
for i in range(10):
await s.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", content=str(i))
# before_seq
msgs = await s.list_messages("t1", before_seq=6, limit=3)
assert [m["seq"] for m in msgs] == [3, 4, 5]
# after_seq
msgs = await s.list_messages("t1", after_seq=7, limit=3)
assert [m["seq"] for m in msgs] == [8, 9, 10]
# default (latest)
msgs = await s.list_messages("t1", limit=3)
assert [m["seq"] for m in msgs] == [8, 9, 10]
await close_engine()
@pytest.mark.anyio
async def test_delete(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
await s.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await s.put(thread_id="t1", run_id="r2", event_type="ai_message", category="message")
c = await s.delete_by_run("t1", "r2")
assert c == 1
assert await s.count_messages("t1") == 1
c = await s.delete_by_thread("t1")
assert c == 1
assert await s.count_messages("t1") == 0
await close_engine()
@pytest.mark.anyio
async def test_put_batch_seq_continuity(self, tmp_path):
"""Batch write produces continuous seq values with no gaps."""
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
events = [{"thread_id": "t1", "run_id": "r1", "event_type": "trace", "category": "trace"} for _ in range(50)]
results = await s.put_batch(events)
seqs = [r["seq"] for r in results]
assert seqs == list(range(1, 51))
await close_engine()
@pytest.mark.anyio
async def test_put_batch_accepts_structured_content(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
content = [{"messages": [{"type": "ai", "content": ""}]}]
results = await s.put_batch(
[
{
"thread_id": "t1",
"run_id": "r1",
"event_type": "run.end",
"category": "outputs",
"content": content,
}
]
)
assert results[0]["content"] == content
assert results[0]["metadata"]["content_is_json"] is True
events = await s.list_events("t1", "r1")
assert events[0]["content"] == content
assert events[0]["metadata"]["content_is_json"] is True
await close_engine()
@pytest.mark.anyio
async def test_dict_content_keeps_legacy_metadata_flag(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
content = {"status": "success"}
record = await s.put(thread_id="t1", run_id="r1", event_type="run.end", category="outputs", content=content)
assert record["content"] == content
assert record["metadata"]["content_is_json"] is True
assert record["metadata"]["content_is_dict"] is True
await close_engine()
class TestDbRunEventStoreWriteLock:
"""Per-thread seq-assignment lock (fixes SQLite UNIQUE(thread_id, seq) races).
Two in-process coroutines writing to the same thread can interleave between
the ``max(seq)`` read and the INSERT, both computing the same next seq and
colliding. A per-thread ``asyncio.Lock`` serializes seq assignment.
"""
def test_get_write_lock_same_thread_returns_same_lock(self):
import asyncio
from unittest.mock import MagicMock
from deerflow.runtime.events.store.db import DbRunEventStore
# The lock accessor does not touch the session factory, so a stub is fine.
store = DbRunEventStore(MagicMock())
lock = store._get_write_lock("thread-1")
assert isinstance(lock, asyncio.Lock)
assert store._get_write_lock("thread-1") is lock
def test_get_write_lock_distinct_threads_get_distinct_locks(self):
from unittest.mock import MagicMock
from deerflow.runtime.events.store.db import DbRunEventStore
store = DbRunEventStore(MagicMock())
assert store._get_write_lock("thread-1") is not store._get_write_lock("thread-2")
@pytest.mark.anyio
async def test_concurrent_put_batch_same_thread_has_no_seq_collision(self, tmp_path):
import asyncio
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
def _batch(run_id: str):
return [{"thread_id": "t1", "run_id": run_id, "event_type": "trace", "category": "trace"} for _ in range(20)]
# Fire two concurrent batches at the same thread; without the per-thread
# lock this races on seq and raises IntegrityError / duplicates seq.
results = await asyncio.gather(s.put_batch(_batch("r1")), s.put_batch(_batch("r2")))
all_seqs = [r["seq"] for batch in results for r in batch]
assert len(all_seqs) == 40
# Seq values are unique and contiguous 1..40 across both writers.
assert sorted(all_seqs) == list(range(1, 41))
await close_engine()
@pytest.mark.anyio
async def test_delete_by_thread_evicts_orphaned_write_lock(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
# A write materializes the per-thread lock in the registry.
await s.put_batch([{"thread_id": "t1", "run_id": "r1", "event_type": "trace", "category": "trace"}])
assert "t1" in s._write_locks
# Deleting the thread must evict the now-orphaned lock so the registry
# does not grow unbounded across the singleton store's lifetime.
await s.delete_by_thread("t1")
assert "t1" not in s._write_locks
# A subsequent write recreates a fresh lock and seq restarts from 1.
result = await s.put_batch([{"thread_id": "t1", "run_id": "r2", "event_type": "trace", "category": "trace"}])
assert "t1" in s._write_locks
assert result[0]["seq"] == 1
await close_engine()
@pytest.mark.anyio
async def test_delete_by_thread_keeps_lock_held_by_inflight_writer(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
s = DbRunEventStore(get_session_factory())
# Simulate a writer mid-flight by holding the lock; the eviction must
# not drop a lock another coroutine is actively using.
lock = s._get_write_lock("t1")
await lock.acquire()
try:
await s.delete_by_thread("t1")
assert "t1" in s._write_locks
assert s._write_locks["t1"] is lock
finally:
lock.release()
await close_engine()
# -- Factory tests --
class TestMakeRunEventStore:
"""Tests for the make_run_event_store factory function."""
@pytest.mark.anyio
async def test_memory_backend_default(self):
from deerflow.runtime.events.store import make_run_event_store
store = make_run_event_store(None)
assert type(store).__name__ == "MemoryRunEventStore"
@pytest.mark.anyio
async def test_memory_backend_explicit(self):
from unittest.mock import MagicMock
from deerflow.runtime.events.store import make_run_event_store
config = MagicMock()
config.backend = "memory"
store = make_run_event_store(config)
assert type(store).__name__ == "MemoryRunEventStore"
@pytest.mark.anyio
async def test_db_backend_with_engine(self, tmp_path):
from unittest.mock import MagicMock
from deerflow.persistence.engine import close_engine, init_engine
from deerflow.runtime.events.store import make_run_event_store
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
config = MagicMock()
config.backend = "db"
config.max_trace_content = 10240
store = make_run_event_store(config)
assert type(store).__name__ == "DbRunEventStore"
await close_engine()
@pytest.mark.anyio
async def test_db_backend_no_engine_falls_back(self):
"""db backend without engine falls back to memory."""
from unittest.mock import MagicMock
from deerflow.persistence.engine import close_engine, init_engine
from deerflow.runtime.events.store import make_run_event_store
await init_engine("memory") # no engine created
config = MagicMock()
config.backend = "db"
store = make_run_event_store(config)
assert type(store).__name__ == "MemoryRunEventStore"
await close_engine()
@pytest.mark.anyio
async def test_jsonl_backend(self):
from unittest.mock import MagicMock
from deerflow.runtime.events.store import make_run_event_store
config = MagicMock()
config.backend = "jsonl"
store = make_run_event_store(config)
assert type(store).__name__ == "JsonlRunEventStore"
@pytest.mark.anyio
async def test_unknown_backend_raises(self):
from unittest.mock import MagicMock
from deerflow.runtime.events.store import make_run_event_store
config = MagicMock()
config.backend = "redis"
with pytest.raises(ValueError, match="Unknown"):
make_run_event_store(config)
# -- JSONL-specific tests --
class TestJsonlRunEventStore:
@pytest.mark.anyio
@pytest.mark.parametrize("thread_id", ["", "thread.with.dot", "../escape", "x" * 65])
async def test_rejects_noncanonical_thread_ids(self, tmp_path, thread_id):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
store = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
with pytest.raises(ValueError, match="Invalid thread_id"):
await store.put(
thread_id=thread_id,
run_id="r1",
event_type="human_message",
category="message",
)
@pytest.mark.anyio
async def test_basic_crud(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
r = await s.put(thread_id="t1", run_id="r1", event_type="human_message", category="message", content="hi")
assert r["seq"] == 1
messages = await s.list_messages("t1")
assert len(messages) == 1
@pytest.mark.anyio
async def test_find_latest_ai_message_run_ids_contract(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
store = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
await _assert_find_latest_ai_message_run_ids_contract(store, allow_empty_run_id=False)
@pytest.mark.anyio
async def test_find_latest_ai_message_run_ids_reads_thread_once_and_ignores_empty_run(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
store = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
events = [
{
"thread_id": "t1",
"run_id": "valid-run",
"event_type": "llm.ai.response",
"category": "message",
"content": {"type": "ai", "id": "target"},
"seq": 1,
},
{
"thread_id": "t1",
"run_id": "",
"event_type": "llm.ai.response",
"category": "message",
"content": {"type": "ai", "id": "target"},
"seq": 2,
},
]
with patch.object(store, "_read_thread_events", return_value=events) as read_thread_events:
assert await store.find_latest_ai_message_run_ids("t1", {"target"}, user_id=None) == {"target": "valid-run"}
read_thread_events.assert_called_once_with("t1")
with patch.object(store, "_read_thread_events", side_effect=AssertionError("empty input must not read")) as read_thread_events:
assert await store.find_latest_ai_message_run_ids("t1", set()) == {}
read_thread_events.assert_not_called()
@pytest.mark.anyio
async def test_put_if_absent_is_idempotent(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
first, created = await s.put_if_absent(thread_id="t1", run_id="r1", event_type="run.delivery", category="outputs", content={"presented": 2})
duplicate, duplicate_created = await s.put_if_absent(thread_id="t1", run_id="r1", event_type="run.delivery", category="outputs", content={"presented": 0})
assert created is True
assert duplicate_created is False
assert duplicate == first
assert len(await s.list_events("t1", "r1", event_types=["run.delivery"])) == 1
@pytest.mark.anyio
async def test_file_at_correct_path(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
await s.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
assert (tmp_path / "jsonl" / "threads" / "t1" / "runs" / "r1.jsonl").exists()
@pytest.mark.anyio
async def test_cross_run_messages(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
await s.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await s.put(thread_id="t1", run_id="r2", event_type="human_message", category="message")
messages = await s.list_messages("t1")
assert len(messages) == 2
assert [m["seq"] for m in messages] == [1, 2]
@pytest.mark.anyio
async def test_delete_by_run(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
await s.put(thread_id="t1", run_id="r1", event_type="human_message", category="message")
await s.put(thread_id="t1", run_id="r2", event_type="human_message", category="message")
c = await s.delete_by_run("t1", "r2")
assert c == 1
assert not (tmp_path / "jsonl" / "threads" / "t1" / "runs" / "r2.jsonl").exists()
assert await s.count_messages("t1") == 1
class TestGetMessageSeqs:
"""Look up the thread-global seq of already-persisted messages by identity.
A checkpoint carries no seq of its own and loses messages to summarization,
so a client merging it with the seq-ordered thread feed cannot place a
surviving old message (#4666). The seq already exists here, keyed by the
message's identity; this exposes it without paging the whole feed.
"""
@pytest.mark.anyio
async def test_returns_seq_for_a_persisted_message(self, store):
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1", "content": "hello"},
)
assert await store.get_message_seqs("t1", ["message:u1"]) == {"message:u1": 1}
@pytest.mark.anyio
async def test_a_tool_message_is_identified_by_its_tool_call_id(self, store):
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.tool.result",
category="message",
content={"type": "tool", "id": "lc-abc", "tool_call_id": "call_1", "content": "OK"},
)
assert await store.get_message_seqs("t1", ["tool:call_1"]) == {"tool:call_1": 1}
@pytest.mark.anyio
async def test_the_injected_user_suffix_collapses_to_one_identity(self, store):
"""DynamicContextMiddleware re-keys the submitted turn ``X`` to ``X__user``.
The feed stores the ``__user`` copy while a caller may ask under either
spelling; both must resolve to the same row, or the very message this
feature exists to place would be the one it cannot find.
"""
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1__user", "content": "hello"},
)
assert await store.get_message_seqs("t1", ["message:u1"]) == {"message:u1": 1}
@pytest.mark.anyio
async def test_unknown_identities_are_absent_rather_than_an_error(self, store):
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1", "content": "hello"},
)
result = await store.get_message_seqs("t1", ["message:u1", "message:never-persisted"])
assert result == {"message:u1": 1}
@pytest.mark.anyio
async def test_non_message_events_are_not_looked_up(self, store):
await store.put(
thread_id="t1",
run_id="r1",
event_type="run.start",
category="trace",
content={"type": "human", "id": "u1"},
)
assert await store.get_message_seqs("t1", ["message:u1"]) == {}
@pytest.mark.anyio
async def test_lookup_is_scoped_to_the_thread(self, store):
await store.put(
thread_id="t2",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1", "content": "hello"},
)
assert await store.get_message_seqs("t1", ["message:u1"]) == {}
@pytest.mark.anyio
async def test_an_empty_request_does_not_scan(self, store):
assert await store.get_message_seqs("t1", []) == {}
@pytest.mark.anyio
async def test_a_replaced_message_keeps_its_first_seq(self, store):
"""A message re-persisted later must not jump to the tail of the feed."""
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1", "content": "hello"},
)
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1", "content": "hello (edited)"},
)
assert await store.get_message_seqs("t1", ["message:u1"]) == {"message:u1": 1}
@pytest.mark.anyio
async def test_the_scan_stops_once_every_wanted_identity_is_resolved(self, store):
"""Rows past the last wanted seq can only lose the earliest-seq-wins
tiebreak, so scanning them is busy-work — on `/state`/`/history` reads
of long threads this lookup is the only one and the wanted set is
typically tiny."""
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1", "content": "hello"},
)
class _Tripwire(dict):
def get(self, *_args, **_kwargs):
raise AssertionError("scan continued past the row that resolved the last wanted identity")
store._messages["t1"].append(_Tripwire())
assert await store.get_message_seqs("t1", ["message:u1"]) == {"message:u1": 1}
@pytest.mark.anyio
async def test_jsonl_store_resolves_identities(self, tmp_path):
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
s = JsonlRunEventStore(base_dir=tmp_path / "jsonl")
await s.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1__user", "content": "hello"},
)
await s.put(
thread_id="t1",
run_id="r1",
event_type="llm.tool.result",
category="message",
content={"type": "tool", "tool_call_id": "call_1", "content": "OK"},
)
assert await s.get_message_seqs("t1", ["message:u1", "tool:call_1"]) == {
"message:u1": 1,
"tool:call_1": 2,
}
@pytest.mark.anyio
async def test_db_store_resolves_identities(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'seqs.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
s = DbRunEventStore(get_session_factory())
await s.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1__user", "content": "hello"},
)
await s.put(
thread_id="t1",
run_id="r1",
event_type="llm.tool.result",
category="message",
content={"type": "tool", "tool_call_id": "call_1", "content": "OK"},
)
await s.put(
thread_id="t1",
run_id="r1",
event_type="run.start",
category="trace",
content={"type": "human", "id": "ignored"},
)
assert await s.get_message_seqs("t1", ["message:u1", "tool:call_1", "message:ignored"]) == {
"message:u1": 1,
"tool:call_1": 2,
}
finally:
await close_engine()
@pytest.mark.anyio
async def test_db_store_only_decodes_rows_that_can_match(self, tmp_path, monkeypatch):
"""Rows that cannot hold a wanted identity must not be fetched and
JSON-decoded in Python.
The ``content`` column carries full tool outputs, so on the long
threads this lookup exists for (a `/state` or `/history` read of a
compacted thread), decoding every message row is heavy I/O plus N
JSON parses — and a wanted identity absent from the feed (a message
still streaming) would defeat any early-exit and force exactly that
full scan. The candidate rows are prefiltered in SQL instead."""
import json as real_json
from types import SimpleNamespace
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store import db as db_module
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'seqs.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
s = DbRunEventStore(get_session_factory())
await s.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1__user", "content": "hello"},
)
for i in range(3):
await s.put(
thread_id="t1",
run_id="r1",
event_type="llm.ai.output",
category="message",
content={"type": "ai", "id": f"unrelated-{i}", "content": "big tool output " * 100},
)
decoded: list[str] = []
def counting_loads(raw, *args, **kwargs):
decoded.append(raw)
return real_json.loads(raw, *args, **kwargs)
monkeypatch.setattr(db_module, "json", SimpleNamespace(loads=counting_loads, dumps=real_json.dumps, JSONDecodeError=real_json.JSONDecodeError))
# "message:in-flight" is not in the feed: without the SQL
# prefilter it would defeat the early exit and decode all rows.
assert await s.get_message_seqs("t1", ["message:u1", "message:in-flight"]) == {"message:u1": 1}
assert len(decoded) == 1
finally:
await close_engine()
@pytest.mark.anyio
async def test_db_store_resolves_an_id_the_sql_prefilter_cannot_express(self, tmp_path):
"""An id carrying LIKE wildcards or JSON-escaped characters cannot be
matched as a raw substring of the stored JSON — the lookup must fall
back to the full scan for the whole wanted set, not silently miss."""
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
url = f"sqlite+aiosqlite:///{tmp_path / 'seqs.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
s = DbRunEventStore(get_session_factory())
await s.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": 'odd%wild"card', "content": "hello"},
)
assert await s.get_message_seqs("t1", ['message:odd%wild"card']) == {'message:odd%wild"card': 1}
finally:
await close_engine()
class TestAttachMessageSeq:
"""The one stamping expression shared by the worker's `_MessageSeqStamper`
and the request-scoped `stamp_messages_with_seq` — a single helper so the
two counterparts cannot silently diverge."""
def test_attaches_the_seq_under_the_server_owned_key(self):
from deerflow.runtime.events.message_identity import attach_message_seq
stamped = attach_message_seq({"type": "human", "id": "u1"}, 7)
assert stamped["additional_kwargs"] == {"deerflow_seq": 7}
def test_existing_additional_kwargs_are_preserved(self):
from deerflow.runtime.events.message_identity import attach_message_seq
stamped = attach_message_seq({"type": "ai", "id": "a1", "additional_kwargs": {"run_id": "r1"}}, 3)
assert stamped["additional_kwargs"] == {"run_id": "r1", "deerflow_seq": 3}
def test_the_input_message_is_not_mutated(self):
from deerflow.runtime.events.message_identity import attach_message_seq
message = {"type": "human", "id": "u1", "additional_kwargs": {"run_id": "r1"}}
attach_message_seq(message, 5)
assert message["additional_kwargs"] == {"run_id": "r1"}
class TestStampMessagesWithSeq:
"""Attach the feed seq to an arbitrary list of checkpoint messages.
The streaming path stamps `values` frames as they are published, but a
client that merely opens a conversation never sees a frame: it reads the
checkpoint over REST. Without a seq there, a summarization-rescued early
turn has no absolute position and lands wherever the nearest anchor puts
it (#4666), which is behind the newest question rather than at the head.
"""
@pytest.mark.anyio
async def test_stamps_a_persisted_message(self, store):
from deerflow.runtime.events.message_seq import stamp_messages_with_seq
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1__user", "content": "MARK-FIRST"},
)
stamped = await stamp_messages_with_seq(store, "t1", [{"type": "human", "id": "u1__user", "content": "MARK-FIRST"}])
assert stamped[0]["additional_kwargs"]["deerflow_seq"] == 1
@pytest.mark.anyio
async def test_a_message_absent_from_the_feed_is_left_alone(self, store):
from deerflow.runtime.events.message_seq import stamp_messages_with_seq
messages = [{"type": "ai", "id": "not-persisted", "content": ""}]
stamped = await stamp_messages_with_seq(store, "t1", messages)
assert "deerflow_seq" not in (stamped[0].get("additional_kwargs") or {})
@pytest.mark.anyio
async def test_the_input_list_is_not_mutated(self, store):
from deerflow.runtime.events.message_seq import stamp_messages_with_seq
await store.put(
thread_id="t1",
run_id="r1",
event_type="llm.human.input",
category="message",
content={"type": "human", "id": "u1", "content": "hi"},
)
original = [{"type": "human", "id": "u1", "content": "hi"}]
await stamp_messages_with_seq(store, "t1", original)
assert original[0].get("additional_kwargs") is None
@pytest.mark.anyio
async def test_a_missing_store_returns_the_messages_unchanged(self):
from deerflow.runtime.events.message_seq import stamp_messages_with_seq
messages = [{"type": "human", "id": "u1", "content": "hi"}]
assert await stamp_messages_with_seq(None, "t1", messages) == messages
@pytest.mark.anyio
async def test_a_failing_store_degrades_instead_of_raising(self, store):
"""Placement is an enhancement; a broken lookup must not fail the read."""
from deerflow.runtime.events.message_seq import stamp_messages_with_seq
class _Broken:
async def get_message_seqs(self, *_args, **_kwargs):
raise RuntimeError("feed unavailable")
messages = [{"type": "human", "id": "u1", "content": "hi"}]
assert await stamp_messages_with_seq(_Broken(), "t1", messages) == messages