deer-flow/backend/tests/test_run_event_stream_contract.py
MiaoRuidx f1632cc351
fix(run): add run event stream contract (#4342)
* docs: document run event stream contract

* fix(run): address event stream review feedback

---------

Co-authored-by: MiaoRuidx <12540796+MiaoRuidx@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-23 21:33:57 +08:00

550 lines
22 KiB
Python

"""Conformance tests for the documented run event stream contract."""
from __future__ import annotations
import ast
import json
from pathlib import Path
from uuid import uuid4
import pytest
from jsonschema import Draft202012Validator, FormatChecker
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, LLMResult
from deerflow.runtime.events.catalog import (
FIXED_RUN_EVENT_DEFINITIONS,
JOURNAL_RUN_EVENT_DEFINITIONS,
MIDDLEWARE_EVENT_PATTERN,
MIDDLEWARE_EVENT_TAG_MAX_LENGTH,
MIDDLEWARE_EVENT_TAGS,
RUN_EVENT_CATEGORY_MAX_LENGTH,
RUN_EVENT_TYPE_MAX_LENGTH,
SUBAGENT_RUN_EVENT_DEFINITIONS,
WORKSPACE_RUN_EVENT_DEFINITIONS,
RunEventDefinition,
RunEventPattern,
)
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.journal import RunJournal
from deerflow.subagents.step_events import SUBAGENT_STEP_MAX_CHARS, capture_step_message, subagent_run_event
REPO_ROOT = Path(__file__).resolve().parents[2]
CONTRACT_PATH = REPO_ROOT / "contracts" / "run_event_stream_contract.json"
def _load_contract() -> dict:
return json.loads(CONTRACT_PATH.read_text(encoding="utf-8"))
def _contract_events() -> dict[str, dict]:
return {event["event_type"]: event for event in _load_contract()["events"]}
def _assert_schema_valid(schema: dict | bool, instance: object) -> None:
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema, format_checker=FormatChecker())
errors = sorted(validator.iter_errors(instance), key=lambda error: list(error.path))
assert not errors, "; ".join(error.message for error in errors)
def _assert_fixed_event_valid(event: dict, *, persisted: bool = False) -> None:
contract_event = _contract_events()[event["event_type"]]
assert event["category"] == contract_event["category"]
_assert_schema_valid(contract_event["content_schema"], event["content"])
_assert_schema_valid(contract_event["metadata_schema"], event.get("metadata", {}))
if persisted:
_assert_schema_valid(_load_contract()["record_schema"], event)
def _make_llm_response(content: str = "answer", usage: dict | None = None) -> LLMResult:
message = AIMessage(
content=content,
id=f"msg-{uuid4()}",
response_metadata={"model_name": "test-model"},
usage_metadata=usage,
)
return LLMResult(generations=[[ChatGeneration(message=message)]])
def _subagent_batch() -> list[dict]:
chunks = [
{"type": "task_started", "task_id": "call-batch", "description": "research"},
{
"type": "task_running",
"task_id": "call-batch",
"message": {
"type": "ai",
"content": "searching",
"tool_calls": [{"name": "web_search", "args": {"query": "deerflow"}}],
},
"message_index": 1,
},
{"type": "task_completed", "task_id": "call-batch", "result": "done"},
]
events = [subagent_run_event(chunk) for chunk in chunks]
assert all(event is not None for event in events)
return [{"thread_id": "thread-batch", "run_id": "run-batch", **event} for event in events if event is not None]
async def _persist_subagent_batch(store) -> list[dict]:
await store.put_batch(_subagent_batch())
return await store.list_events("thread-batch", "run-batch")
async def _record_run_end(store) -> dict:
journal = RunJournal("run-output", "thread-output", store, flush_threshold=100)
journal.on_chain_end(
{"messages": [AIMessage(content="final answer", id="final-message")]},
run_id=uuid4(),
parent_run_id=None,
)
await journal.flush()
events = await store.list_events("thread-output", "run-output", event_types=["run.end"])
assert len(events) == 1
return events[0]
def test_contract_and_runtime_catalog_have_the_same_fixed_events():
contract = _load_contract()
contract_types = [event["event_type"] for event in contract["events"]]
runtime_types = [definition.event_type for definition in FIXED_RUN_EVENT_DEFINITIONS]
contract_pairs = {(event["event_type"], event["category"]) for event in contract["events"]}
runtime_pairs = {(definition.event_type, definition.category) for definition in FIXED_RUN_EVENT_DEFINITIONS}
assert len(set(contract_types)) == len(contract_types)
assert len(set(runtime_types)) == len(runtime_types)
assert contract_pairs == runtime_pairs
assert set(contract["categories"]) == {definition.category for definition in FIXED_RUN_EVENT_DEFINITIONS} | {MIDDLEWARE_EVENT_PATTERN.category}
event_type_schema = contract["record_schema"]["properties"]["event_type"]
category_schema = contract["record_schema"]["properties"]["category"]
middleware_pattern = contract["dynamic_event_patterns"][0]
assert event_type_schema["maxLength"] == RUN_EVENT_TYPE_MAX_LENGTH
assert category_schema["maxLength"] == RUN_EVENT_CATEGORY_MAX_LENGTH
assert middleware_pattern["event_type_schema"]["maxLength"] == RUN_EVENT_TYPE_MAX_LENGTH
assert middleware_pattern["tag_schema"]["maxLength"] == MIDDLEWARE_EVENT_TAG_MAX_LENGTH
from deerflow.persistence.models.run_event import RunEventRow
assert RunEventRow.__table__.c.event_type.type.length == RUN_EVENT_TYPE_MAX_LENGTH
assert RunEventRow.__table__.c.category.type.length == RUN_EVENT_CATEGORY_MAX_LENGTH
@pytest.mark.parametrize(
("definition_type", "kwargs"),
[
(RunEventDefinition, {"event_type": "test.event"}),
(RunEventPattern, {"pattern": "test:{tag}", "prefix": "test:"}),
],
)
def test_runtime_catalog_rejects_categories_that_do_not_fit_persistence(definition_type, kwargs):
assert definition_type(category="x" * RUN_EVENT_CATEGORY_MAX_LENGTH, **kwargs).category
for invalid_category in ("", "x" * (RUN_EVENT_CATEGORY_MAX_LENGTH + 1)):
with pytest.raises(ValueError, match="category"):
definition_type(category=invalid_category, **kwargs)
@pytest.mark.parametrize(
"relative_path",
[
"persistence/models/run_event.py",
"workspace_changes/types.py",
],
)
def test_lower_level_run_event_modules_do_not_import_runtime(relative_path):
module_path = REPO_ROOT / "backend" / "packages" / "harness" / "deerflow" / relative_path
tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path))
imports = [node.module for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module is not None]
imports.extend(alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names)
assert not [module for module in imports if module == "deerflow.runtime" or module.startswith("deerflow.runtime.")]
def test_legacy_aliases_are_read_only_and_outside_the_current_catalog():
contract = _load_contract()
aliases = {alias["event_type"]: alias for alias in contract["legacy_event_aliases"]}
current_types = {definition.event_type for definition in FIXED_RUN_EVENT_DEFINITIONS}
assert set(aliases) == {"ai_message"}
assert aliases["ai_message"]["canonical_event_type"] == "llm.ai.response"
assert aliases["ai_message"]["produced_by_current_runtime"] is False
assert "/messages/page" in aliases["ai_message"]["compatibility_scope"]
assert "legacy /messages endpoint" in aliases["ai_message"]["known_limitations"]
assert set(aliases).isdisjoint(current_types)
def test_record_envelope_accepts_every_json_content_type():
schema = _load_contract()["record_schema"]
envelope = {
"thread_id": "thread-1",
"run_id": "run-1",
"seq": 1,
"event_type": "run.end",
"category": "outputs",
"metadata": {},
"created_at": "2026-07-21T00:00:00+00:00",
}
for content in ("text", {"key": "value"}, ["value"], 1, 1.5, True, None):
_assert_schema_valid(schema, {**envelope, "content": content})
def test_contract_schemas_are_valid_json_schema():
contract = _load_contract()
Draft202012Validator.check_schema(contract["record_schema"])
for event in contract["events"]:
Draft202012Validator.check_schema(event["content_schema"])
Draft202012Validator.check_schema(event["metadata_schema"])
for pattern in contract["dynamic_event_patterns"]:
Draft202012Validator.check_schema(pattern["event_type_schema"])
Draft202012Validator.check_schema(pattern["tag_schema"])
Draft202012Validator.check_schema(pattern["content_schema"])
Draft202012Validator.check_schema(pattern["metadata_schema"])
@pytest.mark.anyio
@pytest.mark.parametrize("backend", ["memory", "jsonl"])
async def test_non_database_stores_return_contract_records(backend, tmp_path):
if backend == "memory":
store = MemoryRunEventStore()
else:
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
store = JsonlRunEventStore(base_dir=tmp_path / "events")
record = await store.put(
thread_id="thread-1",
run_id="run-1",
event_type="context:memory",
category="context",
content={"content_sha256": "a" * 64},
metadata={},
)
_assert_fixed_event_valid(record, persisted=True)
assert record["seq"] == 1
@pytest.mark.anyio
async def test_database_store_returns_contract_record_with_backend_fields(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 / 'events.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
store = DbRunEventStore(get_session_factory())
record = await store.put(
thread_id="thread-1",
run_id="run-1",
event_type="context:memory",
category="context",
content={"content_sha256": "a" * 64},
metadata={},
)
_assert_fixed_event_valid(record, persisted=True)
assert "user_id" in record
assert record["metadata"]["content_is_json"] is True
finally:
await close_engine()
@pytest.mark.anyio
async def test_run_end_backend_storage_semantics_match_contract(tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
contract_event = _contract_events()["run.end"]
assert set(contract_event["storage_semantics"]) == {"memory", "jsonl", "database"}
memory_event = await _record_run_end(MemoryRunEventStore())
jsonl_event = await _record_run_end(JsonlRunEventStore(base_dir=tmp_path / "events"))
url = f"sqlite+aiosqlite:///{tmp_path / 'run-output.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
database_event = await _record_run_end(DbRunEventStore(get_session_factory()))
finally:
await close_engine()
for event in (memory_event, jsonl_event, database_event):
_assert_fixed_event_valid(event, persisted=True)
assert isinstance(memory_event["content"]["messages"][0], AIMessage)
assert isinstance(jsonl_event["content"]["messages"][0], str)
assert isinstance(database_event["content"]["messages"][0], str)
assert "final answer" in jsonl_event["content"]["messages"][0]
assert "final answer" in database_event["content"]["messages"][0]
@pytest.mark.anyio
async def test_run_journal_observed_events_exactly_match_its_catalog():
store = MemoryRunEventStore()
journal = RunJournal("run-1", "thread-1", store, flush_threshold=100)
root_run_id = uuid4()
llm_run_id = uuid4()
journal.on_chain_start(
{"name": "root"},
{},
run_id=root_run_id,
parent_run_id=None,
tags=["lead_agent"],
metadata={"langgraph_step": 1},
)
journal.on_chat_model_start(
{},
[[HumanMessage(content="question", id="human-1")]],
run_id=llm_run_id,
tags=["lead_agent"],
)
journal.on_llm_end(
_make_llm_response("answer", usage={"input_tokens": 3, "output_tokens": 4, "total_tokens": 7}),
run_id=llm_run_id,
parent_run_id=None,
tags=["lead_agent"],
)
journal.on_tool_end(
ToolMessage(content="tool result", tool_call_id="call-1", name="web_search", id="tool-1"),
run_id=uuid4(),
)
journal.on_llm_error(RuntimeError("model failed"), run_id=uuid4())
journal.on_chain_error(ValueError("run failed"), run_id=uuid4())
journal.on_chain_end({"messages": []}, run_id=root_run_id, parent_run_id=None)
journal.record_memory_context(content_sha256="a" * 64)
await journal.flush()
events = await store.list_events("thread-1", "run-1")
expected_types = {definition.event_type for definition in JOURNAL_RUN_EVENT_DEFINITIONS}
assert {event["event_type"] for event in events} == expected_types
for event in events:
_assert_fixed_event_valid(event, persisted=True)
@pytest.mark.anyio
@pytest.mark.parametrize("tag", MIDDLEWARE_EVENT_TAGS)
async def test_dynamic_middleware_event_matches_pattern_contract(tag):
store = MemoryRunEventStore()
journal = RunJournal("run-1", "thread-1", store, flush_threshold=100)
journal.record_middleware(
tag,
name="GuardrailMiddleware",
hook="wrap_tool_call",
action="deny",
changes={"reason": "policy"},
)
await journal.flush()
event = (await store.list_events("thread-1", "run-1"))[0]
pattern = _load_contract()["dynamic_event_patterns"][0]
assert pattern["pattern"] == MIDDLEWARE_EVENT_PATTERN.pattern
assert set(pattern["known_tags"]) == set(MIDDLEWARE_EVENT_TAGS)
assert event["event_type"] == MIDDLEWARE_EVENT_PATTERN.event_type(tag)
assert event["category"] == MIDDLEWARE_EVENT_PATTERN.category == pattern["category"]
_assert_schema_valid(pattern["event_type_schema"], event["event_type"])
_assert_schema_valid(pattern["tag_schema"], tag)
_assert_schema_valid(pattern["content_schema"], event["content"])
_assert_schema_valid(pattern["metadata_schema"], event["metadata"])
@pytest.mark.parametrize("tag", ["", "x" * (MIDDLEWARE_EVENT_TAG_MAX_LENGTH + 1)])
def test_dynamic_middleware_event_rejects_tags_that_do_not_fit_persistence(tag):
journal = RunJournal("run-1", "thread-1", MemoryRunEventStore(), flush_threshold=100)
with pytest.raises(ValueError):
journal.record_middleware(
tag,
name="CustomMiddleware",
hook="after_model",
action="record",
changes={},
)
def test_subagent_observed_events_exactly_match_its_catalog_and_payloads():
long_result = "r" * (SUBAGENT_STEP_MAX_CHARS + 1)
long_error = "e" * (SUBAGENT_STEP_MAX_CHARS + 1)
cases = [
{"type": "task_started", "task_id": "call-1", "description": "research"},
{
"type": "task_running",
"task_id": "call-1",
"message": {
"type": "ai",
"content": "searching",
"tool_calls": [{"name": "web_search", "args": {"query": "deerflow"}}],
},
"message_index": 1,
},
{
"type": "task_running",
"task_id": "call-1",
"message": {"type": "tool", "name": "web_search", "content": "result"},
"message_index": 2,
},
{
"type": "task_completed",
"task_id": "call-1",
"result": "done",
"model_name": "test-model",
"usage": {"input_tokens": 3, "output_tokens": 4, "total_tokens": 7},
},
{"type": "task_failed", "task_id": "call-2", "error": "boom"},
{"type": "task_cancelled", "task_id": "call-3"},
{"type": "task_timed_out", "task_id": "call-4", "error": "timed out"},
{"type": "task_completed", "task_id": "call-5", "result": long_result},
{"type": "task_failed", "task_id": "call-6", "error": long_error},
]
records = [subagent_run_event(case) for case in cases]
assert all(record is not None for record in records)
typed_records = [record for record in records if record is not None]
expected_types = {definition.event_type for definition in SUBAGENT_RUN_EVENT_DEFINITIONS}
assert {record["event_type"] for record in typed_records} == expected_types
for record in typed_records:
_assert_fixed_event_valid(record)
ai_step, tool_step = typed_records[1]["content"], typed_records[2]["content"]
completed, failed = typed_records[3]["content"], typed_records[4]["content"]
timed_out = typed_records[6]["content"]
truncated_result, truncated_error = typed_records[7]["content"], typed_records[8]["content"]
assert ai_step["tool_calls"][0]["name"] == "web_search"
assert tool_step["tool_name"] == "web_search"
assert completed["result"] == "done"
assert completed["model_name"] == "test-model"
assert completed["usage"]["total_tokens"] == 7
assert failed["error"] == "boom"
assert timed_out["error"] == "timed out"
assert len(truncated_result["result"]) == SUBAGENT_STEP_MAX_CHARS
assert truncated_result["result_truncated"] is True
assert len(truncated_error["error"]) == SUBAGENT_STEP_MAX_CHARS
assert truncated_error["error_truncated"] is True
assert {record["content"]["status"] for record in typed_records[3:]} == {
"completed",
"failed",
"cancelled",
"timed_out",
}
def test_captured_subagent_message_survives_task_running_conversion():
captured: list[dict] = []
assert capture_step_message(
AIMessage(
content="searching",
id="ai-step-1",
tool_calls=[{"id": "call-1", "name": "web_search", "args": {"query": "deerflow"}}],
),
captured,
set(),
)
event = subagent_run_event(
{
"type": "task_running",
"task_id": "task-1",
"message": captured[0],
"message_index": 0,
}
)
assert event is not None
assert event["event_type"] == "subagent.step"
assert event["content"]["task_id"] == "task-1"
assert event["content"]["message_index"] == 0
assert event["content"]["text"] == "searching"
assert event["content"]["tool_calls"] == [{"name": "web_search", "args": {"query": "deerflow"}}]
_assert_fixed_event_valid(event)
@pytest.mark.parametrize(
"chunk",
[
{"type": "task_started", "description": "missing task id"},
{"type": "task_started", "task_id": "", "description": "empty task id"},
{"type": "task_started", "task_id": "call-1", "description": 42},
{"type": "task_running", "task_id": "call-1", "message": {"type": "ai"}},
{"type": "task_running", "task_id": "call-1", "message": {"type": "ai"}, "message_index": -1},
{"type": "task_running", "task_id": "call-1", "message": {"type": "ai"}, "message_index": True},
{"type": "task_running", "task_id": "call-1", "message": "not-an-object", "message_index": 0},
{"type": "task_completed"},
],
)
def test_subagent_producer_rejects_chunks_missing_contract_fields(chunk):
assert subagent_run_event(chunk) is None
@pytest.mark.anyio
@pytest.mark.parametrize("backend", ["memory", "jsonl"])
async def test_subagent_batch_round_trip_matches_contract_for_non_database_stores(backend, tmp_path):
if backend == "memory":
store = MemoryRunEventStore()
else:
from deerflow.runtime.events.store.jsonl import JsonlRunEventStore
store = JsonlRunEventStore(base_dir=tmp_path / "subagent-events")
records = await _persist_subagent_batch(store)
assert [record["event_type"] for record in records] == ["subagent.start", "subagent.step", "subagent.end"]
for record in records:
_assert_fixed_event_valid(record, persisted=True)
@pytest.mark.anyio
async def test_subagent_batch_round_trip_matches_contract_for_database_store(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 / 'subagent-events.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
records = await _persist_subagent_batch(DbRunEventStore(get_session_factory()))
finally:
await close_engine()
assert [record["event_type"] for record in records] == ["subagent.start", "subagent.step", "subagent.end"]
for record in records:
_assert_fixed_event_valid(record, persisted=True)
@pytest.mark.anyio
async def test_workspace_change_producer_matches_catalog_and_payload(monkeypatch, tmp_path):
from deerflow.workspace_changes import WorkspaceRoot, scan_workspace_roots
from deerflow.workspace_changes import recorder as recorder_module
workspace = tmp_path / "workspace"
outputs = tmp_path / "outputs"
workspace.mkdir()
outputs.mkdir()
roots = [
WorkspaceRoot("workspace", workspace, "/mnt/user-data/workspace"),
WorkspaceRoot("outputs", outputs, "/mnt/user-data/outputs"),
]
before = scan_workspace_roots(roots)
(workspace / "report.md").write_text("# Report\n", encoding="utf-8")
monkeypatch.setattr(recorder_module, "build_thread_workspace_roots", lambda *_args, **_kwargs: roots)
store = MemoryRunEventStore()
record = await recorder_module.record_workspace_changes(store, "thread-1", "run-1", before)
assert record is not None
assert {record["event_type"]} == {definition.event_type for definition in WORKSPACE_RUN_EVENT_DEFINITIONS}
_assert_fixed_event_valid(record, persisted=True)
def test_known_gaps_do_not_reclassify_current_events_as_missing():
contract = _load_contract()
gap_ids = {gap["id"] for gap in contract["known_gaps"]}
current_types = {definition.event_type for definition in FIXED_RUN_EVENT_DEFINITIONS}
assert {"tool-call-intent", "terminal-run-status"}.issubset(gap_ids)
assert all(gap.get("event_type") not in current_types for gap in contract["known_gaps"])