mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-05 17:27:47 +02:00
Merge branch 'main' into fix-examples-link-readme
This commit is contained in:
@@ -27,6 +27,9 @@ jobs:
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
enable-cache: true
|
||||
cache-suffix: "cli-integration-test"
|
||||
ignore-nothing-to-cache: true
|
||||
- name: Setup env
|
||||
if: steps.changed-files.outputs.all
|
||||
working-directory: libs/cli/examples
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
enable-cache: true
|
||||
cache-siffix: test-${{ inputs.working-directory }}
|
||||
cache-suffix: test-${{ inputs.working-directory }}
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
|
||||
@@ -70,6 +70,7 @@ REDIRECT_MAP = {
|
||||
"cloud/faq/studio.md": "concepts/langgraph_studio.md#studio-faqs",
|
||||
"cloud/how-tos/human_in_the_loop_edit_state.md": "cloud/how-tos/add-human-in-the-loop.md",
|
||||
"cloud/how-tos/human_in_the_loop_user_input.md": "cloud/how-tos/add-human-in-the-loop.md",
|
||||
"concepts/platform_architecture.md": "langgraph/concepts/langgraph_cloud#architecture",
|
||||
# cloud streaming redirects
|
||||
"cloud/how-tos/stream_values.md": "cloud/how-tos/streaming.md#stream-graph-state",
|
||||
"cloud/how-tos/stream_updates.md": "cloud/how-tos/streaming.md#stream-graph-state",
|
||||
|
||||
@@ -71,7 +71,7 @@ Basic usage example:
|
||||
| [`values`](#stream-graph-state) | Streams the full value of the state after each step of the graph. |
|
||||
| [`updates`](#stream-graph-state) | Streams the updates to the state after each step of the graph. If multiple updates are made in the same step (e.g., multiple nodes are run), those updates are streamed separately. |
|
||||
| [`custom`](#stream-custom-data) | Streams custom data from inside your graph nodes. |
|
||||
| [`messages`](#messages) | Streams LLM tokens and metadata for the graph node where the LLM is invoked. |
|
||||
| [`messages`](#messages) | Streams 2-tuples (LLM token, metadata) from any graph nodes where an LLM is invoked. |
|
||||
| [`debug`](#debug) | Streams as much information as possible throughout the execution of the graph. |
|
||||
|
||||
### Stream multiple modes
|
||||
@@ -161,6 +161,8 @@ graph = (
|
||||
|
||||
To include outputs from [subgraphs](../concepts/subgraphs.md) in the streamed outputs, you can set `subgraphs=True` in the `.stream()` method of the parent graph. This will stream outputs from both the parent graph and any subgraphs.
|
||||
|
||||
The outputs will be streamed as tuples `(namespace, data)`, where `namespace` is a tuple with the path to the node where a subgraph is invoked, e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
|
||||
|
||||
```python
|
||||
for chunk in graph.stream(
|
||||
{"foo": "foo"},
|
||||
@@ -179,21 +181,17 @@ for chunk in graph.stream(
|
||||
from langgraph.graph import START, StateGraph
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
# Define subgraph
|
||||
class SubgraphState(TypedDict):
|
||||
foo: str # note that this key is shared with the parent graph state
|
||||
bar: str
|
||||
|
||||
|
||||
def subgraph_node_1(state: SubgraphState):
|
||||
return {"bar": "bar"}
|
||||
|
||||
|
||||
def subgraph_node_2(state: SubgraphState):
|
||||
return {"foo": state["foo"] + state["bar"]}
|
||||
|
||||
|
||||
subgraph_builder = StateGraph(SubgraphState)
|
||||
subgraph_builder.add_node(subgraph_node_1)
|
||||
subgraph_builder.add_node(subgraph_node_2)
|
||||
@@ -201,16 +199,13 @@ for chunk in graph.stream(
|
||||
subgraph_builder.add_edge("subgraph_node_1", "subgraph_node_2")
|
||||
subgraph = subgraph_builder.compile()
|
||||
|
||||
|
||||
# Define parent graph
|
||||
class ParentState(TypedDict):
|
||||
foo: str
|
||||
|
||||
|
||||
def node_1(state: ParentState):
|
||||
return {"foo": "hi! " + state["foo"]}
|
||||
|
||||
|
||||
builder = StateGraph(ParentState)
|
||||
builder.add_node("node_1", node_1)
|
||||
builder.add_node("node_2", subgraph)
|
||||
@@ -229,6 +224,13 @@ for chunk in graph.stream(
|
||||
|
||||
1. Set `subgraphs=True` to stream outputs from subgraphs.
|
||||
|
||||
```
|
||||
((), {'node_1': {'foo': 'hi! foo'}})
|
||||
(('node_2:dfddc4ba-c3c5-6887-5012-a243b5b377c2',), {'subgraph_node_1': {'bar': 'bar'}})
|
||||
(('node_2:dfddc4ba-c3c5-6887-5012-a243b5b377c2',), {'subgraph_node_2': {'foo': 'hi! foobar'}})
|
||||
((), {'node_2': {'foo': 'hi! foobar'}})
|
||||
```
|
||||
|
||||
**Note** that we are receiving not just the node updates, but we also the namespaces which tell us what graph (or subgraph) we are streaming from.
|
||||
|
||||
## Debugging {#debug}
|
||||
|
||||
@@ -14,7 +14,7 @@ OUTPUT ?= out/benchmark.json
|
||||
install: ## Install dependencies
|
||||
uv sync --frozen --all-extras --all-packages --group dev
|
||||
|
||||
benchmark: .uv
|
||||
benchmark:
|
||||
mkdir -p out
|
||||
rm -f $(OUTPUT)
|
||||
uv run python -m bench -o $(OUTPUT) --rigorous
|
||||
|
||||
@@ -2278,7 +2278,7 @@ class Pregel(PregelProtocol):
|
||||
Args:
|
||||
input: The input to the graph.
|
||||
config: The configuration to use for the run.
|
||||
stream_mode: The mode to stream output, defaults to self.stream_mode.
|
||||
stream_mode: The mode to stream output, defaults to `self.stream_mode`.
|
||||
Options are:
|
||||
|
||||
- `"values"`: Emit all values in the state after each step, including interrupts.
|
||||
@@ -2287,112 +2287,28 @@ class Pregel(PregelProtocol):
|
||||
If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
|
||||
- `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
|
||||
- `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
|
||||
Will be emitted as 2-tuples `(LLM token, metadata)`.
|
||||
- `"debug"`: Emit debug events with as much information as possible for each step.
|
||||
|
||||
You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
|
||||
The streamed outputs will be tuples of `(mode, data)`.
|
||||
|
||||
See [LangGraph streaming guide](https://langchain-ai.github.io/langgraph/how-tos/streaming/) for more details.
|
||||
output_keys: The keys to stream, defaults to all non-context channels.
|
||||
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
|
||||
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
|
||||
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
|
||||
debug: Whether to print debug information during execution, defaults to False.
|
||||
subgraphs: Whether to stream subgraphs, defaults to False.
|
||||
subgraphs: Whether to stream events from inside subgraphs, defaults to False.
|
||||
If True, the events will be emitted as tuples `(namespace, data)`,
|
||||
or `(namespace, mode, data)` if `stream_mode` is a list,
|
||||
where `namespace` is a tuple with the path to the node where a subgraph is invoked,
|
||||
e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
|
||||
|
||||
See [LangGraph streaming guide](https://langchain-ai.github.io/langgraph/how-tos/streaming/) for more details.
|
||||
|
||||
Yields:
|
||||
The output of each step in the graph. The output shape depends on the stream_mode.
|
||||
|
||||
Example: Using stream_mode="values":
|
||||
```python
|
||||
import operator
|
||||
from typing_extensions import Annotated, TypedDict
|
||||
from langgraph.graph import StateGraph, START
|
||||
|
||||
class State(TypedDict):
|
||||
alist: Annotated[list, operator.add]
|
||||
another_list: Annotated[list, operator.add]
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("a", lambda _state: {"another_list": ["hi"]})
|
||||
builder.add_node("b", lambda _state: {"alist": ["there"]})
|
||||
builder.add_edge("a", "b")
|
||||
builder.add_edge(START, "a")
|
||||
graph = builder.compile()
|
||||
|
||||
for event in graph.stream({"alist": ['Ex for stream_mode="values"']}, stream_mode="values"):
|
||||
print(event)
|
||||
|
||||
# {'alist': ['Ex for stream_mode="values"'], 'another_list': []}
|
||||
# {'alist': ['Ex for stream_mode="values"'], 'another_list': ['hi']}
|
||||
# {'alist': ['Ex for stream_mode="values"', 'there'], 'another_list': ['hi']}
|
||||
```
|
||||
|
||||
Example: Using stream_mode="updates":
|
||||
```python
|
||||
for event in graph.stream({"alist": ['Ex for stream_mode="updates"']}, stream_mode="updates"):
|
||||
print(event)
|
||||
|
||||
# {'a': {'another_list': ['hi']}}
|
||||
# {'b': {'alist': ['there']}}
|
||||
```
|
||||
|
||||
Example: Using stream_mode="debug":
|
||||
```python
|
||||
for event in graph.stream({"alist": ['Ex for stream_mode="debug"']}, stream_mode="debug"):
|
||||
print(event)
|
||||
|
||||
# {'type': 'task', 'timestamp': '2024-06-23T...+00:00', 'step': 1, 'payload': {'id': '...', 'name': 'a', 'input': {'alist': ['Ex for stream_mode="debug"'], 'another_list': []}, 'triggers': ['start:a']}}
|
||||
# {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 1, 'payload': {'id': '...', 'name': 'a', 'result': [('another_list', ['hi'])]}}
|
||||
# {'type': 'task', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'input': {'alist': ['Ex for stream_mode="debug"'], 'another_list': ['hi']}, 'triggers': ['a']}}
|
||||
# {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'result': [('alist', ['there'])]}}
|
||||
```
|
||||
|
||||
Example: Using stream_mode="custom":
|
||||
```python
|
||||
from langgraph.types import StreamWriter
|
||||
|
||||
def node_a(state: State, writer: StreamWriter):
|
||||
writer({"custom_data": "foo"})
|
||||
return {"alist": ["hi"]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("a", node_a)
|
||||
builder.add_edge(START, "a")
|
||||
graph = builder.compile()
|
||||
|
||||
for event in graph.stream({"alist": ['Ex for stream_mode="custom"']}, stream_mode="custom"):
|
||||
print(event)
|
||||
|
||||
# {'custom_data': 'foo'}
|
||||
```
|
||||
|
||||
Example: Using stream_mode="messages":
|
||||
```python
|
||||
from typing_extensions import Annotated, TypedDict
|
||||
from langgraph.graph import StateGraph, START
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
class State(TypedDict):
|
||||
question: str
|
||||
answer: str
|
||||
|
||||
def node_a(state: State):
|
||||
response = llm.invoke(state["question"])
|
||||
return {"answer": response.content}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("a", node_a)
|
||||
builder.add_edge(START, "a")
|
||||
graph = builder.compile()
|
||||
|
||||
for event in graph.stream({"question": "What is the capital of France?"}, stream_mode="messages"):
|
||||
print(event)
|
||||
|
||||
# (AIMessageChunk(content='The', additional_kwargs={}, response_metadata={}, id='...'), {'langgraph_step': 1, 'langgraph_node': 'a', 'langgraph_triggers': ['start:a'], 'langgraph_path': ('__pregel_pull', 'a'), 'langgraph_checkpoint_ns': '...', 'checkpoint_ns': '...', 'ls_provider': 'openai', 'ls_model_name': 'gpt-4o-mini', 'ls_model_type': 'chat', 'ls_temperature': 0.7})
|
||||
# (AIMessageChunk(content=' capital', additional_kwargs={}, response_metadata={}, id='...'), {'langgraph_step': 1, 'langgraph_node': 'a', 'langgraph_triggers': ['start:a'], ...})
|
||||
# (AIMessageChunk(content=' of', additional_kwargs={}, response_metadata={}, id='...'), {...})
|
||||
# (AIMessageChunk(content=' France', additional_kwargs={}, response_metadata={}, id='...'), {...})
|
||||
# (AIMessageChunk(content=' is', additional_kwargs={}, response_metadata={}, id='...'), {...})
|
||||
# (AIMessageChunk(content=' Paris', additional_kwargs={}, response_metadata={}, id='...'), {...})
|
||||
```
|
||||
"""
|
||||
|
||||
stream = SyncQueue()
|
||||
@@ -2569,7 +2485,7 @@ class Pregel(PregelProtocol):
|
||||
Args:
|
||||
input: The input to the graph.
|
||||
config: The configuration to use for the run.
|
||||
stream_mode: The mode to stream output, defaults to self.stream_mode.
|
||||
stream_mode: The mode to stream output, defaults to `self.stream_mode`.
|
||||
Options are:
|
||||
|
||||
- `"values"`: Emit all values in the state after each step, including interrupts.
|
||||
@@ -2578,112 +2494,28 @@ class Pregel(PregelProtocol):
|
||||
If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
|
||||
- `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
|
||||
- `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
|
||||
Will be emitted as 2-tuples `(LLM token, metadata)`.
|
||||
- `"debug"`: Emit debug events with as much information as possible for each step.
|
||||
|
||||
You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
|
||||
The streamed outputs will be tuples of `(mode, data)`.
|
||||
|
||||
See [LangGraph streaming guide](https://langchain-ai.github.io/langgraph/how-tos/streaming/) for more details.
|
||||
output_keys: The keys to stream, defaults to all non-context channels.
|
||||
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
|
||||
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
|
||||
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
|
||||
debug: Whether to print debug information during execution, defaults to False.
|
||||
subgraphs: Whether to stream subgraphs, defaults to False.
|
||||
subgraphs: Whether to stream events from inside subgraphs, defaults to False.
|
||||
If True, the events will be emitted as tuples `(namespace, data)`,
|
||||
or `(namespace, mode, data)` if `stream_mode` is a list,
|
||||
where `namespace` is a tuple with the path to the node where a subgraph is invoked,
|
||||
e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
|
||||
|
||||
See [LangGraph streaming guide](https://langchain-ai.github.io/langgraph/how-tos/streaming/) for more details.
|
||||
|
||||
Yields:
|
||||
The output of each step in the graph. The output shape depends on the stream_mode.
|
||||
|
||||
Example: Using stream_mode="values":
|
||||
```python
|
||||
import operator
|
||||
from typing_extensions import Annotated, TypedDict
|
||||
from langgraph.graph import StateGraph, START
|
||||
|
||||
class State(TypedDict):
|
||||
alist: Annotated[list, operator.add]
|
||||
another_list: Annotated[list, operator.add]
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("a", lambda _state: {"another_list": ["hi"]})
|
||||
builder.add_node("b", lambda _state: {"alist": ["there"]})
|
||||
builder.add_edge("a", "b")
|
||||
builder.add_edge(START, "a")
|
||||
graph = builder.compile()
|
||||
|
||||
async for event in graph.astream({"alist": ['Ex for stream_mode="values"']}, stream_mode="values"):
|
||||
print(event)
|
||||
|
||||
# {'alist': ['Ex for stream_mode="values"'], 'another_list': []}
|
||||
# {'alist': ['Ex for stream_mode="values"'], 'another_list': ['hi']}
|
||||
# {'alist': ['Ex for stream_mode="values"', 'there'], 'another_list': ['hi']}
|
||||
```
|
||||
|
||||
Example: Using stream_mode="updates":
|
||||
```python
|
||||
async for event in graph.astream({"alist": ['Ex for stream_mode="updates"']}, stream_mode="updates"):
|
||||
print(event)
|
||||
|
||||
# {'a': {'another_list': ['hi']}}
|
||||
# {'b': {'alist': ['there']}}
|
||||
```
|
||||
|
||||
Example: Using stream_mode="debug":
|
||||
```python
|
||||
async for event in graph.astream({"alist": ['Ex for stream_mode="debug"']}, stream_mode="debug"):
|
||||
print(event)
|
||||
|
||||
# {'type': 'task', 'timestamp': '2024-06-23T...+00:00', 'step': 1, 'payload': {'id': '...', 'name': 'a', 'input': {'alist': ['Ex for stream_mode="debug"'], 'another_list': []}, 'triggers': ['start:a']}}
|
||||
# {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 1, 'payload': {'id': '...', 'name': 'a', 'result': [('another_list', ['hi'])]}}
|
||||
# {'type': 'task', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'input': {'alist': ['Ex for stream_mode="debug"'], 'another_list': ['hi']}, 'triggers': ['a']}}
|
||||
# {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'result': [('alist', ['there'])]}}
|
||||
```
|
||||
|
||||
Example: Using stream_mode="custom":
|
||||
```python
|
||||
from langgraph.types import StreamWriter
|
||||
|
||||
async def node_a(state: State, writer: StreamWriter):
|
||||
writer({"custom_data": "foo"})
|
||||
return {"alist": ["hi"]}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("a", node_a)
|
||||
builder.add_edge(START, "a")
|
||||
graph = builder.compile()
|
||||
|
||||
async for event in graph.astream({"alist": ['Ex for stream_mode="custom"']}, stream_mode="custom"):
|
||||
print(event)
|
||||
|
||||
# {'custom_data': 'foo'}
|
||||
```
|
||||
|
||||
Example: Using stream_mode="messages":
|
||||
```python
|
||||
from typing_extensions import Annotated, TypedDict
|
||||
from langgraph.graph import StateGraph, START
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
llm = ChatOpenAI(model="gpt-4o-mini")
|
||||
|
||||
class State(TypedDict):
|
||||
question: str
|
||||
answer: str
|
||||
|
||||
async def node_a(state: State):
|
||||
response = await llm.ainvoke(state["question"])
|
||||
return {"answer": response.content}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("a", node_a)
|
||||
builder.add_edge(START, "a")
|
||||
graph = builder.compile()
|
||||
|
||||
async for event in graph.astream({"question": "What is the capital of France?"}, stream_mode="messages"):
|
||||
print(event)
|
||||
|
||||
# (AIMessageChunk(content='The', additional_kwargs={}, response_metadata={}, id='...'), {'langgraph_step': 1, 'langgraph_node': 'a', 'langgraph_triggers': ['start:a'], 'langgraph_path': ('__pregel_pull', 'a'), 'langgraph_checkpoint_ns': '...', 'checkpoint_ns': '...', 'ls_provider': 'openai', 'ls_model_name': 'gpt-4o-mini', 'ls_model_type': 'chat', 'ls_temperature': 0.7})
|
||||
# (AIMessageChunk(content=' capital', additional_kwargs={}, response_metadata={}, id='...'), {'langgraph_step': 1, 'langgraph_node': 'a', 'langgraph_triggers': ['start:a'], ...})
|
||||
# (AIMessageChunk(content=' of', additional_kwargs={}, response_metadata={}, id='...'), {...})
|
||||
# (AIMessageChunk(content=' France', additional_kwargs={}, response_metadata={}, id='...'), {...})
|
||||
# (AIMessageChunk(content=' is', additional_kwargs={}, response_metadata={}, id='...'), {...})
|
||||
# (AIMessageChunk(content=' Paris', additional_kwargs={}, response_metadata={}, id='...'), {...})
|
||||
```
|
||||
"""
|
||||
|
||||
stream = AsyncQueue()
|
||||
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@langchain/langgraph-sdk",
|
||||
"version": "0.0.74",
|
||||
"version": "0.0.76",
|
||||
"description": "Client library for interacting with the LangGraph API",
|
||||
"type": "module",
|
||||
"packageManager": "yarn@1.22.19",
|
||||
@@ -10,7 +10,7 @@
|
||||
"prepack": "yarn run build",
|
||||
"format": "prettier --write src",
|
||||
"lint": "prettier --check src && tsc --noEmit",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts",
|
||||
"test": "vitest",
|
||||
"typedoc": "typedoc && typedoc src/react/index.ts --out docs/react --options typedoc.react.json && typedoc src/auth/index.ts --out docs/auth --options typedoc.auth.json"
|
||||
},
|
||||
"main": "index.js",
|
||||
@@ -22,28 +22,32 @@
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@langchain/core": "^0.3.31",
|
||||
"@langchain/scripts": "^0.1.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@tsconfig/recommended": "^1.0.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"concat-md": "^0.5.1",
|
||||
"jest": "^29.7.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"msw": "^2.8.2",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-jest": "^29.1.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typedoc": "^0.27.7",
|
||||
"typedoc-plugin-markdown": "^4.4.2",
|
||||
"typescript": "^5.4.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"vitest": "^3.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19",
|
||||
"@langchain/core": ">=0.2.31 <0.4.0"
|
||||
"@langchain/core": ">=0.2.31 <0.4.0",
|
||||
"react": "^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
|
||||
+105
-28
@@ -68,6 +68,24 @@ export function getApiKey(apiKey?: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const REGEX_RUN_METADATA =
|
||||
/(\/threads\/(?<thread_id>.+))?\/runs\/(?<run_id>.+)/;
|
||||
|
||||
function getRunMetadataFromResponse(
|
||||
response: Response,
|
||||
): { run_id: string; thread_id?: string } | undefined {
|
||||
const contentLocation = response.headers.get("Content-Location");
|
||||
if (!contentLocation) return undefined;
|
||||
|
||||
const match = REGEX_RUN_METADATA.exec(contentLocation);
|
||||
|
||||
if (!match?.groups?.run_id) return undefined;
|
||||
return {
|
||||
run_id: match.groups.run_id,
|
||||
thread_id: match.groups.thread_id || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClientConfig {
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
@@ -130,6 +148,7 @@ class BaseClient {
|
||||
json?: unknown;
|
||||
params?: Record<string, unknown>;
|
||||
timeoutMs?: number | null;
|
||||
withResponse?: boolean;
|
||||
},
|
||||
): [url: URL, init: RequestInit] {
|
||||
const mutatedOptions = {
|
||||
@@ -146,6 +165,10 @@ class BaseClient {
|
||||
delete mutatedOptions.json;
|
||||
}
|
||||
|
||||
if (mutatedOptions.withResponse) {
|
||||
delete mutatedOptions.withResponse;
|
||||
}
|
||||
|
||||
let timeoutSignal: AbortSignal | null = null;
|
||||
if (typeof options?.timeoutMs !== "undefined") {
|
||||
if (options.timeoutMs != null) {
|
||||
@@ -175,6 +198,17 @@ class BaseClient {
|
||||
return [targetUrl, mutatedOptions];
|
||||
}
|
||||
|
||||
protected async fetch<T>(
|
||||
path: string,
|
||||
options: RequestInit & {
|
||||
json?: unknown;
|
||||
params?: Record<string, unknown>;
|
||||
timeoutMs?: number | null;
|
||||
signal?: AbortSignal;
|
||||
withResponse: true;
|
||||
},
|
||||
): Promise<[T, Response]>;
|
||||
|
||||
protected async fetch<T>(
|
||||
path: string,
|
||||
options?: RequestInit & {
|
||||
@@ -182,15 +216,36 @@ class BaseClient {
|
||||
params?: Record<string, unknown>;
|
||||
timeoutMs?: number | null;
|
||||
signal?: AbortSignal;
|
||||
withResponse?: false;
|
||||
},
|
||||
): Promise<T> {
|
||||
): Promise<T>;
|
||||
|
||||
protected async fetch<T>(
|
||||
path: string,
|
||||
options?: RequestInit & {
|
||||
json?: unknown;
|
||||
params?: Record<string, unknown>;
|
||||
timeoutMs?: number | null;
|
||||
signal?: AbortSignal;
|
||||
withResponse?: boolean;
|
||||
},
|
||||
): Promise<T | [T, Response]> {
|
||||
const response = await this.asyncCaller.fetch(
|
||||
...this.prepareFetchOptions(path, options),
|
||||
);
|
||||
if (response.status === 202 || response.status === 204) {
|
||||
return undefined as T;
|
||||
|
||||
const body = (() => {
|
||||
if (response.status === 202 || response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
})();
|
||||
|
||||
if (options?.withResponse) {
|
||||
return [await body, response];
|
||||
}
|
||||
return response.json() as T;
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,6 +911,7 @@ export class RunsClient<
|
||||
|
||||
const endpoint =
|
||||
threadId == null ? `/runs/stream` : `/threads/${threadId}/runs/stream`;
|
||||
|
||||
const response = await this.asyncCaller.fetch(
|
||||
...this.prepareFetchOptions(endpoint, {
|
||||
method: "POST",
|
||||
@@ -865,6 +921,9 @@ export class RunsClient<
|
||||
}),
|
||||
);
|
||||
|
||||
const runMetadata = getRunMetadataFromResponse(response);
|
||||
if (runMetadata) payload?.onRunCreated?.(runMetadata);
|
||||
|
||||
const stream: ReadableStream<{ event: any; data: any }> = (
|
||||
response.body || new ReadableStream({ start: (ctrl) => ctrl.close() })
|
||||
)
|
||||
@@ -905,11 +964,18 @@ export class RunsClient<
|
||||
if_not_exists: payload?.ifNotExists,
|
||||
checkpoint_during: payload?.checkpointDuring,
|
||||
};
|
||||
return this.fetch<Run>(`/threads/${threadId}/runs`, {
|
||||
|
||||
const [run, response] = await this.fetch<Run>(`/threads/${threadId}/runs`, {
|
||||
method: "POST",
|
||||
json,
|
||||
signal: payload?.signal,
|
||||
withResponse: true,
|
||||
});
|
||||
|
||||
const runMetadata = getRunMetadataFromResponse(response);
|
||||
if (runMetadata) payload?.onRunCreated?.(runMetadata);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -980,27 +1046,30 @@ export class RunsClient<
|
||||
};
|
||||
const endpoint =
|
||||
threadId == null ? `/runs/wait` : `/threads/${threadId}/runs/wait`;
|
||||
const response = await this.fetch<ThreadState["values"]>(endpoint, {
|
||||
const [run, response] = await this.fetch<ThreadState["values"]>(endpoint, {
|
||||
method: "POST",
|
||||
json,
|
||||
timeoutMs: null,
|
||||
signal: payload?.signal,
|
||||
withResponse: true,
|
||||
});
|
||||
|
||||
const runMetadata = getRunMetadataFromResponse(response);
|
||||
if (runMetadata) payload?.onRunCreated?.(runMetadata);
|
||||
|
||||
const raiseError =
|
||||
payload?.raiseError !== undefined ? payload.raiseError : true;
|
||||
if (
|
||||
raiseError &&
|
||||
"__error__" in response &&
|
||||
typeof response.__error__ === "object" &&
|
||||
response.__error__ &&
|
||||
"error" in response.__error__ &&
|
||||
"message" in response.__error__
|
||||
"__error__" in run &&
|
||||
typeof run.__error__ === "object" &&
|
||||
run.__error__ &&
|
||||
"error" in run.__error__ &&
|
||||
"message" in run.__error__
|
||||
) {
|
||||
throw new Error(
|
||||
`${response.__error__?.error}: ${response.__error__?.message}`,
|
||||
);
|
||||
throw new Error(`${run.__error__?.error}: ${run.__error__?.message}`);
|
||||
}
|
||||
return response;
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1095,13 +1164,12 @@ export class RunsClient<
|
||||
|
||||
/**
|
||||
* Stream output from a run in real-time, until the run is done.
|
||||
* Output is not buffered, so any output produced before this call will
|
||||
* not be received here.
|
||||
*
|
||||
* @param threadId The ID of the thread.
|
||||
* @param threadId The ID of the thread. Can be set to `null` | `undefined` for stateless runs.
|
||||
* @param runId The ID of the run.
|
||||
* @param options Additional options for controlling the stream behavior:
|
||||
* - signal: An AbortSignal that can be used to cancel the stream request
|
||||
* - lastEventId: The ID of the last event received. Can be used to reconnect to a stream without losing events.
|
||||
* - cancelOnDisconnect: When true, automatically cancels the run if the client disconnects from the stream
|
||||
* - streamMode: Controls what types of events to receive from the stream (can be a single mode or array of modes)
|
||||
* Must be a subset of the stream modes passed when creating the run. Background runs default to having the union of all
|
||||
@@ -1109,16 +1177,17 @@ export class RunsClient<
|
||||
* @returns An async generator yielding stream parts.
|
||||
*/
|
||||
async *joinStream(
|
||||
threadId: string,
|
||||
threadId: string | undefined | null,
|
||||
runId: string,
|
||||
options?:
|
||||
| {
|
||||
signal?: AbortSignal;
|
||||
cancelOnDisconnect?: boolean;
|
||||
lastEventId?: string;
|
||||
streamMode?: StreamMode | StreamMode[];
|
||||
}
|
||||
| AbortSignal,
|
||||
): AsyncGenerator<{ event: StreamEvent; data: any }> {
|
||||
): AsyncGenerator<{ id?: string; event: StreamEvent; data: any }> {
|
||||
const opts =
|
||||
typeof options === "object" &&
|
||||
options != null &&
|
||||
@@ -1127,15 +1196,23 @@ export class RunsClient<
|
||||
: options;
|
||||
|
||||
const response = await this.asyncCaller.fetch(
|
||||
...this.prepareFetchOptions(`/threads/${threadId}/runs/${runId}/stream`, {
|
||||
method: "GET",
|
||||
timeoutMs: null,
|
||||
signal: opts?.signal,
|
||||
params: {
|
||||
cancel_on_disconnect: opts?.cancelOnDisconnect ? "1" : "0",
|
||||
stream_mode: opts?.streamMode,
|
||||
...this.prepareFetchOptions(
|
||||
threadId != null
|
||||
? `/threads/${threadId}/runs/${runId}/stream`
|
||||
: `/runs/${runId}/stream`,
|
||||
{
|
||||
method: "GET",
|
||||
timeoutMs: null,
|
||||
signal: opts?.signal,
|
||||
headers: opts?.lastEventId
|
||||
? { "Last-Event-ID": opts.lastEventId }
|
||||
: undefined,
|
||||
params: {
|
||||
cancel_on_disconnect: opts?.cancelOnDisconnect ? "1" : "0",
|
||||
stream_mode: opts?.streamMode,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const stream: ReadableStream<{ event: string; data: any }> = (
|
||||
|
||||
@@ -613,6 +613,12 @@ interface SubmitOptions<
|
||||
optimisticValues?:
|
||||
| Partial<StateType>
|
||||
| ((prev: StateType) => Partial<StateType>);
|
||||
/**
|
||||
* Whether or not to stream the nodes of any subgraphs called
|
||||
* by the assistant.
|
||||
* @default false
|
||||
*/
|
||||
streamSubgraphs?: boolean;
|
||||
}
|
||||
|
||||
export function useStream<
|
||||
@@ -868,6 +874,7 @@ export function useStream<
|
||||
|
||||
checkpoint,
|
||||
streamMode,
|
||||
streamSubgraphs: submitOptions?.streamSubgraphs,
|
||||
}) as AsyncGenerator<EventStreamEvent>;
|
||||
|
||||
let streamError: StreamError | undefined;
|
||||
|
||||
@@ -1,74 +1,78 @@
|
||||
/* eslint-disable no-process-env */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { jest } from "@jest/globals";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Client } from "../client.js";
|
||||
import { overrideFetchImplementation } from "../singletons/fetch.js";
|
||||
|
||||
describe.each([[""], ["mocked"]])("Client uses %s fetch", (description) => {
|
||||
let globalFetchMock: jest.Mock;
|
||||
let overriddenFetch: jest.Mock;
|
||||
let expectedFetchMock: jest.Mock;
|
||||
let unexpectedFetchMock: jest.Mock;
|
||||
describe.each([["global"], ["mocked"]])(
|
||||
"Client uses %s fetch",
|
||||
(description: string) => {
|
||||
let globalFetchMock: ReturnType<typeof vi.fn>;
|
||||
let overriddenFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
globalFetchMock = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
batch_ingest_config: {
|
||||
use_multipart_endpoint: true,
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve(""),
|
||||
}),
|
||||
);
|
||||
overriddenFetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
batch_ingest_config: {
|
||||
use_multipart_endpoint: true,
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve(""),
|
||||
}),
|
||||
);
|
||||
expectedFetchMock =
|
||||
description === "mocked" ? overriddenFetch : globalFetchMock;
|
||||
unexpectedFetchMock =
|
||||
description === "mocked" ? globalFetchMock : overriddenFetch;
|
||||
let expectedFetchMock: ReturnType<typeof vi.fn>;
|
||||
let unexpectedFetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
if (description === "mocked") {
|
||||
overrideFetchImplementation(overriddenFetch);
|
||||
} else {
|
||||
overrideFetchImplementation(globalFetchMock);
|
||||
}
|
||||
// Mock global fetch
|
||||
(globalThis as any).fetch = globalFetchMock;
|
||||
});
|
||||
beforeEach(() => {
|
||||
globalFetchMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
batch_ingest_config: {
|
||||
use_multipart_endpoint: true,
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve(""),
|
||||
headers: new Headers({}),
|
||||
}),
|
||||
);
|
||||
overriddenFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
batch_ingest_config: {
|
||||
use_multipart_endpoint: true,
|
||||
},
|
||||
}),
|
||||
text: () => Promise.resolve(""),
|
||||
headers: new Headers({}),
|
||||
}),
|
||||
);
|
||||
expectedFetchMock =
|
||||
description === "mocked" ? overriddenFetch : globalFetchMock;
|
||||
unexpectedFetchMock =
|
||||
description === "mocked" ? globalFetchMock : overriddenFetch;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("createRuns", () => {
|
||||
it("should create an example with the given input and generation", async () => {
|
||||
const client = new Client({ apiKey: "test-api-key" });
|
||||
|
||||
const thread = await client.threads.create();
|
||||
expect(expectedFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(unexpectedFetchMock).not.toHaveBeenCalled();
|
||||
|
||||
jest.clearAllMocks(); // Clear all mocks before the next operation
|
||||
|
||||
// Then clear & run the function
|
||||
await client.runs.create(thread.thread_id, "somegraph", {
|
||||
input: { foo: "bar" },
|
||||
});
|
||||
expect(expectedFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(unexpectedFetchMock).not.toHaveBeenCalled();
|
||||
if (description === "mocked") {
|
||||
overrideFetchImplementation(overriddenFetch);
|
||||
} else {
|
||||
overrideFetchImplementation(globalFetchMock);
|
||||
}
|
||||
// Mock global fetch
|
||||
(globalThis as any).fetch = globalFetchMock;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("createRuns", () => {
|
||||
it("should create an example with the given input and generation", async () => {
|
||||
const client = new Client({ apiKey: "test-api-key" });
|
||||
|
||||
const thread = await client.threads.create();
|
||||
expect(expectedFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(unexpectedFetchMock).not.toHaveBeenCalled();
|
||||
|
||||
vi.clearAllMocks(); // Clear all mocks before the next operation
|
||||
|
||||
// Then clear & run the function
|
||||
await client.runs.create(thread.thread_id, "somegraph", {
|
||||
input: { foo: "bar" },
|
||||
});
|
||||
expect(expectedFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(unexpectedFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { Readable } from "node:stream";
|
||||
import { IterableReadableStream } from "../utils/stream.js";
|
||||
import { BytesLineDecoder, SSEDecoder } from "../utils/sse.js";
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { setupServer } from "msw/node";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { useStream } from "../react/stream.js";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
function TestChatComponent() {
|
||||
const { messages, isLoading, error, submit, stop } = useStream({
|
||||
assistantId: "test-assistant",
|
||||
apiKey: "test-api-key",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={msg.id ?? i} data-testid={`message-${i}`}>
|
||||
{typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="loading">
|
||||
{isLoading ? "Loading..." : "Not loading"}
|
||||
</div>
|
||||
{error ? <div data-testid="error">{String(error)}</div> : null}
|
||||
<button
|
||||
data-testid="submit"
|
||||
onClick={() =>
|
||||
submit({ messages: [{ content: "Hello", type: "human" }] })
|
||||
}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<button data-testid="stop" onClick={stop}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mock server setup
|
||||
|
||||
const server = setupServer(
|
||||
// Mock thread creation
|
||||
http.post("*/threads", () => {
|
||||
return HttpResponse.json({ thread_id: "test-thread-id" });
|
||||
}),
|
||||
|
||||
// Mock stream endpoint
|
||||
http.post("*/threads/:threadId/runs/stream", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const sendSSE = (event: string, data: unknown) =>
|
||||
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
controller.enqueue(
|
||||
sendSSE("metadata", {
|
||||
run_id: "1f03278a-1734-6518-80a4-3390db59f960",
|
||||
attempt: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
controller.enqueue(
|
||||
sendSSE("values", {
|
||||
messages: [
|
||||
{
|
||||
content: "Hey",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {},
|
||||
type: "human",
|
||||
name: null,
|
||||
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
|
||||
example: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
controller.enqueue(
|
||||
sendSSE("messages", [
|
||||
{
|
||||
content: "",
|
||||
additional_kwargs: {},
|
||||
response_metadata: { model_name: "claude-3-7-sonnet-latest" },
|
||||
type: "AIMessageChunk",
|
||||
name: null,
|
||||
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
|
||||
tool_calls: [],
|
||||
invalid_tool_calls: [],
|
||||
tool_call_chunks: [],
|
||||
},
|
||||
{ run_attempt: 1 },
|
||||
]),
|
||||
);
|
||||
|
||||
controller.enqueue(
|
||||
sendSSE("messages", [
|
||||
{
|
||||
content: "Hello",
|
||||
additional_kwargs: {},
|
||||
response_metadata: { model_name: "claude-3-7-sonnet-latest" },
|
||||
type: "AIMessageChunk",
|
||||
name: null,
|
||||
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
|
||||
tool_calls: [],
|
||||
invalid_tool_calls: [],
|
||||
tool_call_chunks: [],
|
||||
},
|
||||
{ run_attempt: 1 },
|
||||
]),
|
||||
);
|
||||
|
||||
controller.enqueue(
|
||||
sendSSE("messages", [
|
||||
{
|
||||
content: "! How can I assist you today?",
|
||||
additional_kwargs: {},
|
||||
response_metadata: { model_name: "claude-3-7-sonnet-latest" },
|
||||
type: "AIMessageChunk",
|
||||
name: null,
|
||||
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
|
||||
tool_calls: [],
|
||||
invalid_tool_calls: [],
|
||||
tool_call_chunks: [],
|
||||
},
|
||||
{ run_attempt: 1 },
|
||||
]),
|
||||
);
|
||||
|
||||
controller.enqueue(
|
||||
sendSSE("messages", [
|
||||
{
|
||||
content: "",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
},
|
||||
type: "AIMessageChunk",
|
||||
name: null,
|
||||
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
|
||||
tool_calls: [],
|
||||
invalid_tool_calls: [],
|
||||
tool_call_chunks: [],
|
||||
},
|
||||
{ run_attempt: 1 },
|
||||
]),
|
||||
);
|
||||
|
||||
controller.enqueue(
|
||||
sendSSE("values", {
|
||||
messages: [
|
||||
{
|
||||
content: "Hey",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {},
|
||||
type: "human",
|
||||
name: null,
|
||||
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
|
||||
example: false,
|
||||
},
|
||||
{
|
||||
content: "Hello! How can I assist you today?",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {
|
||||
model_name: "claude-3-7-sonnet-latest",
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
},
|
||||
type: "ai",
|
||||
name: null,
|
||||
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
|
||||
tool_calls: [],
|
||||
invalid_tool_calls: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.post("*/threads/:threadId/history", () => {
|
||||
return HttpResponse.json([
|
||||
{
|
||||
values: {
|
||||
messages: [
|
||||
{
|
||||
content: "Hey",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {},
|
||||
type: "human",
|
||||
name: null,
|
||||
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
|
||||
example: false,
|
||||
},
|
||||
{
|
||||
content: "Hello! How can I assist you today?",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {
|
||||
model_name: "claude-3-7-sonnet-latest",
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
},
|
||||
type: "ai",
|
||||
name: null,
|
||||
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
|
||||
example: false,
|
||||
tool_calls: [],
|
||||
invalid_tool_calls: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
next: [],
|
||||
tasks: [],
|
||||
metadata: {
|
||||
run_attempt: 1,
|
||||
source: "loop",
|
||||
writes: {
|
||||
agent: {
|
||||
messages: [
|
||||
{
|
||||
content: "Hello! How can I assist you today?",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {
|
||||
model_name: "claude-3-7-sonnet-latest",
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
},
|
||||
type: "ai",
|
||||
name: null,
|
||||
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
|
||||
example: false,
|
||||
tool_calls: [],
|
||||
invalid_tool_calls: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
step: 1,
|
||||
parents: {},
|
||||
},
|
||||
created_at: "2025-05-16T17:10:16.987537+00:00",
|
||||
checkpoint: {
|
||||
checkpoint_id: "1f03278a-38cf-6c68-8001-22b77ac43ff6",
|
||||
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
|
||||
checkpoint_ns: "",
|
||||
},
|
||||
parent_checkpoint: {
|
||||
checkpoint_id: "1f03278a-206b-67c6-8000-ac34a0872e1a",
|
||||
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
|
||||
checkpoint_ns: "",
|
||||
},
|
||||
checkpoint_id: "1f03278a-38cf-6c68-8001-22b77ac43ff6",
|
||||
parent_checkpoint_id: "1f03278a-206b-67c6-8000-ac34a0872e1a",
|
||||
},
|
||||
{
|
||||
values: {
|
||||
messages: [
|
||||
{
|
||||
content: "Hey",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {},
|
||||
type: "human",
|
||||
name: null,
|
||||
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
|
||||
example: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
next: ["agent"],
|
||||
tasks: [
|
||||
{
|
||||
id: "e1b7b52b-a78e-4b32-0c89-e06bf46405ed",
|
||||
name: "agent",
|
||||
path: ["__pregel_pull", "agent"],
|
||||
error: null,
|
||||
interrupts: [],
|
||||
checkpoint: null,
|
||||
state: null,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
content: "Hello! How can I assist you today?",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {
|
||||
model_name: "claude-3-7-sonnet-latest",
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
},
|
||||
type: "ai",
|
||||
name: null,
|
||||
id: "run-3e90ba6a-71d6-49e7-94a8-6bcac2fd0f40",
|
||||
example: false,
|
||||
tool_calls: [],
|
||||
invalid_tool_calls: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
run_attempt: 1,
|
||||
},
|
||||
created_at: "2025-05-16T17:10:14.429889+00:00",
|
||||
checkpoint: {
|
||||
checkpoint_id: "1f03278a-206b-67c6-8000-ac34a0872e1a",
|
||||
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
|
||||
checkpoint_ns: "",
|
||||
},
|
||||
parent_checkpoint: {
|
||||
checkpoint_id: "1f03278a-2067-6590-bfff-3fb740466fc3",
|
||||
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
|
||||
checkpoint_ns: "",
|
||||
},
|
||||
checkpoint_id: "1f03278a-206b-67c6-8000-ac34a0872e1a",
|
||||
parent_checkpoint_id: "1f03278a-2067-6590-bfff-3fb740466fc3",
|
||||
},
|
||||
{
|
||||
values: {
|
||||
messages: [],
|
||||
},
|
||||
next: ["__start__"],
|
||||
tasks: [
|
||||
{
|
||||
id: "291af033-2ddc-3320-8bbc-28060057cae5",
|
||||
name: "__start__",
|
||||
path: ["__pregel_pull", "__start__"],
|
||||
error: null,
|
||||
interrupts: [],
|
||||
checkpoint: null,
|
||||
state: null,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
|
||||
type: "human",
|
||||
content: "Hey",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
run_attempt: 1,
|
||||
source: "input",
|
||||
writes: {
|
||||
__start__: {
|
||||
messages: [
|
||||
{
|
||||
id: "2d8c0d9f-a614-4e44-b474-6a56e9471cf5",
|
||||
type: "human",
|
||||
content: "Hey",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
step: -1,
|
||||
parents: {},
|
||||
},
|
||||
created_at: "2025-05-16T17:10:14.428191+00:00",
|
||||
checkpoint: {
|
||||
checkpoint_id: "1f03278a-2067-6590-bfff-3fb740466fc3",
|
||||
thread_id: "b06fd92a-955c-446e-b233-7977716c4a9c",
|
||||
checkpoint_ns: "",
|
||||
},
|
||||
parent_checkpoint: null,
|
||||
checkpoint_id: "1f03278a-2067-6590-bfff-3fb740466fc3",
|
||||
parent_checkpoint_id: null,
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
|
||||
return new HttpResponse(stream, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
server.use;
|
||||
|
||||
describe("useStream", () => {
|
||||
beforeEach(() => server.listen());
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
server.close();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders initial state correctly", () => {
|
||||
render(<TestChatComponent />);
|
||||
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
|
||||
expect(screen.getByTestId("messages")).toBeEmptyDOMElement();
|
||||
expect(screen.queryByTestId("error")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles message submission and streaming", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestChatComponent />);
|
||||
|
||||
// Check loading state
|
||||
await user.click(screen.getByTestId("submit"));
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("Loading...");
|
||||
|
||||
// Wait for messages to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("message-0")).toHaveTextContent("Hey");
|
||||
expect(screen.getByTestId("message-1")).toHaveTextContent(
|
||||
"Hello! How can I assist you today?",
|
||||
);
|
||||
});
|
||||
|
||||
// Check final state
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
|
||||
});
|
||||
|
||||
it("handles stop functionality", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TestChatComponent />);
|
||||
|
||||
// Start streaming and stop immediately
|
||||
await user.click(screen.getByTestId("submit"));
|
||||
await user.click(screen.getByTestId("stop"));
|
||||
|
||||
// Check loading state is reset
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,15 +24,21 @@ type MessageTupleMetadata = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type AsSubgraph<TEvent extends { event: string; data: unknown }> = {
|
||||
event: TEvent["event"] | `${TEvent["event"]}|${string}`;
|
||||
data: TEvent["data"];
|
||||
};
|
||||
type AsSubgraph<TEvent extends { id?: string; event: string; data: unknown }> =
|
||||
{
|
||||
id?: TEvent["id"];
|
||||
event: TEvent["event"] | `${TEvent["event"]}|${string}`;
|
||||
data: TEvent["data"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream event with values after completion of each step.
|
||||
*/
|
||||
export type ValuesStreamEvent<StateType> = { event: "values"; data: StateType };
|
||||
export type ValuesStreamEvent<StateType> = {
|
||||
id?: string;
|
||||
event: "values";
|
||||
data: StateType;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export type SubgraphValuesStreamEvent<StateType> = AsSubgraph<
|
||||
@@ -57,6 +63,7 @@ export type SubgraphMessagesTupleStreamEvent =
|
||||
* Metadata stream event with information about the run and thread
|
||||
*/
|
||||
export type MetadataStreamEvent = {
|
||||
id?: string;
|
||||
event: "metadata";
|
||||
data: { run_id: string; thread_id: string };
|
||||
};
|
||||
@@ -65,6 +72,7 @@ export type MetadataStreamEvent = {
|
||||
* Stream event with error information.
|
||||
*/
|
||||
export type ErrorStreamEvent = {
|
||||
id?: string;
|
||||
event: "error";
|
||||
data: { error: string; message: string };
|
||||
};
|
||||
@@ -78,6 +86,7 @@ export type SubgraphErrorStreamEvent = AsSubgraph<ErrorStreamEvent>;
|
||||
* produced the update as well as the update.
|
||||
*/
|
||||
export type UpdatesStreamEvent<UpdateType> = {
|
||||
id?: string;
|
||||
event: "updates";
|
||||
data: { [node: string]: UpdateType };
|
||||
};
|
||||
@@ -96,14 +105,17 @@ export type CustomStreamEvent<T> = { event: "custom"; data: T };
|
||||
export type SubgraphCustomStreamEvent<T> = AsSubgraph<CustomStreamEvent<T>>;
|
||||
|
||||
type MessagesMetadataStreamEvent = {
|
||||
id?: string;
|
||||
event: "messages/metadata";
|
||||
data: { [messageId: string]: { metadata: unknown } };
|
||||
};
|
||||
type MessagesCompleteStreamEvent = {
|
||||
id?: string;
|
||||
event: "messages/complete";
|
||||
data: Message[];
|
||||
};
|
||||
type MessagesPartialStreamEvent = {
|
||||
id?: string;
|
||||
event: "messages/partial";
|
||||
data: Message[];
|
||||
};
|
||||
@@ -126,7 +138,7 @@ export type SubgraphMessagesStreamEvent =
|
||||
/**
|
||||
* Stream event with detailed debug information.
|
||||
*/
|
||||
export type DebugStreamEvent = { event: "debug"; data: unknown };
|
||||
export type DebugStreamEvent = { id?: string; event: "debug"; data: unknown };
|
||||
|
||||
/** @internal */
|
||||
export type SubgraphDebugStreamEvent = AsSubgraph<DebugStreamEvent>;
|
||||
@@ -135,6 +147,7 @@ export type SubgraphDebugStreamEvent = AsSubgraph<DebugStreamEvent>;
|
||||
* Stream event with events occurring during execution.
|
||||
*/
|
||||
export type EventsStreamEvent = {
|
||||
id?: string;
|
||||
event: "events";
|
||||
data: {
|
||||
event:
|
||||
@@ -157,6 +170,7 @@ export type SubgraphEventsStreamEvent = AsSubgraph<EventsStreamEvent>;
|
||||
* the `RunsStreamPayload` to receive this event.
|
||||
*/
|
||||
export type FeedbackStreamEvent = {
|
||||
id?: string;
|
||||
event: "feedback";
|
||||
data: { [feedbackKey: string]: string };
|
||||
};
|
||||
|
||||
@@ -135,6 +135,11 @@ interface RunsInvokePayload {
|
||||
* One or more commands to invoke the graph with.
|
||||
*/
|
||||
command?: Command;
|
||||
|
||||
/**
|
||||
* Callback when a run is created.
|
||||
*/
|
||||
onRunCreated?: (params: { run_id: string; thread_id?: string }) => void;
|
||||
}
|
||||
|
||||
export interface RunsStreamPayload<
|
||||
|
||||
@@ -93,6 +93,7 @@ export class BytesLineDecoder extends TransformStream<Uint8Array, Uint8Array> {
|
||||
}
|
||||
|
||||
interface StreamPart {
|
||||
id: string | undefined;
|
||||
event: string;
|
||||
data: unknown;
|
||||
}
|
||||
@@ -113,6 +114,7 @@ export class SSEDecoder extends TransformStream<Uint8Array, StreamPart> {
|
||||
if (!event && !data.length && !lastEventId && retry == null) return;
|
||||
|
||||
const sse = {
|
||||
id: lastEventId || undefined,
|
||||
event,
|
||||
data: data.length ? decodeArraysToJson(decoder, data) : null,
|
||||
};
|
||||
@@ -151,6 +153,7 @@ export class SSEDecoder extends TransformStream<Uint8Array, StreamPart> {
|
||||
flush(controller) {
|
||||
if (event) {
|
||||
controller.enqueue({
|
||||
id: lastEventId || undefined,
|
||||
event,
|
||||
data: data.length ? decodeArraysToJson(decoder, data) : null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
},
|
||||
});
|
||||
+1265
-1579
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user