mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
* 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>
1545 lines
62 KiB
Python
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
|