Files
forge/apps/api/tests/test_sse_reconnect.py
T
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

87 lines
3.8 KiB
Python

"""Durable SSE (finding #12): run execution is decoupled from the client connection.
A mid-run client disconnect must NOT end the run - the graph keeps executing in a detached
background task, and a client can reattach (same run_id) to get the final answer. Frames carry
a monotonic id so a `Last-Event-ID` reconnect replays only what was missed.
"""
from __future__ import annotations
import uuid
from langgraph.checkpoint.memory import InMemorySaver
from forge.db.base import SessionLocal
from forge.models import Run, Thread, Workflow
from forge.services.runs import RunService
_ANSWER = "Hello from the durable run."
_WF = {
"id": "wf_sse", "version": 1,
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"}},
"entry_node": "agent",
"nodes": [
{"id": "agent", "type": "agent", "config": {"flavor": "agent", "model": f"fake:{_ANSWER}", "tools": []}},
{"id": "end", "type": "end", "config": {}},
],
"edges": [{"source": "agent", "target": "end"}],
}
async def _seed_queued_run() -> tuple[str, str, str]:
"""Insert a workflow + thread + a queued run directly; returns (tenant, project, run_id)."""
t, p = f"t_{uuid.uuid4().hex[:8]}", f"p_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as s:
wf = Workflow(tenant_id=t, project_id=p, name="w", executable=_WF, status="active")
s.add(wf)
await s.flush()
thread = Thread(tenant_id=t, project_id=p, workflow_id=wf.id, lg_thread_id=f"lg_{uuid.uuid4().hex}", meta={})
s.add(thread)
await s.flush()
run = Run(tenant_id=t, project_id=p, workflow_id=wf.id, thread_id=thread.id, status="queued",
input={"messages": [{"role": "user", "content": "hi"}]})
s.add(run)
await s.commit()
return t, p, run.id
async def test_disconnect_leaves_run_running_and_reattach_gets_answer():
t, p, rid = await _seed_queued_run()
rs = RunService(checkpointer=InMemorySaver())
# First connection: take a single frame, then "disconnect" by closing the subscriber.
agen = rs.stream(run_id=rid, tenant_id=t, project_id=p)
first = await agen.__anext__()
assert first["event"] == "run" and first["id"] == "1"
await agen.aclose() # client gone - the detached executor must keep running
# Reattach from where we left off; the run completes and we get the final answer.
frames = [f async for f in rs.stream(run_id=rid, tenant_id=t, project_id=p, last_event_id=int(first["id"]))]
done = [f for f in frames if f["event"] == "done"]
assert done, [f["event"] for f in frames]
assert _ANSWER in (done[-1]["data"].get("answer") or "")
# The disconnect did NOT cancel the run - it ran to completion.
async with SessionLocal() as s:
run = await s.get(Run, rid)
assert run.status == "done", run.status
async def test_last_event_id_replays_only_later_frames():
t, p, rid = await _seed_queued_run()
rs = RunService(checkpointer=InMemorySaver())
# Drive the run to completion on the first connection, recording every frame id.
frames = [f async for f in rs.stream(run_id=rid, tenant_id=t, project_id=p)]
ids = [int(f["id"]) for f in frames if f.get("id")]
assert ids == sorted(ids) and ids[0] == 1 # monotonic, starting at 1
assert any(f["event"] == "done" for f in frames)
# Reattach with a mid-stream Last-Event-ID: only strictly-later frames are replayed.
cutoff = ids[len(ids) // 2]
replayed = [f async for f in rs.stream(run_id=rid, tenant_id=t, project_id=p, last_event_id=cutoff)]
assert replayed, "reattach after completion should replay the retained tail"
assert all(int(f["id"]) > cutoff for f in replayed)
# The retained tail still includes the terminal done frame.
assert any(f["event"] == "done" for f in replayed)