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

186 lines
8.1 KiB
Python

"""Tests for `thread.output` — REST-backed awaitable for terminal thread state."""
from __future__ import annotations
import asyncio
import httpx
import pytest
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
from streaming._events import (
lifecycle_completed_event,
lifecycle_errored_event,
lifecycle_started_event,
)
from streaming._fake_server import FakeServer
async def test_output_waits_for_lifecycle_then_fetches_state():
"""run.start + lifecycle completion → await thread.output returns state values."""
fake = FakeServer()
fake.script(
[
lifecycle_started_event(seq=0),
lifecycle_completed_event(seq=1),
]
)
fake.set_state({"messages": ["hello"]})
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={})
result = await thread.output
assert result == {"messages": ["hello"]}
assert fake.state_request_count == 1
async def test_output_with_lifecycle_replay():
"""Lifecycle completion event already in stream when output is awaited → returns immediately."""
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=0)])
fake.set_state({"counter": 42})
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={})
# Yield to the event loop until the lifecycle watcher has processed
# the completed event and resolved _run_done.
for _ in range(50):
run_done = thread._run_done
if run_done is not None and run_done.done():
break
await asyncio.sleep(0)
result = await thread.output
assert result == {"counter": 42}
assert fake.state_request_count == 1
async def test_output_completed_before_attach_returns_rest_state():
"""Explicit thread_id, no run.start, state is terminal → returns REST state without hanging."""
fake = FakeServer()
fake.script([]) # No lifecycle events — nothing in flight.
fake.set_state({"done": True}) # Terminal: next=[], tasks=[]
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
# Pass explicit thread_id — this is the reattach scenario.
async with threads.stream(
thread_id="existing-1", assistant_id="agent"
) as thread:
result = await thread.output
assert result == {"done": True}
assert fake.state_request_count == 1
async def test_output_explicit_thread_id_non_terminal_falls_through_to_lifecycle():
"""Explicit thread_id, non-terminal state → falls through fast path and waits for lifecycle."""
fake = FakeServer()
# Non-terminal state: next has a pending node so the fast-path check fails.
# The same state is returned on the second fetch (after lifecycle fires).
fake.set_state(values={"result": "done"}, next=["still_running"])
# Script a lifecycle completion event — the watcher fires this to resolve _run_done.
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))
# Explicit thread_id triggers the fast-path check (no run.start called).
async with threads.stream(
thread_id="existing-2", assistant_id="agent"
) as thread:
# _run_seen is False (no run.start), explicit_thread_id is True:
# _can_return_existing_state_immediately() returns True.
# First fetch returns non-terminal state → falls through to _wait_for_run_done.
# Lifecycle completed event resolves _run_done.
# Second fetch returns same state; values are returned.
result = await thread.output
assert result == {"result": "done"}
# Two fetches: one for the fast-path terminal check, one after lifecycle fires.
assert fake.state_request_count == 2
async def test_output_does_not_open_values_stream():
"""Awaiting thread.output must NOT open a values SSE channel."""
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=0)])
fake.set_state({"x": 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={})
await thread.output
# No stream request body should contain a "values" channel.
for body in fake.stream_request_bodies:
channels = body.get("channels", [])
assert "values" not in channels, (
f"Expected no 'values' channel, but found one in: {body}"
)
async def test_output_multiple_awaiters_share_one_state_request():
"""Awaiting thread.output twice shares a single underlying task and REST call."""
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=0)])
fake.set_state({"shared": True})
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={})
result1, result2 = await asyncio.gather(thread.output, thread.output)
assert result1 == {"shared": True}
assert result2 == {"shared": True}
assert fake.state_request_count == 1
async def test_output_terminal_error_raises():
"""Lifecycle errored event → awaiting thread.output raises RuntimeError."""
fake = FakeServer()
fake.script([lifecycle_errored_event(seq=0, error="something exploded")])
fake.set_state({})
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={})
with pytest.raises(RuntimeError, match="something exploded"):
await thread.output
async def test_output_no_run_no_lifecycle_raises():
"""Minted thread_id, no run.start, no lifecycle events → raises usage error."""
fake = FakeServer()
fake.script([])
asgi = httpx.ASGITransport(app=fake.app)
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
threads = ThreadsClient(HttpClient(raw))
# thread_id=None → minted UUID, explicit_thread_id=False
async with threads.stream(thread_id=None, assistant_id="agent") as thread:
with pytest.raises(RuntimeError, match="no run has been started"):
await thread.output
async def test_output_headers_propagate_to_state_request():
"""Custom headers on the stream session propagate to the GET /state REST call."""
fake = FakeServer()
fake.script([lifecycle_completed_event(seq=0)])
fake.set_state({"ok": True})
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",
headers={"X-Custom-Header": "test-value"},
) as thread:
await thread.run.start(input={})
await thread.output
assert fake.state_request_count == 1
assert fake.state_request_headers[0].get("x-custom-header") == "test-value"