Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:37:18 +08:00

639 lines
25 KiB
Python

"""Tests for nested scoped stream handles."""
from __future__ import annotations
import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
from streaming._events import (
lifecycle_completed_event,
lifecycle_started_event,
message_finish_event,
message_start_event,
message_text_delta_event,
message_text_finish_event,
tasks_result_event,
tasks_start_event,
tool_finished_event,
tool_output_delta_event,
tool_started_event,
)
from streaming._fake_server import FakeServer, _StreamScript
async def test_subgraphs_subscribes_to_tasks_channel():
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=1)])
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
_ = [handle async for handle in thread.subgraphs]
assert any(
"tasks" in body.get("channels", []) for body in fake.stream_request_bodies
)
async def test_subgraphs_yields_handle_and_completes_status():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
tasks_result_event(seq=2, namespace=[], task_id="abc", name="worker"),
lifecycle_completed_event(seq=3),
]
)
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
handles = [handle async for handle in thread.subgraphs]
assert len(handles) == 1
handle = handles[0]
assert handle.path == ("worker:abc",)
assert handle.namespace == ["worker:abc"]
assert handle.graph_name == "worker"
assert handle.trigger_call_id == "abc"
assert handle.status == "completed"
assert handle.error is None
async def test_subgraphs_failed_and_interrupted_statuses():
failed = FakeServer()
failed.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
tasks_result_event(
seq=2,
namespace=[],
task_id="abc",
name="worker",
error="boom",
),
lifecycle_completed_event(seq=3),
]
)
failed_asgi = httpx.ASGITransport(app=failed.app)
async with httpx.AsyncClient(transport=failed_asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[failed_handle] = [handle async for handle in thread.subgraphs]
interrupted = FakeServer()
interrupted.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:def"], task_id="t-child"),
tasks_result_event(
seq=2,
namespace=[],
task_id="def",
name="worker",
interrupts=[{"value": "pause"}],
),
lifecycle_completed_event(seq=3),
]
)
interrupted_asgi = httpx.ASGITransport(app=interrupted.app)
async with httpx.AsyncClient(
transport=interrupted_asgi,
base_url="http://test",
) as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[interrupted_handle] = [handle async for handle in thread.subgraphs]
assert failed_handle.status == "failed"
assert failed_handle.error == "boom"
assert interrupted_handle.status == "interrupted"
assert interrupted_handle.error is None
async def test_subgraph_messages_are_scoped_to_child_namespace():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
message_start_event(
seq=2,
namespace=["worker:abc"],
message_id="msg-child",
run_id="run-child",
),
message_text_delta_event(seq=3, namespace=["worker:abc"], text="child"),
message_text_finish_event(seq=4, namespace=["worker:abc"], text="child"),
message_finish_event(seq=5, namespace=["worker:abc"]),
tasks_result_event(seq=6, namespace=[], task_id="abc", name="worker"),
lifecycle_completed_event(seq=7),
]
)
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[handle] = [h async for h in thread.subgraphs]
child_messages = [message async for message in handle.messages]
root_messages = [message async for message in thread.messages]
assert [message.message_id for message in child_messages] == ["msg-child"]
assert [await message.text for message in child_messages] == ["child"]
assert root_messages == []
async def test_subgraph_tool_calls_are_scoped_to_child_namespace():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
tool_started_event(
seq=2,
namespace=["worker:abc"],
tool_call_id="call-child",
tool_name="search",
),
tool_output_delta_event(
seq=3,
namespace=["worker:abc"],
tool_call_id="call-child",
delta="child-delta",
),
tool_finished_event(
seq=4,
namespace=["worker:abc"],
tool_call_id="call-child",
output={"ok": True},
),
tasks_result_event(seq=5, namespace=[], task_id="abc", name="worker"),
lifecycle_completed_event(seq=6),
]
)
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[handle] = [h async for h in thread.subgraphs]
child_calls = [call async for call in handle.tool_calls]
root_calls = [call async for call in thread.tool_calls]
assert [call.tool_call_id for call in child_calls] == ["call-child"]
assert [delta async for delta in child_calls[0].deltas] == ["child-delta"]
assert await child_calls[0].output == {"ok": True}
assert root_calls == []
async def test_subgraph_handles_are_recursive_for_grandchildren():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
tasks_start_event(
seq=2,
namespace=["worker:abc", "tool:def"],
task_id="t-grandchild",
),
tasks_result_event(
seq=3,
namespace=["worker:abc"],
task_id="def",
name="tool",
),
tasks_result_event(seq=4, namespace=[], task_id="abc", name="worker"),
lifecycle_completed_event(seq=5),
]
)
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[child] = [handle async for handle in thread.subgraphs]
[grandchild] = [handle async for handle in child.subgraphs]
assert child.path == ("worker:abc",)
assert grandchild.path == ("worker:abc", "tool:def")
assert grandchild.graph_name == "tool"
assert grandchild.trigger_call_id == "def"
assert grandchild.status == "completed"
async def test_subagents_aliases_subgraphs_until_protocol_distinguishes_them():
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=1)])
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
assert thread.subagents is thread.subgraphs
await thread.run.start(input={})
async def test_root_and_child_projections_do_not_cross_talk():
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
message_start_event(seq=2, message_id="root-msg", run_id="root-run"),
message_text_delta_event(seq=3, text="root"),
message_text_finish_event(seq=4, text="root"),
message_finish_event(seq=5),
message_start_event(
seq=6,
namespace=["worker:abc"],
message_id="child-msg",
run_id="child-run",
),
message_text_delta_event(seq=7, namespace=["worker:abc"], text="child"),
message_text_finish_event(seq=8, namespace=["worker:abc"], text="child"),
message_finish_event(seq=9, namespace=["worker:abc"]),
tasks_result_event(seq=10, namespace=[], task_id="abc", name="worker"),
lifecycle_completed_event(seq=11),
]
)
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[handle] = [h async for h in thread.subgraphs]
root_messages = [message async for message in thread.messages]
child_messages = [message async for message in handle.messages]
assert [message.message_id for message in root_messages] == ["root-msg"]
assert [await message.text for message in root_messages] == ["root"]
assert [message.message_id for message in child_messages] == ["child-msg"]
assert [await message.text for message in child_messages] == ["child"]
async def test_grandchild_sibling_routing_preserves_event_order():
"""Events enqueued in a child handle's _messages_inbox before a grandchild
is discovered via child.subgraphs must be delivered in arrival order, not
reordered by the drain-and-replay path in _route_sibling_inboxes_to_grandchildren."""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
# Child discovered by thread.subgraphs.
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
# Grandchild messages arrive *before* grandchild tasks-start.
message_start_event(
seq=2,
namespace=["worker:abc", "tool:gc1"],
message_id="msg-E1",
run_id="r1",
),
message_text_delta_event(
seq=3, namespace=["worker:abc", "tool:gc1"], text="E1"
),
message_text_finish_event(
seq=4, namespace=["worker:abc", "tool:gc1"], text="E1"
),
message_finish_event(seq=5, namespace=["worker:abc", "tool:gc1"]),
message_start_event(
seq=6,
namespace=["worker:abc", "tool:gc1"],
message_id="msg-E2",
run_id="r2",
),
message_text_delta_event(
seq=7, namespace=["worker:abc", "tool:gc1"], text="E2"
),
message_text_finish_event(
seq=8, namespace=["worker:abc", "tool:gc1"], text="E2"
),
message_finish_event(seq=9, namespace=["worker:abc", "tool:gc1"]),
message_start_event(
seq=10,
namespace=["worker:abc", "tool:gc1"],
message_id="msg-E3",
run_id="r3",
),
message_text_delta_event(
seq=11, namespace=["worker:abc", "tool:gc1"], text="E3"
),
message_text_finish_event(
seq=12, namespace=["worker:abc", "tool:gc1"], text="E3"
),
message_finish_event(seq=13, namespace=["worker:abc", "tool:gc1"]),
# Grandchild tasks-start arrives *after* its messages.
tasks_start_event(
seq=14,
namespace=["worker:abc", "tool:gc1"],
task_id="t-grandchild",
),
tasks_result_event(
seq=15, namespace=["worker:abc"], task_id="gc1", name="tool"
),
tasks_result_event(seq=16, namespace=[], task_id="abc", name="worker"),
lifecycle_completed_event(seq=17),
]
)
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[child] = [h async for h in thread.subgraphs]
[grandchild] = [h async for h in child.subgraphs]
gc_messages = [m async for m in grandchild.messages]
assert [m.message_id for m in gc_messages] == ["msg-E1", "msg-E2", "msg-E3"]
texts = [await m.text for m in gc_messages]
assert texts == ["E1", "E2", "E3"]
async def test_grandchild_events_dispatched_to_correct_sibling_not_first_match():
"""When two sibling grandchildren exist, messages scoped to one grandchild
must not bleed into the other grandchild's inbox."""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
# Two grandchildren discovered.
tasks_start_event(
seq=2, namespace=["worker:abc", "tool:gc1"], task_id="t-gc1"
),
tasks_start_event(
seq=3, namespace=["worker:abc", "tool:gc2"], task_id="t-gc2"
),
# Messages for gc1.
message_start_event(
seq=4,
namespace=["worker:abc", "tool:gc1"],
message_id="msg-gc1",
run_id="ra",
),
message_text_delta_event(
seq=5, namespace=["worker:abc", "tool:gc1"], text="GC1"
),
message_text_finish_event(
seq=6, namespace=["worker:abc", "tool:gc1"], text="GC1"
),
message_finish_event(seq=7, namespace=["worker:abc", "tool:gc1"]),
# Messages for gc2.
message_start_event(
seq=8,
namespace=["worker:abc", "tool:gc2"],
message_id="msg-gc2",
run_id="rb",
),
message_text_delta_event(
seq=9, namespace=["worker:abc", "tool:gc2"], text="GC2"
),
message_text_finish_event(
seq=10, namespace=["worker:abc", "tool:gc2"], text="GC2"
),
message_finish_event(seq=11, namespace=["worker:abc", "tool:gc2"]),
tasks_result_event(
seq=12, namespace=["worker:abc"], task_id="gc1", name="tool"
),
tasks_result_event(
seq=13, namespace=["worker:abc"], task_id="gc2", name="tool"
),
tasks_result_event(seq=14, namespace=[], task_id="abc", name="worker"),
lifecycle_completed_event(seq=15),
]
)
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[child] = [h async for h in thread.subgraphs]
grandchildren = [h async for h in child.subgraphs]
by_path = {h.path: h for h in grandchildren}
gc1_messages = [
m async for m in by_path[("worker:abc", "tool:gc1")].messages
]
gc2_messages = [
m async for m in by_path[("worker:abc", "tool:gc2")].messages
]
assert [m.message_id for m in gc1_messages] == ["msg-gc1"]
assert [m.message_id for m in gc2_messages] == ["msg-gc2"]
def test_scoped_handle_inboxes_bounded_by_max_queue_size():
"""ScopedStreamHandle with max_queue_size=N creates queues with maxsize=N."""
from unittest.mock import MagicMock
from langgraph_sdk._async.stream import ScopedStreamHandle
fake_thread = MagicMock()
handle = ScopedStreamHandle(
thread=fake_thread,
path=("worker:1",),
graph_name="worker",
trigger_call_id="1",
max_queue_size=16,
)
assert handle._messages_inbox.maxsize == 16
assert handle._tools_inbox.maxsize == 16
assert handle._tasks_inbox.maxsize == 16
async def test_child_handle_inherits_max_queue_size_from_parent():
"""Grandchild ScopedStreamHandles created by _HandleSubgraphsProjection
inherit the parent's max_queue_size so all queues are consistently bounded."""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
tasks_start_event(
seq=2, namespace=["worker:abc", "tool:gc1"], task_id="t-gc1"
),
tasks_result_event(
seq=3, namespace=["worker:abc"], task_id="gc1", name="tool"
),
tasks_result_event(seq=4, namespace=[], task_id="abc", name="worker"),
lifecycle_completed_event(seq=5),
]
)
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
[child] = [h async for h in thread.subgraphs]
[grandchild] = [h async for h in child.subgraphs]
# Grandchild handles created by _HandleSubgraphsProjection must use the
# parent handle's max_queue_size (default 0 = unbounded in asyncio.Queue).
assert grandchild._messages_inbox.maxsize == child._messages_inbox.maxsize
assert grandchild._tools_inbox.maxsize == child._tools_inbox.maxsize
assert grandchild._tasks_inbox.maxsize == child._tasks_inbox.maxsize
async def test_force_complete_uses_failed_when_run_errored():
"""If the lifecycle signals an errored run, scoped children that are still
'started' when the subgraphs projection's finally block runs must be
force-finished as 'failed', not 'completed'."""
from streaming._events import lifecycle_errored_event
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
# No tasks_result — run errored before the child task finished.
lifecycle_errored_event(seq=2, error="boom"),
]
)
asgi = httpx.ASGITransport(app=fake.app)
handles: list = []
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
async for handle in thread.subgraphs:
handles.append(handle)
assert len(handles) == 1
child = handles[0]
# The run errored, so the child should be force-finished as "failed".
assert child.status == "failed"
async def test_force_complete_uses_completed_when_run_completed():
"""If the lifecycle signals a completed run, any subgraph child still
'started' at finally time is force-finished as 'completed' (normal case)."""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
tasks_start_event(seq=1, namespace=["worker:abc"], task_id="t-child"),
# No tasks_result — but lifecycle completed normally.
lifecycle_completed_event(seq=2),
]
)
asgi = httpx.ASGITransport(app=fake.app)
handles: list = []
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
async for handle in thread.subgraphs:
handles.append(handle)
assert len(handles) == 1
child = handles[0]
assert child.status == "completed"
def test_close_inboxes_does_not_enqueue_on_uniterated_inboxes():
"""_close_inboxes must not push a sentinel on inboxes that had no consumer."""
from unittest.mock import MagicMock
from langgraph_sdk._async.stream import ScopedStreamHandle
fake_thread = MagicMock()
handle = ScopedStreamHandle(
thread=fake_thread,
path=("worker:1",),
graph_name="worker",
trigger_call_id="1",
)
# No projection iterated — _close_inboxes should leave all queues empty.
handle._close_inboxes()
assert handle._messages_inbox.qsize() == 0
assert handle._tools_inbox.qsize() == 0
assert handle._tasks_inbox.qsize() == 0
def test_close_inboxes_enqueues_sentinel_on_iterated_inboxes():
"""_close_inboxes must push a None sentinel only on inboxes that had a consumer,
so projection iterators see the EOF signal."""
from unittest.mock import MagicMock
from langgraph_sdk._async.stream import ScopedStreamHandle
fake_thread = MagicMock()
handle = ScopedStreamHandle(
thread=fake_thread,
path=("worker:1",),
graph_name="worker",
trigger_call_id="1",
)
handle._mark_iterated("messages")
handle._close_inboxes()
# Only the messages inbox should have a sentinel.
assert handle._messages_inbox.qsize() == 1
assert handle._messages_inbox.get_nowait() is None
assert handle._tools_inbox.qsize() == 0
assert handle._tasks_inbox.qsize() == 0
async def test_subgraph_scoped_messages_survive_shared_stream_reconnect():
fake = FakeServer()
# Connection order (async): shared stream opens first (via _reconcile_stream),
# then the lifecycle watcher task runs. Three scripts are needed:
# 1. shared stream initial (fail_after=2 to trigger reconnect)
# 2. lifecycle watcher
# 3. shared stream reconnect (carries the message events)
fake.script_sequence(
[
_StreamScript(
events=[
lifecycle_started_event(seq=0),
tasks_start_event(
seq=1, namespace=["worker:abc"], task_id="t-child"
),
],
fail_after=2,
),
_StreamScript(
events=[lifecycle_completed_event(seq=7)],
),
_StreamScript(
events=[
message_start_event(
seq=2,
namespace=["worker:abc"],
message_id="child-msg",
run_id="child-run",
),
message_text_delta_event(
seq=3, namespace=["worker:abc"], text="child"
),
message_text_finish_event(
seq=4, namespace=["worker:abc"], text="child"
),
message_finish_event(seq=5, namespace=["worker:abc"]),
tasks_result_event(
seq=6, namespace=[], task_id="abc", name="worker"
),
lifecycle_completed_event(seq=7),
]
),
]
)
async with httpx.AsyncClient(
transport=fake.transport, base_url="http://test"
) as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
await thread.run.start(input={})
handles = [handle async for handle in thread.subgraphs]
child = handles[0]
chunks = [chunk async for chunk in child.messages]
assert child.path == ("worker:abc",)
assert [await chunk.text for chunk in chunks] == ["child"]
assert fake.stream_request_bodies[-1]["since"] >= 1