Files
langgraph/libs/sdk-py/tests/streaming/test_thread_stream_path_encoding.py
T
Nick HollonandGitHub d89c7e100e fix(sdk-py): percent-encode thread_id and assistant_id in thread stream requests (#9213)
## Summary

Two REST helpers on the thread stream built their paths by interpolating
the raw identifier:

- `thread.agent.get_tree()` built `/assistants/{assistant_id}/graph`
- the state fetch behind `thread.output` built
`/threads/{thread_id}/state`

httpx collapses `..` segments and treats `#` as a fragment, so an
identifier containing those characters was rewritten into a different
path before the request was sent. Both now go through
`_quote_path_param` in the async and sync stream modules, matching the
other SDK endpoints and the stream transports.

## Tests

`tests/streaming/test_thread_stream_path_encoding.py` drives
`threads.stream(...)` followed by `thread.output` or
`thread.agent.get_tree()`, async and sync, with identifiers containing
`..`, `/`, and `#`. It asserts that the request path keeps each
identifier as a single encoded segment. All four tests fail without the
change.

`make format`, `make lint`, and `make test` pass in `libs/sdk-py`.
2026-10-06 09:57:28 -04:00

93 lines
3.4 KiB
Python

"""Thread stream REST helpers must percent-encode `thread_id` and `assistant_id`
so a value containing reserved characters or dot-segments stays an opaque path
segment instead of being normalized into a different path by the HTTP stack.
Each test drives a public entry point (`threads.stream(...)` followed by
`thread.output` or `thread.agent.get_tree()`) with an identifier containing
`..`, `/`, and `#`, and asserts the request path keeps it as one segment.
"""
from __future__ import annotations
import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._async.threads import ThreadsClient
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk._sync.threads import SyncThreadsClient
# Interpolated raw, httpx would collapse the `..` segments and treat `#` as a
# fragment, producing `/assistants/a/graph` and `/other/path`.
THREAD_ID = "../assistants/a/graph#"
ASSISTANT_ID = "../../other/path#"
ENCODED_STATE_PATH = "/threads/..%2Fassistants%2Fa%2Fgraph%23/state"
ENCODED_GRAPH_PATH = "/assistants/..%2F..%2Fother%2Fpath%23/graph"
TERMINAL_STATE = {"values": {"ok": True}, "next": [], "tasks": []}
def _recording_handler(gets: list[str]):
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET":
path = request.url.raw_path.decode("ascii").split("?", 1)[0]
gets.append(path)
return httpx.Response(200, json=TERMINAL_STATE)
# Lifecycle watcher subscription: an empty event stream.
return httpx.Response(
200, headers={"content-type": "text/event-stream"}, content=b""
)
return handler
async def test_async_output_encodes_thread_id():
gets: list[str] = []
async with httpx.AsyncClient(
transport=httpx.MockTransport(_recording_handler(gets)),
base_url="https://example.com",
) as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id=THREAD_ID, assistant_id="agent") as thread:
assert await thread.output == {"ok": True}
assert gets == [ENCODED_STATE_PATH]
async def test_async_agent_get_tree_encodes_assistant_id():
gets: list[str] = []
async with httpx.AsyncClient(
transport=httpx.MockTransport(_recording_handler(gets)),
base_url="https://example.com",
) as raw:
threads = ThreadsClient(HttpClient(raw))
async with threads.stream(thread_id="t-1", assistant_id=ASSISTANT_ID) as thread:
await thread.agent.get_tree()
assert gets == [ENCODED_GRAPH_PATH]
def test_sync_output_encodes_thread_id():
gets: list[str] = []
with httpx.Client(
transport=httpx.MockTransport(_recording_handler(gets)),
base_url="https://example.com",
) as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id=THREAD_ID, assistant_id="agent") as thread:
assert thread.output == {"ok": True}
assert gets == [ENCODED_STATE_PATH]
def test_sync_agent_get_tree_encodes_assistant_id():
gets: list[str] = []
with httpx.Client(
transport=httpx.MockTransport(_recording_handler(gets)),
base_url="https://example.com",
) as raw:
threads = SyncThreadsClient(SyncHttpClient(raw))
with threads.stream(thread_id="t-1", assistant_id=ASSISTANT_ID) as thread:
thread.agent.get_tree()
assert gets == [ENCODED_GRAPH_PATH]