mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
import asyncio
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI, Request
|
|
from langgraph_sdk.sse import SSEDecoder, aiter_lines_raw
|
|
from starlette.responses import StreamingResponse
|
|
|
|
from deerflow.runtime.stream_bridge import END_SENTINEL, MemoryStreamBridge, StreamEvent, StreamGap
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("capacity", [1, 2, 256])
|
|
async def test_reconnect_replays_all_successors_of_last_evicted_event(capacity):
|
|
bridge = MemoryStreamBridge(queue_maxsize=capacity)
|
|
run_id = "complete-reconnect"
|
|
await bridge.publish(run_id, "messages", {"index": 0})
|
|
live = bridge.subscribe(run_id)
|
|
try:
|
|
cursor = await anext(live)
|
|
assert isinstance(cursor, StreamEvent)
|
|
for index in range(1, capacity + 1):
|
|
await bridge.publish(run_id, "messages", {"index": index})
|
|
await bridge.publish_end(run_id)
|
|
|
|
live_events = [entry async for entry in live]
|
|
reconnect = bridge.subscribe(run_id, last_event_id=cursor.id)
|
|
try:
|
|
replayed = await asyncio.wait_for(anext(reconnect), timeout=1)
|
|
assert replayed == live_events[0]
|
|
remaining = [entry async for entry in reconnect]
|
|
assert [replayed, *remaining] == live_events
|
|
finally:
|
|
await reconnect.aclose()
|
|
|
|
assert [entry.data for entry in live_events[:-1]] == [{"index": index} for index in range(1, capacity + 1)]
|
|
assert live_events[-1] is END_SENTINEL
|
|
finally:
|
|
await live.aclose()
|
|
await bridge.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconnect_reports_gap_when_a_successor_was_evicted():
|
|
bridge = MemoryStreamBridge(queue_maxsize=2)
|
|
run_id = "incomplete-reconnect"
|
|
await bridge.publish(run_id, "messages", {"index": 0})
|
|
live = bridge.subscribe(run_id)
|
|
try:
|
|
cursor = await anext(live)
|
|
for index in range(1, 4):
|
|
await bridge.publish(run_id, "messages", {"index": index})
|
|
await bridge.publish_end(run_id)
|
|
live_events = [entry async for entry in live]
|
|
replayed = [entry async for entry in bridge.subscribe(run_id, last_event_id=cursor.id)]
|
|
assert len(replayed) == 1
|
|
assert isinstance(replayed[0], StreamGap)
|
|
assert replayed == live_events
|
|
assert replayed[0].requested_event_id == cursor.id
|
|
finally:
|
|
await live.aclose()
|
|
await bridge.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_foreign_cursor_with_same_evicted_sequence_still_reports_gap():
|
|
bridge = MemoryStreamBridge(queue_maxsize=1)
|
|
run_id = "foreign-reconnect"
|
|
try:
|
|
await bridge.publish(run_id, "messages", {"index": 0})
|
|
stream = bridge.subscribe(run_id)
|
|
try:
|
|
cursor = await anext(stream)
|
|
finally:
|
|
await stream.aclose()
|
|
timestamp, sequence = cursor.id.split("-")
|
|
foreign_cursor = f"{int(timestamp) + 1}-{sequence}"
|
|
await bridge.publish(run_id, "messages", {"index": 1})
|
|
await bridge.publish_end(run_id)
|
|
replayed = [entry async for entry in bridge.subscribe(run_id, last_event_id=foreign_cursor)]
|
|
assert len(replayed) == 1
|
|
assert isinstance(replayed[0], StreamGap)
|
|
assert replayed[0].requested_event_id == foreign_cursor
|
|
finally:
|
|
await bridge.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_sse_reconnect_preserves_complete_retained_output():
|
|
from langchain_core.messages import AIMessageChunk
|
|
|
|
from app.gateway.services import sse_consumer
|
|
from deerflow.runtime import RunManager, RunStatus
|
|
from deerflow.runtime.serialization import serialize
|
|
|
|
bridge = MemoryStreamBridge(queue_maxsize=2)
|
|
manager = RunManager()
|
|
record = await manager.create("complete-sse-reconnect")
|
|
await manager.set_status(record.run_id, RunStatus.success)
|
|
app = FastAPI()
|
|
|
|
@app.get("/stream")
|
|
async def stream(request: Request):
|
|
return StreamingResponse(sse_consumer(bridge, record, request, manager, apply_on_disconnect=False), media_type="text/event-stream")
|
|
|
|
try:
|
|
payloads = [serialize((AIMessageChunk(content=f"part-{index}", id="answer"), {"langgraph_node": "model"}), mode="messages") for index in range(3)]
|
|
await bridge.publish(record.run_id, "messages", payloads[0])
|
|
initial = bridge.subscribe(record.run_id)
|
|
try:
|
|
cursor = await anext(initial)
|
|
finally:
|
|
await initial.aclose()
|
|
for index in (1, 2):
|
|
await bridge.publish(record.run_id, "messages", payloads[index])
|
|
await bridge.publish_end(record.run_id)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
|
response = await client.get("/stream", headers={"Last-Event-ID": cursor.id})
|
|
assert response.status_code == 200
|
|
decoder = SSEDecoder()
|
|
frames = []
|
|
async for line in aiter_lines_raw(response):
|
|
decoded = decoder.decode(line)
|
|
if decoded is not None:
|
|
frames.append(decoded)
|
|
assert [(frame.event, frame.data) for frame in frames] == [
|
|
("messages", payloads[1]),
|
|
("messages", payloads[2]),
|
|
("end", None),
|
|
]
|
|
assert [frame.id for frame in frames[:-1]] == [entry.id for entry in bridge._streams[record.run_id].events]
|
|
finally:
|
|
await manager.shutdown()
|
|
await bridge.close()
|