deer-flow/backend/tests/test_run_events_endpoint.py
NanPan 7cfd3c57d2
fix(gateway): bound run event type filters (#7370)
* fix(gateway): bound run event type filters

* test(gateway): cover run event filter cardinality

* style(gateway): group run event filter limit with module constants
2026-10-24 16:11:57 +08:00

256 lines
7.9 KiB
Python

"""The /events route forwards task_id + after_seq to the store (#3779).
The subtask card pages through one subagent task's persisted steps via these
query params; this locks the wiring so a rename/typo can't silently drop them
(which would make reload backfill fetch the whole run again, or nothing).
"""
import hashlib
from types import SimpleNamespace
from unittest import mock
import pytest
from langchain_core.messages import HumanMessage
from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.journal import RunJournal
class _ModelRequestFake:
"""Minimal ModelRequest stand-in for the wrap_model_call hooks."""
def __init__(self, messages, runtime):
self.messages = list(messages)
self.runtime = runtime
def override(self, **kwargs):
return _ModelRequestFake(kwargs.get("messages", self.messages), self.runtime)
@pytest.mark.anyio
async def test_list_run_events_forwards_task_id_and_after_seq():
from app.gateway.routers.thread_runs import list_run_events
calls: dict = {}
class FakeStore:
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types, task_id=task_id, limit=limit, after_seq=after_seq, user_id=user_id)
return [{"seq": 1, "event_type": "subagent.step"}]
class FakeState:
run_event_store = FakeStore()
class FakeApp:
state = FakeState()
class FakeRequest:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
result = await list_run_events(
thread_id="t1",
run_id="r1",
request=FakeRequest(),
event_types="subagent.start,subagent.step,subagent.end",
task_id="task-A",
limit=500,
after_seq=7,
)
assert result == [{"seq": 1, "event_type": "subagent.step"}]
assert calls["task_id"] == "task-A"
assert calls["after_seq"] == 7
assert calls["event_types"] == ["subagent.start", "subagent.step", "subagent.end"]
assert calls["user_id"] is None
@pytest.mark.anyio
async def test_list_run_events_redacts_historical_run_start_metadata():
from app.gateway.routers.thread_runs import list_run_events
stored_row = {
"seq": 1,
"event_type": "run.start",
"metadata": {
"caller": "lead_agent",
"auth_token": "legacy-secret",
"token_usage": 7,
},
}
class FakeStore:
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None, user_id=None):
return [stored_row]
class FakeState:
run_event_store = FakeStore()
class FakeApp:
state = FakeState()
class FakeRequest:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
events = await list_run_events(
thread_id="legacy-thread",
run_id="legacy-run",
request=FakeRequest(),
event_types=None,
task_id=None,
limit=500,
after_seq=None,
)
assert events[0]["metadata"] == {
"caller": "lead_agent",
"token_usage": 7,
}
assert stored_row["metadata"]["auth_token"] == "legacy-secret"
assert events[0] is not stored_row
@pytest.mark.parametrize("run_id", ["run.1", "a%20b", "%2e%2e"])
@pytest.mark.parametrize("route", ["events", "messages", "workspace-changes"])
def test_run_scoped_reads_of_a_noncanonical_run_id_on_jsonl_match_the_memory_store(tmp_path, route, run_id):
"""The URL's run_id reaches the event store unvalidated; JSONL must answer it as an unknown run, not a 500."""
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.gateway.routers import thread_runs
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
def get(store):
app = make_authed_test_app()
app.include_router(thread_runs.router)
app.state.run_event_store = store
with TestClient(app, raise_server_exceptions=False) as client:
return client.get(f"/api/threads/t1/runs/{run_id}/{route}")
jsonl = get(JsonlRunEventStore(tmp_path))
memory = get(MemoryRunEventStore())
assert (jsonl.status_code, jsonl.json()) == (200, memory.json())
@pytest.mark.anyio
async def test_effective_memory_flows_from_injection_to_the_existing_debug_api():
"""The production run-events route is the field-level consumer for M1.
The event fires at the first model-request assembly (wrap_model_call),
once injection results are known.
"""
from app.gateway.routers.thread_runs import list_run_events
store = MemoryRunEventStore()
journal = RunJournal("r1", "t1", store, flush_threshold=100)
runtime = SimpleNamespace(context={"__run_journal": journal})
memory = "<memory>\nUser prefers Python.\n</memory>\n"
mw = DynamicContextMiddleware()
with (
mock.patch("deerflow.agents.lead_agent.prompt._get_memory_context", return_value=memory),
mock.patch("deerflow.agents.middlewares.dynamic_context_middleware.datetime") as mock_dt,
):
mock_dt.now.return_value.strftime.return_value = "2026-05-08, Friday"
update = mw.before_agent(
{"messages": [HumanMessage(content="Hi", id="msg-1")]},
runtime,
)
from langgraph.graph.message import add_messages
assembled = add_messages([HumanMessage(content="Hi", id="msg-1")], update["messages"])
mw.wrap_model_call(_ModelRequestFake(assembled, runtime), lambda _request: "response")
await journal.flush()
class FakeState:
run_event_store = store
class FakeApp:
state = FakeState()
class FakeRequest_:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
events = await list_run_events(
thread_id="t1",
run_id="r1",
request=FakeRequest_(),
event_types="context:memory",
task_id=None,
limit=500,
after_seq=None,
)
effective_content = update["messages"][1].content
assert events[0]["content"] == {
"content_sha256": hashlib.sha256(effective_content.encode("utf-8")).hexdigest(),
"project_context_revision": None,
"project_shelf_revision": None,
}
@pytest.mark.parametrize("count", [100, 101])
@pytest.mark.anyio
async def test_list_run_events_bounds_event_type_filter_cardinality(count: int) -> None:
from fastapi import HTTPException
from app.gateway.routers.thread_runs import MAX_EVENT_TYPE_FILTERS, list_run_events
calls: dict = {}
class FakeStore:
async def list_events(
self,
thread_id,
run_id,
*,
event_types=None,
task_id=None,
limit=500,
after_seq=None,
user_id=None,
):
calls["event_types"] = event_types
return []
class FakeState:
run_event_store = FakeStore()
class FakeApp:
state = FakeState()
class FakeRequest:
app = FakeApp()
_deerflow_test_bypass_auth = True
cookies: dict = {}
event_types = ",".join(f"type-{i}" for i in range(count))
kwargs = {
"thread_id": "t1",
"run_id": "r1",
"request": FakeRequest(),
"event_types": event_types,
"task_id": None,
"limit": 500,
"after_seq": None,
}
if count > MAX_EVENT_TYPE_FILTERS:
with pytest.raises(HTTPException) as exc_info:
await list_run_events(**kwargs)
assert exc_info.value.status_code == 422
assert str(MAX_EVENT_TYPE_FILTERS) in exc_info.value.detail
assert calls == {}
else:
assert await list_run_events(**kwargs) == []
assert len(calls["event_types"]) == MAX_EVENT_TYPE_FILTERS