Files
langgraph/libs/sdk-py/tests/test_client_stream.py
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>John KennedyClaude Opus 4.7
e49c093f48 chore(deps): bump ty from 0.0.23 to 0.0.33 in /libs/sdk-py (#7666)
Bumps the minor-and-patch group with 1 update in the /libs/sdk-py
directory: [ty](https://github.com/astral-sh/ty).

Updates `ty` from 0.0.23 to 0.0.33
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ty/releases">ty's
releases</a>.</em></p>
<blockquote>
<h2>0.0.33</h2>
<h2>Release Notes</h2>
<p>Released on 2026-04-28.</p>
<h3>Notable changes</h3>
<ul>
<li>
<p>ty now prefers the declared type of an annotated assignment in more
situations (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24802">#24802</a>).
Consider this example:</p>
<pre lang="py"><code>from some_library import untyped_function
<p>threshold: int | None = 0
result: str = untyped_function()
</code></pre></p>
<p>ty previously favored the <em>inferred</em> type of the right hand
side expression when <code>threshold</code> and <code>result</code> were
used. This is useful for <code>threshold</code>, as it allows something
like <code>threshold += 1</code> to work without an error: we know that
<code>threshold</code> could later become <code>None</code>, but
<em>right now</em>, we see that it is an <code>int</code>. However, for
<code>result</code>, the inferred type is <code>Unknown</code>. This is
<em>not</em> a useful type and it can lead to false negatives. Starting
with this release, ty will therefore prefer
the declared type <em>if the inferred and declared types are mutually
assignable</em>. In the above example, <code>threshold</code> will still
be inferred as <code>int</code> (or rather <code>Literal[1]</code>), but
<code>result</code> will now be inferred as <code>str</code>. If you
previously added <code>cast</code>s to work around this behavior, you
should be able to remove them after upgrading.</p>
</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix reporting of annotation-only locals as unused (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24811">#24811</a>)</li>
<li>Fix project and workspace selection (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24824">#24824</a>)</li>
<li>Fix go-to definition for generic classes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24714">#24714</a>)</li>
<li>Fix receiver coloring for aliased decorators (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24884">#24884</a>)</li>
</ul>
<h3>LSP server</h3>
<ul>
<li>Add support for go-to definition in literal enum member inlay hints
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24792">#24792</a>)</li>
<li>Add support for &quot;baking&quot; keyword argument inlay hints into
the source code (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24667">#24667</a>)</li>
<li>Don't allow inlay hint edits when introducing a non global scope
symbol (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24797">#24797</a>)</li>
<li>Omit semantic highlighting for unresolved symbols (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24718">#24718</a>)</li>
</ul>
<h3>Core type checking</h3>
<ul>
<li>Support narrowing with aliased conditional expressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24302">#24302</a>)</li>
<li>Model short-circuiting control flow in Boolean expressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24458">#24458</a>)</li>
<li>Handle <code>finally</code> blocks where all
<code>try</code>/<code>except</code> blocks are terminal (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24882">#24882</a>)</li>
<li>Detect invalid <code>ClassVar</code> vs instance-attribute overrides
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24767">#24767</a>)</li>
<li>Emit diagnostic for invalid uses of <code>Unpack[...]</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24868">#24868</a>)</li>
<li>Infer lambda parameter types with <code>Callable</code> type context
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24317">#24317</a>)</li>
<li>Support <code>**</code> unpacking of <code>TypedDict</code> in
dict-literal assignments (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24703">#24703</a>)</li>
<li>Support <code>Unpack[TypedDict]</code> in <code>**kwargs</code>
signatures (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24653">#24653</a>)</li>
<li>Treat <code>[*xs]</code> as an irrefutable pattern when matching on
<code>Sequence</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24787">#24787</a>)</li>
<li>Improve generics solving for unions in invariant positions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24698">#24698</a>)</li>
<li>Improve generics solving for unions when matching against protocols
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24837">#24837</a>)</li>
</ul>
<h3>Diagnostics</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/astral-sh/ty/blob/main/CHANGELOG.md">ty's
changelog</a>.</em></p>
<blockquote>
<h2>0.0.33</h2>
<p>Released on 2026-04-28.</p>
<h3>Notable changes</h3>
<ul>
<li>
<p>ty now prefers the declared type of an annotated assignment in more
situations (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24802">#24802</a>).
Consider this example:</p>
<pre lang="py"><code>from some_library import untyped_function
<p>threshold: int | None = 0
result: str = untyped_function()
</code></pre></p>
<p>ty previously favored the <em>inferred</em> type of the right hand
side expression when <code>threshold</code> and <code>result</code> were
used. This is useful for <code>threshold</code>, as it allows something
like <code>threshold += 1</code> to work without an error: we know that
<code>threshold</code> could later become <code>None</code>, but
<em>right now</em>, we see that it is an <code>int</code>. However, for
<code>result</code>, the inferred type is <code>Unknown</code>. This is
<em>not</em> a useful type and it can lead to false negatives. Starting
with this release, ty will therefore prefer
the declared type <em>if the inferred and declared types are mutually
assignable</em>. In the above example, <code>threshold</code> will still
be inferred as <code>int</code> (or rather <code>Literal[1]</code>), but
<code>result</code> will now be inferred as <code>str</code>. If you
previously added <code>cast</code>s to work around this behavior, you
should be able to remove them after upgrading.</p>
</li>
</ul>
<h3>Bug fixes</h3>
<ul>
<li>Fix reporting of annotation-only locals as unused (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24811">#24811</a>)</li>
<li>Fix project and workspace selection (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24824">#24824</a>)</li>
<li>Fix go-to definition for generic classes (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24714">#24714</a>)</li>
<li>Fix receiver coloring for aliased decorators (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24884">#24884</a>)</li>
</ul>
<h3>LSP server</h3>
<ul>
<li>Add support for go-to definition in literal enum member inlay hints
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24792">#24792</a>)</li>
<li>Add support for &quot;baking&quot; keyword argument inlay hints into
the source code (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24667">#24667</a>)</li>
<li>Don't allow inlay hint edits when introducing a non global scope
symbol (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24797">#24797</a>)</li>
<li>Omit semantic highlighting for unresolved symbols (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24718">#24718</a>)</li>
</ul>
<h3>Core type checking</h3>
<ul>
<li>Support narrowing with aliased conditional expressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24302">#24302</a>)</li>
<li>Model short-circuiting control flow in Boolean expressions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24458">#24458</a>)</li>
<li>Handle <code>finally</code> blocks where all
<code>try</code>/<code>except</code> blocks are terminal (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24882">#24882</a>)</li>
<li>Detect invalid <code>ClassVar</code> vs instance-attribute overrides
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24767">#24767</a>)</li>
<li>Emit diagnostic for invalid uses of <code>Unpack[...]</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24868">#24868</a>)</li>
<li>Infer lambda parameter types with <code>Callable</code> type context
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24317">#24317</a>)</li>
<li>Support <code>**</code> unpacking of <code>TypedDict</code> in
dict-literal assignments (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24703">#24703</a>)</li>
<li>Support <code>Unpack[TypedDict]</code> in <code>**kwargs</code>
signatures (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24653">#24653</a>)</li>
<li>Treat <code>[*xs]</code> as an irrefutable pattern when matching on
<code>Sequence</code> (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24787">#24787</a>)</li>
<li>Improve generics solving for unions in invariant positions (<a
href="https://redirect.github.com/astral-sh/ruff/pull/24698">#24698</a>)</li>
<li>Improve generics solving for unions when matching against protocols
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24837">#24837</a>)</li>
</ul>
<h3>Diagnostics</h3>
<ul>
<li>Add error context to <code>invalid-return-type</code> diagnostics,
<code>invalid-yield</code> diagnostics, attribute assignment diagnostics
(<a
href="https://redirect.github.com/astral-sh/ruff/pull/24770">#24770</a>,
<a
href="https://redirect.github.com/astral-sh/ruff/pull/24771">#24771</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/astral-sh/ty/commit/c512d8425418a2170e92aa7fbbd70952d4e04118"><code>c512d84</code></a>
Bump version to 0.0.33 (<a
href="https://redirect.github.com/astral-sh/ty/issues/3368">#3368</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/4cd7b334b90eba09042700e7b654044c2d6bcd15"><code>4cd7b33</code></a>
Upgrade Depot runners from macOS 14 to 15 (<a
href="https://redirect.github.com/astral-sh/ty/issues/3363">#3363</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/c78b8324515bf15a662f61e1d74fd85484d67e8c"><code>c78b832</code></a>
Update rui314/setup-mold digest to 9c9c13b (<a
href="https://redirect.github.com/astral-sh/ty/issues/3342">#3342</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/dea338134aef96d741a48886e414f622dbf10426"><code>dea3381</code></a>
Update actions/cache action to v5.0.5 (<a
href="https://redirect.github.com/astral-sh/ty/issues/3343">#3343</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/d451af477bd5517e132609c3b016799d697f4182"><code>d451af4</code></a>
update typing-features and faqs (<a
href="https://redirect.github.com/astral-sh/ty/issues/3335">#3335</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/052d70bc1a7457f50c5acbafe8de7a846f7d5e77"><code>052d70b</code></a>
Update prek dependencies (<a
href="https://redirect.github.com/astral-sh/ty/issues/3344">#3344</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/66b5e878163ce4ab4810a45a2679a98011932b8b"><code>66b5e87</code></a>
Update astral-sh/setup-uv action to v8.1.0 (<a
href="https://redirect.github.com/astral-sh/ty/issues/3345">#3345</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/7ec6712a6f02d0255de4c7f0066d5bb23987a9bb"><code>7ec6712</code></a>
Add a 'Diagnostics improvements' section to the changelogs (<a
href="https://redirect.github.com/astral-sh/ty/issues/3309">#3309</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/978dfdb38dfb568943f73779d769a965c1d5a397"><code>978dfdb</code></a>
Add version metadata publishing to the release process (<a
href="https://redirect.github.com/astral-sh/ty/issues/3292">#3292</a>)</li>
<li><a
href="https://github.com/astral-sh/ty/commit/4d1e1fc57ca8bfdcbcee513ba92135d2932eb279"><code>4d1e1fc</code></a>
Bump version to 0.0.32 (<a
href="https://redirect.github.com/astral-sh/ty/issues/3302">#3302</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/astral-sh/ty/compare/0.0.23...0.0.33">compare
view</a></li>
</ul>
</details>
<br />

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: John Kennedy <65985482+jkennedyvz@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:21:49 -04:00

468 lines
14 KiB
Python

from __future__ import annotations
from collections.abc import Iterator, Sequence
from pathlib import Path
from typing import Any
import httpx
import pytest
from typing_extensions import assert_type
from langgraph_sdk._shared.utilities import _sse_to_v2_dict
from langgraph_sdk.client import HttpClient, SyncHttpClient
from langgraph_sdk.schema import (
CheckpointPayload,
CheckpointsStreamPart,
CustomStreamPart,
DebugPayload,
DebugStreamPart,
MetadataStreamPart,
RunMetadataPayload,
StreamPart,
StreamPartV2,
TaskPayload,
TaskResultPayload,
TasksStreamPart,
UpdatesStreamPart,
ValuesStreamPart,
)
from langgraph_sdk.sse import BytesLike, BytesLineDecoder, SSEDecoder
with open(Path(__file__).parent / "fixtures" / "response.txt", "rb") as f:
RESPONSE_PAYLOAD = f.read()
# --- test helpers ---
class AsyncListByteStream(httpx.AsyncByteStream):
def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None:
self._chunks = list(chunks)
self._exc = exc
async def __aiter__(self):
for chunk in self._chunks:
yield chunk
if self._exc is not None:
raise self._exc
async def aclose(self) -> None:
return None
class ListByteStream(httpx.ByteStream):
def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None:
self._chunks = list(chunks)
self._exc = exc
def __iter__(self):
yield from self._chunks
if self._exc is not None:
raise self._exc
def close(self) -> None:
return None
def iter_lines_raw(payload: list[bytes]) -> Iterator[BytesLike]:
decoder = BytesLineDecoder()
for part in payload:
yield from decoder.decode(part)
yield from decoder.flush()
_V2_REQUIRED_KEYS = {"type", "ns", "data"}
def _assert_v2_shape(part: Any) -> None:
"""Assert a v2 stream part has the required keys and types."""
assert isinstance(part, dict), f"Expected dict, got {type(part)}"
assert part.keys() >= _V2_REQUIRED_KEYS, (
f"Missing keys: {_V2_REQUIRED_KEYS - part.keys()}"
)
assert isinstance(part["type"], str)
assert isinstance(part["ns"], list)
for elem in part["ns"]:
assert isinstance(elem, str)
# --- SSE parsing ---
def test_stream_sse():
for groups in (
[RESPONSE_PAYLOAD],
RESPONSE_PAYLOAD.splitlines(keepends=True),
):
parts: list[StreamPart] = []
decoder = SSEDecoder()
for line in iter_lines_raw(groups):
sse = decoder.decode(line=line.rstrip(b"\n")) # type: ignore
if sse is not None:
parts.append(sse)
if sse := decoder.decode(b""):
parts.append(sse)
assert decoder.decode(b"") is None
assert len(parts) == 79
# --- HTTP client streaming ---
@pytest.mark.asyncio
async def test_http_client_stream_flushes_trailing_event():
payload = b'event: foo\ndata: {"bar": 1}\n'
async def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["accept"] == "text/event-stream"
assert request.headers["cache-control"] == "no-store"
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
content=payload,
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
http_client = HttpClient(client)
parts = [part async for part in http_client.stream("/stream", "GET")]
assert parts == [StreamPart(event="foo", data={"bar": 1})]
def test_sync_http_client_stream_flushes_trailing_event():
payload = b'event: foo\ndata: {"bar": 1}\n'
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers["accept"] == "text/event-stream"
assert request.headers["cache-control"] == "no-store"
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
content=payload,
)
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
http_client = SyncHttpClient(client)
parts = list(http_client.stream("/stream", "GET"))
assert parts == [StreamPart(event="foo", data={"bar": 1})]
def test_sync_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
b"id: 1\n",
b"event: values\n",
b'data: {"step": 1}\n\n',
]
second_chunks = [
b"id: 2\n",
b"event: values\n",
b'data: {"step": 2}\n\n',
b"event: end\n",
b"data: null\n\n",
]
call_count = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
assert request.method == "POST"
assert request.url.path == "/stream"
assert request.headers["accept"] == "text/event-stream"
assert request.headers["cache-control"] == "no-store"
assert "last-event-id" not in {
k.lower(): v for k, v in request.headers.items()
}
assert request.read()
return httpx.Response(
200,
headers={
"Content-Type": "text/event-stream",
"Location": reconnect_path,
},
stream=ListByteStream(
first_chunks,
httpx.RemoteProtocolError("incomplete chunked read"),
),
)
if call_count == 2:
assert request.method == "GET"
assert request.url.path == reconnect_path
assert request.headers["Last-Event-ID"] == "1"
assert request.read() == b""
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
stream=ListByteStream(second_chunks),
)
raise AssertionError("unexpected request")
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
http_client = SyncHttpClient(client)
parts = list(http_client.stream("/stream", "POST", json={"payload": "value"}))
assert call_count == 2
assert parts == [
StreamPart(event="values", data={"step": 1}, id="1"),
StreamPart(event="values", data={"step": 2}, id="2"),
StreamPart(event="end", data=None, id="2"),
]
@pytest.mark.asyncio
async def test_http_client_stream_recovers_after_disconnect():
reconnect_path = "/reconnect"
first_chunks = [
b"id: 1\n",
b"event: values\n",
b'data: {"step": 1}\n\n',
]
second_chunks = [
b"id: 2\n",
b"event: values\n",
b'data: {"step": 2}\n\n',
b"event: end\n",
b"data: null\n\n",
]
call_count = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal call_count
call_count += 1
if call_count == 1:
assert request.method == "POST"
assert request.url.path == "/stream"
assert request.headers["accept"] == "text/event-stream"
assert request.headers["cache-control"] == "no-store"
assert "last-event-id" not in {
k.lower(): v for k, v in request.headers.items()
}
assert await request.aread()
return httpx.Response(
200,
headers={
"Content-Type": "text/event-stream",
"Location": reconnect_path,
},
stream=AsyncListByteStream(
first_chunks,
httpx.RemoteProtocolError("incomplete chunked read"),
),
)
if call_count == 2:
assert request.method == "GET"
assert request.url.path == reconnect_path
assert request.headers["Last-Event-ID"] == "1"
assert await request.aread() == b""
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
stream=AsyncListByteStream(second_chunks),
)
raise AssertionError("unexpected request")
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
http_client = HttpClient(client)
parts = [
part
async for part in http_client.stream(
"/stream", "POST", json={"payload": "value"}
)
]
assert call_count == 2
assert parts == [
StreamPart(event="values", data={"step": 1}, id="1"),
StreamPart(event="values", data={"step": 2}, id="2"),
StreamPart(event="end", data=None, id="2"),
]
# --- _sse_to_v2_dict conversion ---
def test_sse_to_v2_dict_basic() -> None:
result = _sse_to_v2_dict("values", {"messages": [{"role": "user"}]})
assert result is not None
_assert_v2_shape(result)
assert result == {
"type": "values",
"ns": [],
"data": {"messages": [{"role": "user"}]},
"interrupts": [],
}
def test_sse_to_v2_dict_with_namespace() -> None:
result = _sse_to_v2_dict("updates|sub:abc", {"key": "val"})
assert result is not None
_assert_v2_shape(result)
assert result == {
"type": "updates",
"ns": ["sub:abc"],
"data": {"key": "val"},
"interrupts": [],
}
def test_sse_to_v2_dict_with_multiple_ns() -> None:
result = _sse_to_v2_dict("custom|parent|child:123", "hello")
assert result is not None
_assert_v2_shape(result)
assert result == {
"type": "custom",
"ns": ["parent", "child:123"],
"data": "hello",
"interrupts": [],
}
def test_sse_to_v2_dict_end_event() -> None:
assert _sse_to_v2_dict("end", None) is None
def test_sse_to_v2_dict_metadata_event() -> None:
result = _sse_to_v2_dict("metadata", {"run_id": "abc-123"})
assert result is not None
_assert_v2_shape(result)
assert result == {
"type": "metadata",
"ns": [],
"data": {"run_id": "abc-123"},
"interrupts": [],
}
def test_sse_to_v2_dict_messages_partial() -> None:
result = _sse_to_v2_dict("messages/partial", [{"type": "ai", "content": "hi"}])
assert result is not None
_assert_v2_shape(result)
assert result == {
"type": "messages/partial",
"ns": [],
"data": [{"type": "ai", "content": "hi"}],
"interrupts": [],
}
def test_sse_to_v2_dict_values_with_interrupts() -> None:
data = {
"messages": [{"role": "user"}],
"__interrupt__": [{"value": "confirm?", "resumable": True}],
}
result = _sse_to_v2_dict("values", data)
assert result is not None
_assert_v2_shape(result)
assert result == {
"type": "values",
"ns": [],
"data": {"messages": [{"role": "user"}]},
"interrupts": [{"value": "confirm?", "resumable": True}],
}
# __interrupt__ should be popped from data
assert "__interrupt__" not in result["data"]
# --- client-side v2 stream wrapping ---
@pytest.mark.asyncio
async def test_async_stream_v2_client_side_conversion() -> None:
from langgraph_sdk._async.runs import _wrap_stream_v2
async def mock_stream() -> Any:
yield StreamPart(event="metadata", data={"run_id": "r1"})
yield StreamPart(
event="values", data={"messages": [{"role": "user", "content": "hi"}]}
)
yield StreamPart(event="updates|sub:abc", data={"node": {"out": 1}})
yield StreamPart(event="end", data=None) # ty: ignore[invalid-argument-type]
parts: list[StreamPartV2] = [part async for part in _wrap_stream_v2(mock_stream())]
assert len(parts) == 3
for part in parts:
_assert_v2_shape(part)
assert parts[0] == {
"type": "metadata",
"ns": [],
"data": {"run_id": "r1"},
"interrupts": [],
}
assert parts[1] == {
"type": "values",
"ns": [],
"data": {"messages": [{"role": "user", "content": "hi"}]},
"interrupts": [],
}
assert parts[2] == {
"type": "updates",
"ns": ["sub:abc"],
"data": {"node": {"out": 1}},
"interrupts": [],
}
def test_sync_stream_v2_client_side_conversion() -> None:
from langgraph_sdk._sync.runs import _wrap_stream_v2_sync
def mock_stream() -> Any:
yield StreamPart(event="metadata", data={"run_id": "r1"})
yield StreamPart(event="values", data={"state": "full"})
yield StreamPart(event="end", data=None) # ty: ignore[invalid-argument-type]
parts: list[StreamPartV2] = list(_wrap_stream_v2_sync(mock_stream()))
assert len(parts) == 2
for part in parts:
_assert_v2_shape(part)
assert parts[0] == {
"type": "metadata",
"ns": [],
"data": {"run_id": "r1"},
"interrupts": [],
}
assert parts[1] == {
"type": "values",
"ns": [],
"data": {"state": "full"},
"interrupts": [],
}
# --- type narrowing compile-time checks ---
def _check_v2_type_narrowing(part: StreamPartV2) -> None:
"""Compile-time type narrowing checks — validates mypy narrows the union."""
if part["type"] == "values":
assert_type(part, ValuesStreamPart)
assert_type(part["data"], dict[str, Any])
elif part["type"] == "updates":
assert_type(part, UpdatesStreamPart)
assert_type(part["data"], dict[str, Any])
elif part["type"] == "custom":
assert_type(part, CustomStreamPart)
elif part["type"] == "checkpoints":
assert_type(part, CheckpointsStreamPart)
assert_type(part["data"], CheckpointPayload)
elif part["type"] == "tasks":
assert_type(part, TasksStreamPart)
assert_type(part["data"], TaskPayload | TaskResultPayload)
elif part["type"] == "debug":
assert_type(part, DebugStreamPart)
assert_type(part["data"], DebugPayload)
elif part["type"] == "metadata":
assert_type(part, MetadataStreamPart)
assert_type(part["data"], RunMetadataPayload)