a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""Integration tests for RemoteGraph v3 streaming.
|
|
|
|
Tests the end-to-end wiring: RemoteGraph -> langgraph_sdk client.threads.stream(...) ->
|
|
docker-running langgraph-api -> SSE projections -> adapter classes.
|
|
|
|
Run with: pytest tests/integration/test_remote_graph_v3.py -m integration
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langgraph.pregel.remote import RemoteGraph
|
|
from langgraph.types import Command
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
URL = "http://localhost:2024"
|
|
|
|
# Input shapes matching what the integration graphs expect.
|
|
# `agent` graph (streaming_graph.py): AgentState has messages, value, items.
|
|
# `tools_agent` graph (tools_agent.py): create_agent graph expects messages list.
|
|
_AGENT_INPUT = {"messages": [], "value": "init", "items": []}
|
|
_TOOLS_AGENT_INPUT = {"messages": [{"role": "user", "content": "search for v3"}]}
|
|
|
|
|
|
@pytest.fixture
|
|
def remote_agent() -> RemoteGraph:
|
|
return RemoteGraph("agent", url=URL)
|
|
|
|
|
|
@pytest.fixture
|
|
def remote_tools_agent() -> RemoteGraph:
|
|
return RemoteGraph("tools_agent", url=URL)
|
|
|
|
|
|
async def test_async_happy_path_yields_output(remote_tools_agent: RemoteGraph) -> None:
|
|
"""tools_agent completes without interrupt; ``await stream.output`` drives
|
|
the run to terminal via the lifecycle watcher (no explicit event iteration
|
|
needed — the SSE subscription stays open by design after run completion)."""
|
|
async with await remote_tools_agent.astream_events(
|
|
_TOOLS_AGENT_INPUT,
|
|
version="v3",
|
|
) as stream:
|
|
output = await stream.output()
|
|
assert output is not None
|
|
assert (await stream.interrupted()) is False
|
|
|
|
|
|
async def test_async_interrupt_path_surfaces_interrupts(
|
|
remote_agent: RemoteGraph,
|
|
) -> None:
|
|
"""agent graph hits ask_human; interrupted must be True with >= 1 interrupt.
|
|
|
|
Note: interrupts pause the run but DON'T resolve `_run_done` (only
|
|
`completed` / `failed` lifecycle phases do), so `await stream.output()`
|
|
would hang. The adapter doesn't expose `interleave()` on the async
|
|
side (mirrors local `AsyncGraphRunStream`), so drain the `values`
|
|
projection directly until the run reports it is interrupted.
|
|
"""
|
|
async with await remote_agent.astream_events(
|
|
_AGENT_INPUT,
|
|
version="v3",
|
|
) as stream:
|
|
async for _ in stream.values:
|
|
if await stream.interrupted():
|
|
break
|
|
assert (await stream.interrupted()) is True
|
|
interrupts = await stream.interrupts()
|
|
assert len(interrupts) >= 1
|
|
|
|
|
|
async def test_async_resume_after_interrupt(remote_agent: RemoteGraph) -> None:
|
|
"""Interrupt the agent at ask_human, then resume the SAME thread with
|
|
`Command(resume=...)`.
|
|
|
|
Validates the v3 resume path end-to-end. The client sends the raw resume
|
|
value as `input` (not a serialized Command); the server detects the
|
|
thread's pending interrupt from persisted state — which survives the first
|
|
session's close — and wraps it as `Command(resume=...)`, driving the run
|
|
past `ask_human` to completion (the graph interrupts only once).
|
|
"""
|
|
thread_id = str(uuid.uuid4())
|
|
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
|
|
|
|
# First session: drive until the agent pauses at the ask_human interrupt.
|
|
async with await remote_agent.astream_events(
|
|
_AGENT_INPUT,
|
|
config=config,
|
|
version="v3",
|
|
) as stream:
|
|
async for _ in stream.values:
|
|
if await stream.interrupted():
|
|
break
|
|
assert (await stream.interrupted()) is True
|
|
|
|
# Second session on the same thread: resume with the human's answer. The
|
|
# run continues past ask_human to completion with no further interrupt.
|
|
async with await remote_agent.astream_events(
|
|
Command(resume="yes"),
|
|
config=config,
|
|
version="v3",
|
|
) as stream:
|
|
output = await stream.output()
|
|
assert output is not None
|
|
assert (await stream.interrupted()) is False
|
|
|
|
|
|
def test_sync_happy_path_yields_output(remote_tools_agent: RemoteGraph) -> None:
|
|
"""Sync stream: tools_agent completes; ``stream.output`` (sync property)
|
|
blocks until terminal."""
|
|
with remote_tools_agent.stream_events(
|
|
_TOOLS_AGENT_INPUT,
|
|
version="v3",
|
|
) as stream:
|
|
output = stream.output
|
|
assert output is not None
|
|
assert stream.interrupted is False
|
|
|
|
|
|
async def test_abort_mid_run_cancels_server_side(
|
|
remote_tools_agent: RemoteGraph,
|
|
) -> None:
|
|
"""Abort immediately after run.start; reaching the end without exception
|
|
confirms abort + __aexit__ cleanup worked."""
|
|
async with await remote_tools_agent.astream_events(
|
|
_TOOLS_AGENT_INPUT,
|
|
version="v3",
|
|
) as stream:
|
|
await stream.abort()
|
|
# Reaching here without unhandled exceptions confirms abort + __aexit__ succeeded.
|