mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-21 23:22:27 +02:00
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 "baking" 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 "baking" 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>
468 lines
14 KiB
Python
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)
|