mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* feat(gateway): support idempotent thread runs Accept Idempotency-Key on thread-scoped create, stream, and wait endpoints, scoped by owner and thread before durable admission.\n\nRefs #5257. * fix(gateway): handle idempotent run reuse on wait and stream Reused store-only records have no local task. /wait now waits on the bridge when it can observe the stream, and otherwise returns durable status instead of a stale checkpoint. A reused terminal stream that has been evicted emits gap/reload_durable_state. Replay is bound to the original input and assistant_id. * fix(gateway): 409 reused in-flight streams on this worker A store-only running record on a process-local bridge has no owner stream. POST /runs/stream used to subscribe anyway, which created an empty log and waited forever. Match join: 409 unless the run is already terminal, so missing-stream retries can still emit gap. * fix(gateway): keep observer joins off the idempotent stream-gap path sse_consumer keyed missing-stream gap on the sticky idempotency_reused flag, so a later join of a terminal run inherited it. Gate that branch on apply_on_disconnect, which already separates creating streams from joins. Document the retry outcomes clients have to handle. * fix(gateway): gate missing-stream gap on creating retry Reuse apply_on_disconnect to pick gap vs end changed sse_consumer default path, so a missing stream started returning gap for default callers and for out-of-scope POST /api/runs/stream. Keep that branch behind emit_gap_on_missing_stream and pass it only from thread-scoped /runs/stream on this request reuse. * fix(gateway): keep wait reuse off later checkpoints Direct handler calls were crashing because FastAPI Header() leaked in as the Python default. Bind Idempotency-Key with Annotated so the default is None, and ignore non-str keys. A reused completed /wait was still reading the latest thread checkpoint. After a later run on the same thread that is the later run's result. Return durable status instead of claiming the head as this run's output. * fix(gateway): snapshot wait reuse and refresh store status idempotency_reused lives on the shared cached record. Capture it before awaiting completion so an overlapping retry cannot suppress the original creating /wait checkpoint. A store-only peer record still holds admission-time status after the owner publishes END. Refresh durable status/error before returning them.
793 lines
28 KiB
Python
793 lines
28 KiB
Python
"""HTTP contract tests for idempotent thread-run creation (issue #5257)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from _router_auth_helpers import call_unwrapped, make_authed_test_app
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.routers import thread_runs
|
|
from app.gateway.run_models import RunCreateRequest
|
|
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
|
|
from deerflow.runtime import DisconnectMode, RunManager, RunRecord, RunStatus
|
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
|
|
|
|
|
def _user(email: str) -> User:
|
|
return User(email=email, password_hash="x", system_role="user", id=uuid4())
|
|
|
|
|
|
def _run(run_id: str, thread_id: str) -> RunRecord:
|
|
return RunRecord(
|
|
run_id=run_id,
|
|
thread_id=thread_id,
|
|
assistant_id=None,
|
|
status=RunStatus.success,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
error=run_id,
|
|
)
|
|
|
|
|
|
def _make_client(monkeypatch, user: User, admissions: dict[str, RunRecord]) -> TestClient:
|
|
async def fake_start_run(body, thread_id, request, *, idempotency_key=None, require_existing_thread=False):
|
|
del body, request, require_existing_thread
|
|
if idempotency_key is not None and idempotency_key in admissions:
|
|
record = admissions[idempotency_key]
|
|
record.idempotency_reused = True
|
|
return record
|
|
record = _run(f"run-{len(admissions) + 1}", thread_id)
|
|
admissions[idempotency_key or f"unkeyed-{record.run_id}"] = record
|
|
return record
|
|
|
|
monkeypatch.setattr(thread_runs, "start_run", fake_start_run)
|
|
app = make_authed_test_app(user_factory=lambda: user)
|
|
app.include_router(thread_runs.router)
|
|
app.state.stream_bridge = MagicMock(stream_exists=AsyncMock(return_value=False))
|
|
app.state.run_manager = MagicMock()
|
|
return TestClient(app)
|
|
|
|
|
|
def test_same_idempotency_key_reuses_thread_run(monkeypatch):
|
|
admissions: dict[str, RunRecord] = {}
|
|
client = _make_client(monkeypatch, _user("alice@example.com"), admissions)
|
|
url = "/api/threads/thread-1/runs"
|
|
headers = {"Idempotency-Key": "send-message-1"}
|
|
|
|
first = client.post(url, json={"input": {"messages": []}}, headers=headers)
|
|
retry = client.post(url, json={"input": {"messages": []}}, headers=headers)
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert retry.status_code == 200, retry.text
|
|
assert retry.json()["run_id"] == first.json()["run_id"]
|
|
|
|
|
|
def test_same_idempotency_key_reuses_stream_run(monkeypatch):
|
|
admissions: dict[str, RunRecord] = {}
|
|
client = _make_client(monkeypatch, _user("alice@example.com"), admissions)
|
|
url = "/api/threads/thread-1/runs/stream"
|
|
headers = {"Idempotency-Key": "send-message-1"}
|
|
|
|
first = client.post(url, json={"input": {"messages": []}}, headers=headers)
|
|
retry = client.post(url, json={"input": {"messages": []}}, headers=headers)
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert retry.status_code == 200, retry.text
|
|
assert retry.headers["Content-Location"] == first.headers["Content-Location"]
|
|
assert "event: end" in first.text
|
|
assert "event: gap" not in first.text
|
|
assert "event: gap" in retry.text
|
|
assert "stream_replay_gap" in retry.text
|
|
assert "reload_durable_state" in retry.text
|
|
assert "event: end" not in retry.text
|
|
|
|
|
|
def test_same_idempotency_key_reuses_wait_run(monkeypatch):
|
|
admissions: dict[str, RunRecord] = {}
|
|
client = _make_client(monkeypatch, _user("alice@example.com"), admissions)
|
|
url = "/api/threads/thread-1/runs/wait"
|
|
headers = {"Idempotency-Key": "send-message-1"}
|
|
|
|
first = client.post(url, json={"input": {"messages": []}}, headers=headers)
|
|
retry = client.post(url, json={"input": {"messages": []}}, headers=headers)
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert retry.status_code == 200, retry.text
|
|
assert retry.json()["error"] == first.json()["error"]
|
|
|
|
|
|
def test_idempotency_key_is_scoped_to_thread(monkeypatch):
|
|
admissions: dict[str, RunRecord] = {}
|
|
client = _make_client(monkeypatch, _user("alice@example.com"), admissions)
|
|
headers = {"Idempotency-Key": "send-message-1"}
|
|
|
|
first = client.post("/api/threads/thread-1/runs", json={}, headers=headers)
|
|
second = client.post("/api/threads/thread-2/runs", json={}, headers=headers)
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert second.status_code == 200, second.text
|
|
assert second.json()["run_id"] != first.json()["run_id"]
|
|
|
|
|
|
def test_idempotency_key_is_scoped_to_authenticated_user(monkeypatch):
|
|
admissions: dict[str, RunRecord] = {}
|
|
alice = _make_client(monkeypatch, _user("alice@example.com"), admissions)
|
|
bob = _make_client(monkeypatch, _user("bob@example.com"), admissions)
|
|
url = "/api/threads/thread-1/runs"
|
|
headers = {"Idempotency-Key": "send-message-1"}
|
|
|
|
first = alice.post(url, json={}, headers=headers)
|
|
second = bob.post(url, json={}, headers=headers)
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert second.status_code == 200, second.text
|
|
assert second.json()["run_id"] != first.json()["run_id"]
|
|
|
|
|
|
def test_missing_idempotency_key_keeps_creating_runs(monkeypatch):
|
|
admissions: dict[str, RunRecord] = {}
|
|
client = _make_client(monkeypatch, _user("alice@example.com"), admissions)
|
|
url = "/api/threads/thread-1/runs"
|
|
|
|
first = client.post(url, json={})
|
|
second = client.post(url, json={})
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert second.status_code == 200, second.text
|
|
assert second.json()["run_id"] != first.json()["run_id"]
|
|
|
|
|
|
def test_different_idempotency_keys_create_different_runs(monkeypatch):
|
|
admissions: dict[str, RunRecord] = {}
|
|
client = _make_client(monkeypatch, _user("alice@example.com"), admissions)
|
|
url = "/api/threads/thread-1/runs"
|
|
|
|
first = client.post(url, json={}, headers={"Idempotency-Key": "send-message-1"})
|
|
second = client.post(url, json={}, headers={"Idempotency-Key": "send-message-2"})
|
|
|
|
assert first.status_code == 200, first.text
|
|
assert second.status_code == 200, second.text
|
|
assert second.json()["run_id"] != first.json()["run_id"]
|
|
|
|
|
|
def test_blank_idempotency_key_is_rejected(monkeypatch):
|
|
client = _make_client(monkeypatch, _user("alice@example.com"), {})
|
|
|
|
response = client.post(
|
|
"/api/threads/thread-1/runs",
|
|
json={},
|
|
headers={"Idempotency-Key": " "},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_oversized_idempotency_key_is_rejected(monkeypatch):
|
|
client = _make_client(monkeypatch, _user("alice@example.com"), {})
|
|
|
|
response = client.post(
|
|
"/api/threads/thread-1/runs",
|
|
json={},
|
|
headers={"Idempotency-Key": "x" * 256},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
class _LocalBridge:
|
|
supports_cross_process = False
|
|
|
|
async def stream_exists(self, run_id):
|
|
del run_id
|
|
return False
|
|
|
|
|
|
class _StaleSnapshot:
|
|
config = {"configurable": {"checkpoint_id": "cp-previous"}}
|
|
values = {"messages": [{"type": "ai", "content": "PREVIOUS_TURN"}]}
|
|
|
|
|
|
def test_wait_reused_store_only_run_does_not_return_stale_checkpoint(monkeypatch):
|
|
"""A reused running record has no local task; /wait must not serialize the current checkpoint."""
|
|
|
|
async def fake_start_run(body, thread_id, request, *, idempotency_key=None, require_existing_thread=False):
|
|
del body, request, idempotency_key, require_existing_thread
|
|
return RunRecord(
|
|
run_id="run-live",
|
|
thread_id=thread_id,
|
|
assistant_id=None,
|
|
status=RunStatus.running,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
store_only=True,
|
|
idempotency_reused=True,
|
|
)
|
|
|
|
async def fake_aget(config):
|
|
del config
|
|
return _StaleSnapshot()
|
|
|
|
monkeypatch.setattr(thread_runs, "start_run", fake_start_run)
|
|
monkeypatch.setattr(
|
|
thread_runs,
|
|
"build_checkpoint_state_accessor",
|
|
lambda *args, **kwargs: (SimpleNamespace(aget=fake_aget), {}),
|
|
)
|
|
monkeypatch.setattr(thread_runs, "serialize_channel_values_for_api", lambda values: values)
|
|
|
|
app = make_authed_test_app(user_factory=lambda: _user("alice@example.com"))
|
|
app.include_router(thread_runs.router)
|
|
app.state.stream_bridge = _LocalBridge()
|
|
app.state.run_manager = MagicMock()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/threads/thread-1/runs/wait",
|
|
json={"input": {"messages": []}},
|
|
headers={"Idempotency-Key": "send-message-1"},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"status": "running", "error": None}
|
|
assert "PREVIOUS_TURN" not in response.text
|
|
|
|
|
|
def test_wait_reused_completed_run_does_not_return_later_checkpoint(monkeypatch):
|
|
"""A locally cached completed reuse must not serialize a later thread head."""
|
|
|
|
async def fake_start_run(body, thread_id, request, *, idempotency_key=None, require_existing_thread=False):
|
|
del body, request, idempotency_key, require_existing_thread
|
|
return RunRecord(
|
|
run_id="run-a",
|
|
thread_id=thread_id,
|
|
assistant_id=None,
|
|
status=RunStatus.success,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
store_only=False,
|
|
idempotency_reused=True,
|
|
)
|
|
|
|
async def fake_aget(config):
|
|
del config
|
|
return SimpleNamespace(
|
|
config={"configurable": {"checkpoint_id": "cp-later"}},
|
|
values={"messages": [{"type": "ai", "content": "LATER_RUN_RESULT"}]},
|
|
)
|
|
|
|
monkeypatch.setattr(thread_runs, "start_run", fake_start_run)
|
|
monkeypatch.setattr(
|
|
thread_runs,
|
|
"build_checkpoint_state_accessor",
|
|
lambda *args, **kwargs: (SimpleNamespace(aget=fake_aget), {}),
|
|
)
|
|
monkeypatch.setattr(thread_runs, "serialize_channel_values_for_api", lambda values: values)
|
|
|
|
app = make_authed_test_app(user_factory=lambda: _user("alice@example.com"))
|
|
app.include_router(thread_runs.router)
|
|
app.state.stream_bridge = _LocalBridge()
|
|
app.state.run_manager = MagicMock()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/threads/thread-1/runs/wait",
|
|
json={"input": {"messages": []}},
|
|
headers={"Idempotency-Key": "send-message-1"},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"status": "success", "error": None}
|
|
assert "LATER_RUN_RESULT" not in response.text
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_wait_original_request_keeps_checkpoint_when_retry_overlaps():
|
|
"""An overlapping retry must not suppress the original creating /wait result."""
|
|
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
|
|
|
|
bridge = MemoryStreamBridge()
|
|
record = RunRecord(
|
|
run_id="run-a",
|
|
thread_id="thread-1",
|
|
assistant_id=None,
|
|
status=RunStatus.running,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
store_only=False,
|
|
idempotency_reused=False,
|
|
)
|
|
record.task = asyncio.create_task(asyncio.Event().wait())
|
|
snapshot = SimpleNamespace(
|
|
config={"configurable": {"checkpoint_id": "cp-a"}},
|
|
values={"messages": [{"type": "ai", "content": "FIRST_RUN_RESULT"}]},
|
|
)
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
|
|
async def fake_start_run(body, thread_id, request, *, idempotency_key=None, require_existing_thread=False):
|
|
del body, thread_id, request, idempotency_key, require_existing_thread
|
|
return record
|
|
|
|
async def fake_aget(config):
|
|
del config
|
|
return snapshot
|
|
|
|
with (
|
|
patch.object(thread_runs, "start_run", fake_start_run),
|
|
patch.object(thread_runs, "get_stream_bridge", return_value=bridge),
|
|
patch.object(thread_runs, "get_run_manager", return_value=MagicMock()),
|
|
patch.object(
|
|
thread_runs,
|
|
"build_checkpoint_state_accessor",
|
|
lambda *args, **kwargs: (SimpleNamespace(aget=fake_aget), {}),
|
|
),
|
|
patch.object(thread_runs, "serialize_channel_values_for_api", lambda values: values),
|
|
):
|
|
wait_task = asyncio.create_task(
|
|
call_unwrapped(
|
|
thread_runs.wait_run,
|
|
"thread-1",
|
|
RunCreateRequest(input={"messages": []}),
|
|
request,
|
|
)
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
record.idempotency_reused = True
|
|
record.status = RunStatus.success
|
|
await bridge.publish_end(record.run_id)
|
|
result = await asyncio.wait_for(wait_task, timeout=2)
|
|
|
|
record.task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await record.task
|
|
|
|
assert result["messages"][0]["content"] == "FIRST_RUN_RESULT"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_wait_peer_refreshes_status_after_owner_completes():
|
|
"""A cross-worker reuse must not keep admission-time running after END."""
|
|
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
|
|
|
|
store = MemoryRunStore()
|
|
owner = RunManager(store=store, worker_id="worker-a")
|
|
peer = RunManager(store=store, worker_id="worker-b")
|
|
bridge = MemoryStreamBridge()
|
|
bridge.supports_cross_process = True
|
|
input_payload = {"messages": [{"role": "user", "content": "hello"}]}
|
|
first = await owner.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:same",
|
|
kwargs={"input": input_payload, "config": None},
|
|
)
|
|
await owner.set_status(first.run_id, RunStatus.running)
|
|
reused = await peer.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:same",
|
|
kwargs={"input": input_payload, "config": None},
|
|
)
|
|
assert reused.run_id == first.run_id
|
|
assert reused.store_only is True
|
|
assert reused.idempotency_reused is True
|
|
assert reused.status == RunStatus.running
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
|
|
async def fake_start_run(body, thread_id, request, *, idempotency_key=None, require_existing_thread=False):
|
|
del body, thread_id, request, idempotency_key, require_existing_thread
|
|
return reused
|
|
|
|
with (
|
|
patch.object(thread_runs, "start_run", fake_start_run),
|
|
patch.object(thread_runs, "get_stream_bridge", return_value=bridge),
|
|
patch.object(thread_runs, "get_run_manager", return_value=peer),
|
|
patch.object(
|
|
thread_runs,
|
|
"build_checkpoint_state_accessor",
|
|
side_effect=AssertionError("reused wait must not read latest checkpoint"),
|
|
),
|
|
):
|
|
wait_task = asyncio.create_task(
|
|
call_unwrapped(
|
|
thread_runs.wait_run,
|
|
"thread-1",
|
|
RunCreateRequest(input=input_payload),
|
|
request,
|
|
)
|
|
)
|
|
await asyncio.sleep(0.05)
|
|
await owner.set_status(first.run_id, RunStatus.success)
|
|
await bridge.publish_end(first.run_id)
|
|
result = await asyncio.wait_for(wait_task, timeout=2)
|
|
|
|
assert result == {"status": "success", "error": None}
|
|
assert reused.status == RunStatus.success
|
|
|
|
|
|
def test_scope_http_run_idempotency_key_ignores_header_default():
|
|
"""Direct handler calls pass FastAPI's Header() object, not None."""
|
|
from fastapi.params import Header as HeaderParam
|
|
|
|
request = SimpleNamespace(state=SimpleNamespace(user=None))
|
|
assert thread_runs._scope_http_run_idempotency_key(request, "thread-1", HeaderParam(default=None)) is None
|
|
assert thread_runs._scope_http_run_idempotency_key(request, "thread-1", None) is None
|
|
|
|
|
|
def test_stream_reused_store_only_running_run_returns_409(monkeypatch):
|
|
"""A reused running record on a process-local bridge must not hang on an empty stream."""
|
|
|
|
async def fake_start_run(body, thread_id, request, *, idempotency_key=None, require_existing_thread=False):
|
|
del body, request, idempotency_key, require_existing_thread
|
|
return RunRecord(
|
|
run_id="run-live",
|
|
thread_id=thread_id,
|
|
assistant_id=None,
|
|
status=RunStatus.running,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
store_only=True,
|
|
idempotency_reused=True,
|
|
)
|
|
|
|
monkeypatch.setattr(thread_runs, "start_run", fake_start_run)
|
|
|
|
app = make_authed_test_app(user_factory=lambda: _user("alice@example.com"))
|
|
app.include_router(thread_runs.router)
|
|
app.state.stream_bridge = _LocalBridge()
|
|
app.state.run_manager = MagicMock()
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/threads/thread-1/runs/stream",
|
|
json={"input": {"messages": []}},
|
|
headers={"Idempotency-Key": "send-message-1"},
|
|
)
|
|
|
|
assert response.status_code == 409, response.text
|
|
assert "not active on this worker" in response.json()["detail"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sse_consumer_reused_terminal_missing_stream_yields_gap():
|
|
from app.gateway.services import sse_consumer
|
|
|
|
record = RunRecord(
|
|
run_id="run-done",
|
|
thread_id="thread-1",
|
|
assistant_id=None,
|
|
status=RunStatus.success,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
store_only=True,
|
|
idempotency_reused=True,
|
|
)
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
|
|
frames = [
|
|
frame
|
|
async for frame in sse_consumer(
|
|
_LocalBridge(),
|
|
record,
|
|
request,
|
|
MagicMock(),
|
|
emit_gap_on_missing_stream=True,
|
|
)
|
|
]
|
|
|
|
assert len(frames) == 1
|
|
assert frames[0].startswith("event: gap\n")
|
|
assert "stream_replay_gap" in frames[0]
|
|
assert "reload_durable_state" in frames[0]
|
|
assert "event: end" not in frames[0]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sse_consumer_observer_join_keeps_end_after_sticky_reuse_flag():
|
|
"""Observer joins must not inherit create_or_reject's sticky reuse flag."""
|
|
from app.gateway.services import sse_consumer
|
|
|
|
record = RunRecord(
|
|
run_id="run-done",
|
|
thread_id="thread-1",
|
|
assistant_id=None,
|
|
status=RunStatus.success,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
idempotency_reused=True,
|
|
)
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
|
|
frames = [frame async for frame in sse_consumer(_LocalBridge(), record, request, MagicMock(), apply_on_disconnect=False)]
|
|
|
|
assert len(frames) == 1
|
|
assert frames[0].startswith("event: end\n")
|
|
assert "event: gap" not in frames[0]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sse_consumer_default_path_keeps_end_after_sticky_reuse_flag():
|
|
"""Default sse_consumer, including stateless /api/runs/stream, must not emit gap
|
|
just because create_or_reject left idempotency_reused set, or because
|
|
apply_on_disconnect still defaults to True.
|
|
"""
|
|
from app.gateway.services import sse_consumer
|
|
|
|
record = RunRecord(
|
|
run_id="run-done",
|
|
thread_id="thread-1",
|
|
assistant_id=None,
|
|
status=RunStatus.success,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
store_only=True,
|
|
idempotency_reused=True,
|
|
)
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
|
|
frames = [frame async for frame in sse_consumer(_LocalBridge(), record, request, MagicMock())]
|
|
|
|
assert len(frames) == 1
|
|
assert frames[0].startswith("event: end\n")
|
|
assert "event: gap" not in frames[0]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sse_consumer_missing_stream_gap_requires_explicit_flag():
|
|
"""apply_on_disconnect must not select gap vs end by itself."""
|
|
from app.gateway.services import sse_consumer
|
|
|
|
record = RunRecord(
|
|
run_id="run-done",
|
|
thread_id="thread-1",
|
|
assistant_id=None,
|
|
status=RunStatus.success,
|
|
on_disconnect=DisconnectMode.continue_,
|
|
store_only=True,
|
|
)
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
|
|
default_frames = [frame async for frame in sse_consumer(_LocalBridge(), record, request, MagicMock(), apply_on_disconnect=True)]
|
|
gap_frames = [
|
|
frame
|
|
async for frame in sse_consumer(
|
|
_LocalBridge(),
|
|
record,
|
|
request,
|
|
MagicMock(),
|
|
apply_on_disconnect=False,
|
|
emit_gap_on_missing_stream=True,
|
|
)
|
|
]
|
|
|
|
assert default_frames[0].startswith("event: end\n")
|
|
assert gap_frames[0].startswith("event: gap\n")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_observer_join_stays_end_after_real_manager_reuse():
|
|
"""Join of a terminal missing stream stays `end` after a later key reuse.
|
|
|
|
``create_or_reject`` sets ``idempotency_reused`` on the cached record that
|
|
``RunManager.get()`` returns. Observer joins read that same object; the
|
|
missing-stream branch must still follow ``emit_gap_on_missing_stream``,
|
|
not the sticky flag or ``apply_on_disconnect``.
|
|
"""
|
|
from app.gateway.services import sse_consumer
|
|
|
|
store = MemoryRunStore()
|
|
manager = RunManager(store=store, worker_id="worker-a")
|
|
first = await manager.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:same",
|
|
)
|
|
await manager.set_status(first.run_id, RunStatus.success)
|
|
request = SimpleNamespace(headers={}, is_disconnected=AsyncMock(return_value=False))
|
|
|
|
async def _frames(*, apply_on_disconnect: bool = True, emit_gap_on_missing_stream: bool = False):
|
|
record = await manager.get(first.run_id)
|
|
assert record is not None
|
|
return [
|
|
frame
|
|
async for frame in sse_consumer(
|
|
_LocalBridge(),
|
|
record,
|
|
request,
|
|
manager,
|
|
apply_on_disconnect=apply_on_disconnect,
|
|
emit_gap_on_missing_stream=emit_gap_on_missing_stream,
|
|
)
|
|
]
|
|
|
|
before = await _frames(apply_on_disconnect=False)
|
|
assert before[0].startswith("event: end\n")
|
|
|
|
reused = await manager.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:same",
|
|
)
|
|
assert reused.run_id == first.run_id
|
|
assert reused.idempotency_reused is True
|
|
|
|
after = await _frames(apply_on_disconnect=False)
|
|
assert after[0].startswith("event: end\n")
|
|
assert "event: gap" not in after[0]
|
|
|
|
after_default = await _frames()
|
|
assert after_default[0].startswith("event: end\n")
|
|
assert "event: gap" not in after_default[0]
|
|
|
|
creating = await _frames(emit_gap_on_missing_stream=True)
|
|
assert creating[0].startswith("event: gap\n")
|
|
assert "event: end" not in creating[0]
|
|
|
|
|
|
def _make_start_run_request(run_manager):
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
from langgraph.store.memory import InMemoryStore
|
|
|
|
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
|
|
|
store = InMemoryStore()
|
|
return SimpleNamespace(
|
|
headers={},
|
|
state=SimpleNamespace(auth_source=None, user=None),
|
|
app=SimpleNamespace(
|
|
state=SimpleNamespace(
|
|
stream_bridge=SimpleNamespace(),
|
|
run_manager=run_manager,
|
|
checkpointer=InMemorySaver(),
|
|
store=store,
|
|
run_event_store=MemoryRunEventStore(),
|
|
run_events_config=None,
|
|
thread_store=MemoryThreadMetaStore(store),
|
|
)
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def _stub_app_config():
|
|
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
|
|
yield
|
|
reset_app_config()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_start_run_reuses_store_backed_running_row_without_attaching_worker(_stub_app_config):
|
|
from app.gateway.services import start_run
|
|
|
|
input_payload = {"messages": [{"role": "user", "content": "hello"}]}
|
|
store = MemoryRunStore()
|
|
owner = RunManager(store=store, worker_id="worker-a")
|
|
peer = RunManager(store=store, worker_id="worker-b")
|
|
first = await owner.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:same",
|
|
kwargs={"input": input_payload, "config": None},
|
|
)
|
|
|
|
attached = False
|
|
|
|
async def fake_run_agent(*args, **kwargs):
|
|
del args, kwargs
|
|
nonlocal attached
|
|
attached = True
|
|
|
|
with (
|
|
patch("app.gateway.services.resolve_agent_factory", return_value=object()),
|
|
patch("app.gateway.services.run_agent", side_effect=fake_run_agent),
|
|
):
|
|
record = await start_run(
|
|
RunCreateRequest(input=input_payload),
|
|
"thread-1",
|
|
_make_start_run_request(peer),
|
|
idempotency_key="http-run:same",
|
|
)
|
|
|
|
assert record.run_id == first.run_id
|
|
assert record.idempotency_reused is True
|
|
assert record.store_only is True
|
|
assert record.task is None
|
|
assert attached is False
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_start_run_rejects_reused_key_with_different_input(_stub_app_config):
|
|
from app.gateway.services import start_run
|
|
|
|
store = MemoryRunStore()
|
|
owner = RunManager(store=store, worker_id="worker-a")
|
|
peer = RunManager(store=store, worker_id="worker-b")
|
|
await owner.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:same",
|
|
kwargs={"input": {"messages": [{"role": "user", "content": "summarize"}]}, "config": None},
|
|
)
|
|
|
|
with (
|
|
patch("app.gateway.services.resolve_agent_factory", return_value=object()),
|
|
patch("app.gateway.services.run_agent", side_effect=AssertionError("worker must not attach")),
|
|
pytest.raises(HTTPException) as excinfo,
|
|
):
|
|
await start_run(
|
|
RunCreateRequest(input={"messages": [{"role": "user", "content": "translate"}]}),
|
|
"thread-1",
|
|
_make_start_run_request(peer),
|
|
idempotency_key="http-run:same",
|
|
)
|
|
|
|
assert excinfo.value.status_code == 409
|
|
assert "different request" in str(excinfo.value.detail)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_wait_retry_after_later_run_does_not_return_later_checkpoint(monkeypatch):
|
|
"""Complete two runs, then retry the first key: /wait must not return run B."""
|
|
first_input = {"messages": [{"role": "user", "content": "one"}]}
|
|
later_input = {"messages": [{"role": "user", "content": "two"}]}
|
|
store = MemoryRunStore()
|
|
manager = RunManager(store=store, worker_id="worker-a")
|
|
first = await manager.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:first",
|
|
kwargs={"input": first_input, "config": None},
|
|
)
|
|
await manager.set_status(first.run_id, RunStatus.success)
|
|
later = await manager.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:later",
|
|
kwargs={"input": later_input, "config": None},
|
|
)
|
|
await manager.set_status(later.run_id, RunStatus.success)
|
|
reused = await manager.create_or_reject(
|
|
"thread-1",
|
|
user_id=None,
|
|
idempotency_key="http-run:first",
|
|
kwargs={"input": first_input, "config": None},
|
|
)
|
|
assert reused.run_id == first.run_id
|
|
assert reused.idempotency_reused is True
|
|
assert reused.store_only is False
|
|
assert reused.status == RunStatus.success
|
|
|
|
async def fake_start_run(body, thread_id, request, *, idempotency_key=None, require_existing_thread=False):
|
|
del body, thread_id, request, idempotency_key, require_existing_thread
|
|
return reused
|
|
|
|
async def fake_aget(config):
|
|
del config
|
|
return SimpleNamespace(
|
|
config={"configurable": {"checkpoint_id": "cp-later"}},
|
|
values={"messages": [{"type": "ai", "content": "LATER_RUN_RESULT"}]},
|
|
)
|
|
|
|
monkeypatch.setattr(thread_runs, "start_run", fake_start_run)
|
|
monkeypatch.setattr(
|
|
thread_runs,
|
|
"build_checkpoint_state_accessor",
|
|
lambda *args, **kwargs: (SimpleNamespace(aget=fake_aget), {}),
|
|
)
|
|
monkeypatch.setattr(thread_runs, "serialize_channel_values_for_api", lambda values: values)
|
|
|
|
app = make_authed_test_app(user_factory=lambda: _user("alice@example.com"))
|
|
app.include_router(thread_runs.router)
|
|
app.state.stream_bridge = _LocalBridge()
|
|
app.state.run_manager = manager
|
|
|
|
with TestClient(app) as client:
|
|
response = client.post(
|
|
"/api/threads/thread-1/runs/wait",
|
|
json={"input": first_input},
|
|
headers={"Idempotency-Key": "first"},
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"status": "success", "error": None}
|
|
assert "LATER_RUN_RESULT" not in response.text
|