Compare commits

..
Author SHA1 Message Date
Sydney Runkle 5720a21c45 lockfile changes 2025-07-02 14:13:39 -04:00
Sydney Runkle c0aa6ab612 min deps ci and pydantic version fix 2025-07-02 14:09:59 -04:00
Josh RogersandGitHub 669cf817e8 Bump js sdk to 0.0.89 (#5313) 2025-07-02 09:56:04 -07:00
Sydney RunkleandGitHub 000f5c3043 fix[deps]: update lockfiles / deps bounds for internal tools (#5301)
update lockfiles / deps bounds
2025-07-02 10:30:55 -04:00
Sydney RunkleandGitHub b3708bd7f6 ci: add automated uv lock --upgrade workflow (#5307) 2025-07-02 10:10:01 -04:00
Sydney RunkleandGitHub 8271e39e00 dependabot: no kafka (#5306)
* fix list of dirs
* another patch
2025-07-02 13:15:00 +00:00
Sydney RunkleandGitHub 60560ea755 dependabot: fix list of dirs for pip updates (#5305)
fix list of dirs
2025-07-02 13:12:22 +00:00
waqarahmed6095andGitHub e2acfb24cc Update use_stream_react.md (#5304)
Problem of two times heading 
"How to integrate LangGraph into your React application"
2025-07-02 13:10:57 +00:00
Sydney RunkleandGitHub 191192b142 upgrade dependabot scope (#5303) 2025-07-02 09:09:11 -04:00
Josh RogersandGitHub df368bdd30 Updating message types to include all base message fields (#5298) 2025-07-01 15:40:13 -07:00
Josh RogersandGitHub 4ec897033f Update LGP api reference docs (#5297) 2025-07-01 11:45:30 -07:00
c16e42e6d5 fix broken link (#5291)
* fix broken link

* Apply suggestions from code review

Fix link

Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>

---------

Co-authored-by: Lauren Hirata Singh <lauren@langchain.dev>
Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
2025-07-01 13:52:26 +00:00
David DuongandGitHub 376469ea90 release(sdk-js): 0.0.88 (#5294) 2025-07-01 14:30:37 +02:00
Tat Dat Duong 7e2af0ce8d release(sdk-js): 0.0.88 2025-07-01 14:21:11 +02:00
Youssef Ahmed Mohamed AbdelrahmanandGitHub 1b205a99cb docs: fix typo in application_structure (#5289) 2025-07-01 12:09:14 +00:00
Sam CrowderandGitHub 0a8ba20f5f docs: remove beta flag on self hosted plane (#5288) 2025-06-30 22:41:05 -04:00
Sam CrowderandGitHub 048cb3584c self hosted control plane no longer in beta (#5286)
* self hosted control plane no longer in beta

* accidental changes
2025-06-30 17:34:18 -07:00
Sam CrowderandGitHub 22c35b7bc8 switch order of MCP methods in API spec (#5287) 2025-06-30 17:33:59 -07:00
David DuongandGitHub f3ed32e611 feat(react): enhance useStream with initialValues, newThreadId, and onStop callback for improved UX (#5111) 2025-07-01 01:53:28 +02:00
Tat Dat Duong 276675b618 Make sure to spread stream values 2025-07-01 01:39:53 +02:00
Tat Dat Duong 882de42996 Fix non-existent assistantId 2025-07-01 01:35:59 +02:00
Tat Dat Duong 70be50f37b Fix typo 2025-07-01 01:33:08 +02:00
Tat Dat Duong 1c7234e9c5 Update README.md 2025-07-01 01:32:27 +02:00
Tat Dat Duong 3d88f75254 Cleanup 2025-07-01 01:19:07 +02:00
Lauren Hirata SinghandGitHub 407abbe9ff Add forum links (#5282) 2025-06-30 16:11:46 -04:00
MauritsBrinkmanandTat Dat Duong c7bbb26ac0 test: add useStream onStop callback tests 2025-06-30 16:43:48 +02:00
MauritsBrinkmanandTat Dat Duong ac9b6c416e feat: add onStop callback to useStream for custom stop behavior
Add onStop callback to useStream hook enabling developers to customize
UI behavior when streams are stopped. This is especially useful for
UI messages with loading states that need to show "stopped" status
instead of remaining in infinite loading state.

The callback provides the same mutate function as onCustomEvent for
immediate local state updates, while users can optionally update
server thread state using the threads client.

Example usage:
```typescript
const stream = useStream({
  assistantId: "my-assistant",
  onStop: async ({ mutate }) => {
    // Immediate UI update - stop loading components
    mutate((prev) => ({
      ...prev,
      ui: prev.ui?.map(component =>
        component.props?.isLoading
          ? {
              ...component,
              props: {
                ...component.props,
                isLoading: false,
                isStopped: true
              }
            }
          : component
      )
    }));

    // Optional server thread state update
    if (stream.threadId) {
      await stream.client.threads.updateState(stream.threadId, {
        values: {
          ui: prev.ui // persist stopped state to server
        }
      });
    }
  }
});
```

This is especially useful for cases where gen UI components have loading states,
where we don't want the loading state to persist on cancellation.
2025-06-30 16:43:13 +02:00
MauritsBrinkmanandTat Dat Duong d4b4eebe4a fix(sdk-js): convert SSE classes to factory functions to resolve tree shaking
- Convert BytesLineDecoder and SSEDecoder from classes extending TransformStream to factory functions
- Fixes tree shaking failures that prevented build completion
- Maintains identical API functionality, just removes 'new' keyword usage
- All tests continue to pass

Resolves tree shaking side effect detection issues with TransformStream extension
2025-06-30 16:43:13 +02:00
MauritsBrinkmanandTat Dat Duong f8e1e803e1 docs(react): add documentation and tests for initialValues and newThreadId options
- Document initialValues for cached thread display
- Document newThreadId for optimistic thread creation
- Add comprehensive test coverage for both features
2025-06-30 16:43:12 +02:00
MauritsBrinkmanandTat Dat Duong 141a6af4f7 feat(react): add initialValues option to useStream for cached thread display
Add initialValues parameter to UseStreamOptions to enable immediate display
of cached thread data while official history is being fetched from the server.

This addresses the common use case where applications cache thread data
locally (IndexedDB, localStorage, etc.) and want to show it instantly when
users navigate to existing threads, providing better UX with faster loading.

Key changes:
- Add initialValues?: Partial<StateType> | null to UseStreamOptions interface
- Update values precedence: streamValues > initialValues > historyValues
- Ensure optimisticValues properly override initialValues during submission
- Maintain full backward compatibility with existing API

Example usage:
```typescript
const stream = useStream({
  threadId,
  assistantId: 'my-assistant',
  initialValues: cachedThreadData?.values // Show cached data immediately
});
```

The values flow now follows this priority:
1. Initial load: shows initialValues while history loads
2. During submit: optimisticValues take precedence
3. After server response: official history replaces all
2025-06-30 16:43:12 +02:00
MauritsBrinkmanandTat Dat Duong 8a763ad358 feat(react): add newThreadId option to useStream for optimistic UI
Add optional newThreadId parameter to useStream hook that allows specifying
a thread ID for new thread creation while keeping threadId null. This enables
optimistic UI patterns where developers need to know the thread ID beforehand
for routing/navigation without causing 404 errors from attempting to fetch
non-existent thread history.

Usage:
- Set threadId: null and newThreadId: "predetermined-id"
- Submit message to create thread with specified ID
- Use onThreadId callback to update threadId after creation

This solves the UX problem of having to await thread creation before
enabling optimistic navigation to e.g. /[threadId] routes.
2025-06-30 16:43:12 +02:00
36 changed files with 2126 additions and 639 deletions
+3 -3
View File
@@ -10,6 +10,6 @@ contact_links:
- name: Show and tell
about: Show what you built with LangChain
url: https://github.com/langchain-ai/langgraph/discussions/categories/show-and-tell
- name: Slack
url: https://www.langchain.com/join-community
about: General community discussions
- name: LangChain Forum
url: https://forum.langchain.com/
about: General community discussions and support
+12 -5
View File
@@ -1,11 +1,18 @@
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# and
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directories:
- "libs/checkpoint"
- "libs/checkpoint-postgres"
- "libs/checkpoint-sqlite"
- "libs/cli"
- "libs/langgraph"
- "libs/prebuilt"
- "libs/sdk-py"
schedule:
interval: "weekly"
+10
View File
@@ -58,3 +58,13 @@ jobs:
# grep will exit non-zero if the target message isn't found,
# and `set -e` above will cause the step to fail.
echo "$STATUS" | grep 'nothing to commit, working tree clean'
- name: Install min version of deps
shell: bash
working-directory: ${{ inputs.working-directory }}
run: uv sync --no-default-groups --resolution lowest-direct --no-sources --force-reinstall
- name: Run tests with min version of deps
shell: bash
working-directory: ${{ inputs.working-directory }}
run: make test
+10
View File
@@ -53,3 +53,13 @@ jobs:
# grep will exit non-zero if the target message isn't found,
# and `set -e` above will cause the step to fail.
echo "$STATUS" | grep 'nothing to commit, working tree clean'
- name: Install min version of deps
shell: bash
working-directory: ${{ inputs.working-directory }}
run: uv sync --no-default-groups --resolution lowest-direct --no-sources --force-reinstall
- name: Run tests with min version of deps
shell: bash
working-directory: ${{ inputs.working-directory }}
run: make test
+8 -4
View File
@@ -22,6 +22,7 @@ jobs:
outputs:
python: ${{ steps.filter.outputs.python }}
sdk-js: ${{ steps.filter.outputs.sdk-js }}
deps: ${{ steps.filter.outputs.deps }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@@ -38,6 +39,9 @@ jobs:
- 'libs/prebuilt/**'
sdk-js:
- 'libs/sdk-js/**'
deps:
- '**/pyproject.toml'
- '**/uv.lock'
lint:
needs: changes
@@ -55,7 +59,7 @@ jobs:
"libs/prebuilt",
]
if: needs.changes.outputs.python == 'true'
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
uses: ./.github/workflows/_lint.yml
with:
working-directory: ${{ matrix.working-directory }}
@@ -74,7 +78,7 @@ jobs:
"libs/checkpoint-postgres",
"libs/prebuilt",
]
if: needs.changes.outputs.python == 'true'
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
uses: ./.github/workflows/_test.yml
with:
working-directory: ${{ matrix.working-directory }}
@@ -83,7 +87,7 @@ jobs:
# NOTE: we're testing langgraph separately because it requires a different matrix
test-langgraph:
needs: changes
if: needs.changes.outputs.python == 'true'
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
name: "cd libs/langgraph"
uses: ./.github/workflows/_test_langgraph.yml
secrets: inherit
@@ -140,7 +144,7 @@ jobs:
integration-test:
needs: changes
if: needs.changes.outputs.python == 'true'
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
name: CLI integration test
uses: ./.github/workflows/_integration_test.yml
secrets: inherit
+45
View File
@@ -0,0 +1,45 @@
name: UV Lock Upgrade
on:
schedule:
# run at midnight every Sunday
- cron: '0 0 * * 0'
# allow manual triggering
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
upgrade-dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
# use minimum supported Python version
python-version: "3.9"
enable-cache: true
cache-suffix: "uv-lock-upgrade"
- name: Run uv lock --upgrade in all Python packages
run: make lock-upgrade
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: upgrade dependencies with `uv lock --upgrade`"
title: "chore: upgrade dependencies with `uv lock --upgrade`"
body: |
This PR updates the dependencies in all Python packages using `uv lock --upgrade`.
This is an automated PR created by the UV Lock Upgrade workflow.
branch: deps/uv-lock-upgrade
delete-branch: true
labels: |
dependencies
+10
View File
@@ -47,6 +47,16 @@ lock:
fi; \
done
# Lock all projects and upgrade dependencies
.PHONY: lock-upgrade
lock-upgrade:
@for dir in $(LIBS_DIRS); do \
if [ -f $$dir/Makefile ]; then \
echo "Running lock-upgrade in $$dir"; \
(cd $$dir && uv lock --upgrade); \
fi; \
done
# Test all projects
.PHONY: test
test:
+1 -1
View File
@@ -63,7 +63,7 @@ LangGraph provides low-level supporting infrastructure for *any* long-running, s
While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. To improve your LLM application development, pair LangGraph with:
- [LangSmith](http://www.langchain.com/langsmith) — Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time.
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
- [LangChain](https://python.langchain.com/docs/introduction/) Provides integrations and composable components to streamline LLM application development.
> [!NOTE]
@@ -3,7 +3,7 @@
Before deploying, review the [conceptual guide for the Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployment option.
!!! info "Important"
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](../../concepts/plans.md) plan.
The Self-Hosted Control Plane deployment option requires an [Enterprise](../../concepts/plans.md) plan.
## Prerequisites
@@ -3,7 +3,7 @@
Before deploying, review the [conceptual guide for the Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) deployment option.
!!! info "Important"
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](../../concepts/plans.md) plan.
The Self-Hosted Data Plane deployment option requires an [Enterprise](../../concepts/plans.md) plan.
## Prerequisites
+69 -1
View File
@@ -1,4 +1,4 @@
How to integrate LangGraph into your React application# How to integrate LangGraph into your React application
# How to integrate LangGraph into your React application
!!! info "Prerequisites"
@@ -503,6 +503,74 @@ const handleSubmit = (text: string) => {
};
```
### Cached Thread Display
Use the `initialValues` option to display cached thread data immediately while the history is being loaded from the server. This improves user experience by showing cached data instantly when navigating to existing threads.
```tsx
import { useStream } from "@langchain/langgraph-sdk/react";
const CachedThreadExample = ({ threadId, cachedThreadData }) => {
const stream = useStream({
apiUrl: "http://localhost:2024",
assistantId: "agent",
threadId,
// Show cached data immediately while history loads
initialValues: cachedThreadData?.values,
messagesKey: "messages",
});
return (
<div>
{stream.messages.map((message) => (
<div key={message.id}>{message.content as string}</div>
))}
</div>
);
};
```
### Optimistic Thread Creation
Use the `threadId` option in `submit` function to enable optimistic UI patterns where you need to know the thread ID before the thread is actually created.
```tsx
import { useState } from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
const OptimisticThreadExample = () => {
const [threadId, setThreadId] = useState<string | null>(null);
const [optimisticThreadId] = useState(() => crypto.randomUUID());
const stream = useStream({
apiUrl: "http://localhost:2024",
assistantId: "agent",
threadId,
onThreadId: setThreadId, // (3) Updated after thread has been created.
messagesKey: "messages",
});
const handleSubmit = (text: string) => {
// (1) Perform a soft navigation to /threads/${optimisticThreadId}
// without waiting for thread creation.
window.history.pushState({}, "", `/threads/${optimisticThreadId}`);
// (2) Submit message to create thread with the predetermined ID.
stream.submit(
{ messages: [{ type: "human", content: text }] },
{ threadId: optimisticThreadId }
);
};
return (
<div>
<p>Thread ID: {threadId ?? optimisticThreadId}</p>
{/* Rest of component */}
</div>
);
};
```
### TypeScript
The `useStream()` hook is friendly for apps written in TypeScript and you can specify types for the state to get better type safety and IDE support.
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -395,7 +395,7 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
=== "Python"
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Platform closed beta. Requires a license key for production use.
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Platform. Requires a license key for production use.
**Usage**
@@ -422,7 +422,7 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
=== "JS"
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Platform closed beta. Requires a license key for production use.
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Platform. Requires a license key for production use.
**Usage**
+2 -2
View File
@@ -48,7 +48,7 @@ Below are examples of directory structures for Python and JavaScript application
│ ├── utils # utilities for your graph
│ │ ├── __init__.py
│ │ ├── tools.py # tools for your graph
│ │ ├── nodes.py # node functions for you graph
│ │ ├── nodes.py # node functions for your graph
│ │ └── state.py # state definition of your graph
│ ├── __init__.py
│ └── agent.py # code for constructing your graph
@@ -64,7 +64,7 @@ Below are examples of directory structures for Python and JavaScript application
├── src # all project code lies within here
│ ├── utils # optional utilities for your graph
│ │ ├── tools.ts # tools for your graph
│ │ ├── nodes.ts # node functions for you graph
│ │ ├── nodes.ts # node functions for your graph
│ │ └── state.ts # state definition of your graph
│ └── agent.ts # code for constructing your graph
├── package.json # package dependencies
+4 -4
View File
@@ -18,9 +18,9 @@ There are 4 main options for deploying with the [LangGraph Platform](langgraph_p
1. [Cloud SaaS](#cloud-saas)
1. [Self-Hosted Data Plane<sup>(Beta)</sup>](#self-hosted-data-plane)
1. [Self-Hosted Data Plane](#self-hosted-data-plane)
1. [Self-Hosted Control Plane<sup>(Beta)</sup>](#self-hosted-control-plane)
1. [Self-Hosted Control Plane](#self-hosted-control-plane)
1. [Standalone Container](#standalone-container)
@@ -50,7 +50,7 @@ For more information, please see:
## Self-Hosted Data Plane
!!! info "Important"
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](../concepts/plans.md) plan.
The Self-Hosted Data Plane deployment option requires an [Enterprise](../concepts/plans.md) plan.
The [Self-Hosted Data Plane](./langgraph_self_hosted_data_plane.md) deployment option is a "hybrid" model for deployment where we manage the [control plane](./langgraph_control_plane.md) in our cloud and you manage the [data plane](./langgraph_data_plane.md) in your cloud. This option provides a way to securely manage your data plane infrastructure, while offloading control plane management to us.
@@ -66,7 +66,7 @@ For more information, please see:
## Self-Hosted Control Plane
!!! info "Important"
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](../concepts/plans.md) plan.
The Self-Hosted Control Plane deployment option requires an [Enterprise](../concepts/plans.md) plan.
The [Self-Hosted Control Plane](./langgraph_self_hosted_control_plane.md) deployment option is a fully self-hosted model for deployment where you manage the [control plane](./langgraph_control_plane.md) and [data plane](./langgraph_data_plane.md) in your cloud. This option gives you full control and responsibility of the control plane and data plane infrastructure.
+1 -1
View File
@@ -47,7 +47,7 @@ LangGraph is a stateful, orchestration framework that brings added control to ag
No. LangGraph Platform is proprietary software.
There is a free, self-hosted version of LangGraph Platform with access to basic features. The Cloud SaaS deployment option is free while in beta, but will eventually be a paid service. We will always give ample notice before charging for a service and reward our early adopters with preferential pricing. The Self-Hosted deployment options are paid services. [Contact our sales team](https://www.langchain.com/contact-sales) to learn more.
There is a free, self-hosted version of LangGraph Platform with access to basic features. The Cloud SaaS deployment option and the Self-Hosted deployment options are paid services. [Contact our sales team](https://www.langchain.com/contact-sales) to learn more.
For more information, see our [LangGraph Platform pricing page](https://www.langchain.com/pricing-langgraph-platform).
@@ -3,7 +3,7 @@
There are two versions of the self-hosted deployment: [Self-Hosted Data Plane](./deployment_options.md#self-hosted-data-plane) and [Self-Hosted Control Plane](./deployment_options.md#self-hosted-control-plane).
!!! info "Important"
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](plans.md) plan.
The Self-Hosted Control Plane deployment option requires an [Enterprise](plans.md) plan.
## Requirements
@@ -8,7 +8,7 @@ search:
There are two versions of the self-hosted deployment: [Self-Hosted Data Plane](./deployment_options.md#self-hosted-data-plane) and [Self-Hosted Control Plane](./deployment_options.md#self-hosted-control-plane).
!!! info "Important"
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](plans.md) plan.
The Self-Hosted Data Plane deployment option requires an [Enterprise](plans.md) plan.
## Requirements
+1
View File
@@ -289,6 +289,7 @@ nav:
- Case studies: adopters.md
- concepts/faq.md
- llms.txt: llms-txt-overview.md
- LangChain Forum: https://forum.langchain.com/
- Troubleshooting:
- Errors:
- troubleshooting/errors/index.md
+1 -1
View File
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langgraph-checkpoint>=2.0.21",
"langgraph-checkpoint>=2.0.21,<3.0.0",
"orjson>=3.10.1",
"psycopg>=3.2.0",
"psycopg-pool>=3.2.0",
+1 -1
View File
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langgraph-checkpoint>=2.0.21",
"langgraph-checkpoint>=2.0.21,<3.0.0",
"aiosqlite>=0.20",
"sqlite-vec>=0.1.6",
]
+1 -1
View File
@@ -208,7 +208,7 @@ def up(
):
click.secho("Starting LangGraph API server...", fg="green")
click.secho(
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangGraph Platform closed beta.
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangGraph Platform.
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY.""",
)
with Runner() as runner, Progress(message="Pulling...") as set:
+2 -2
View File
@@ -18,8 +18,8 @@ dependencies = [
[project.optional-dependencies]
inmem = [
"langgraph-api>=0.2.67 ; python_version >= '3.11'",
"langgraph-runtime-inmem>=0.3.0 ; python_version >= '3.11'",
"langgraph-api>=0.2.67,<0.3.0 ; python_version >= '3.11'",
"langgraph-runtime-inmem>=0.3.0,<0.4.0 ; python_version >= '3.11'",
"python-dotenv>=0.8.0",
]
+2 -2
View File
@@ -530,8 +530,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.7" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.2.67" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.3.0" },
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.2.67,<0.3.0" },
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.3.0,<0.4.0" },
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" },
{ name = "python-dotenv", marker = "extra == 'inmem'", specifier = ">=0.8.0" },
]
+1 -1
View File
@@ -63,7 +63,7 @@ LangGraph provides low-level supporting infrastructure for *any* long-running, s
While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. To improve your LLM application development, pair LangGraph with:
- [LangSmith](http://www.langchain.com/langsmith) — Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time.
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
- [LangChain](https://python.langchain.com/docs/introduction/) Provides integrations and composable components to streamline LLM application development.
> [!NOTE]
+5 -4
View File
@@ -13,11 +13,12 @@ license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langchain-core>=0.1",
"langgraph-checkpoint>=2.1.0",
"langgraph-sdk>=0.1.42",
"langgraph-prebuilt>=0.5.0",
"langgraph-checkpoint>=2.1.0,<3.0.0",
"langgraph-sdk>=0.1.42,<0.2.0",
"langgraph-prebuilt>=0.5.0,<0.6.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
"pydantic>=2.7.4; python_version < '3.13'",
"pydantic>=2.0.0; python_version >= '3.13'",
]
[project.urls]
+2 -1
View File
@@ -1246,7 +1246,8 @@ requires-dist = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
{ name = "langgraph-sdk", editable = "../sdk-py" },
{ name = "pydantic", specifier = ">=2.7.4" },
{ name = "pydantic", marker = "python_full_version < '3.13'", specifier = ">=2.7.4" },
{ name = "pydantic", marker = "python_full_version >= '3.13'", specifier = ">=2.0.0" },
{ name = "xxhash", specifier = ">=3.5.0" },
]
+1 -1
View File
@@ -12,7 +12,7 @@ readme = "README.md"
license = "MIT"
license-files = ['LICENSE']
dependencies = [
"langgraph-checkpoint>=2.1.0",
"langgraph-checkpoint>=2.1.0,<3.0.0",
"langchain-core>=0.3.67",
]
+4 -2
View File
@@ -2,7 +2,8 @@ version = 1
revision = 2
requires-python = ">=3.9"
resolution-markers = [
"python_full_version >= '3.12.4'",
"python_full_version >= '3.13'",
"python_full_version >= '3.12.4' and python_full_version < '3.13'",
"python_full_version < '3.12.4'",
]
@@ -337,7 +338,8 @@ requires-dist = [
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
{ name = "langgraph-prebuilt", editable = "." },
{ name = "langgraph-sdk", editable = "../sdk-py" },
{ name = "pydantic", specifier = ">=2.7.4" },
{ name = "pydantic", marker = "python_full_version < '3.13'", specifier = ">=2.7.4" },
{ name = "pydantic", marker = "python_full_version >= '3.13'", specifier = ">=2.0.0" },
{ name = "xxhash", specifier = ">=3.5.0" },
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.87",
"version": "0.0.89",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
+4 -4
View File
@@ -1014,8 +1014,8 @@ export class RunsClient<
const stream: ReadableStream<{ event: any; data: any }> = (
response.body || new ReadableStream({ start: (ctrl) => ctrl.close() })
)
.pipeThrough(new BytesLineDecoder())
.pipeThrough(new SSEDecoder());
.pipeThrough(BytesLineDecoder())
.pipeThrough(SSEDecoder());
yield* IterableReadableStream.fromReadableStream(stream);
}
@@ -1318,8 +1318,8 @@ export class RunsClient<
const stream: ReadableStream<{ event: string; data: any }> = (
response.body || new ReadableStream({ start: (ctrl) => ctrl.close() })
)
.pipeThrough(new BytesLineDecoder())
.pipeThrough(new SSEDecoder());
.pipeThrough(BytesLineDecoder())
.pipeThrough(SSEDecoder());
yield* IterableReadableStream.fromReadableStream(stream);
}
+113 -24
View File
@@ -31,7 +31,7 @@ import type {
} from "../types.stream.js";
import {
type MutableRefObject,
type RefObject,
useCallback,
useEffect,
useMemo,
@@ -316,8 +316,8 @@ function fetchHistory<StateType extends Record<string, unknown>>(
function useThreadHistory<StateType extends Record<string, unknown>>(
threadId: string | undefined | null,
client: Client,
clearCallbackRef: MutableRefObject<(() => void) | undefined>,
submittingRef: MutableRefObject<boolean>,
clearCallbackRef: RefObject<(() => void) | undefined>,
submittingRef: RefObject<boolean>,
) {
const [history, setHistory] = useState<ThreadState<StateType>[]>([]);
@@ -511,6 +511,31 @@ export interface UseStreamOptions<
*/
onDebugEvent?: (data: DebugStreamEvent["data"]) => void;
/**
* Callback that is called when the stream is stopped by the user.
* Provides a mutate function to update the stream state immediately
* without requiring a server roundtrip.
*
* @example
* ```typescript
* onStop: ({ mutate }) => {
* mutate((prev) => ({
* ...prev,
* ui: prev.ui?.map(component =>
* component.props.isLoading
* ? { ...component, props: { ...component.props, stopped: true, isLoading: false }}
* : component
* )
* }));
* }
* ```
*/
onStop?: (options: {
mutate: (
update: Partial<StateType> | ((prev: StateType) => Partial<StateType>),
) => void;
}) => void;
/**
* The ID of the thread to fetch history and current values from.
*/
@@ -523,6 +548,17 @@ export interface UseStreamOptions<
/** Will reconnect the stream on mount */
reconnectOnMount?: boolean | (() => RunMetadataStorage);
/**
* Initial values to display immediately when loading a thread.
* Useful for displaying cached thread data while official history loads.
* These values will be replaced when official thread data is fetched.
*
* Note: UI components from initialValues will render immediately if they're
* predefined in LoadExternalComponent's components prop, providing instant
* cached UI display without server fetches.
*/
initialValues?: StateType | null;
}
interface RunMetadataStorage {
@@ -656,6 +692,61 @@ interface SubmitOptions<
*/
streamSubgraphs?: boolean;
streamResumable?: boolean;
/**
* The ID to use when creating a new thread. When provided, this ID will be used
* for thread creation when threadId is `null` or `undefined`.
* This enables optimistic UI updates where you know the thread ID
* before the thread is actually created.
*/
threadId?: string;
}
function useStreamValuesState<StateType extends Record<string, unknown>>() {
type Kind = "stream" | "stop";
type Values = StateType | null;
type Update = Values | ((prev: Values, kind?: Kind) => Values);
type Mutate = Partial<StateType> | ((prev: StateType) => Partial<StateType>);
const [values, setValues] = useState<[values: StateType, kind: Kind] | null>(
null,
);
const setStreamValues = useCallback(
(values: Update, kind: Kind = "stream") => {
if (typeof values === "function") {
setValues((prevTuple) => {
const [prevValues, prevKind] = prevTuple ?? [null, "stream"];
const next = values(prevValues, prevKind);
if (next == null) return null;
return [next, kind] as [StateType, Kind];
});
return;
}
if (values == null) setValues(null);
setValues([values, kind] as [StateType, Kind]);
},
[],
);
const mutate = useCallback(
(kind: Kind, serverValues: StateType) => (update: Mutate) => {
setStreamValues((clientValues) => {
const prev = { ...serverValues, ...clientValues };
const next = typeof update === "function" ? update(prev) : update;
return { ...prev, ...next };
}, kind);
},
[setStreamValues],
);
return [values?.[0] ?? null, setStreamValues, mutate] as [
Values,
(update: Update, kind?: Kind) => void,
(kind: Kind, serverValues: StateType) => (update: Mutate) => void,
];
}
export function useStream<
@@ -721,7 +812,8 @@ export function useStream<
const [isLoading, setIsLoading] = useState(false);
const [streamError, setStreamError] = useState<unknown>(undefined);
const [streamValues, setStreamValues] = useState<StateType | null>(null);
const [streamValues, setStreamValues, getMutateFn] =
useStreamValuesState<StateType>();
const messageManagerRef = useRef(new MessageTupleManager());
const submittingRef = useRef(false);
@@ -792,7 +884,9 @@ export function useStream<
);
const threadHead: ThreadState<StateType> | undefined = flatHistory.at(-1);
const historyValues = threadHead?.values ?? ({} as StateType);
const historyValues =
threadHead?.values ?? options.initialValues ?? ({} as StateType);
const historyError = (() => {
const error = threadHead?.tasks?.at(-1)?.error;
if (error == null) return undefined;
@@ -857,6 +951,8 @@ export function useStream<
if (runId) client.runs.cancel(threadId, runId);
runMetadataStorage.removeItem(`lg:stream:${threadId}`);
}
options?.onStop?.({ mutate: getMutateFn("stop", historyValues) });
};
async function consumeStream(
@@ -889,15 +985,7 @@ export function useStream<
if (event === "updates") options.onUpdateEvent?.(data);
if (event === "custom")
options.onCustomEvent?.(data, {
mutate: (update) =>
setStreamValues((prev) => {
// should not happen
if (prev == null) return prev;
return {
...prev,
...(typeof update === "function" ? update(prev) : update),
};
}),
mutate: getMutateFn("stream", historyValues),
});
if (event === "metadata") options.onMetadataEvent?.(data);
if (event === "events") options.onLangChainEvent?.(data);
@@ -939,8 +1027,11 @@ export function useStream<
// TODO: stream created checkpoints to avoid an unnecessary network request
const result = await run.onSuccess();
setStreamValues(null);
setStreamValues((values, kind) => {
// Do not clear out the user values set on `stop`.
if (kind === "stop") return values;
return null;
});
if (streamError != null) throw streamError;
const lastHead = result.at(0);
@@ -1003,26 +1094,24 @@ export function useStream<
if (newPath != null) setBranch(newPath ?? "");
// Assumption: we're setting the initial value
// Used for instant feedback
setStreamValues(() => {
const values = { ...historyValues };
if (submitOptions?.optimisticValues != null) {
return {
...values,
...historyValues,
...(typeof submitOptions.optimisticValues === "function"
? submitOptions.optimisticValues(values)
? submitOptions.optimisticValues(historyValues)
: submitOptions.optimisticValues),
};
}
return values;
return { ...historyValues };
});
let usableThreadId = threadId;
if (!usableThreadId) {
const thread = await client.threads.create();
const thread = await client.threads.create({
threadId: submitOptions?.threadId,
});
onThreadId(thread.thread_id);
usableThreadId = thread.thread_id;
}
+16 -16
View File
@@ -20,7 +20,7 @@ describe("BytesLineDecoder", () => {
test("handles single line with newline", async () => {
const input = createStream([textEncoder.encode("hello\n")]);
const decoded = input.pipeThrough(new BytesLineDecoder());
const decoded = input.pipeThrough(BytesLineDecoder());
const results = await gather(decoded);
expect(results.length).toBe(1);
@@ -29,7 +29,7 @@ describe("BytesLineDecoder", () => {
test("handles multiple lines", async () => {
const input = createStream([textEncoder.encode("line1\nline2\nline3\n")]);
const decoded = input.pipeThrough(new BytesLineDecoder());
const decoded = input.pipeThrough(BytesLineDecoder());
const results = await gather(decoded);
expect(results.length).toBe(3);
@@ -44,7 +44,7 @@ describe("BytesLineDecoder", () => {
textEncoder.encode("ne1\nli"),
textEncoder.encode("ne2\n"),
]);
const decoded = input.pipeThrough(new BytesLineDecoder());
const decoded = input.pipeThrough(BytesLineDecoder());
const results = await gather(decoded);
expect(results.length).toBe(2);
@@ -54,7 +54,7 @@ describe("BytesLineDecoder", () => {
test("handles CR LF line endings", async () => {
const input = createStream([textEncoder.encode("line1\r\nline2\r\n")]);
const decoded = input.pipeThrough(new BytesLineDecoder());
const decoded = input.pipeThrough(BytesLineDecoder());
const results = await gather(decoded);
expect(results.length).toBe(2);
@@ -67,7 +67,7 @@ describe("BytesLineDecoder", () => {
textEncoder.encode("line1\r"),
textEncoder.encode("\nline2\r\n"),
]);
const decoded = input.pipeThrough(new BytesLineDecoder());
const decoded = input.pipeThrough(BytesLineDecoder());
const results = await gather(decoded);
expect(results.length).toBe(2);
@@ -77,7 +77,7 @@ describe("BytesLineDecoder", () => {
test("handles stale line", async () => {
const input = createStream([textEncoder.encode("hello")]);
const decoded = input.pipeThrough(new BytesLineDecoder());
const decoded = input.pipeThrough(BytesLineDecoder());
const results = await gather(decoded);
expect(results.length).toBe(1);
@@ -99,8 +99,8 @@ describe("SSEDecoder", () => {
"\n",
]);
const decoded = input
.pipeThrough(new BytesLineDecoder())
.pipeThrough(new SSEDecoder());
.pipeThrough(BytesLineDecoder())
.pipeThrough(SSEDecoder());
const results = await gather(decoded);
expect(results.length).toBe(1);
@@ -117,8 +117,8 @@ describe("SSEDecoder", () => {
'data: {"message": "hello"}\n',
]);
const decoded = input
.pipeThrough(new BytesLineDecoder())
.pipeThrough(new SSEDecoder());
.pipeThrough(BytesLineDecoder())
.pipeThrough(SSEDecoder());
const results = await gather(decoded);
expect(results.length).toBe(1);
@@ -138,8 +138,8 @@ describe("SSEDecoder", () => {
"\n",
]);
const decoded = input
.pipeThrough(new BytesLineDecoder())
.pipeThrough(new SSEDecoder());
.pipeThrough(BytesLineDecoder())
.pipeThrough(SSEDecoder());
const results = await gather(decoded);
expect(results.length).toBe(2);
@@ -156,8 +156,8 @@ describe("SSEDecoder", () => {
test("end event without data", async () => {
const input = createStream(["event: test\n"]);
const decoded = input
.pipeThrough(new BytesLineDecoder())
.pipeThrough(new SSEDecoder());
.pipeThrough(BytesLineDecoder())
.pipeThrough(SSEDecoder());
const results = await gather(decoded);
expect(results.length).toBe(1);
@@ -170,8 +170,8 @@ describe("SSEDecoder", () => {
test("end event without newline", async () => {
const input = createStream(["event: end"]);
const decoded = input
.pipeThrough(new BytesLineDecoder())
.pipeThrough(new SSEDecoder());
.pipeThrough(BytesLineDecoder())
.pipeThrough(SSEDecoder());
const results = await gather(decoded);
expect(results.length).toBe(1);
+318 -9
View File
@@ -6,15 +6,15 @@ import { userEvent } from "@testing-library/user-event";
import { setupServer } from "msw/node";
import { http } from "msw";
import { useStream } from "../react/stream.js";
import type { Message } from "../types.messages.js";
import { StateGraph, MessagesAnnotation, START } from "@langchain/langgraph";
import { MemorySaver } from "@langchain/langgraph-checkpoint";
import { FakeStreamingChatModel } from "@langchain/core/utils/testing";
import { AIMessage, BaseMessageLike } from "@langchain/core/messages";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { AIMessage } from "@langchain/core/messages";
import { createEmbedServer } from "@langchain/langgraph-api/experimental/embed";
import { randomUUID } from "node:crypto";
import { useState } from "react";
const threads = (() => {
const THREADS: Record<
@@ -40,17 +40,14 @@ const checkpointer = new MemorySaver();
const model = new FakeStreamingChatModel({ responses: [new AIMessage("Hey")] });
const agent = new StateGraph(MessagesAnnotation)
.addNode("agent", async (state: { messages: BaseMessageLike[] }) => {
.addNode("agent", async (state: { messages: Message[] }) => {
const response = await model.invoke(state.messages);
return { messages: [response] };
})
.addEdge(START, "agent")
.compile();
const app = new Hono();
app.use(logger());
app.route("/", createEmbedServer({ graph: { agent }, checkpointer, threads }));
const app = createEmbedServer({ graph: { agent }, checkpointer, threads });
const server = setupServer(http.all("*", (ctx) => app.fetch(ctx.request)));
function TestChatComponent() {
@@ -138,4 +135,316 @@ describe("useStream", () => {
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
});
});
it("displays initial values immediately and clears them when submitting", async () => {
const user = userEvent.setup();
function TestCachedComponent() {
const { messages, values, submit } = useStream<{
messages: Message[];
}>({
assistantId: "agent",
apiKey: "test-api-key",
initialValues: {
messages: [
{ id: "cached-1", type: "human", content: "Cached user message" },
{ id: "cached-2", type: "ai", content: "Cached AI response" },
],
},
});
return (
<div>
<div data-testid="messages">
{messages.map((msg, i) => (
<div
key={msg.id ?? i}
data-testid={
msg.id?.includes("cached")
? `message-cached-${i}`
: `message-${i}`
}
>
{typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content)}
</div>
))}
</div>
<div data-testid="values">{JSON.stringify(values)}</div>
<button
data-testid="submit"
onClick={() =>
submit({ messages: [{ content: "Hello", type: "human" }] })
}
>
Submit
</button>
</div>
);
}
render(<TestCachedComponent />);
// Should immediately show cached messages
expect(screen.getByTestId("message-cached-0")).toHaveTextContent(
"Cached user message",
);
expect(screen.getByTestId("message-cached-1")).toHaveTextContent(
"Cached AI response",
);
// Values should include initial values
expect(screen.getByTestId("values")).toHaveTextContent(
"Cached user message",
);
// Submitting should clear out the cached messages
await user.click(screen.getByTestId("submit"));
// Wait for messages to appear
await waitFor(() => {
expect(screen.getByTestId("message-0")).toHaveTextContent("Hello");
expect(screen.getByTestId("message-1")).toHaveTextContent("Hey");
});
});
it("accepts newThreadId option without errors", async () => {
const user = userEvent.setup();
const spy = vi.fn();
const predeterminedThreadId = randomUUID();
// Test that newThreadId option can be passed without causing errors
function TestNewThreadComponent() {
const stream = useStream<{ messages: Message[] }>({
assistantId: "agent",
apiKey: "test-api-key",
threadId: null, // Start with no thread
onThreadId: spy, // Mock callback
});
return (
<div>
<div data-testid="loading">
{stream.isLoading ? "Loading..." : "Not loading"}
</div>
<div data-testid="thread-id">
{stream.client ? "Client ready" : "No client"}
</div>
<button
data-testid="submit"
onClick={() =>
stream.submit({}, { threadId: predeterminedThreadId })
}
>
Submit
</button>
</div>
);
}
render(<TestNewThreadComponent />);
// Should render without errors
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
expect(screen.getByTestId("thread-id")).toHaveTextContent("Client ready");
await user.click(screen.getByTestId("submit"));
expect(spy).toHaveBeenCalledWith(predeterminedThreadId);
expect(await threads.get(predeterminedThreadId)).toEqual({
thread_id: predeterminedThreadId,
metadata: {
graph_id: "agent",
assistant_id: "agent",
},
});
});
it("onStop callback is called when stop is called", async () => {
const user = userEvent.setup();
const onStopCallback = vi.fn();
function TestComponent() {
const { submit, stop } = useStream({
assistantId: "agent",
apiKey: "test-api-key",
onStop: onStopCallback,
});
return (
<div>
<button data-testid="submit" onClick={() => submit({})}>
Send
</button>
<button data-testid="stop" onClick={stop}>
Stop
</button>
</div>
);
}
render(<TestComponent />);
// Start a stream and stop it
await user.click(screen.getByTestId("submit"));
await user.click(screen.getByTestId("stop"));
// Verify onStop was called with mutate function
expect(onStopCallback).toHaveBeenCalledTimes(1);
expect(onStopCallback).toHaveBeenCalledWith(
expect.objectContaining({
mutate: expect.any(Function),
}),
);
});
it("onStop mutate function updates stream values immediately", async () => {
const user = userEvent.setup();
function TestComponent() {
const [stopped, setStopped] = useState(false);
const { submit, stop, messages } = useStream<{ messages: Message[] }>({
assistantId: "agent",
apiKey: "test-api-key",
onStop: ({ mutate }) => {
setStopped(true);
mutate((prev) => ({
...prev,
messages: [
...(prev.messages ?? []),
{ type: "ai", content: "Stream stopped" },
],
}));
},
});
return (
<div>
<div data-testid="stopped-status">
{stopped ? "Stopped" : "Not stopped"}
</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>
<button data-testid="submit" onClick={() => submit({})}>
Send
</button>
<button data-testid="stop" onClick={stop}>
Stop
</button>
</div>
);
}
render(<TestComponent />);
// Initial state
expect(screen.getByTestId("stopped-status")).toHaveTextContent(
"Not stopped",
);
// Start and stop stream
await user.click(screen.getByTestId("submit"));
await user.click(screen.getByTestId("stop"));
// Verify state was updated immediately
await waitFor(() => {
expect(screen.getByTestId("stopped-status")).toHaveTextContent("Stopped");
expect(screen.getByTestId("message-0")).toHaveTextContent(
"Stream stopped",
);
});
});
it("onStop handles functional updates correctly", async () => {
const user = userEvent.setup();
function TestComponent() {
const { submit, stop, values } = useStream({
assistantId: "agent",
apiKey: "test-api-key",
initialValues: {
counter: 5,
items: ["item1", "item2"],
},
onStop: ({ mutate }) => {
mutate((prev: any) => ({
...prev,
counter: (prev.counter || 0) + 10,
items: [...(prev.items || []), "stopped"],
}));
},
});
return (
<div>
<div data-testid="counter">{(values as any).counter}</div>
<div data-testid="items">{(values as any).items?.join(", ")}</div>
<button data-testid="submit" onClick={() => submit({})}>
Send
</button>
<button data-testid="stop" onClick={stop}>
Stop
</button>
</div>
);
}
render(<TestComponent />);
// Initial state
expect(screen.getByTestId("counter")).toHaveTextContent("5");
expect(screen.getByTestId("items")).toHaveTextContent("item1, item2");
// Start and stop stream
await user.click(screen.getByTestId("submit"));
await user.click(screen.getByTestId("stop"));
// Verify functional update was applied correctly
await waitFor(() => {
expect(screen.getByTestId("counter")).toHaveTextContent("15");
expect(screen.getByTestId("items")).toHaveTextContent(
"item1, item2, stopped",
);
});
});
it("onStop is not called when stream completes naturally", async () => {
const user = userEvent.setup();
const onStopCallback = vi.fn();
function TestComponent() {
const { submit } = useStream({
assistantId: "agent",
apiKey: "test-api-key",
onStop: onStopCallback,
});
return (
<div>
<button data-testid="submit" onClick={() => submit({})}>
Send
</button>
</div>
);
}
render(<TestComponent />);
// Start a stream and let it complete naturally
await user.click(screen.getByTestId("submit"));
// Wait for stream to complete naturally
await waitFor(() => {
expect(onStopCallback).not.toHaveBeenCalled();
});
});
});
+16 -23
View File
@@ -13,16 +13,22 @@ type MessageContent = string | MessageContentComplex[];
*/
type MessageAdditionalKwargs = Record<string, unknown>;
export type HumanMessage = {
type: "human";
id?: string | undefined;
type BaseMessage = {
additional_kwargs?: MessageAdditionalKwargs | undefined;
content: MessageContent;
id?: string | undefined;
name?: string | undefined;
response_metadata?: Record<string, unknown> | undefined;
};
export type AIMessage = {
export type HumanMessage = BaseMessage & {
type: "human";
example?: boolean | undefined;
};
export type AIMessage = BaseMessage & {
type: "ai";
id?: string | undefined;
content: MessageContent;
example?: boolean | undefined;
tool_calls?:
| {
name: string;
@@ -57,19 +63,12 @@ export type AIMessage = {
| undefined;
}
| undefined;
additional_kwargs?: MessageAdditionalKwargs | undefined;
response_metadata?: Record<string, unknown> | undefined;
};
export type ToolMessage = {
export type ToolMessage = BaseMessage & {
type: "tool";
name?: string | undefined;
id?: string | undefined;
content: MessageContent;
status?: "error" | "success" | undefined;
tool_call_id: string;
additional_kwargs?: MessageAdditionalKwargs | undefined;
response_metadata?: Record<string, unknown> | undefined;
/**
* Artifact of the Tool execution which is not meant to be sent to the model.
*
@@ -81,22 +80,16 @@ export type ToolMessage = {
artifact?: any;
};
export type SystemMessage = {
export type SystemMessage = BaseMessage & {
type: "system";
id?: string | undefined;
content: MessageContent;
};
export type FunctionMessage = {
export type FunctionMessage = BaseMessage & {
type: "function";
id?: string | undefined;
content: MessageContent;
};
export type RemoveMessage = {
export type RemoveMessage = BaseMessage & {
type: "remove";
id: string;
content: MessageContent;
};
export type Message =
+119 -123
View File
@@ -6,90 +6,88 @@ const SPACE = " ".charCodeAt(0);
const TRAILING_NEWLINE = [CR, LF];
export class BytesLineDecoder extends TransformStream<Uint8Array, Uint8Array> {
constructor() {
let buffer: Uint8Array[] = [];
let trailingCr = false;
export function BytesLineDecoder() {
let buffer: Uint8Array[] = [];
let trailingCr = false;
super({
start() {
buffer = [];
return new TransformStream<Uint8Array, Uint8Array>({
start() {
buffer = [];
trailingCr = false;
},
transform(chunk, controller) {
// See https://docs.python.org/3/glossary.html#term-universal-newlines
let text = chunk;
// Handle trailing CR from previous chunk
if (trailingCr) {
text = joinArrays([[CR], text]);
trailingCr = false;
},
}
transform(chunk, controller) {
// See https://docs.python.org/3/glossary.html#term-universal-newlines
let text = chunk;
// Check for trailing CR in current chunk
if (text.length > 0 && text.at(-1) === CR) {
trailingCr = true;
text = text.subarray(0, -1);
}
// Handle trailing CR from previous chunk
if (trailingCr) {
text = joinArrays([[CR], text]);
trailingCr = false;
}
if (!text.length) return;
const trailingNewline = TRAILING_NEWLINE.includes(text.at(-1)!);
// Check for trailing CR in current chunk
if (text.length > 0 && text.at(-1) === CR) {
trailingCr = true;
text = text.subarray(0, -1);
}
const lastIdx = text.length - 1;
const { lines } = text.reduce<{ lines: Uint8Array[]; from: number }>(
(acc, cur, idx) => {
if (acc.from > idx) return acc;
if (!text.length) return;
const trailingNewline = TRAILING_NEWLINE.includes(text.at(-1)!);
const lastIdx = text.length - 1;
const { lines } = text.reduce<{ lines: Uint8Array[]; from: number }>(
(acc, cur, idx) => {
if (acc.from > idx) return acc;
if (cur === CR || cur === LF) {
acc.lines.push(text.subarray(acc.from, idx));
if (cur === CR && text[idx + 1] === LF) {
acc.from = idx + 2;
} else {
acc.from = idx + 1;
}
if (cur === CR || cur === LF) {
acc.lines.push(text.subarray(acc.from, idx));
if (cur === CR && text[idx + 1] === LF) {
acc.from = idx + 2;
} else {
acc.from = idx + 1;
}
}
if (idx === lastIdx && acc.from <= lastIdx) {
acc.lines.push(text.subarray(acc.from));
}
if (idx === lastIdx && acc.from <= lastIdx) {
acc.lines.push(text.subarray(acc.from));
}
return acc;
},
{ lines: [], from: 0 },
);
return acc;
},
{ lines: [], from: 0 },
);
if (lines.length === 1 && !trailingNewline) {
buffer.push(lines[0]);
return;
}
if (lines.length === 1 && !trailingNewline) {
buffer.push(lines[0]);
return;
}
if (buffer.length) {
// Include existing buffer in first line
buffer.push(lines[0]);
lines[0] = joinArrays(buffer);
buffer = [];
}
if (buffer.length) {
// Include existing buffer in first line
buffer.push(lines[0]);
lines[0] = joinArrays(buffer);
buffer = [];
}
if (!trailingNewline) {
// If the last segment is not newline terminated,
// buffer it for the next chunk
if (lines.length) buffer = [lines.pop()!];
}
if (!trailingNewline) {
// If the last segment is not newline terminated,
// buffer it for the next chunk
if (lines.length) buffer = [lines.pop()!];
}
// Enqueue complete lines
for (const line of lines) {
controller.enqueue(line);
}
},
// Enqueue complete lines
for (const line of lines) {
controller.enqueue(line);
}
},
flush(controller) {
if (buffer.length) {
controller.enqueue(joinArrays(buffer));
}
},
});
}
flush(controller) {
if (buffer.length) {
controller.enqueue(joinArrays(buffer));
}
},
});
}
interface StreamPart {
@@ -98,69 +96,67 @@ interface StreamPart {
data: unknown;
}
export class SSEDecoder extends TransformStream<Uint8Array, StreamPart> {
constructor() {
let event = "";
let data: Uint8Array[] = [];
let lastEventId = "";
let retry: number | null = null;
export function SSEDecoder() {
let event = "";
let data: Uint8Array[] = [];
let lastEventId = "";
let retry: number | null = null;
const decoder = new TextDecoder();
const decoder = new TextDecoder();
super({
transform(chunk, controller) {
// Handle empty line case
if (!chunk.length) {
if (!event && !data.length && !lastEventId && retry == null) return;
return new TransformStream<Uint8Array, StreamPart>({
transform(chunk, controller) {
// Handle empty line case
if (!chunk.length) {
if (!event && !data.length && !lastEventId && retry == null) return;
const sse = {
id: lastEventId || undefined,
event,
data: data.length ? decodeArraysToJson(decoder, data) : null,
};
const sse = {
id: lastEventId || undefined,
event,
data: data.length ? decodeArraysToJson(decoder, data) : null,
};
// NOTE: as per the SSE spec, do not reset lastEventId
event = "";
data = [];
retry = null;
// NOTE: as per the SSE spec, do not reset lastEventId
event = "";
data = [];
retry = null;
controller.enqueue(sse);
return;
}
controller.enqueue(sse);
return;
}
// Ignore comments
if (chunk[0] === COLON) return;
// Ignore comments
if (chunk[0] === COLON) return;
const sepIdx = chunk.indexOf(COLON);
if (sepIdx === -1) return;
const sepIdx = chunk.indexOf(COLON);
if (sepIdx === -1) return;
const fieldName = decoder.decode(chunk.subarray(0, sepIdx));
let value = chunk.subarray(sepIdx + 1);
if (value[0] === SPACE) value = value.subarray(1);
const fieldName = decoder.decode(chunk.subarray(0, sepIdx));
let value = chunk.subarray(sepIdx + 1);
if (value[0] === SPACE) value = value.subarray(1);
if (fieldName === "event") {
event = decoder.decode(value);
} else if (fieldName === "data") {
data.push(value);
} else if (fieldName === "id") {
if (value.indexOf(NULL) === -1) lastEventId = decoder.decode(value);
} else if (fieldName === "retry") {
const retryNum = Number.parseInt(decoder.decode(value));
if (!Number.isNaN(retryNum)) retry = retryNum;
}
},
if (fieldName === "event") {
event = decoder.decode(value);
} else if (fieldName === "data") {
data.push(value);
} else if (fieldName === "id") {
if (value.indexOf(NULL) === -1) lastEventId = decoder.decode(value);
} else if (fieldName === "retry") {
const retryNum = Number.parseInt(decoder.decode(value));
if (!Number.isNaN(retryNum)) retry = retryNum;
}
},
flush(controller) {
if (event) {
controller.enqueue({
id: lastEventId || undefined,
event,
data: data.length ? decodeArraysToJson(decoder, data) : null,
});
}
},
});
}
flush(controller) {
if (event) {
controller.enqueue({
id: lastEventId || undefined,
event,
data: data.length ? decodeArraysToJson(decoder, data) : null,
});
}
},
});
}
function joinArrays(data: ArrayLike<number>[]) {