snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
All checks were successful
Release / Create Version PR or Publish Stable Release (push) Has been skipped
Release / Publish Prerelease to NPM (push) Has been skipped
Monitor Registries / Monitor registry health (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:44:43 +05:00
commit 1416db152c
5727 changed files with 630238 additions and 0 deletions

2
packages/helpers/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
.turbo

View File

@@ -0,0 +1,15 @@
# @shadcn/helpers
## 0.2.0
### Minor Changes
- [#11484](https://github.com/shadcn-ui/ui/pull/11484) [`401d10b8180b17eb1fdd36d537abaa8aeb68708f`](https://github.com/shadcn-ui/ui/commit/401d10b8180b17eb1fdd36d537abaa8aeb68708f) Thanks [@shadcn](https://github.com/shadcn)! - Add human-in-the-loop mocking for the AI SDK.
- [#11484](https://github.com/shadcn-ui/ui/pull/11484) [`401d10b8180b17eb1fdd36d537abaa8aeb68708f`](https://github.com/shadcn-ui/ui/commit/401d10b8180b17eb1fdd36d537abaa8aeb68708f) Thanks [@shadcn](https://github.com/shadcn)! - Make `createChat` generic over the UI message type, like `useChat`.
## 0.1.0
### Minor Changes
- [#11168](https://github.com/shadcn-ui/ui/pull/11168) [`0ec4a7ffbf21af08ed31b721696614533aa591c7`](https://github.com/shadcn-ui/ui/commit/0ec4a7ffbf21af08ed31b721696614533aa591c7) Thanks [@shadcn](https://github.com/shadcn)! - initial release of @shadcn/helpers

View File

@@ -0,0 +1,94 @@
# @shadcn/helpers
A collection of helpers for building AI applications.
## createChat
```ts
import { createChat } from "@shadcn/helpers/ai-sdk"
const chat = createChat()
.user("What's the weather in San Francisco?")
.sleep(800)
.assistant(({ writer }) => {
writer.reasoning("I should call the weather tool first.")
writer
.tool("getWeather", { input: { city: "San Francisco" } })
.sleep(1200)
.output({ temperature: 72, condition: "sunny" })
writer.text("It's sunny and 72 degrees in San Francisco.")
})
```
## Human in the loop
A tool call left unresolved pauses the turn: the input streams, the turn
finishes, and the client decides what happens next. A callback turn scripted
after a paused turn becomes a continuation. It materializes when the follow-up
request arrives and receives the resolved `toolCall` in its context.
For client-executed tools, the user supplies the output through
`addToolOutput` and the continuation reads it:
```ts
const chat = createChat()
.user("Help me plan the release.")
.assistant(({ writer }) => {
writer.tool("askQuestions", { dynamic: true, input: { questions } })
})
.assistant(({ writer, toolCall }) => {
writer.text(`Got it. Starting with ${toolCall?.output?.answers.direction}.`)
})
```
For approval-gated tools, `needsApproval` pauses behind the user's decision.
`output` (or `errorText`) then means "stream this if approved"; denial streams
`tool-output-denied` automatically:
```ts
const chat = createChat()
.user("Clean up old deployments.")
.assistant(({ writer }) => {
writer.text("This will delete 3 deployments. Approve?")
writer.tool("deleteDeployments", {
input: { count: 3 },
needsApproval: true,
output: { deleted: 3 },
})
})
.assistant(({ writer, toolCall }) => {
writer.text(
toolCall?.approved
? "Deleted 3 deployments."
: "Okay, leaving them in place."
)
})
```
Wire the client with the AI SDK's own triggers:
`sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` for
client-executed tools, or `lastAssistantMessageIsCompleteWithApprovalResponses`
plus `addToolApprovalResponse({ id: part.approval.id, approved })` for
approvals.
Continuation callbacks must be pure; regenerating re-resolves them against the
current transcript. `get()` stops before the first continuation turn, since a
continuation has no message without a live transcript.
## Installation
```bash
npm install @shadcn/helpers
```
## Documentation
Visit https://ui.shadcn.com/docs to view the documentation.
## Contributing
Please read the [contributing guide](https://github.com/shadcn-ui/ui/blob/main/CONTRIBUTING.md).
## License
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).

View File

@@ -0,0 +1,81 @@
{
"name": "@shadcn/helpers",
"version": "0.2.0",
"description": "Small helpers for developing apps.",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"author": {
"name": "shadcn",
"url": "https://twitter.com/shadcn"
},
"repository": {
"type": "git",
"url": "https://github.com/shadcn-ui/ui.git",
"directory": "packages/helpers"
},
"homepage": "https://ui.shadcn.com",
"bugs": {
"url": "https://github.com/shadcn-ui/ui/issues"
},
"files": [
"dist"
],
"keywords": [
"ai",
"chat",
"helpers",
"development",
"ai-sdk",
"tanstack",
"shadcn"
],
"type": "module",
"sideEffects": false,
"exports": {
"./ai-sdk": {
"types": "./dist/ai-sdk/index.d.ts",
"default": "./dist/ai-sdk/index.js"
},
"./tanstack-ai": {
"types": "./dist/tanstack-ai/index.d.ts",
"default": "./dist/tanstack-ai/index.js"
}
},
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc --noEmit",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"clean": "rimraf dist",
"test": "vitest run"
},
"peerDependencies": {
"@tanstack/ai": ">=0.40.0 <0.41.0",
"@tanstack/ai-client": ">=0.20.0 <0.21.0",
"ai": ">=7.0.0 <8"
},
"peerDependenciesMeta": {
"@tanstack/ai": {
"optional": true
},
"@tanstack/ai-client": {
"optional": true
},
"ai": {
"optional": true
}
},
"devDependencies": {
"@tanstack/ai": "0.40.0",
"@tanstack/ai-client": "0.20.0",
"ai": "7.0.0-canary.159",
"zod": "3.25.76",
"rimraf": "^6.0.1",
"tsup": "^8.5.0",
"typescript": "^5.9.2",
"vitest": "^3.2.6"
}
}

View File

@@ -0,0 +1,390 @@
import type { UIMessage } from "ai"
import { describe, expect, expectTypeOf, it } from "vitest"
import { testChatContract } from "../test/chat-contract"
import { createChat } from "./index"
import { getMessageText } from "./parts"
import {
artifact,
weatherLoading,
weatherOutput,
weatherSuccess,
} from "./test-utils"
import type { DataParts, TestMessage, Tools } from "./test-utils"
testChatContract("AI SDK", () =>
createChat().user("One").assistant("A").user("Two").assistant("B")
)
describe("AI SDK chat", () => {
it("narrows the continuation toolCall by name", () => {
createChat<TestMessage>()
.user("Weather?")
.assistant(({ writer }) => {
writer.tool("getWeather", { input: { city: "San Francisco" } })
})
.assistant(({ toolCall }) => {
if (toolCall?.name === "getWeather") {
expectTypeOf(toolCall.input).toEqualTypeOf<
Tools["getWeather"]["input"]
>()
expectTypeOf(toolCall.output).toEqualTypeOf<
Tools["getWeather"]["output"] | undefined
>()
}
})
})
it("preserves the native message type through get and next", () => {
type Metadata = { model: string }
const chat = createChat<UIMessage<Metadata, DataParts, Tools>>()
.user("Weather?", { metadata: { model: "test" } })
.assistant("Sunny.", { metadata: { model: "test" } })
const messages = chat.get()
const next = chat.next([])
expectTypeOf(messages).toEqualTypeOf<
Array<UIMessage<Metadata, DataParts, Tools>>
>()
expectTypeOf(next).toEqualTypeOf<UIMessage<
Metadata,
DataParts,
Tools
> | null>()
})
it("creates chat fixture user messages with attachments", () => {
const chat = createChat().user("Analyze this PDF and summarize it.", {
files: [
{
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
},
],
})
const [message] = chat.get()
expect(message).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "Analyze this PDF and summarize it.",
},
{
type: "file",
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
},
],
})
})
it("creates a default chat fixture with createChat", () => {
const chat = createChat().user("Hello").assistant("Hey.")
expect(chat.get().map((message) => message.role)).toEqual([
"user",
"assistant",
])
})
it("gets text from chat fixture messages", () => {
const chat = createChat()
.user("Hello")
.assistant(({ writer }) => {
writer.reasoning("Reasoning should not be included.")
writer.text("Hey, ")
writer.text("how's it going?")
})
.user("What's next?")
const messages = chat.get(2)
const [, assistantMessage] = messages
const nextMessage = chat.next(messages)
expect(messages.map(getMessageText).join("\n")).toBe(
"Hello\nHey, how's it going?"
)
expect(getMessageText(assistantMessage)).toBe("Hey, how's it going?")
expect(nextMessage ? getMessageText(nextMessage) : "").toBe("What's next?")
})
it("supports assistant ids and metadata in chat fixtures", () => {
const chat = createChat<UIMessage<{ model: string }>>()
.user("Hello")
.assistant("Hey.", {
id: "assistant-1",
metadata: {
model: "gpt-5",
},
})
const [, assistantMessage] = chat.get()
expect(assistantMessage).toMatchObject({
id: "assistant-1",
role: "assistant",
metadata: {
model: "gpt-5",
},
})
})
it("creates arbitrary data and tool parts with createChat", () => {
const chat = createChat().assistant(({ writer }) => {
writer.data({
type: "data-invoice",
id: "invoice-1",
data: {
id: "inv-1",
status: "paid",
},
})
writer
.tool("lookupInvoice", {
input: {
id: "inv-1",
},
})
.output({
id: "inv-1",
total: 128,
})
})
const [message] = chat.get()
expect(message.parts).toMatchObject([
{
type: "data-invoice",
id: "invoice-1",
data: {
status: "paid",
},
},
{
type: "tool-lookupInvoice",
state: "output-available",
input: {
id: "inv-1",
},
output: {
total: 128,
},
},
])
})
it("returns top messages and then the next scripted message", () => {
const chat = createChat()
.user("Hello")
.assistant("Hey, how's it going?")
.user("What's the weather?")
.assistant("It's sunny.")
const initialMessages = chat.get(2)
const nextMessage = chat.next(initialMessages)
expect(initialMessages.map((message) => message.role)).toEqual([
"user",
"assistant",
])
expect(nextMessage).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "What's the weather?",
},
],
})
})
it("does not mutate playback when next is called again", () => {
const chat = createChat()
.user("Hello")
.assistant("Hey.")
.user("What's the weather?")
.assistant("It's sunny.")
.user("What time is it?")
.assistant("It is noon.")
const initialMessages = chat.get(2)
const first = chat.next(initialMessages)
const second = chat.next(initialMessages)
expect(first).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "What's the weather?",
},
],
})
expect(second).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "What's the weather?",
},
],
})
})
it("finds the next user message from a transcript", () => {
const chat = createChat()
.user("Hello")
.assistant("Hey.")
.user("What's the weather?")
.assistant("It's sunny.")
const [firstMessage] = chat.get(1)
const nextMessage = chat.next([firstMessage])
expect(nextMessage).toMatchObject({
role: "user",
parts: [
{
type: "text",
text: "What's the weather?",
},
],
})
})
it("materializes assistant writer parts into the final chat transcript", () => {
const chat = createChat<TestMessage>().assistant(({ writer }) => {
writer.reasoning("I need to call the weather tool.")
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherLoading,
})
const weather = writer.tool("getWeather", {
title: "Checking weather",
input: {
city: "San Francisco",
},
})
writer.sleep(100)
weather.output(weatherOutput)
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherSuccess,
})
writer.text("It's sunny and 72 degrees in San Francisco.")
})
const [message] = chat.get()
expect(message.parts.map((part) => part.type)).toEqual([
"reasoning",
"data-weather",
"tool-getWeather",
"text",
])
expect(message.parts[1]).toMatchObject({
type: "data-weather",
id: "weather-1",
data: {
status: "success",
},
})
expect(message.parts[2]).toMatchObject({
type: "tool-getWeather",
state: "output-available",
output: {
condition: "sunny",
},
})
})
it("produces identical transcripts for identical scripts", () => {
function script() {
return createChat<TestMessage>()
.user("Hello")
.assistant(({ writer }) => {
writer.reasoning("Checking the forecast.")
writer
.tool("getWeather", {
input: {
city: "San Francisco",
},
})
.output(weatherOutput)
writer.text("It's sunny.")
})
}
const first = script()
const second = script()
expect(first.get()).toEqual(second.get())
expect(first.get().map((message) => message.id)).toEqual(["msg-1", "msg-2"])
})
it("materializes user text parts without a streaming state", () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [userMessage, assistantMessage] = chat.get()
expect(userMessage.parts[0]).toEqual({
type: "text",
text: "Hello",
})
expect(assistantMessage.parts[0]).toMatchObject({
type: "text",
state: "done",
})
})
it("hydrates a chat from an existing transcript", () => {
const source = createChat<TestMessage>()
.user("What's the weather?")
.assistant(({ writer }) => {
writer
.tool("getWeather", {
input: { city: "San Francisco" },
})
.output(weatherOutput)
writer.text("It's sunny.", { mode: "instant" })
})
.user("Thanks!")
.assistant("Anytime.")
const recorded = source.get()
const replay = createChat({ messages: recorded })
expect(replay.get(2)).toEqual(recorded.slice(0, 2))
expect(replay.next(recorded.slice(0, 2))).toMatchObject({
id: recorded[2].id,
role: "user",
})
expect(replay.get()).toEqual(recorded)
})
it("continues generated ids past the seeded range", () => {
const source = createChat().user("Hello").assistant("Hey.")
const replay = createChat({ messages: source.get() }).user("What's next?")
const messages = replay.get()
expect(messages.map((message) => message.id)).toEqual([
"msg-1",
"msg-2",
"msg-3",
])
})
})

View File

@@ -0,0 +1,143 @@
import type {
ChatTransport,
UIDataTypes,
UIMessage,
UIMessagePart,
UITools,
} from "ai"
import { createChatRuntime, wait } from "../core"
import type { Chat, ChatOptions } from "../core"
import { createAiSdkFormat } from "./format"
// Local equivalents of the AI SDK's InferUIMessage* utilities; not all of
// them are exported from the pinned ai version.
type MessageMetadata<UI_MESSAGE extends UIMessage> =
UI_MESSAGE extends UIMessage<infer METADATA> ? METADATA : unknown
type MessageData<UI_MESSAGE extends UIMessage> =
UI_MESSAGE extends UIMessage<unknown, infer DATA_PARTS>
? DATA_PARTS
: UIDataTypes
type MessageTools<UI_MESSAGE extends UIMessage> =
UI_MESSAGE extends UIMessage<unknown, UIDataTypes, infer TOOLS>
? TOOLS
: UITools
/** Options for creating an AI SDK chat, optionally hydrated from existing messages. */
export type CreateChatOptions<UI_MESSAGE extends UIMessage = UIMessage> =
ChatOptions & {
messages?: Array<
UIMessage<
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>
>
}
export type YieldMessagePartsOptions = {
/** Also yield an initial snapshot with zero parts. */
includeEmpty?: boolean
}
export type StreamMessagePartsOptions = YieldMessagePartsOptions & {
/** Delay before each snapshot. */
delayMs?: number
}
/** The chat type returned by the AI SDK adapter's `createChat`. */
export type AiSdkChat<UI_MESSAGE extends UIMessage = UIMessage> = Chat<
UIMessage<
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>,
UIMessagePart<MessageData<UI_MESSAGE>, MessageTools<UI_MESSAGE>>,
ChatTransport<
UIMessage<
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>
>,
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>
/**
* Yields progressive copies of a message — one more part per snapshot — for
* rendering "message assembling itself" states without a transport.
*/
export function* yieldMessageParts<
METADATA = unknown,
DATA_PARTS extends UIDataTypes = UIDataTypes,
TOOLS extends UITools = UITools,
>(
message: UIMessage<METADATA, DATA_PARTS, TOOLS>,
options: YieldMessagePartsOptions = {}
) {
if (options.includeEmpty) {
yield {
...message,
parts: [],
}
}
for (let index = 0; index < message.parts.length; index++) {
yield {
...message,
parts: message.parts.slice(0, index + 1),
}
}
}
/** Async variant of {@link yieldMessageParts} that waits `delayMs` before each snapshot. */
export async function* streamMessageParts<
METADATA = unknown,
DATA_PARTS extends UIDataTypes = UIDataTypes,
TOOLS extends UITools = UITools,
>(
message: UIMessage<METADATA, DATA_PARTS, TOOLS>,
options: StreamMessagePartsOptions = {}
) {
const snapshots = yieldMessageParts(message, options)
while (true) {
const snapshot = snapshots.next()
if (snapshot.done) {
break
}
if (options.delayMs) {
await wait(options.delayMs)
}
yield snapshot.value
}
}
/**
* Creates an AI SDK chat, optionally hydrated from existing messages. Generic
* over the UI message type, mirroring `useChat`.
*/
export function createChat<UI_MESSAGE extends UIMessage = UIMessage>(
options: CreateChatOptions<UI_MESSAGE> = {}
) {
const { messages, ...chatOptions } = options
return createChatRuntime(
createAiSdkFormat<
MessageMetadata<UI_MESSAGE>,
MessageData<UI_MESSAGE>,
MessageTools<UI_MESSAGE>
>(),
{
...chatOptions,
messages,
}
)
}

View File

@@ -0,0 +1,931 @@
import {
lastAssistantMessageIsCompleteWithApprovalResponses,
lastAssistantMessageIsCompleteWithToolCalls,
readUIMessageStream,
} from "ai"
import type { ChatTransport, UIMessage } from "ai"
import { describe, expect, it } from "vitest"
import { createChat } from "./index"
import { readStream, weatherLoading, weatherOutput } from "./test-utils"
import type { DataParts, TestMessage, Tools } from "./test-utils"
describe("AI SDK transport", () => {
it("chains tool output after an in-stream sleep", async () => {
const chat = createChat<TestMessage>()
.user("Weather?")
.assistant(({ writer }) => {
writer
.tool("getWeather", {
input: {
city: "San Francisco",
},
})
.sleep(100)
.output(weatherOutput)
})
const [userMessage] = chat.get(1)
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const reader = stream.getReader()
expect((await reader.read()).value?.type).toBe("start")
expect((await reader.read()).value?.type).toBe("tool-input-available")
const startedAt = Date.now()
const outputChunk = await reader.read()
expect(outputChunk.value?.type).toBe("tool-output-available")
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(75)
})
it("streams denied static tool parts", async () => {
const chat = createChat<TestMessage>()
.user("Use the available tools.")
.assistant([
{
type: "tool-getWeather",
toolCallId: "call-weather",
state: "output-denied",
input: { city: "San Francisco" },
approval: {
id: "approval-weather",
approved: false,
},
},
{
type: "dynamic-tool",
toolName: "searchDocs",
toolCallId: "call-docs",
state: "output-denied",
input: { query: "streaming" },
approval: {
id: "approval-docs",
approved: false,
},
},
])
const [userMessage] = chat.get(1)
const stream = await chat.transport({ delayMs: 0 }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"tool-input-available",
"tool-output-denied",
"tool-input-available",
"tool-output-denied",
"finish",
])
})
it("streams the next scripted assistant response through the transport", async () => {
const chat = createChat<TestMessage>()
.user("Hello")
.assistant("Hey, how's it going?")
.user("What's the weather?")
.assistant(({ writer }) => {
writer.reasoning("I need to call the weather tool.", {
mode: "instant",
})
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherLoading,
})
const weather = writer.tool("getWeather", {
input: {
city: "San Francisco",
},
})
weather.output(weatherOutput)
writer.text("It's sunny.", {
mode: "instant",
})
})
const initialMessages = chat.get(2)
const nextMessage = chat.next(initialMessages)
if (!nextMessage) {
throw new Error("Expected next message.")
}
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [
...initialMessages,
{
id: nextMessage.id ?? "next-message",
role: nextMessage.role ?? "user",
metadata: nextMessage.metadata,
parts: nextMessage.parts,
},
],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"reasoning-start",
"reasoning-delta",
"reasoning-end",
"data-weather",
"tool-input-available",
"tool-output-available",
"text-start",
"text-delta",
"text-end",
"finish",
])
expect(chunks).toContainEqual(
expect.objectContaining({
type: "tool-output-available",
output: {
city: "San Francisco",
temperature: 72,
condition: "sunny",
},
})
)
})
it("preserves streamed file filenames", async () => {
const chat = createChat()
.user("Send the report.")
.assistant(({ writer }) => {
writer.file({
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
})
})
const [userMessage] = chat.get(1)
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks).toContainEqual(
expect.objectContaining({
type: "file",
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
})
)
})
it("sleeps before the next scripted turn", async () => {
const chat = createChat()
.user("Hello")
.sleep(100)
.assistant(({ writer }) => {
writer.text("Hey.", {
mode: "instant",
})
})
const [userMessage] = chat.get(1)
const startedAt = Date.now()
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const reader = stream.getReader()
const firstChunk = await reader.read()
if (firstChunk.done) {
throw new Error("Expected first chunk.")
}
const chunks = [firstChunk.value]
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
expect(firstChunk.value?.type).toBe("start")
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(75)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"text-start",
"text-delta",
"text-end",
"finish",
])
})
it("emits a start chunk carrying the assistant message id", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [userMessage, assistantMessage] = chat.get()
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const reader = stream.getReader()
const firstChunk = await reader.read()
if (firstChunk.done) {
throw new Error("Expected first chunk.")
}
expect(firstChunk.value).toEqual({
type: "start",
messageId: assistantMessage.id,
})
const secondChunk = await reader.read()
if (secondChunk.done) {
throw new Error("Expected second chunk.")
}
expect(secondChunk.value?.type).toBe("text-start")
})
it("streams an app-level error before assistant content", async () => {
const chat = createChat().user("Hello").error("Model unavailable.")
const [userMessage] = chat.get(1)
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks).toEqual([
{
type: "start",
messageId: "msg-2",
},
{
type: "error",
errorText: "Model unavailable.",
},
])
})
it("streams an app-level error after partial assistant content", async () => {
const chat = createChat()
.user("Hello")
.assistant(({ writer }) => {
writer.text("Partial answer.", {
mode: "instant",
})
writer.error("Connection lost.")
})
const [userMessage] = chat.get(1)
const stream = await chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"text-start",
"text-delta",
"text-end",
"error",
])
expect(chunks.at(-1)).toEqual({
type: "error",
errorText: "Connection lost.",
})
})
it("emits an abort chunk when the request is aborted mid-stream", async () => {
const chat = createChat()
.user("Hello")
.assistant(({ writer }) => {
writer.text("First.", {
mode: "instant",
})
writer.sleep(150)
writer.text("Second.", {
mode: "instant",
})
})
const [userMessage] = chat.get(1)
const controller = new AbortController()
const stream = await chat.transport({ delayMs: 0 }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: controller.signal,
})
const reader = stream.getReader()
const received: string[] = []
while (received.at(-1) !== "text-end") {
const { done, value } = await reader.read()
if (done) {
throw new Error("Expected more chunks before the abort.")
}
received.push(value.type)
}
controller.abort()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
expect(received).toEqual(["start", "text-start", "text-delta", "text-end"])
expect(chunks.map((chunk) => chunk.type)).toEqual(["abort"])
})
it("throws when no scripted assistant response remains", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const messages = chat.get()
await expect(
chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages,
abortSignal: undefined,
})
).rejects.toThrow("No assistant response found for this transcript.")
})
it("disables delta delays when delayMs is explicitly undefined", async () => {
const chat = createChat()
.user("Hello")
.assistant("This reply has several streamed words.")
const [userMessage] = chat.get(1)
const startedAt = Date.now()
const stream = await chat.transport({ delayMs: undefined }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(Date.now() - startedAt).toBeLessThan(100)
expect(
chunks.filter((chunk) => chunk.type === "text-delta").length
).toBeGreaterThan(1)
})
it("streams hydrated assistant turns through the transport", async () => {
const source = createChat<TestMessage>()
.user("What's the weather?")
.assistant(({ writer }) => {
writer
.tool("getWeather", {
input: { city: "San Francisco" },
})
.output(weatherOutput)
writer.text("It's sunny.", { mode: "instant" })
})
const replay = createChat<TestMessage>({
messages: source.get(),
})
const [userMessage] = replay.get(1)
const stream = await replay.transport({ delayMs: 0 }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"tool-input-available",
"tool-output-available",
"text-start",
"text-delta",
"text-end",
"finish",
])
})
it("streams a string fallback when the script is exhausted", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [unknownMessage] = createChat()
.user("Something unscripted", { id: "unknown-1" })
.get()
const stream = await chat
.transport({ delayMs: 0, fallback: "This demo is scripted." })
.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [...chat.get(), unknownMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"text-start",
"text-delta",
"text-delta",
"text-delta",
"text-delta",
"text-end",
"finish",
])
expect(chunks).toContainEqual(
expect.objectContaining({
type: "text-delta",
delta: "scripted.",
})
)
})
it("passes the transcript to a fallback writer callback", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [unknownMessage] = createChat()
.user("Something unscripted", { id: "unknown-1" })
.get()
const stream = await chat
.transport({
delayMs: 0,
fallback: ({ writer, messages }) => {
writer.text(`You sent ${messages.length} messages.`, {
mode: "instant",
})
},
})
.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [...chat.get(), unknownMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks).toContainEqual(
expect.objectContaining({
type: "text-delta",
delta: "You sent 3 messages.",
})
)
})
it("prefers scripted turns over the fallback", async () => {
const chat = createChat().user("Hello").assistant("Hey.", {
id: "scripted-reply",
})
const [userMessage] = chat.get(1)
const stream = await chat
.transport({ delayMs: 0, fallback: "Should not stream." })
.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: [userMessage],
abortSignal: undefined,
})
const chunks = await readStream(stream)
expect(chunks).toContainEqual(
expect.objectContaining({
type: "text-delta",
delta: "Hey.",
})
)
})
it("still throws without a fallback", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
await expect(
chat.transport().sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: chat.get(),
abortSignal: undefined,
})
).rejects.toThrow("No assistant response found for this transcript.")
})
})
describe("AI SDK live client integration", () => {
// Sends the transcript the way the client does — `messageId` only on
// regeneration — then reduces the response through the real AI SDK
// message state machine, so streamed messages carry the ids a live
// `useChat` transcript would.
async function runLiveTurn(
transport: ChatTransport<UIMessage>,
messages: UIMessage[],
options: {
trigger?: "submit-message" | "regenerate-message"
messageId?: string
} = {}
) {
const stream = await transport.sendMessages({
trigger: options.trigger ?? "submit-message",
chatId: "chat-1",
messageId: options.messageId,
messages,
abortSignal: undefined,
})
let assistantMessage: UIMessage | undefined
for await (const message of readUIMessageStream({ stream })) {
assistantMessage = message
}
if (!assistantMessage) {
throw new Error("The stream produced no assistant message.")
}
return assistantMessage
}
function getText(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
}
it("streams assistant messages under their configured ids", async () => {
const chat = createChat()
.user("What changed?")
.assistant("Keyboard shortcuts and faster search.")
const [userMessage, assistantMessage] = chat.get()
const transport = chat.transport({ delayMs: 0 })
const streamed = await runLiveTurn(transport, [userMessage])
expect(streamed.id).toBe(assistantMessage.id)
expect(getText(streamed)).toBe("Keyboard shortcuts and faster search.")
})
it("falls back once a live transcript exhausts the conversation", async () => {
const chat = createChat().user("Hello").assistant("Hi.")
const transport = chat.transport({
delayMs: 0,
fallback: "No more scripted replies.",
})
const [userMessage] = chat.get(1)
const transcript: UIMessage[] = [userMessage]
transcript.push(await runLiveTurn(transport, transcript))
transcript.push({
id: "client-user-1",
role: "user",
parts: [{ type: "text", text: "Anything else?" }],
})
const fallbackMessage = await runLiveTurn(transport, transcript)
expect(getText(fallbackMessage)).toBe("No more scripted replies.")
})
it("regenerates the same turn from its adopted id", async () => {
const chat = createChat()
.user("Hello")
.assistant("Hi.")
.user("More?")
.assistant("Sure.")
const transport = chat.transport({ delayMs: 0 })
const [userMessage] = chat.get(1)
const streamed = await runLiveTurn(transport, [userMessage])
const regenerated = await runLiveTurn(transport, [userMessage], {
trigger: "regenerate-message",
messageId: streamed.id,
})
expect(regenerated.id).toBe(streamed.id)
expect(getText(regenerated)).toBe("Hi.")
})
it("regenerates a fallback response as another fallback", async () => {
const chat = createChat().user("Hello").assistant("Hi.")
const transport = chat.transport({
delayMs: 0,
fallback: "Still nothing scripted.",
})
const [userMessage] = chat.get(1)
const transcript: UIMessage[] = [userMessage]
transcript.push(await runLiveTurn(transport, transcript))
transcript.push({
id: "client-user-1",
role: "user",
parts: [{ type: "text", text: "More?" }],
})
const fallbackMessage = await runLiveTurn(transport, transcript)
const regenerated = await runLiveTurn(transport, transcript, {
trigger: "regenerate-message",
messageId: fallbackMessage.id,
})
expect(getText(regenerated)).toBe("Still nothing scripted.")
})
})
describe("AI SDK human-in-the-loop", () => {
function createApprovalChat() {
return createChat<TestMessage>()
.user("Fetch the weather?")
.assistant(({ writer }) => {
writer.text("I need your approval first.", { mode: "instant" })
writer.tool("getWeather", {
input: { city: "San Francisco" },
needsApproval: true,
output: weatherOutput,
})
})
.assistant(({ writer, toolCall }) => {
writer.text(toolCall?.approved ? "Fetched it." : "Okay, skipping.", {
mode: "instant",
})
})
}
async function readTurnChunks(
transport: ChatTransport<UIMessage<unknown, DataParts, Tools>>,
messages: Array<UIMessage<unknown, DataParts, Tools>>
) {
// Mirrors useChat's automatic sends, which pass the last message's id
// with the submit-message trigger. The transport must not treat that as
// a regeneration of the identified turn.
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: messages[messages.length - 1]?.id,
messages,
abortSignal: undefined,
})
return readStream(stream)
}
function respondToApproval(
message: UIMessage<unknown, DataParts, Tools>,
approved: boolean
) {
return {
...message,
parts: message.parts.map((part) =>
part.type === "tool-getWeather"
? ({
...part,
state: "approval-responded",
approval: { id: "approval-1", approved },
} as (typeof message.parts)[number])
: part
),
}
}
it("streams the approval request and the client parks the call", async () => {
const chat = createApprovalChat()
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
chat.get(1)
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"text-start",
"text-delta",
"text-end",
"tool-input-available",
"tool-approval-request",
"finish",
])
expect(chunks[5]).toMatchObject({
approvalId: "approval-1",
toolCallId: "call-1",
})
const stream = await chat.transport({ delayMs: undefined }).sendMessages({
trigger: "submit-message",
chatId: "chat-1",
messageId: undefined,
messages: chat.get(1),
abortSignal: undefined,
})
let clientMessage: UIMessage<unknown, DataParts, Tools> | undefined
for await (const message of readUIMessageStream({ stream })) {
clientMessage = message as UIMessage<unknown, DataParts, Tools>
}
expect(clientMessage?.parts).toMatchObject([
{ type: "text", text: "I need your approval first." },
{
type: "tool-getWeather",
state: "approval-requested",
approval: { id: "approval-1" },
},
])
})
it("streams the gated output and approved wording after approval", async () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
[userMessage, respondToApproval(assistantMessage, true)]
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"start-step",
"tool-output-available",
"text-start",
"text-delta",
"text-end",
"finish",
])
// Continuations omit the message id so the client updates the paused
// assistant message in place instead of forking a duplicate.
expect(chunks[0]).toMatchObject({ messageId: undefined })
expect(chunks[2]).toMatchObject({
toolCallId: "call-1",
output: weatherOutput,
})
expect(chunks[4]).toMatchObject({ delta: "Fetched it." })
})
it("streams the denial and denied wording after denial", async () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
[userMessage, respondToApproval(assistantMessage, false)]
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"start-step",
"tool-output-denied",
"text-start",
"text-delta",
"text-end",
"finish",
])
expect(chunks[2]).toMatchObject({ toolCallId: "call-1" })
expect(chunks[4]).toMatchObject({ delta: "Okay, skipping." })
})
it("does not retrigger automatic sending after a continuation merges", async () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const respondedMessage = respondToApproval(assistantMessage, true)
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
[userMessage, respondedMessage]
)
// The client merges these chunks into the prior assistant message as a
// new step. Rebuild that merged shape and assert the triggers ignore the
// already-resolved tool call behind the step boundary.
const mergedMessage = {
...respondedMessage,
parts: [
...respondedMessage.parts.map((part) =>
part.type === "tool-getWeather"
? ({
...part,
state: "output-available",
output: weatherOutput,
approval: { id: "approval-1", approved: true },
} as (typeof respondedMessage.parts)[number])
: part
),
{ type: "step-start" } as (typeof respondedMessage.parts)[number],
{
type: "text",
text: "Fetched it.",
} as (typeof respondedMessage.parts)[number],
],
}
expect(chunks.some((chunk) => chunk.type === "start-step")).toBe(true)
expect(
lastAssistantMessageIsCompleteWithToolCalls({
messages: [userMessage, mergedMessage],
})
).toBe(false)
expect(
lastAssistantMessageIsCompleteWithApprovalResponses({
messages: [userMessage, mergedMessage],
})
).toBe(false)
})
it("continues an elicitation with the user-submitted output", async () => {
const chat = createChat<TestMessage>()
.user("Help me plan the release.")
.assistant(({ writer }) => {
writer.tool("createFile", {
dynamic: true,
input: { filename: "plan.md", content: "" },
})
})
.assistant(({ writer, toolCall }) => {
writer.text(
toolCall?.name === "createFile" && toolCall.output
? `Saved ${toolCall.output.filename}.`
: "No file yet.",
{ mode: "instant" }
)
})
const [userMessage, assistantMessage] = chat.get(2)
const answeredMessage = {
...assistantMessage,
parts: assistantMessage.parts.map((part) =>
part.type === "dynamic-tool"
? ({
...part,
state: "output-available",
output: {
filename: "plan.md",
url: "https://example.com/plan.md",
},
} as (typeof assistantMessage.parts)[number])
: part
),
}
const chunks = await readTurnChunks(
chat.transport({ delayMs: undefined }),
[userMessage, answeredMessage]
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"start-step",
"text-start",
"text-delta",
"text-end",
"finish",
])
expect(chunks[3]).toMatchObject({ delta: "Saved plan.md." })
})
})

View File

@@ -0,0 +1,276 @@
import type {
ChatTransport,
InferUIMessageChunk,
TextUIPart,
UIDataTypes,
UIMessage,
UIMessagePart,
UITools,
} from "ai"
import { assertNever } from "../core"
import type { ChatFormat, ChunkEncoder } from "../core"
import {
eventsFromParts,
getMessageText,
getToolCalls,
materializeParts,
} from "./parts"
/**
* The AI SDK `ChatFormat` plugin. Pass to `createChatRuntime` for advanced
* composition; `createChat` wires it up for you. Its transport `satisfies
* ChatTransport` and throws `"No assistant response found for this transcript."`
* when no assistant response remains.
*/
export function createAiSdkFormat<
METADATA = unknown,
DATA_PARTS extends UIDataTypes = UIDataTypes,
TOOLS extends UITools = UITools,
>(): ChatFormat<
UIMessage<METADATA, DATA_PARTS, TOOLS>,
UIMessagePart<DATA_PARTS, TOOLS>,
InferUIMessageChunk<UIMessage<METADATA, DATA_PARTS, TOOLS>>,
ChatTransport<UIMessage<METADATA, DATA_PARTS, TOOLS>>,
METADATA,
DATA_PARTS,
TOOLS
> {
type Message = UIMessage<METADATA, DATA_PARTS, TOOLS>
type Chunk = InferUIMessageChunk<Message>
const format: ChatFormat<
Message,
UIMessagePart<DATA_PARTS, TOOLS>,
Chunk,
ChatTransport<Message>,
METADATA,
DATA_PARTS,
TOOLS
> & {
encodeChunk: ChunkEncoder<Chunk, METADATA, DATA_PARTS, TOOLS>
} = {
materializeParts,
eventsFromParts,
createMessage(input) {
// User turns never stream, so their text parts carry no `state` —
// matching messages hand-built from `parts.text()`.
const parts =
input.role === "user"
? input.parts.map((part) => {
if (part.type !== "text") {
return part
}
const { state: _state, ...textPart } = part as TextUIPart
return textPart as UIMessagePart<DATA_PARTS, TOOLS>
})
: input.parts
return {
id: input.id,
role: input.role,
metadata: input.metadata,
parts,
} as Message
},
getMessageId(message) {
return message.id
},
getMessageRole(message) {
return message.role
},
getMessageText(message) {
return getMessageText(message)
},
getMessageParts(message) {
return message.parts
},
getMessageMetadata(message) {
return message.metadata
},
getToolCalls(message) {
return getToolCalls(message)
},
encodeChunk(chunk) {
switch (chunk.type) {
case "start":
// The client adopts this id for the streamed assistant message, so
// live transcripts keep the configured ids that turn matching,
// fallback, and regeneration rely on.
return {
type: "start",
messageId: chunk.messageId,
} as Chunk
case "start-step":
return {
type: "start-step",
} as Chunk
case "finish":
return {
type: "finish",
finishReason: chunk.finishReason,
messageMetadata: chunk.messageMetadata,
} as Chunk
case "abort":
return {
type: "abort",
reason: chunk.reason,
} as Chunk
case "error":
return {
type: "error",
errorText: chunk.errorText,
} as Chunk
case "text-start":
return {
type: "text-start",
id: chunk.id,
} as Chunk
case "text-delta":
return {
type: "text-delta",
id: chunk.id,
delta: chunk.delta,
} as Chunk
case "text-end":
return {
type: "text-end",
id: chunk.id,
} as Chunk
case "reasoning-start":
return {
type: "reasoning-start",
id: chunk.id,
} as Chunk
case "reasoning-delta":
return {
type: "reasoning-delta",
id: chunk.id,
delta: chunk.delta,
} as Chunk
case "reasoning-end":
return {
type: "reasoning-end",
id: chunk.id,
} as Chunk
case "data":
return {
type: `data-${chunk.name}`,
id: chunk.id,
data: chunk.data,
transient: chunk.transient,
} as Chunk
case "tool-input-available":
return {
type: "tool-input-available",
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
input: chunk.input,
providerExecuted: chunk.providerExecuted,
toolMetadata: chunk.toolMetadata,
dynamic: chunk.dynamic,
title: chunk.title,
} as Chunk
case "tool-approval-request":
return {
type: "tool-approval-request",
approvalId: chunk.approvalId,
toolCallId: chunk.toolCallId,
} as Chunk
case "tool-output-available":
return {
type: "tool-output-available",
toolCallId: chunk.toolCallId,
output: chunk.output,
providerExecuted: chunk.providerExecuted,
toolMetadata: chunk.toolMetadata,
dynamic: chunk.dynamic,
} as Chunk
case "tool-output-error":
return {
type: "tool-output-error",
toolCallId: chunk.toolCallId,
errorText: chunk.errorText,
providerExecuted: chunk.providerExecuted,
toolMetadata: chunk.toolMetadata,
dynamic: chunk.dynamic,
} as Chunk
case "tool-output-denied":
return {
type: "tool-output-denied",
toolCallId: chunk.toolCallId,
} as Chunk
case "file":
return {
type: "file",
filename: chunk.part.filename,
mediaType: chunk.part.mediaType,
url: chunk.part.url,
providerMetadata: chunk.part.providerMetadata,
} as Chunk
case "reasoning-file":
return {
...chunk.part,
type: "reasoning-file",
} as Chunk
case "source-url":
return {
...chunk.part,
type: "source-url",
} as Chunk
case "source-document":
return {
...chunk.part,
type: "source-document",
} as Chunk
case "custom":
return {
...chunk.part,
type: "custom",
} as Chunk
default:
return assertNever(chunk)
}
},
createTransport(transportContext, options = {}) {
return {
async sendMessages({ messages, messageId, abortSignal, trigger }) {
// Automatic sends after tool results and approvals pass the last
// assistant message's id; only regeneration may replay a turn by id.
const turn = transportContext.resolveTurn(
messages,
trigger === "regenerate-message" ? messageId : undefined
)
if (!turn) {
throw new Error("No assistant response found for this transcript.")
}
return transportContext.streamTurn(
turn,
format.encodeChunk,
options,
abortSignal
)
},
async reconnectToStream() {
return null
},
} satisfies ChatTransport<Message>
},
}
return format
}

View File

@@ -0,0 +1,2 @@
export { createChat } from "./chat"
export type { AiSdkChat, CreateChatOptions } from "./chat"

View File

@@ -0,0 +1,270 @@
import { describe, expect, it } from "vitest"
import { streamMessageParts, yieldMessageParts } from "./chat"
import { createChat } from "./index"
import { getToolCalls } from "./parts"
import {
artifact,
weatherLoading,
weatherOutput,
weatherSuccess,
} from "./test-utils"
import type { DataParts, TestMessage, Tools } from "./test-utils"
describe("AI SDK parts", () => {
it("creates typed messages with explicit parts", () => {
const [message] = createChat<TestMessage>()
.assistant(({ writer }) => {
writer.text("Here is the forecast.")
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherSuccess,
})
writer.tool("getWeather", {
toolCallId: "call-weather",
input: {
city: "San Francisco",
},
output: weatherOutput,
})
})
.get()
expect(message).toMatchObject({
id: "msg-1",
role: "assistant",
parts: [
{
type: "text",
text: "Here is the forecast.",
},
{
type: "data-weather",
id: "weather-1",
data: {
city: "San Francisco",
status: "success",
},
},
{
type: "tool-getWeather",
toolCallId: "call-weather",
state: "output-available",
},
],
})
})
it("creates rich messages with the fluent writer", () => {
const [message] = createChat<TestMessage>()
.assistant(
({ writer }) => {
writer.stepStart()
writer.reasoning("I should call a tool first.")
writer.tool("createFile", {
input: {
filename: "answer.md",
content: "Done.",
},
output: {
filename: "answer.md",
url: "https://example.com/answer.md",
},
})
writer.data({
type: "data-artifact",
data: artifact,
})
writer.text("The file is ready.")
},
{
id: "assistant-1",
}
)
.get()
expect(message.id).toBe("assistant-1")
expect(message.parts.map((part) => part.type)).toEqual([
"step-start",
"reasoning",
"tool-createFile",
"data-artifact",
"text",
])
})
it("yields message snapshots as parts become available", () => {
const [message] = createChat()
.assistant(
({ writer }) => {
writer.reasoning("Checking context.")
writer.text("Done.")
},
{
id: "assistant-1",
}
)
.get()
const snapshots = Array.from(
yieldMessageParts(message, {
includeEmpty: true,
})
)
expect(snapshots.map((snapshot) => snapshot.parts.length)).toEqual([
0, 1, 2,
])
expect(message.parts).toHaveLength(2)
})
it("streams message snapshots with the async generator", async () => {
const [message] = createChat()
.assistant(({ writer }) => {
writer.text("First.")
writer.text("Second.")
})
.get()
const snapshots = []
for await (const snapshot of streamMessageParts(message)) {
snapshots.push(snapshot)
}
expect(snapshots.map((snapshot) => snapshot.parts.length)).toEqual([1, 2])
})
it("materializes all writer part variants", () => {
const [message] = createChat<TestMessage>()
.assistant(
({ writer }) => {
writer.stepStart()
writer.reasoning("I will gather context before answering.")
writer.sourceUrl({
sourceId: "source-ai-sdk",
title: "AI SDK",
url: "https://ai-sdk.dev",
})
writer.file({
filename: "receipt.png",
mediaType: "image/png",
url: "https://example.com/receipt.png",
})
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherLoading,
})
writer.tool("getWeather", {
title: "Get weather",
input: { city: "San Francisco" },
output: weatherOutput,
})
writer.data({
type: "data-weather",
id: "weather-1",
data: weatherSuccess,
})
writer.data({
type: "data-artifact",
data: artifact,
})
writer.text("The weather is sunny and the artifact is ready.")
},
{
id: "sample-all-parts",
}
)
.get()
expect(message.id).toBe("sample-all-parts")
expect(message.parts.map((part) => part.type)).toEqual([
"step-start",
"reasoning",
"source-url",
"file",
"data-weather",
"tool-getWeather",
"data-artifact",
"text",
])
expect(message.parts).toContainEqual(
expect.objectContaining({
type: "data-weather",
id: "weather-1",
data: weatherSuccess,
})
)
})
})
describe("AI SDK approval parts", () => {
function createApprovalMessage() {
const [, assistantMessage] = createChat<TestMessage>()
.user("Fetch the weather?")
.assistant(({ writer }) => {
writer.tool("getWeather", {
input: { city: "San Francisco" },
needsApproval: true,
output: weatherOutput,
})
})
.get(2)
return assistantMessage
}
it("materializes a needsApproval call as an approval-requested part", () => {
expect(createApprovalMessage().parts).toMatchObject([
{
type: "tool-getWeather",
toolCallId: "call-1",
state: "approval-requested",
input: { city: "San Francisco" },
approval: { id: "approval-1" },
},
])
})
it("summarizes tool calls with their approval state", () => {
const message = createApprovalMessage()
const respondedMessage = {
...message,
parts: message.parts.map((part) =>
part.type === "tool-getWeather"
? ({
...part,
state: "approval-responded",
approval: { id: "approval-1", approved: true },
} as (typeof message.parts)[number])
: part
),
}
expect(getToolCalls(respondedMessage)).toMatchObject([
{
toolCallId: "call-1",
name: "getWeather",
state: "approval-responded",
input: { city: "San Francisco" },
approval: { id: "approval-1", approved: true },
},
])
})
it("hydrates approval-requested parts back into approval events", () => {
const chat = createChat<TestMessage>({
messages: [createApprovalMessage()],
})
const [message] = chat.get(1)
expect(message.parts).toMatchObject([
{
type: "tool-getWeather",
state: "approval-requested",
approval: { id: "approval-1" },
},
])
})
})

View File

@@ -0,0 +1,478 @@
import type {
CustomContentUIPart,
DynamicToolUIPart,
FileUIPart,
ReasoningFileUIPart,
ReasoningUIPart,
SourceDocumentUIPart,
SourceUrlUIPart,
StepStartUIPart,
TextUIPart,
ToolUIPart,
UIDataTypes,
UIMessage,
UIMessagePart,
UITools,
} from "ai"
import { assertNever } from "../core"
import type { ChatEvent, ToolCallSummary } from "../core"
/** Concatenates the text of every `text` part in a message. */
export function getMessageText(message: Pick<UIMessage, "parts">) {
return message.parts
.filter((part): part is TextUIPart => part.type === "text")
.map((part) => part.text)
.join("")
}
function toToolCallSummary(
name: string,
part: ToolUIPart | DynamicToolUIPart,
dynamic?: boolean
): ToolCallSummary {
return {
toolCallId: part.toolCallId,
name,
dynamic,
input: "input" in part ? part.input : undefined,
output: part.state === "output-available" ? part.output : undefined,
errorText: part.state === "output-error" ? part.errorText : undefined,
state: part.state,
approval:
"approval" in part && part.approval
? {
id: part.approval.id,
approved: part.approval.approved,
reason: part.approval.reason,
}
: undefined,
}
}
/** Reads a message's typed and dynamic tool calls into framework-neutral summaries. */
export function getToolCalls(message: Pick<UIMessage, "parts">) {
const summaries: ToolCallSummary[] = []
for (const part of message.parts) {
if (part.type === "dynamic-tool") {
const dynamicToolPart = part as DynamicToolUIPart
summaries.push(
toToolCallSummary(dynamicToolPart.toolName, dynamicToolPart, true)
)
continue
}
if (part.type.startsWith("tool-") && "toolCallId" in part) {
const toolPart = part as ToolUIPart
summaries.push(
toToolCallSummary(part.type.replace("tool-", ""), toolPart)
)
}
}
return summaries
}
/** Replaces the part with the same `type` + `id` in place, or appends. */
export function replaceOrPushPart<
DATA_PARTS extends UIDataTypes,
TOOLS extends UITools,
>(
parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>,
part: UIMessagePart<DATA_PARTS, TOOLS>
) {
if ("id" in part && part.id) {
const index = parts.findIndex(
(currentPart) =>
currentPart.type === part.type &&
"id" in currentPart &&
currentPart.id === part.id
)
if (index !== -1) {
parts[index] = part
return
}
}
parts.push(part)
}
function findToolPartIndex<
DATA_PARTS extends UIDataTypes,
TOOLS extends UITools,
>(parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>, toolCallId: string) {
return parts.findIndex(
(part) =>
(part.type === "dynamic-tool" || part.type.startsWith("tool-")) &&
"toolCallId" in part &&
part.toolCallId === toolCallId
)
}
/** Materializes an event log into final AI SDK message parts (transient data excluded). */
export function materializeParts<
DATA_PARTS extends UIDataTypes,
TOOLS extends UITools,
>(
events: ChatEvent<DATA_PARTS, TOOLS>[]
): Array<UIMessagePart<DATA_PARTS, TOOLS>> {
const parts: Array<UIMessagePart<DATA_PARTS, TOOLS>> = []
for (const event of events) {
switch (event.kind) {
case "text": {
parts.push({
type: "text",
text: event.text,
state: "done",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "reasoning": {
parts.push({
type: "reasoning",
text: event.text,
state: "done",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "data": {
if (event.transient) {
continue
}
replaceOrPushPart(parts, {
type: `data-${event.name}`,
id: event.id,
data: event.data,
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "tool-input": {
replaceOrPushPart(parts, {
type: event.dynamic ? "dynamic-tool" : `tool-${event.name}`,
...(event.dynamic ? { toolName: event.name } : {}),
toolCallId: event.toolCallId,
title: event.title,
toolMetadata: event.toolMetadata,
providerExecuted: event.providerExecuted,
state: "input-available",
input: event.input,
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "tool-approval-request": {
const index = findToolPartIndex(parts, event.toolCallId)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "approval-requested",
approval: { id: event.approvalId },
} as UIMessagePart<DATA_PARTS, TOOLS>
}
break
}
case "tool-output": {
const index = findToolPartIndex(parts, event.toolCallId)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "output-available",
output: event.output,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
} as UIMessagePart<DATA_PARTS, TOOLS>
}
break
}
case "tool-error": {
const index = findToolPartIndex(parts, event.toolCallId)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "output-error",
errorText: event.errorText,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
} as UIMessagePart<DATA_PARTS, TOOLS>
}
break
}
case "tool-denied": {
const index = findToolPartIndex(parts, event.toolCallId)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "output-denied",
} as UIMessagePart<DATA_PARTS, TOOLS>
}
break
}
case "source-url": {
parts.push({
...event.part,
type: "source-url",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "source-document": {
parts.push({
...event.part,
type: "source-document",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "file": {
parts.push({
...event.part,
type: "file",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "reasoning-file": {
parts.push({
...event.part,
type: "reasoning-file",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "custom": {
parts.push({
...event.part,
type: "custom",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "step-start": {
parts.push({
type: "step-start",
} as UIMessagePart<DATA_PARTS, TOOLS>)
break
}
case "sleep":
case "error":
break
default:
assertNever(event)
}
}
return parts
}
/** Parses AI SDK message parts back into an event log so static parts can stream. */
export function eventsFromParts<
DATA_PARTS extends UIDataTypes,
TOOLS extends UITools,
>(
parts: Array<UIMessagePart<DATA_PARTS, TOOLS>>
): ChatEvent<DATA_PARTS, TOOLS>[] {
const events: ChatEvent<DATA_PARTS, TOOLS>[] = []
for (const part of parts) {
// Framework-owned part unions are open, so parsing is non-exhaustive.
if (part.type === "text") {
const textPart = part as TextUIPart
events.push({
kind: "text",
id: `text-${events.length + 1}`,
text: textPart.text,
options: {
mode: "instant",
},
})
}
if (part.type === "reasoning") {
const reasoningPart = part as ReasoningUIPart
events.push({
kind: "reasoning",
id: `reasoning-${events.length + 1}`,
text: reasoningPart.text,
options: {
mode: "instant",
},
})
}
if (part.type.startsWith("data-")) {
events.push({
kind: "data",
name: part.type.replace("data-", "") as keyof DATA_PARTS & string,
id: "id" in part ? part.id : undefined,
data: "data" in part ? part.data : undefined,
} as ChatEvent<DATA_PARTS, TOOLS>)
}
if (part.type.startsWith("tool-") && "toolCallId" in part) {
const toolPart = part as ToolUIPart<TOOLS>
const name = part.type.replace("tool-", "") as keyof TOOLS & string
events.push({
kind: "tool-input",
name,
toolCallId: toolPart.toolCallId,
title: toolPart.title,
toolMetadata: toolPart.toolMetadata,
providerExecuted: toolPart.providerExecuted,
input: "input" in toolPart ? toolPart.input : {},
})
if (
toolPart.state === "approval-requested" ||
toolPart.state === "approval-responded"
) {
events.push({
kind: "tool-approval-request",
toolCallId: toolPart.toolCallId,
approvalId: toolPart.approval.id,
})
}
if (toolPart.state === "output-available") {
events.push({
kind: "tool-output",
toolCallId: toolPart.toolCallId,
output: toolPart.output,
providerExecuted: toolPart.providerExecuted,
toolMetadata: toolPart.toolMetadata,
})
}
if (toolPart.state === "output-error") {
events.push({
kind: "tool-error",
toolCallId: toolPart.toolCallId,
errorText: toolPart.errorText,
providerExecuted: toolPart.providerExecuted,
toolMetadata: toolPart.toolMetadata,
})
}
if (toolPart.state === "output-denied") {
events.push({
kind: "tool-denied",
toolCallId: toolPart.toolCallId,
})
}
}
if (part.type === "dynamic-tool") {
const dynamicToolPart = part as DynamicToolUIPart
events.push({
kind: "tool-input",
name: dynamicToolPart.toolName as keyof TOOLS & string,
toolCallId: dynamicToolPart.toolCallId,
title: dynamicToolPart.title,
toolMetadata: dynamicToolPart.toolMetadata,
providerExecuted: dynamicToolPart.providerExecuted,
dynamic: true,
input: "input" in dynamicToolPart ? dynamicToolPart.input : {},
})
if (
dynamicToolPart.state === "approval-requested" ||
dynamicToolPart.state === "approval-responded"
) {
events.push({
kind: "tool-approval-request",
toolCallId: dynamicToolPart.toolCallId,
approvalId: dynamicToolPart.approval.id,
})
}
if (dynamicToolPart.state === "output-available") {
events.push({
kind: "tool-output",
toolCallId: dynamicToolPart.toolCallId,
output: dynamicToolPart.output,
providerExecuted: dynamicToolPart.providerExecuted,
toolMetadata: dynamicToolPart.toolMetadata,
dynamic: true,
})
}
if (dynamicToolPart.state === "output-error") {
events.push({
kind: "tool-error",
toolCallId: dynamicToolPart.toolCallId,
errorText: dynamicToolPart.errorText,
providerExecuted: dynamicToolPart.providerExecuted,
toolMetadata: dynamicToolPart.toolMetadata,
dynamic: true,
})
}
if (dynamicToolPart.state === "output-denied") {
events.push({
kind: "tool-denied",
toolCallId: dynamicToolPart.toolCallId,
})
}
}
if (part.type === "source-url") {
const sourceUrlPart = part as SourceUrlUIPart
events.push({
kind: "source-url",
part: sourceUrlPart,
})
}
if (part.type === "source-document") {
const sourceDocumentPart = part as SourceDocumentUIPart
events.push({
kind: "source-document",
part: sourceDocumentPart,
})
}
if (part.type === "file") {
const filePart = part as FileUIPart
events.push({
kind: "file",
part: filePart,
})
}
if (part.type === "reasoning-file") {
const reasoningFilePart = part as ReasoningFileUIPart
events.push({
kind: "reasoning-file",
part: reasoningFilePart,
})
}
if (part.type === "custom") {
const customPart = part as CustomContentUIPart
events.push({
kind: "custom",
part: customPart,
})
}
if (part.type === "step-start") {
events.push({
kind: "step-start",
})
}
}
return events
}

View File

@@ -0,0 +1,83 @@
import type { UIMessage } from "ai"
export type TestMessage = UIMessage<unknown, DataParts, Tools>
export type DataParts = {
weather: {
city: string
status: "loading" | "success" | "error"
temperature?: number
condition?: string
}
artifact: {
id: string
title: string
kind: "code" | "document" | "image"
status: "draft" | "streaming" | "complete"
}
}
export type Tools = {
getWeather: {
input: {
city: string
}
output: {
city: string
temperature: number
condition: string
}
}
createFile: {
input: {
filename: string
content: string
}
output: {
filename: string
url: string
}
}
}
export const weatherLoading: DataParts["weather"] = {
city: "San Francisco",
status: "loading",
}
export const weatherSuccess: DataParts["weather"] = {
city: "San Francisco",
status: "success",
temperature: 72,
condition: "sunny",
}
export const weatherOutput: Tools["getWeather"]["output"] = {
city: "San Francisco",
temperature: 72,
condition: "sunny",
}
export const artifact: DataParts["artifact"] = {
id: "artifact-1",
title: "CitedAnswer.jsx",
kind: "code",
status: "complete",
}
export async function readStream<T>(stream: ReadableStream<T>) {
const chunks = []
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
return chunks
}

View File

@@ -0,0 +1,621 @@
import { describe, expect, it, vi } from "vitest"
import { createChatRuntime } from "./chat"
import { createTestChat, createTestFormat, readStream } from "./test-utils"
import type {
TestData,
TestMessage,
TestPart,
TestTools,
TestTransport,
} from "./test-utils"
import type { NeutralChunk } from "./types"
describe("createChatRuntime", () => {
it("creates user and assistant turns with sequential ids and metadata", () => {
const chat = createTestChat().user("Hello").assistant("Hey.")
const messages = chat.get()
expect(messages).toMatchObject([
{
id: "msg-1",
role: "user",
metadata: { createdAt: "2026-01-01T00:00:00.000Z" },
parts: [{ type: "text", text: "Hello" }],
},
{
id: "msg-2",
role: "assistant",
parts: [{ type: "text", text: "Hey." }],
},
])
})
it("attaches user files as parts", () => {
const chat = createTestChat().user("See attached.", {
files: [
{
filename: "report.pdf",
mediaType: "application/pdf",
url: "https://example.com/report.pdf",
},
],
})
expect(chat.get()[0].parts).toMatchObject([
{ type: "text", text: "See attached." },
{ type: "file", filename: "report.pdf" },
])
})
it("stores explicit assistant parts as-is", () => {
const parts = [{ type: "text", text: "Prebuilt." }]
const chat = createTestChat().assistant(parts)
expect(chat.get()[0].parts).toMatchObject(parts)
})
it("returns cloned messages from get", () => {
const chat = createTestChat().user("Hello")
const [first] = chat.get()
first.parts[0].text = "Mutated"
expect(chat.get()[0].parts[0].text).toBe("Hello")
})
it("owns explicit parts and deeply detached snapshots", () => {
const parts = [
{
type: "tool-getWeather",
input: { location: { city: "SF" } },
},
]
const chat = createTestChat().assistant(parts)
;(parts[0].input.location as { city: string }).city = "mutated input"
const [snapshot] = chat.get()
const snapshotPart = snapshot.parts[0] as unknown as {
input: { location: { city: string } }
}
snapshotPart.input.location.city = "mutated snapshot"
expect(chat.get()[0].parts[0]).toMatchObject({
input: { location: { city: "SF" } },
})
})
it("returns a transcript prefix without mutating playback", () => {
const chat = createTestChat()
.user("One")
.assistant("A")
.user("Two")
.assistant("B")
.user("Three")
const initialMessages = chat.get(2)
const first = chat.next(initialMessages)
const second = chat.next(initialMessages)
expect(first?.parts[0].text).toBe("Two")
expect(second?.parts[0].text).toBe("Two")
})
it("finds the next user turn from a transcript by id", () => {
const chat = createTestChat().user("One").assistant("A").user("Two")
const [firstMessage] = chat.get(1)
const nextMessage = chat.next([firstMessage])
expect(nextMessage?.parts[0].text).toBe("Two")
})
it("falls back to role and text matching for unscripted transcripts", () => {
const chat = createTestChat().user("One").assistant("A").user("Two")
const next = chat.next([
{
id: "external-1",
role: "user",
parts: [{ type: "text", text: "One" }],
},
])
expect(next?.parts[0].text).toBe("Two")
})
it("returns the first user message for an empty transcript and null when exhausted", () => {
const chat = createTestChat().user("One").assistant("A")
expect(chat.next([])?.parts[0].text).toBe("One")
expect(chat.next(chat.get())).toBeNull()
})
it("rejects invalid get counts", () => {
const chat = createTestChat().user("One")
expect(() => chat.get(-1)).toThrowError(
"count must be a non-negative integer."
)
expect(() => chat.get(1.5)).toThrowError(
"count must be a non-negative integer."
)
})
it("requires counts for get and message transcripts for next", () => {
const chat = createTestChat()
if (false) {
// @ts-expect-error get accepts a count, not an options object.
chat.get({ count: 1 })
// @ts-expect-error next requires the current message transcript.
chat.next()
// @ts-expect-error next accepts messages, not transcript indexes.
chat.next([1])
}
expect(chat.get()).toEqual([])
})
it("streams the next scripted assistant turn through the transport", async () => {
const chat = createTestChat()
.user("Weather?")
.assistant(({ writer }) => {
writer.reasoning("Checking.", { mode: "instant" })
writer
.tool("getWeather", { input: { city: "SF" } })
.output({ temperature: 72 })
writer.text("Sunny.", { mode: "instant" })
})
const [userMessage] = chat.get(1)
const { context, options } = chat.transport({ delayMs: 0 })
const turn = context.resolveTurn([userMessage])
expect(options).toEqual({ delayMs: 0 })
expect(turn?.role).toBe("assistant")
const chunks = await readStream(
context.streamTurn(turn!, (chunk) => chunk, options)
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"start",
"reasoning-start",
"reasoning-delta",
"reasoning-end",
"tool-input-available",
"tool-output-available",
"text-start",
"text-delta",
"text-end",
"finish",
])
})
it("streams scripted errors and stops", async () => {
const chat = createTestChat().user("Hello").error("Model unavailable.")
const [userMessage] = chat.get(1)
const { context } = chat.transport()
const turn = context.resolveTurn([userMessage])
const chunks = await readStream(
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
)
expect(chunks).toMatchObject([
{ type: "start" },
{ type: "error", errorText: "Model unavailable." },
])
})
it("resolves turns by message id", () => {
const chat = createTestChat()
.user("One")
.assistant("A", { id: "assistant-a" })
.user("Two")
.assistant("B", { id: "assistant-b" })
const { context } = chat.transport()
expect(context.resolveTurn([], "assistant-b")?.message.id).toBe(
"assistant-b"
)
const turnAfterOne = context.resolveTurn(chat.get(1))
expect(turnAfterOne?.message.id).toBe("assistant-a")
})
it("carries assistant metadata on the finish chunk", async () => {
const chat = createTestChat()
.user("Hello")
.assistant("Hey.", { metadata: { model: "gpt-5" } })
const [userMessage] = chat.get(1)
const { context } = chat.transport()
const turn = context.resolveTurn([userMessage])
const chunks = await readStream(
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
)
const finish = chunks.find((chunk) => chunk.type === "finish")
expect(finish).toMatchObject({
messageMetadata: { model: "gpt-5" },
})
})
it("sleeps before the next scripted turn", async () => {
const chat = createTestChat()
.user("Hello")
.sleep(60)
.assistant(({ writer }) => {
writer.text("Hey.", { mode: "instant" })
})
const [userMessage] = chat.get(1)
const { context } = chat.transport()
const turn = context.resolveTurn([userMessage])
const startedAt = Date.now()
const chunks = await readStream(
context.streamTurn(turn!, (chunk) => chunk, { delayMs: 0 })
)
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(40)
expect(chunks[0]).toMatchObject({ type: "start" })
})
})
describe("createChatRuntime hydration and fallback", () => {
it("seeds turns from existing messages and reserves message ids", () => {
const format = createTestFormat<TestData, TestTools>()
const source = createTestChat().user("Hello").assistant("Hey.")
const recorded = source.get()
const replay = createChatRuntime<
TestMessage,
TestPart,
NeutralChunk<unknown, TestData, TestTools>,
TestTransport<TestData, TestTools>,
unknown,
TestData,
TestTools
>(format, { messages: recorded })
expect(replay.get()).toEqual(recorded)
replay.user("What's next?")
const messages = replay.get()
expect(messages[2].id).toBe("msg-3")
expect(messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
])
})
it("continues past the highest seeded id without relying on transcript length", () => {
const format = createTestFormat<TestData, TestTools>()
const replay = createChatRuntime<
TestMessage,
TestPart,
NeutralChunk<unknown, TestData, TestTools>,
TestTransport<TestData, TestTools>,
unknown,
TestData,
TestTools
>(format, {
messages: [
{
id: "msg-2",
role: "assistant",
parts: [{ type: "text", text: "Seeded" }],
},
],
})
replay.user("Next")
expect(replay.get().map((message) => message.id)).toEqual([
"msg-2",
"msg-3",
])
})
it("resolves a fallback turn only when configured", () => {
const chat = createTestChat().user("Hello").assistant("Hey.")
// A transcript containing the full script is exhausted — no scripted
// assistant turn remains, which is when the fallback kicks in.
const exhausted: TestMessage[] = [
...chat.get(),
{
id: "unknown-1",
role: "user",
parts: [{ type: "text", text: "Unscripted" }],
},
]
const strict = chat.transport()
expect(strict.context.resolveTurn(exhausted)).toBeUndefined()
const lenient = chat.transport({ fallback: "Scripted demo." })
const turn = lenient.context.resolveTurn(exhausted)
expect(turn).toMatchObject({
role: "assistant",
})
expect(turn?.events).toContainEqual(
expect.objectContaining({
kind: "text",
text: "Scripted demo.",
})
)
})
it("does not add fallback turns to the script", () => {
const chat = createTestChat().user("Hello").assistant("Hey.")
const transport = chat.transport({ fallback: "Scripted demo." })
transport.context.resolveTurn([
...chat.get(),
{
id: "unknown-1",
role: "user",
parts: [{ type: "text", text: "Unscripted" }],
},
])
expect(chat.get()).toHaveLength(2)
})
})
describe("human-in-the-loop turns", () => {
function createApprovalChat() {
return createTestChat()
.user("Fetch the weather?")
.assistant(({ writer }) => {
writer.tool("getWeather", {
input: { city: "Paris" },
needsApproval: true,
output: { temperature: 21 },
})
})
.assistant(({ writer, toolCall }) => {
writer.text(toolCall?.approved ? "Fetched it." : "Okay, skipping.")
})
}
function withApprovalResponse(message: TestMessage, approved: boolean) {
return {
...message,
parts: message.parts.map((part) =>
part.type === "tool-getWeather"
? {
...part,
state: "approval-responded",
approval: { id: "approval-1", approved },
}
: part
),
}
}
it("scripts the approval request and pauses the turn", () => {
const chat = createApprovalChat()
const turn = chat.transport().context.resolveTurn(chat.get(1))
expect(turn?.events).toMatchObject([
{ kind: "tool-input", toolCallId: "call-1" },
{
kind: "tool-approval-request",
toolCallId: "call-1",
approvalId: "approval-1",
output: { temperature: 21 },
},
])
expect(turn?.message.parts).toMatchObject([
{
type: "tool-getWeather",
state: "approval-requested",
approval: { id: "approval-1" },
},
])
})
it("bounds get() at the first continuation turn", () => {
const chat = createApprovalChat()
expect(chat.get()).toHaveLength(2)
expect(() => chat.get(3)).toThrow(/continuation/)
})
it("streams the gated output and approved context after approval", () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const turn = chat
.transport()
.context.resolveTurn([
userMessage,
withApprovalResponse(assistantMessage, true),
])
expect(turn?.message.id).toBe("msg-3")
expect(turn?.events).toMatchObject([
{ kind: "step-start" },
{
kind: "tool-output",
toolCallId: "call-1",
output: { temperature: 21 },
},
{ kind: "text", text: "Fetched it." },
])
})
it("streams the denial and denied context after denial", () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const turn = chat
.transport()
.context.resolveTurn([
userMessage,
withApprovalResponse(assistantMessage, false),
])
expect(turn?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "tool-denied", toolCallId: "call-1" },
{ kind: "text", text: "Okay, skipping." },
])
})
it("re-resolves a continuation to the same message", () => {
const chat = createApprovalChat()
const [userMessage, assistantMessage] = chat.get(2)
const transcript = [
userMessage,
withApprovalResponse(assistantMessage, true),
]
const { context } = chat.transport()
const first = context.resolveTurn(transcript)
const second = context.resolveTurn(transcript)
expect(second?.message).toEqual(first?.message)
})
it("reads a user-submitted elicitation output into the continuation", () => {
const chat = createTestChat()
.user("Plan the release?")
.assistant(({ writer }) => {
writer.tool("getWeather", { input: { city: "Paris" } })
})
.assistant(({ writer, toolCall }) => {
writer.text(
toolCall?.name === "getWeather" && toolCall.output
? `Temperature ${toolCall.output.temperature}.`
: "No answer."
)
})
const [userMessage, assistantMessage] = chat.get(2)
const answeredMessage = {
...assistantMessage,
parts: assistantMessage.parts.map((part) =>
part.type === "tool-getWeather"
? { ...part, state: "output-available", output: { temperature: 72 } }
: part
),
}
const turn = chat
.transport()
.context.resolveTurn([userMessage, answeredMessage])
expect(turn?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "text", text: "Temperature 72." },
])
})
it("chains continuations and warns when nothing is pending", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
const chat = createTestChat()
.user("Plan the release?")
.assistant(({ writer }) => {
writer.tool("getWeather", { input: { city: "Paris" } })
})
.assistant(({ writer }) => {
writer.text("Thanks.")
})
.assistant(({ writer, toolCalls }) => {
writer.text(`Pending ${toolCalls?.length ?? 0}.`)
})
const [userMessage, assistantMessage] = chat.get(2)
const answeredMessage = {
...assistantMessage,
parts: assistantMessage.parts.map((part) =>
part.type === "tool-getWeather"
? { ...part, state: "output-available", output: { temperature: 72 } }
: part
),
}
const { context } = chat.transport()
const firstContinuation = context.resolveTurn([
userMessage,
answeredMessage,
])
const secondContinuation = context.resolveTurn([
userMessage,
answeredMessage,
firstContinuation!.message,
])
expect(firstContinuation?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "text", text: "Thanks." },
])
expect(secondContinuation?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "text", text: "Pending 0." },
])
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("without a pending tool call")
)
warn.mockRestore()
})
it("starts eager continuation turns with a step boundary", () => {
const chat = createTestChat()
.user("Plan the release?")
.assistant(({ writer }) => {
writer.tool("getWeather", { input: { city: "Paris" } })
})
.assistant("Thanks.")
const [userMessage, assistantMessage] = chat.get(2)
const turn = chat
.transport()
.context.resolveTurn([userMessage, assistantMessage])
expect(turn?.events).toMatchObject([
{ kind: "step-start" },
{ kind: "text", text: "Thanks." },
])
})
it("warns when an approval request has no continuation turn", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
createTestChat()
.user("Fetch the weather?")
.assistant(({ writer }) => {
writer.tool("getWeather", {
input: { city: "Paris" },
needsApproval: true,
output: { temperature: 21 },
})
})
.transport()
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("no continuation turn follows")
)
warn.mockRestore()
})
it("throws when a needsApproval handle is resolved by the script", () => {
createTestChat()
.user("Fetch the weather?")
.assistant(({ writer }) => {
const handle = writer.tool("getWeather", {
input: { city: "Paris" },
needsApproval: true,
})
expect(() => handle.output({ temperature: 21 })).toThrow(
/needsApproval/
)
})
})
})

View File

@@ -0,0 +1,814 @@
import { createChatIds } from "./ids"
import { lowerEvents } from "./lower"
import { createChatPayloads, createMetadataFactory } from "./payloads"
import { createTurnStream } from "./stream"
import type {
ChatAssistantOptions,
ChatEvent,
ChatFormat,
ChatOptions,
ChatTurn,
ChatUserOptions,
DataTypes,
MessageRole,
PendingToolCall,
ToolSet,
TurnStreamOptions,
} from "./types"
import {
cloneValue,
DEFAULT_STREAM_DELAY_MS,
DEFAULT_TEXT,
devWarn,
} from "./utils"
import { createEventWriter } from "./writer"
import type { EventWriter } from "./writer"
/**
* A transport response for when no configured assistant response remains —
* most commonly an exhausted chat (the user keeps sending past the end).
* A string (one streamed text part), a parts array, or a writer callback
* that also receives the incoming transcript. Note: fully unrecognized
* transcripts replay the chat from the top rather than falling back.
*/
export type ChatFallback<
MESSAGE,
PART,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
WRITER = EventWriter<DATA, TOOLS>,
> =
| string
| PART[]
| ((context: { writer: WRITER; messages: MESSAGE[] }) => void)
/**
* Transport options: `delayMs` between text and reasoning deltas (default 50,
* explicit `undefined` disables delays) and an optional `fallback` used when
* no configured assistant response remains instead of throwing.
*/
export type ChatTransportOptions<
MESSAGE,
PART,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
WRITER = EventWriter<DATA, TOOLS>,
> = TurnStreamOptions & {
fallback?: ChatFallback<MESSAGE, PART, DATA, TOOLS, WRITER>
}
/**
* The context handed to assistant writer callbacks. A continuation turn — a
* callback turn scripted after a turn that pauses for user input — also
* receives the live transcript and the resolved human-in-the-loop tool
* calls; eager turns receive only the writer.
*/
export type AssistantTurnContext<
WRITER,
MESSAGE,
TOOLS extends ToolSet = ToolSet,
> = {
writer: WRITER
messages?: MESSAGE[]
toolCall?: PendingToolCall<TOOLS>
toolCalls?: Array<PendingToolCall<TOOLS>>
}
/** A deterministic conversation: chain turns, then read them back or stream them through a transport. */
export type Chat<
MESSAGE,
PART,
TRANSPORT,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
WRITER = EventWriter<DATA, TOOLS>,
> = {
/** Scripts a user turn: one text part plus optional file attachments. */
user(
text?: string,
options?: ChatUserOptions<METADATA>
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
/**
* Scripts an assistant turn from a string (one streamed text part), a
* parts array (static parts, streamed instantly), or a writer callback
* (full control over parts and timing). A callback turn scripted after a
* paused turn — an unresolved tool call or `needsApproval` — becomes a
* continuation: it materializes when the follow-up request arrives, with
* the live transcript and resolved tool calls in its context.
*/
assistant(
input?:
| string
| PART[]
| ((context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void),
options?: ChatAssistantOptions<METADATA>
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
/** Scripts an assistant turn that streams a bare error chunk and ends. */
error(
errorText?: string
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
/** Delays the next turn's stream. */
sleep(
delayMs: number
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER>
/**
* Returns clones of the first `count` configured messages, or all
* materializable messages when omitted. Continuation turns have no message
* without a live transcript, so `get` stops before the first one and
* throws when `count` reaches past it.
*/
get(count?: number): MESSAGE[]
/**
* Returns the next configured user message after the given transcript, or
* `null` when none remain. Matches by message id, falling back to role and
* text, without mutating the chat.
*/
next(messages: readonly MESSAGE[]): MESSAGE | null
/** Creates the framework transport that streams configured assistant responses. */
transport(
options?: ChatTransportOptions<MESSAGE, PART, DATA, TOOLS, WRITER>
): TRANSPORT
}
/**
* Creates a {@link Chat} bound to a {@link ChatFormat}. This is the
* factory every adapter's `createChat` wraps — pass your own format to
* target a framework this package doesn't ship an adapter for.
*/
export function createChatRuntime<
MESSAGE,
PART,
CHUNK,
TRANSPORT,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
WRITER = EventWriter<DATA, TOOLS>,
>(
format: ChatFormat<MESSAGE, PART, CHUNK, TRANSPORT, METADATA, DATA, TOOLS>,
options: ChatOptions & {
/** Hydrate the chat from existing messages, e.g. a recorded transcript. */
messages?: MESSAGE[]
} = {}
): Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER> {
type InternalTurn = {
role: MessageRole
events: ChatEvent<DATA, TOOLS>[]
messageId: string
metadata?: METADATA
// Continuation turns carry `resolve` instead of a materialized message.
message?: MESSAGE
resolve?: (context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void
// Continuations stream without a message id so the client keeps merging
// into the assistant message it is already continuing.
continuation?: boolean
// The events last streamed for a continuation turn. Later continuations
// read pending calls from here so re-runs cannot drift generated ids
// away from the transcript.
lastEvents?: ChatEvent<DATA, TOOLS>[]
}
type ResolvedTurn = ChatTurn<MESSAGE, DATA, TOOLS> & {
metadata?: METADATA
continuation?: boolean
}
const ids = createChatIds(options)
const payloads = createChatPayloads(ids)
const metadata = createMetadataFactory<METADATA>(options.now)
const turns: InternalTurn[] = []
const pendingEvents: ChatEvent<DATA, TOOLS>[] = []
function reserveEventIds(events: ChatEvent<DATA, TOOLS>[]) {
for (const event of events) {
if (
event.kind === "tool-input" ||
event.kind === "tool-output" ||
event.kind === "tool-error" ||
event.kind === "tool-denied" ||
event.kind === "tool-approval-request"
) {
ids.reserveToolCallId(event.toolCallId)
}
if (event.kind === "tool-approval-request") {
ids.reserveApprovalId(event.approvalId)
}
if (event.kind === "source-url" || event.kind === "source-document") {
ids.reserveSourceId(event.part.sourceId)
}
}
}
function getPendingToolInputs(events: ChatEvent<DATA, TOOLS>[]) {
const resolvedToolCallIds = new Set<string>()
for (const event of events) {
if (
event.kind === "tool-output" ||
event.kind === "tool-error" ||
event.kind === "tool-denied"
) {
resolvedToolCallIds.add(event.toolCallId)
}
}
return events.flatMap((event) =>
event.kind === "tool-input" && !resolvedToolCallIds.has(event.toolCallId)
? [
{
input: event,
approval: events.find(
(
candidate
): candidate is Extract<
ChatEvent<DATA, TOOLS>,
{ kind: "tool-approval-request" }
> =>
candidate.kind === "tool-approval-request" &&
candidate.toolCallId === event.toolCallId
),
},
]
: []
)
}
function resolveMessageId(id?: string) {
if (id !== undefined) {
ids.reserveMessageId(id)
return id
}
return ids.nextMessageId()
}
for (const seededMessage of options.messages ?? []) {
const message = cloneValue(seededMessage)
const events = format.eventsFromParts(format.getMessageParts(message))
ids.reserveMessageId(format.getMessageId(message))
reserveEventIds(events)
turns.push({
role: format.getMessageRole(message),
message,
messageId: format.getMessageId(message),
events,
metadata: format.getMessageMetadata?.(message),
})
}
function takePendingEvents() {
return pendingEvents.splice(0, pendingEvents.length)
}
function pushTurn(turn: InternalTurn) {
turns.push(turn)
return api
}
function findLatestChatIndex(messages: readonly MESSAGE[]) {
const messageIds = new Set(
messages.map((message) => format.getMessageId(message))
)
let latestChatIndex = -1
for (let index = 0; index < turns.length; index++) {
if (messageIds.has(turns[index].messageId)) {
latestChatIndex = index
}
}
if (latestChatIndex === -1) {
const lastMessage = messages[messages.length - 1]
latestChatIndex = turns.findIndex(
(turn) =>
lastMessage !== undefined &&
turn.message !== undefined &&
turn.role === format.getMessageRole(lastMessage) &&
format.getMessageText(turn.message) ===
format.getMessageText(lastMessage)
)
}
return latestChatIndex
}
function findAssistantTurnIndexAfter(startIndex: number) {
for (let index = startIndex; index < turns.length; index++) {
if (turns[index].role === "assistant") {
return index
}
}
return -1
}
function findNextAssistantTurnIndex(messages: MESSAGE[], messageId?: string) {
if (messageId) {
const index = turns.findIndex((turn) => turn.messageId === messageId)
if (turns[index]?.role === "assistant") {
return index
}
// Unknown ids fall through to transcript matching so regenerating a
// fallback-produced message falls back again instead of replaying the
// first configured response.
if (index !== -1) {
return findAssistantTurnIndexAfter(index + 1)
}
}
return findAssistantTurnIndexAfter(findLatestChatIndex(messages) + 1)
}
function findNextUserTurn(messages: readonly MESSAGE[]) {
const latestChatIndex = findLatestChatIndex(messages)
return turns.slice(latestChatIndex + 1).find((turn) => turn.role === "user")
}
function materializeAssistantInput(
input:
| string
| PART[]
| ((context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>) => void),
events: ChatEvent<DATA, TOOLS>[]
): PART[] {
if (typeof input === "string") {
const writer = createEventWriter(events, { ids, payloads })
writer.text(input)
return format.materializeParts(events)
}
if (typeof input === "function") {
// `WRITER` is an adapter-narrowed subset of the full writer created
// here. This is the single cast point that passes the full writer
// through the adapter's narrower callback type.
const writer = createEventWriter(events, {
ids,
payloads,
}) as unknown as WRITER
input({ writer })
reserveEventIds(events)
return format.materializeParts(events)
}
const parts = cloneValue(input)
const partEvents = format.eventsFromParts(parts)
reserveEventIds(partEvents)
events.push(...partEvents)
return parts
}
function createFallbackTurn(
fallback: ChatFallback<MESSAGE, PART, DATA, TOOLS, WRITER>,
messages: MESSAGE[]
): ResolvedTurn {
const events: ChatEvent<DATA, TOOLS>[] = []
const input =
typeof fallback === "function"
? (context: { writer: WRITER }) => fallback({ ...context, messages })
: fallback
const parts = materializeAssistantInput(input, events)
const turnMetadata = metadata()
return {
role: "assistant",
message: format.createMessage({
id: resolveMessageId(),
role: "assistant",
metadata: turnMetadata,
parts,
}),
events,
metadata: turnMetadata,
}
}
function findPreviousAssistantIndex(turnIndex: number) {
for (let index = turnIndex - 1; index >= 0; index--) {
if (turns[index].role === "assistant") {
return index
}
}
return -1
}
function isPreviousTurnPausedAssistant() {
const previousTurn = turns[turns.length - 1]
if (previousTurn?.role !== "assistant") {
return false
}
// A continuation may itself pause, so turns after one stay continuations.
return (
previousTurn.resolve !== undefined ||
getPendingToolInputs(previousTurn.events).length > 0
)
}
function resolvePendingToolCalls(turnIndex: number, messages: MESSAGE[]) {
const previousIndex = findPreviousAssistantIndex(turnIndex)
const previousTurn = turns[previousIndex]
if (!previousTurn) {
return []
}
const previousEvents = previousTurn.resolve
? (previousTurn.lastEvents ??
materializeDeferredTurn(previousIndex, messages).events)
: previousTurn.events
const pendingInputs = getPendingToolInputs(previousEvents)
if (!pendingInputs.length) {
return []
}
const previousMessage = messages.find(
(message) => format.getMessageId(message) === previousTurn.messageId
)
const summaries =
previousMessage && format.getToolCalls
? format.getToolCalls(previousMessage)
: []
return pendingInputs.map((pendingInput) => ({
...pendingInput,
summary: summaries.find(
(summary) => summary.toolCallId === pendingInput.input.toolCallId
),
}))
}
function toPendingToolCall(
call: ReturnType<typeof resolvePendingToolCalls>[number]
) {
const approved = call.approval
? call.summary?.approval?.approved
: undefined
return {
name: call.input.name,
toolCallId: call.input.toolCallId,
input: call.summary?.input ?? call.input.input,
output: call.approval
? approved === true
? call.approval.output
: undefined
: call.summary?.state === "output-available"
? call.summary.output
: undefined,
...(call.approval
? {
approved: approved === true,
denied: approved === false,
}
: {}),
} as PendingToolCall<TOOLS>
}
function materializeDeferredTurn(
turnIndex: number,
messages: MESSAGE[]
): ResolvedTurn {
const turn = turns[turnIndex]
const resolve = turn.resolve
if (!resolve) {
throw new Error("Only continuation turns materialize from a transcript.")
}
const events = [...turn.events]
// The client merges a continuation into the prior assistant message as a
// new step. The step boundary keeps the resolved tool call out of the
// latest step so automatic sending does not retrigger on it.
events.push({ kind: "step-start" })
const pending = resolvePendingToolCalls(turnIndex, messages)
if (!pending.length) {
devWarn(
"A continuation turn resolved without a pending tool call. Its toolCall context is empty."
)
}
for (const call of pending) {
if (!call.approval || call.summary?.state !== "approval-responded") {
continue
}
if (call.summary.approval?.approved) {
if (call.approval.errorText !== undefined) {
events.push({
kind: "tool-error",
toolCallId: call.input.toolCallId,
errorText: call.approval.errorText,
providerExecuted: call.input.providerExecuted,
toolMetadata: call.input.toolMetadata,
dynamic: call.input.dynamic,
})
} else {
events.push({
kind: "tool-output",
toolCallId: call.input.toolCallId,
output: call.approval.output,
providerExecuted: call.input.providerExecuted,
toolMetadata: call.input.toolMetadata,
dynamic: call.input.dynamic,
})
}
} else {
events.push({
kind: "tool-denied",
toolCallId: call.input.toolCallId,
})
}
}
const toolCalls = pending.map(toPendingToolCall)
// Same cast point as materializeAssistantInput: the full writer passes
// through the adapter's narrower callback type.
const writer = createEventWriter(events, {
ids,
payloads,
}) as unknown as WRITER
resolve({
writer,
messages,
toolCall: toolCalls[toolCalls.length - 1],
toolCalls,
})
reserveEventIds(events)
turn.lastEvents = events
const parts = format.materializeParts(events)
const turnMetadata =
turn.metadata === undefined ? metadata() : cloneValue(turn.metadata)
return {
role: "assistant",
message: format.createMessage({
id: turn.messageId,
role: "assistant",
metadata: turnMetadata,
parts,
}),
events,
metadata: turnMetadata,
continuation: true,
}
}
const api: Chat<MESSAGE, PART, TRANSPORT, METADATA, DATA, TOOLS, WRITER> = {
user(text = DEFAULT_TEXT, userOptions: ChatUserOptions<METADATA> = {}) {
const events = takePendingEvents()
const turnEvents: ChatEvent<DATA, TOOLS>[] = []
turnEvents.push({
kind: "text",
id: `text-${turnEvents.length + 1}`,
text,
options: {
mode: "instant",
},
})
for (const file of userOptions.files ?? []) {
const { type: _type, ...part } = file
turnEvents.push({
kind: "file",
part: cloneValue(part),
})
}
const parts = format.materializeParts(turnEvents)
events.push(...turnEvents)
const turnMetadata =
userOptions.metadata === undefined
? metadata()
: cloneValue(userOptions.metadata)
const messageId = resolveMessageId(userOptions.id)
const userMessage = format.createMessage({
id: messageId,
role: "user",
metadata: turnMetadata,
parts,
})
return pushTurn({
role: "user",
message: userMessage,
messageId,
events,
metadata: turnMetadata,
})
},
assistant(
input:
| string
| PART[]
| ((
context: AssistantTurnContext<WRITER, MESSAGE, TOOLS>
) => void) = DEFAULT_TEXT,
assistantOptions: ChatAssistantOptions<METADATA> = {}
) {
const events = takePendingEvents()
const continuation = isPreviousTurnPausedAssistant()
if (typeof input === "function" && continuation) {
return pushTurn({
role: "assistant",
messageId: resolveMessageId(assistantOptions.id),
events,
metadata:
assistantOptions.metadata === undefined
? undefined
: cloneValue(assistantOptions.metadata),
resolve: input,
continuation,
})
}
if (continuation) {
events.push({ kind: "step-start" })
}
const messageParts = materializeAssistantInput(input, events)
const turnMetadata =
assistantOptions.metadata === undefined
? metadata()
: cloneValue(assistantOptions.metadata)
const messageId = resolveMessageId(assistantOptions.id)
const assistantMessage = format.createMessage({
id: messageId,
role: "assistant",
metadata: turnMetadata,
parts: messageParts,
})
return pushTurn({
role: "assistant",
message: assistantMessage,
messageId,
events,
metadata: turnMetadata,
continuation: continuation || undefined,
})
},
error(errorText = "An error occurred.") {
const events = takePendingEvents()
events.push({
kind: "error",
errorText,
})
const turnMetadata = metadata()
const messageId = resolveMessageId()
return pushTurn({
role: "assistant",
message: format.createMessage({
id: messageId,
role: "assistant",
metadata: turnMetadata,
parts: [],
}),
messageId,
events,
metadata: turnMetadata,
})
},
sleep(delayMs: number) {
pendingEvents.push({
kind: "sleep",
delayMs,
phase: "before-start",
})
return api
},
get(count) {
const deferredIndex = turns.findIndex(
(turn) => turn.resolve !== undefined
)
const limit = deferredIndex === -1 ? turns.length : deferredIndex
if (count === undefined) {
count = limit
}
if (!Number.isInteger(count) || count < 0) {
throw new RangeError("count must be a non-negative integer.")
}
if (deferredIndex !== -1 && count > deferredIndex) {
throw new Error(
"get() cannot materialize a continuation turn without a live transcript."
)
}
return turns
.slice(0, count)
.map((turn) => cloneValue(turn.message as MESSAGE))
},
next(messages) {
const turn = findNextUserTurn(messages)
return turn?.message ? cloneValue(turn.message) : null
},
transport(
transportOptions: ChatTransportOptions<
MESSAGE,
PART,
DATA,
TOOLS,
WRITER
> = {}
) {
const lastAssistantIndex = findPreviousAssistantIndex(turns.length)
const lastAssistant = turns[lastAssistantIndex]
if (
lastAssistant &&
!lastAssistant.resolve &&
getPendingToolInputs(lastAssistant.events).some(
(pendingInput) => pendingInput.approval
)
) {
devWarn(
"The last scripted assistant turn requests approval but no continuation turn follows. The decision will have no response."
)
}
return format.createTransport(
{
resolveTurn(messages, messageId) {
const turnIndex = findNextAssistantTurnIndex(messages, messageId)
const turn = turnIndex === -1 ? undefined : turns[turnIndex]
if (turn) {
return turn.resolve
? materializeDeferredTurn(turnIndex, messages)
: (turn as ChatTurn<MESSAGE, DATA, TOOLS>)
}
if (transportOptions.fallback === undefined) {
return undefined
}
return createFallbackTurn(transportOptions.fallback, messages)
},
streamTurn(turn, encodeChunk, streamOptions = {}, abortSignal) {
const steps = lowerEvents<METADATA, DATA, TOOLS>(turn.events, {
delayMs: DEFAULT_STREAM_DELAY_MS,
...streamOptions,
// A continuation stream carries no message id: the client is
// already continuing the paused assistant message, and a new
// id would fork it into a duplicate instead of updating it.
messageId: (turn as ResolvedTurn).continuation
? undefined
: format.getMessageId(turn.message),
messageMetadata: (turn as ResolvedTurn).metadata,
})
return createTurnStream(steps, encodeChunk, abortSignal)
},
},
transportOptions
)
},
}
return api
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest"
import { createChatIds } from "./ids"
describe("createChatIds", () => {
it("generates sequential ids with default prefixes", () => {
const ids = createChatIds()
expect(ids.nextMessageId()).toBe("msg-1")
expect(ids.nextMessageId()).toBe("msg-2")
expect(ids.nextToolCallId()).toBe("call-1")
expect(ids.nextSourceId()).toBe("source-1")
expect(ids.nextApprovalId()).toBe("approval-1")
})
it("supports custom prefixes", () => {
const ids = createChatIds({
messageIdPrefix: "m",
toolCallIdPrefix: "t",
sourceIdPrefix: "s",
})
expect(ids.nextMessageId()).toBe("m-1")
expect(ids.nextToolCallId()).toBe("t-1")
expect(ids.nextSourceId()).toBe("s-1")
})
it("continues after explicitly reserved ids", () => {
const ids = createChatIds()
ids.reserveMessageId("msg-2")
ids.reserveToolCallId("call-4")
ids.reserveSourceId("source-3")
ids.reserveApprovalId("approval-2")
expect(ids.nextMessageId()).toBe("msg-3")
expect(ids.nextToolCallId()).toBe("call-5")
expect(ids.nextSourceId()).toBe("source-4")
expect(ids.nextApprovalId()).toBe("approval-3")
})
})

View File

@@ -0,0 +1,94 @@
import type { ChatIds, ChatIdsOptions } from "./types"
/**
* Creates deterministic sequential id generators (`msg-1`, `call-1`,
* `source-1`, …). A chat and its transport share one instance so ids never
* collide across scripted turns.
*/
export function createChatIds(options: ChatIdsOptions = {}): ChatIds {
const messageIdPrefix = options.messageIdPrefix ?? "msg"
const toolCallIdPrefix = options.toolCallIdPrefix ?? "call"
const sourceIdPrefix = options.sourceIdPrefix ?? "source"
const approvalIdPrefix = options.approvalIdPrefix ?? "approval"
function createSequence(prefix: string) {
const reserved = new Set<string>()
let index = 0
function reserve(id: string) {
reserved.add(id)
const prefixWithSeparator = `${prefix}-`
if (!id.startsWith(prefixWithSeparator)) {
return
}
const suffix = id.slice(prefixWithSeparator.length)
if (!/^\d+$/.test(suffix)) {
return
}
const observedIndex = Number(suffix)
if (Number.isSafeInteger(observedIndex)) {
index = Math.max(index, observedIndex)
}
}
function next() {
let id: string
do {
index += 1
id = `${prefix}-${index}`
} while (reserved.has(id))
reserve(id)
return id
}
return { next, reserve }
}
const messageIds = createSequence(messageIdPrefix)
const toolCallIds = createSequence(toolCallIdPrefix)
const sourceIds = createSequence(sourceIdPrefix)
const approvalIds = createSequence(approvalIdPrefix)
return {
nextMessageId() {
return messageIds.next()
},
nextToolCallId() {
return toolCallIds.next()
},
nextSourceId() {
return sourceIds.next()
},
nextApprovalId() {
return approvalIds.next()
},
reserveMessageId(id) {
messageIds.reserve(id)
},
reserveToolCallId(id) {
toolCallIds.reserve(id)
},
reserveSourceId(id) {
sourceIds.reserve(id)
},
reserveApprovalId(id) {
approvalIds.reserve(id)
},
}
}

View File

@@ -0,0 +1,8 @@
export * from "./types"
export * from "./ids"
export * from "./utils"
export * from "./payloads"
export * from "./writer"
export * from "./lower"
export * from "./stream"
export * from "./chat"

View File

@@ -0,0 +1,218 @@
import { describe, expect, it } from "vitest"
import { lowerEvents } from "./lower"
import { chunkSteps } from "./test-utils"
import type { TestData, TestTools } from "./test-utils"
import { splitTextDeltas } from "./utils"
describe("lowerEvents", () => {
it("places before-start sleeps ahead of the start chunk", () => {
const steps = lowerEvents(
[
{ kind: "sleep", delayMs: 100, phase: "before-start" },
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"sleep",
"start",
"text-start",
"text-delta",
"text-end",
"finish",
"close",
])
})
it("keeps later sleeps after the start chunk", () => {
const steps = lowerEvents(
[
{ kind: "step-start" },
{ kind: "sleep", delayMs: 100, phase: "after-start" },
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"start-step",
"sleep",
"finish",
"close",
])
})
it("omits delta sleeps when delayMs is undefined", () => {
const steps = lowerEvents(
[{ kind: "text", id: "text-1", text: "Hello there world" }],
{ delayMs: undefined }
)
expect(chunkSteps(steps)).toEqual([
"start",
"text-start",
"text-delta",
"text-delta",
"text-delta",
"text-end",
"finish",
"close",
])
})
it("splits streamed text into word deltas with sleeps", () => {
const steps = lowerEvents(
[{ kind: "text", id: "text-1", text: "Hello there world" }],
{ delayMs: 25 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"text-start",
"sleep",
"text-delta",
"sleep",
"text-delta",
"sleep",
"text-delta",
"text-end",
"finish",
"close",
])
expect(
steps.flatMap((step) =>
step.kind === "chunk" && step.chunk.type === "text-delta"
? [step.chunk.delta]
: []
)
).toEqual(splitTextDeltas("Hello there world"))
})
it("emits a single delta in instant mode and none of the word sleeps when delay is zero", () => {
const steps = lowerEvents(
[
{
kind: "reasoning",
id: "reasoning-1",
text: "Thinking about it",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"reasoning-start",
"reasoning-delta",
"reasoning-end",
"finish",
"close",
])
})
it("honors per-event delay overrides", () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant", delayMs: 10 },
},
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"text-start",
"sleep",
"text-delta",
"text-end",
"finish",
"close",
])
})
it("closes after an error chunk without a finish", () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
{ kind: "error", errorText: "Boom." },
{ kind: "step-start" },
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"text-start",
"text-delta",
"text-end",
"error",
"close",
])
})
it("carries message metadata on the finish chunk", () => {
const steps = lowerEvents([], {
delayMs: 0,
messageMetadata: { model: "gpt-5" },
})
const finish = steps.find(
(step) => step.kind === "chunk" && step.chunk.type === "finish"
)
expect(finish).toMatchObject({
chunk: {
type: "finish",
finishReason: "stop",
messageMetadata: { model: "gpt-5" },
},
})
})
it("maps tool events to tool chunks", () => {
const steps = lowerEvents<unknown, TestData, TestTools>(
[
{
kind: "tool-input",
name: "getWeather",
toolCallId: "call-1",
input: { city: "SF" },
},
{
kind: "tool-output",
toolCallId: "call-1",
output: { temperature: 72 },
},
{ kind: "tool-error", toolCallId: "call-1", errorText: "Down." },
{ kind: "tool-denied", toolCallId: "call-1" },
],
{ delayMs: 0 }
)
expect(chunkSteps(steps)).toEqual([
"start",
"tool-input-available",
"tool-output-available",
"tool-output-error",
"tool-output-denied",
"finish",
"close",
])
})
})

View File

@@ -0,0 +1,298 @@
import type {
ChatEvent,
DataTypes,
NeutralChunk,
StreamStep,
ToolSet,
} from "./types"
import { assertNever, splitTextDeltas } from "./utils"
/**
* Options for {@link lowerEvents}. An `undefined` `delayMs` disables
* delta delays entirely; `messageId` rides on the `start` chunk and
* `messageMetadata` on the `finish` chunk.
*/
export type LowerEventsOptions<METADATA> = {
delayMs?: number
messageId?: string
messageMetadata?: METADATA
}
function lowerTextEvent<
METADATA,
DATA extends DataTypes,
TOOLS extends ToolSet,
>(
steps: StreamStep<METADATA, DATA, TOOLS>[],
event: Extract<ChatEvent<DATA, TOOLS>, { kind: "text" | "reasoning" }>,
options: LowerEventsOptions<METADATA>
) {
const startType = event.kind === "text" ? "text-start" : "reasoning-start"
const deltaType = event.kind === "text" ? "text-delta" : "reasoning-delta"
const endType = event.kind === "text" ? "text-end" : "reasoning-end"
steps.push({
kind: "chunk",
chunk: {
type: startType,
id: event.id,
} as NeutralChunk<METADATA, DATA, TOOLS>,
})
const deltas =
event.options?.mode === "instant"
? [event.text]
: splitTextDeltas(event.text)
const delayMs = event.options?.delayMs ?? options.delayMs
for (const delta of deltas) {
if (delayMs) {
steps.push({
kind: "sleep",
delayMs,
})
}
steps.push({
kind: "chunk",
chunk: {
type: deltaType,
id: event.id,
delta,
} as NeutralChunk<METADATA, DATA, TOOLS>,
})
}
steps.push({
kind: "chunk",
chunk: {
type: endType,
id: event.id,
} as NeutralChunk<METADATA, DATA, TOOLS>,
})
}
/**
* Lowers a turn's event log into playback steps: leading `before-start`
* sleeps precede the `start` chunk, text/reasoning split into
* start/delta/end with per-delta sleeps, an `error` event ends the stream
* without a `finish` chunk, and everything else maps to one chunk.
*/
export function lowerEvents<
METADATA,
DATA extends DataTypes,
TOOLS extends ToolSet,
>(
events: ChatEvent<DATA, TOOLS>[],
options: LowerEventsOptions<METADATA>
): StreamStep<METADATA, DATA, TOOLS>[] {
const steps: StreamStep<METADATA, DATA, TOOLS>[] = []
let eventIndex = 0
while (true) {
const event = events[eventIndex]
if (event?.kind !== "sleep" || event.phase !== "before-start") {
break
}
steps.push({
kind: "sleep",
delayMs: event.delayMs,
})
eventIndex += 1
}
steps.push({
kind: "chunk",
chunk: {
type: "start",
messageId: options.messageId,
},
})
for (const event of events.slice(eventIndex)) {
switch (event.kind) {
case "sleep": {
steps.push({
kind: "sleep",
delayMs: event.delayMs,
})
break
}
case "text":
case "reasoning": {
lowerTextEvent(steps, event, options)
break
}
case "data": {
steps.push({
kind: "chunk",
chunk: {
type: "data",
name: event.name,
id: event.id,
data: event.data,
transient: event.transient,
},
})
break
}
case "tool-input": {
steps.push({
kind: "chunk",
chunk: {
type: "tool-input-available",
toolCallId: event.toolCallId,
toolName: event.name,
input: event.input,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
dynamic: event.dynamic,
title: event.title,
},
})
break
}
case "tool-approval-request": {
// The gated output stays script-side; only the request goes on the wire.
steps.push({
kind: "chunk",
chunk: {
type: "tool-approval-request",
approvalId: event.approvalId,
toolCallId: event.toolCallId,
},
})
break
}
case "tool-output": {
steps.push({
kind: "chunk",
chunk: {
type: "tool-output-available",
toolCallId: event.toolCallId,
output: event.output,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
dynamic: event.dynamic,
},
})
break
}
case "tool-error": {
steps.push({
kind: "chunk",
chunk: {
type: "tool-output-error",
toolCallId: event.toolCallId,
errorText: event.errorText,
providerExecuted: event.providerExecuted,
toolMetadata: event.toolMetadata,
dynamic: event.dynamic,
},
})
break
}
case "tool-denied": {
steps.push({
kind: "chunk",
chunk: {
type: "tool-output-denied",
toolCallId: event.toolCallId,
},
})
break
}
case "error": {
steps.push({
kind: "chunk",
chunk: {
type: "error",
errorText: event.errorText,
},
})
steps.push({
kind: "close",
})
return steps
}
case "source-url": {
steps.push({
kind: "chunk",
chunk: {
type: "source-url",
part: event.part,
},
})
break
}
case "source-document": {
steps.push({
kind: "chunk",
chunk: {
type: "source-document",
part: event.part,
},
})
break
}
case "file": {
steps.push({
kind: "chunk",
chunk: {
type: "file",
part: event.part,
},
})
break
}
case "reasoning-file": {
steps.push({
kind: "chunk",
chunk: {
type: "reasoning-file",
part: event.part,
},
})
break
}
case "custom": {
steps.push({
kind: "chunk",
chunk: {
type: "custom",
part: event.part,
},
})
break
}
case "step-start": {
steps.push({
kind: "chunk",
chunk: {
type: "start-step",
},
})
break
}
default:
return assertNever(event)
}
}
steps.push({
kind: "chunk",
chunk: {
type: "finish",
finishReason: "stop",
messageMetadata: options.messageMetadata,
},
})
steps.push({
kind: "close",
})
return steps
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest"
import { createChatIds } from "./ids"
import { createChatPayloads, createMetadataFactory } from "./payloads"
describe("createChatPayloads", () => {
it("creates default file payloads with overrides", () => {
const payloads = createChatPayloads(createChatIds())
expect(payloads.file()).toMatchObject({
mediaType: "image/png",
filename: "receipt.png",
url: "https://example.com/receipt.png",
})
expect(payloads.file({ filename: "report.pdf" })).toMatchObject({
filename: "report.pdf",
mediaType: "image/png",
})
})
it("assigns sequential source ids", () => {
const payloads = createChatPayloads(createChatIds())
expect(payloads.sourceUrl().sourceId).toBe("source-1")
expect(payloads.sourceDocument().sourceId).toBe("source-2")
expect(payloads.sourceUrl({ sourceId: "fixed" }).sourceId).toBe("fixed")
})
it("reserves explicit source ids and detaches nested metadata", () => {
const payloads = createChatPayloads(createChatIds())
const providerMetadata = { nested: { source: "original" } }
const source = payloads.sourceUrl({
sourceId: "source-2",
providerMetadata,
})
providerMetadata.nested.source = "mutated"
expect(payloads.sourceUrl().sourceId).toBe("source-3")
expect(source.providerMetadata).toEqual({
nested: { source: "original" },
})
})
it("creates custom payloads with a default kind", () => {
const payloads = createChatPayloads(createChatIds())
expect(payloads.custom()).toEqual({ kind: "test.output" })
expect(payloads.custom("app.widget")).toEqual({ kind: "app.widget" })
})
})
describe("createMetadataFactory", () => {
it("stamps a deterministic createdAt", () => {
const metadata = createMetadataFactory()
expect(metadata()).toEqual({
createdAt: "2026-01-01T00:00:00.000Z",
})
})
it("accepts a custom now and overrides", () => {
const metadata = createMetadataFactory<{
createdAt: string
model: string
}>("2025-06-15T12:00:00.000Z")
expect(metadata({ model: "gpt-5" })).toEqual({
createdAt: "2025-06-15T12:00:00.000Z",
model: "gpt-5",
})
})
})

View File

@@ -0,0 +1,106 @@
import type {
ChatIds,
CustomPayload,
FilePayload,
JsonRecord,
ReasoningFilePayload,
SourceDocumentPayload,
SourceUrlPayload,
} from "./types"
import { cloneValue } from "./utils"
/** Framework-neutral payload factories with sensible defaults for anything omitted. */
export type ChatPayloads = {
file(overrides?: Partial<FilePayload>): FilePayload
reasoningFile(overrides?: Partial<ReasoningFilePayload>): ReasoningFilePayload
sourceUrl(overrides?: Partial<SourceUrlPayload>): SourceUrlPayload
sourceDocument(
overrides?: Partial<SourceDocumentPayload>
): SourceDocumentPayload
custom(
kind?: string,
options?: { providerMetadata?: JsonRecord }
): CustomPayload
}
/**
* Creates the default payload factories. Source payloads draw their
* `sourceId` from the shared {@link ChatIds} sequence unless overridden.
*/
export function createChatPayloads(ids: ChatIds): ChatPayloads {
function resolveSourceId(sourceId?: string) {
if (sourceId !== undefined) {
ids.reserveSourceId(sourceId)
return sourceId
}
return ids.nextSourceId()
}
return {
file(overrides = {}) {
return {
mediaType: "image/png",
filename: "receipt.png",
url: "https://example.com/receipt.png",
...cloneValue(overrides),
}
},
reasoningFile(overrides = {}) {
return {
mediaType: "text/plain",
url: "data:text/plain;base64,cmVhc29uaW5n",
...cloneValue(overrides),
}
},
sourceUrl(overrides = {}) {
const clonedOverrides = cloneValue(overrides)
return {
sourceId: resolveSourceId(clonedOverrides.sourceId),
url: clonedOverrides.url ?? "https://ai-sdk.dev/docs",
title: clonedOverrides.title ?? "AI SDK Docs",
providerMetadata: clonedOverrides.providerMetadata,
}
},
sourceDocument(overrides = {}) {
const clonedOverrides = cloneValue(overrides)
return {
sourceId: resolveSourceId(clonedOverrides.sourceId),
mediaType: clonedOverrides.mediaType ?? "text/markdown",
title: clonedOverrides.title ?? "Retrieved document",
filename: clonedOverrides.filename,
providerMetadata: clonedOverrides.providerMetadata,
}
},
custom(kind = "test.output", options = {}) {
return {
kind,
...cloneValue(options),
}
},
}
}
/**
* Creates the default message-metadata factory: a fixed-clock
* `{ createdAt }` stamp (default `2026-01-01T00:00:00.000Z`) merged with any
* overrides, so snapshots stay stable across runs.
*/
export function createMetadataFactory<METADATA>(now?: Date | string) {
const resolvedNow =
now instanceof Date ? now : new Date(now ?? "2026-01-01T00:00:00.000Z")
return function metadata(overrides: Partial<METADATA> = {}) {
return {
createdAt: resolvedNow.toISOString(),
...cloneValue(overrides),
} as METADATA
}
}

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest"
import { lowerEvents } from "./lower"
import { createTurnStream } from "./stream"
import { readStream } from "./test-utils"
describe("createTurnStream", () => {
it("plays chunks through the encoder in order", async () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const chunks = await readStream(
createTurnStream(steps, (chunk) => ({ encoded: chunk.type }))
)
expect(chunks).toEqual([
{ encoded: "start" },
{ encoded: "text-start" },
{ encoded: "text-delta" },
{ encoded: "text-end" },
{ encoded: "finish" },
])
})
it("skips chunks when the encoder returns null and expands arrays", async () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const chunks = await readStream(
createTurnStream(steps, (chunk) => {
if (chunk.type === "finish") {
return null
}
if (chunk.type === "text-delta") {
return [chunk.type, chunk.type]
}
return chunk.type
})
)
expect(chunks).toEqual([
"start",
"text-start",
"text-delta",
"text-delta",
"text-end",
])
})
it("waits for sleep steps", async () => {
const steps = lowerEvents(
[
{ kind: "sleep", delayMs: 60, phase: "before-start" },
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const startedAt = Date.now()
const chunks = await readStream(
createTurnStream(steps, (chunk) => chunk.type)
)
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(40)
expect(chunks[0]).toBe("start")
})
it("closes silently when aborted during before-start sleeps", async () => {
const steps = lowerEvents(
[{ kind: "sleep", delayMs: 500, phase: "before-start" }],
{
delayMs: 0,
}
)
const controller = new AbortController()
const startedAt = Date.now()
const streamPromise = readStream(
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
)
setTimeout(() => controller.abort(), 10)
expect(await streamPromise).toEqual([])
expect(Date.now() - startedAt).toBeLessThan(250)
})
it("closes without chunks when the signal is already aborted", async () => {
const steps = lowerEvents(
[
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const controller = new AbortController()
controller.abort()
const chunks = await readStream(
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
)
expect(chunks).toEqual([])
})
it("stops encoding after the consumer cancels the stream", async () => {
const encoded: string[] = []
const stream = createTurnStream(
[
{ kind: "chunk", chunk: { type: "start" } },
{ kind: "sleep", delayMs: 30 },
{ kind: "chunk", chunk: { type: "text-start", id: "text-1" } },
{ kind: "chunk", chunk: { type: "text-end", id: "text-1" } },
{ kind: "close" },
],
(chunk) => {
encoded.push(chunk.type)
return chunk.type
}
)
const reader = stream.getReader()
expect((await reader.read()).value).toBe("start")
await reader.cancel()
await new Promise((resolve) => setTimeout(resolve, 60))
expect(encoded).toEqual(["start"])
})
it("emits an abort chunk when aborted mid-stream", async () => {
const steps = lowerEvents(
[
{ kind: "sleep", delayMs: 500, phase: "after-start" },
{
kind: "text",
id: "text-1",
text: "Hi",
options: { mode: "instant" },
},
],
{ delayMs: 0 }
)
const controller = new AbortController()
const startedAt = Date.now()
const streamPromise = readStream(
createTurnStream(steps, (chunk) => chunk.type, controller.signal)
)
setTimeout(() => controller.abort(), 10)
expect(await streamPromise).toEqual(["start", "abort"])
expect(Date.now() - startedAt).toBeLessThan(250)
})
})

View File

@@ -0,0 +1,116 @@
import type {
ChunkEncoder,
DataTypes,
NeutralChunk,
StreamStep,
ToolSet,
} from "./types"
import { wait } from "./utils"
/**
* Plays lowered steps through an encoder into a `ReadableStream`. Honors
* sleep steps, skips chunks the encoder maps to `null`, fans out arrays, and
* handles aborts: before the `start` chunk the stream closes silently, after
* it an `abort` chunk is emitted first.
*/
export function createTurnStream<
CHUNK,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
>(
steps: StreamStep<METADATA, DATA, TOOLS>[],
encode: ChunkEncoder<CHUNK, METADATA, DATA, TOOLS>,
abortSignal?: AbortSignal
): ReadableStream<CHUNK> {
let cancelled = false
return new ReadableStream<CHUNK>({
async start(controller) {
let aborted = abortSignal?.aborted ?? false
const abort = () => {
aborted = true
}
abortSignal?.addEventListener("abort", abort)
function enqueue(chunk: NeutralChunk<METADATA, DATA, TOOLS>) {
const encoded = encode(chunk)
if (encoded === null) {
return
}
if (Array.isArray(encoded)) {
for (const item of encoded) {
controller.enqueue(item)
}
return
}
controller.enqueue(encoded)
}
try {
let started = false
function closeAfterAbort() {
if (started) {
enqueue({
type: "abort",
reason: "aborted",
})
}
controller.close()
}
for (const step of steps) {
// The consumer already tore the stream down — enqueueing or
// closing a cancelled stream would throw.
if (cancelled) {
return
}
if (aborted) {
closeAfterAbort()
return
}
if (step.kind === "sleep") {
await wait(step.delayMs, abortSignal)
if (aborted) {
closeAfterAbort()
return
}
continue
}
if (step.kind === "close") {
controller.close()
return
}
enqueue(step.chunk)
if (step.chunk.type === "start") {
started = true
}
}
if (!cancelled) {
controller.close()
}
} finally {
abortSignal?.removeEventListener("abort", abort)
}
},
cancel() {
cancelled = true
},
})
}

View File

@@ -0,0 +1,231 @@
import { createChatRuntime } from "./chat"
import type {
ChatEvent,
ChatFormat,
DataTypes,
MessageRole,
NeutralChunk,
StreamStep,
ToolCallSummary,
ToolSet,
TransportContext,
TurnStreamOptions,
} from "./types"
export type TestData = {
weather: {
city: string
status: "loading" | "success"
}
}
export type TestTools = {
getWeather: {
input: {
city: string
}
output: {
temperature: number
}
}
}
export type TestPart = {
type: string
[key: string]: unknown
}
export type TestMessage = {
id: string
role: MessageRole
metadata?: unknown
parts: TestPart[]
}
export type TestTransport<
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = {
context: TransportContext<
TestMessage,
NeutralChunk<unknown, DATA, TOOLS>,
unknown,
DATA,
TOOLS
>
options?: TurnStreamOptions
}
export function createTestFormat<
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
>(): ChatFormat<
TestMessage,
TestPart,
NeutralChunk<unknown, DATA, TOOLS>,
TestTransport<DATA, TOOLS>,
unknown,
DATA,
TOOLS
> {
return {
materializeParts(events) {
const parts: TestPart[] = []
for (const event of events) {
if (event.kind === "text") {
parts.push({ type: "text", text: event.text })
}
if (event.kind === "reasoning") {
parts.push({ type: "reasoning", text: event.text })
}
if (event.kind === "data" && !event.transient) {
parts.push({
type: `data-${event.name}`,
id: event.id,
data: event.data,
})
}
if (event.kind === "tool-input") {
parts.push({
type: `tool-${event.name}`,
toolCallId: event.toolCallId,
state: "input-available",
input: event.input,
})
}
if (event.kind === "tool-approval-request") {
const index = parts.findIndex(
(part) => part.toolCallId === event.toolCallId
)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "approval-requested",
approval: { id: event.approvalId },
}
}
}
if (event.kind === "tool-output") {
const index = parts.findIndex(
(part) => part.toolCallId === event.toolCallId
)
if (index !== -1) {
parts[index] = {
...parts[index],
state: "output-available",
output: event.output,
}
}
}
if (event.kind === "file") {
parts.push({ type: "file", ...event.part })
}
}
return parts
},
eventsFromParts(parts) {
const events: ChatEvent<DATA, TOOLS>[] = []
for (const part of parts) {
if (part.type === "text") {
events.push({
kind: "text",
id: `text-${events.length + 1}`,
text: part.text as string,
options: {
mode: "instant",
},
})
}
}
return events
},
createMessage(input) {
return { ...input }
},
getMessageId(message) {
return message.id
},
getMessageRole(message) {
return message.role
},
getMessageText(message) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
},
getMessageParts(message) {
return message.parts
},
getToolCalls(message) {
return message.parts
.filter((part) => part.type.startsWith("tool-"))
.map((part) => ({
toolCallId: part.toolCallId as string,
name: part.type.replace("tool-", ""),
input: part.input,
output: part.state === "output-available" ? part.output : undefined,
state: part.state as ToolCallSummary["state"],
approval: part.approval as ToolCallSummary["approval"],
}))
},
createTransport(context, options) {
return { context, options }
},
}
}
export function createTestChat() {
return createChatRuntime<
TestMessage,
TestPart,
NeutralChunk<unknown, TestData, TestTools>,
TestTransport<TestData, TestTools>,
unknown,
TestData,
TestTools
>(createTestFormat<TestData, TestTools>())
}
export async function readStream<T>(stream: ReadableStream<T>) {
const chunks = []
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
}
return chunks
}
export function chunkSteps(steps: StreamStep[]) {
return steps.map((step) =>
step.kind === "chunk" ? step.chunk.type : step.kind
)
}

View File

@@ -0,0 +1,404 @@
/** A JSON-like object used for framework provider metadata. */
export type JsonRecord = Record<string, unknown>
/** Message roles supported by the framework-neutral chat model. */
export type MessageRole = "system" | "user" | "assistant"
/** Map of data part names to their payload shapes, e.g. `{ weather: { temp: number } }`. */
export type DataTypes = Record<string, unknown>
/** The input and output shapes associated with one scripted tool. */
export type ToolDefinition = {
input: unknown
output: unknown
}
/** Map of tool names to their `input`/`output` shapes. Structurally compatible with the AI SDK's `UITools`. */
export type ToolSet = Record<string, ToolDefinition>
/** Options for scripting a typed or dynamic tool call. */
export type ToolWriterOptions<
TOOLS extends ToolSet,
NAME extends keyof TOOLS,
> = {
toolCallId?: string
title?: string
toolMetadata?: JsonRecord
providerExecuted?: boolean
input?: TOOLS[NAME]["input"]
output?: TOOLS[NAME]["output"]
errorText?: string
/** Script the call as a `dynamic-tool` part instead of a typed `tool-<name>` part. */
dynamic?: boolean
/**
* Pause the turn behind a user approval. `output` (or `errorText`) then
* means "stream this after approval" instead of "resolve immediately";
* denial streams `tool-output-denied` automatically.
*/
needsApproval?: boolean
approvalId?: string
}
/**
* A human-in-the-loop tool call as a continuation turn sees it: the scripted
* input, the user-submitted output (elicitation) or gated output (approval),
* and the approval decision when one was requested.
*/
export type PendingToolCall<TOOLS extends ToolSet> = {
[NAME in keyof TOOLS & string]: {
name: NAME
toolCallId: string
input: TOOLS[NAME]["input"]
output: TOOLS[NAME]["output"] | undefined
approved?: boolean
denied?: boolean
}
}[keyof TOOLS & string]
/**
* A framework-neutral summary of one tool call read back from a transcript
* message. Adapters produce these through `ChatFormat.getToolCalls`.
*/
export type ToolCallSummary = {
toolCallId: string
name: string
dynamic?: boolean
input: unknown
output?: unknown
errorText?: string
state:
| "input-streaming"
| "input-available"
| "approval-requested"
| "approval-responded"
| "output-available"
| "output-error"
| "output-denied"
| "undefined-input"
approval?: { id: string; approved?: boolean; reason?: string }
}
/**
* Per-part streaming controls. `delayMs` overrides the transport delay for
* this part; `mode: "instant"` emits the whole text as a single delta instead
* of word-by-word.
*/
export type StreamTextOptions = {
id?: string
delayMs?: number
mode?: "stream" | "instant"
}
/** Framework-neutral file attachment payload. */
export type FilePayload = {
mediaType: string
filename?: string
url: string
providerMetadata?: JsonRecord
}
/** Framework-neutral file attached to a reasoning part. */
export type ReasoningFilePayload = {
mediaType: string
filename?: string
url: string
providerMetadata?: JsonRecord
}
/** Framework-neutral URL source citation. */
export type SourceUrlPayload = {
sourceId: string
url: string
title?: string
providerMetadata?: JsonRecord
}
/** Framework-neutral document source citation. */
export type SourceDocumentPayload = {
sourceId: string
mediaType: string
title: string
filename?: string
providerMetadata?: JsonRecord
}
/** Framework-neutral custom part payload. */
export type CustomPayload = {
kind: string
providerMetadata?: JsonRecord
}
/**
* A typed `data-<name>` part input. Repeating an `id` updates the previous
* part in place (reconciliation); `transient` parts stream but never
* materialize into message parts.
*/
export type DataEventInput<DATA extends DataTypes> = {
[NAME in keyof DATA & string]: {
type: `data-${NAME}`
id?: string
data: DATA[NAME]
transient?: boolean
}
}[keyof DATA & string]
/**
* One entry in a turn's event log — the framework-neutral source of truth a
* turn is scripted as. Adapters materialize events into message parts and
* the stream driver lowers them into chunks.
*/
export type ChatEvent<
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> =
| { kind: "sleep"; delayMs: number; phase: "before-start" | "after-start" }
| { kind: "text"; id: string; text: string; options?: StreamTextOptions }
| { kind: "reasoning"; id: string; text: string; options?: StreamTextOptions }
| {
kind: "data"
name: keyof DATA & string
id?: string
data: DATA[keyof DATA & string]
transient?: boolean
}
| {
kind: "tool-input"
name: keyof TOOLS & string
toolCallId: string
title?: string
toolMetadata?: JsonRecord
providerExecuted?: boolean
dynamic?: boolean
input: unknown
}
| {
kind: "tool-approval-request"
toolCallId: string
approvalId: string
output?: unknown
errorText?: string
}
| {
kind: "tool-output"
toolCallId: string
output: unknown
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
}
| {
kind: "tool-error"
toolCallId: string
errorText: string
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
}
| { kind: "tool-denied"; toolCallId: string }
| { kind: "error"; errorText: string }
| { kind: "source-url"; part: SourceUrlPayload }
| { kind: "source-document"; part: SourceDocumentPayload }
| { kind: "file"; part: FilePayload }
| { kind: "reasoning-file"; part: ReasoningFilePayload }
| { kind: "custom"; part: CustomPayload }
| { kind: "step-start" }
/**
* The framework-neutral stream chunk vocabulary. An adapter's
* {@link ChunkEncoder} maps each chunk to its framework's wire format —
* return `null` to skip a chunk the framework has no equivalent for.
*/
export type NeutralChunk<
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> =
| { type: "start"; messageId?: string }
| { type: "start-step" }
| { type: "finish"; finishReason: "stop"; messageMetadata?: METADATA }
| { type: "abort"; reason: string }
| { type: "error"; errorText: string }
| { type: "text-start"; id: string }
| { type: "text-delta"; id: string; delta: string }
| { type: "text-end"; id: string }
| { type: "reasoning-start"; id: string }
| { type: "reasoning-delta"; id: string; delta: string }
| { type: "reasoning-end"; id: string }
| {
type: "data"
name: keyof DATA & string
id?: string
data: DATA[keyof DATA & string]
transient?: boolean
}
| {
type: "tool-input-available"
toolCallId: string
toolName: keyof TOOLS & string
input: unknown
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
title?: string
}
| {
type: "tool-approval-request"
approvalId: string
toolCallId: string
}
| {
type: "tool-output-available"
toolCallId: string
output: unknown
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
}
| {
type: "tool-output-error"
toolCallId: string
errorText: string
providerExecuted?: boolean
toolMetadata?: JsonRecord
dynamic?: boolean
}
| { type: "tool-output-denied"; toolCallId: string }
| { type: "file"; part: FilePayload }
| { type: "reasoning-file"; part: ReasoningFilePayload }
| { type: "source-url"; part: SourceUrlPayload }
| { type: "source-document"; part: SourceDocumentPayload }
| { type: "custom"; part: CustomPayload }
/** One playback instruction produced by `lowerEvents`: emit a chunk, sleep, or close the stream. */
export type StreamStep<
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> =
| { kind: "sleep"; delayMs: number }
| { kind: "chunk"; chunk: NeutralChunk<METADATA, DATA, TOOLS> }
| { kind: "close" }
/** A scripted turn: the materialized message plus the event log it streams from. */
export type ChatTurn<
MESSAGE,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = {
role: MessageRole
message: MESSAGE
events: ChatEvent<DATA, TOOLS>[]
}
/** Deterministic sequential id generators (`msg-1`, `call-1`, `source-1`, …). */
export type ChatIds = {
nextMessageId(): string
nextToolCallId(): string
nextSourceId(): string
nextApprovalId(): string
reserveMessageId(id: string): void
reserveToolCallId(id: string): void
reserveSourceId(id: string): void
reserveApprovalId(id: string): void
}
/** Prefixes used by the deterministic chat id generators. */
export type ChatIdsOptions = {
messageIdPrefix?: string
toolCallIdPrefix?: string
sourceIdPrefix?: string
approvalIdPrefix?: string
}
/** Chat-wide options: id prefixes plus the fixed clock used for default metadata. */
export type ChatOptions = ChatIdsOptions & {
now?: Date | string
}
/** Transport options. `delayMs` is the delay between text/reasoning deltas; defaults to 50ms. */
export type TurnStreamOptions = {
delayMs?: number
}
/**
* Maps a {@link NeutralChunk} to a framework chunk. Return `null` to skip
* the chunk or an array to fan one neutral chunk out into several.
*/
export type ChunkEncoder<
CHUNK,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = (chunk: NeutralChunk<METADATA, DATA, TOOLS>) => CHUNK | CHUNK[] | null
/**
* What the core hands an adapter's `createTransport`: `resolveTurn` matches a
* transcript to the next scripted assistant turn, `streamTurn` plays that
* turn as a chunk stream.
*/
export type TransportContext<
MESSAGE,
CHUNK,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = {
resolveTurn(
messages: MESSAGE[],
messageId?: string
): ChatTurn<MESSAGE, DATA, TOOLS> | undefined
streamTurn(
turn: ChatTurn<MESSAGE, DATA, TOOLS>,
encodeChunk: ChunkEncoder<CHUNK, METADATA, DATA, TOOLS>,
options?: TurnStreamOptions,
abortSignal?: AbortSignal
): ReadableStream<CHUNK>
}
/**
* The adapter plugin interface. Implement one of these per framework and pass
* it to `createChatRuntime` — the core never imports a framework itself.
*/
export type ChatFormat<
MESSAGE,
PART,
CHUNK,
TRANSPORT,
METADATA = unknown,
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
> = {
materializeParts(events: ChatEvent<DATA, TOOLS>[]): PART[]
eventsFromParts(parts: PART[]): ChatEvent<DATA, TOOLS>[]
createMessage(input: {
id: string
role: MessageRole
metadata: METADATA
parts: PART[]
}): MESSAGE
getMessageId(message: MESSAGE): string
getMessageRole(message: MESSAGE): MessageRole
getMessageText(message: MESSAGE): string
getMessageParts(message: MESSAGE): PART[]
/** Optional: extract a message's metadata when hydrating chats from existing messages. */
getMessageMetadata?(message: MESSAGE): METADATA | undefined
/** Optional: read a message's tool calls so continuation turns can resolve human-in-the-loop state. */
getToolCalls?(message: MESSAGE): ToolCallSummary[]
createTransport(
context: TransportContext<MESSAGE, CHUNK, METADATA, DATA, TOOLS>,
options?: TurnStreamOptions
): TRANSPORT
}
/** Options for one user turn. */
export type ChatUserOptions<METADATA> = {
id?: string
metadata?: METADATA
files?: Array<FilePayload & { type?: "file" }>
}
/** Options for one assistant turn. */
export type ChatAssistantOptions<METADATA> = {
id?: string
metadata?: METADATA
}

View File

@@ -0,0 +1,55 @@
/** Sample text used when `text()`/`user()` is called without content. */
export const DEFAULT_TEXT = "Summarize the uploaded receipt."
/** Sample reasoning used when `reasoning()` is called without content. */
export const DEFAULT_REASONING =
"I need to inspect the available context before answering."
/** Default delay between streamed text/reasoning deltas. */
export const DEFAULT_STREAM_DELAY_MS = 50
/** Compile-time exhaustiveness guard for discriminated unions we own. */
export function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`)
}
/** Resolves after `delayMs`, or immediately when `abortSignal` aborts. */
export function wait(delayMs: number, abortSignal?: AbortSignal) {
if (abortSignal?.aborted) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeoutId = setTimeout(finish, delayMs)
function finish() {
clearTimeout(timeoutId)
abortSignal?.removeEventListener("abort", finish)
resolve()
}
abortSignal?.addEventListener("abort", finish, { once: true })
})
}
/** Returns a detached copy of a scripted value. */
export function cloneValue<T>(value: T): T {
return structuredClone(value)
}
/** Splits text into word-plus-whitespace deltas for streaming. */
export function splitTextDeltas(text: string) {
return text.match(/\S+\s*/g) ?? [text]
}
/** Extracts the data part name from a `data-<name>` type string. */
export function getDataPartName(type: `data-${string}`) {
return type.slice("data-".length)
}
/** Logs a development-only warning. */
export function devWarn(message: string) {
if (process.env.NODE_ENV !== "production") {
console.warn(`[helpers] ${message}`)
}
}

View File

@@ -0,0 +1,214 @@
import { describe, expect, it } from "vitest"
import { createChatIds } from "./ids"
import { createChatPayloads } from "./payloads"
import type { TestData, TestTools } from "./test-utils"
import type { ChatEvent } from "./types"
import { DEFAULT_REASONING, DEFAULT_TEXT } from "./utils"
import { createEventWriter } from "./writer"
describe("createEventWriter", () => {
function createWriterContext() {
const ids = createChatIds()
return { ids, payloads: createChatPayloads(ids) }
}
it("records text and reasoning events with generated part ids", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer.text().reasoning()
expect(events).toMatchObject([
{
kind: "text",
id: "text-1",
text: DEFAULT_TEXT,
},
{
kind: "reasoning",
id: "reasoning-2",
text: DEFAULT_REASONING,
},
])
})
it("respects explicit stream text ids", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer.text("Hello", { id: "greeting" })
expect(events[0]).toMatchObject({
kind: "text",
id: "greeting",
})
})
it("records sleeps as after-start events", () => {
const events: ChatEvent<TestData, TestTools>[] = []
createEventWriter(events, createWriterContext()).sleep(100)
expect(events).toEqual([
{
kind: "sleep",
delayMs: 100,
phase: "after-start",
},
])
})
it("extracts data part names", () => {
const events: ChatEvent<TestData, TestTools>[] = []
createEventWriter(events, createWriterContext()).data({
type: "data-weather",
id: "weather-1",
data: { city: "SF", status: "loading" },
transient: true,
})
expect(events[0]).toMatchObject({
kind: "data",
name: "weather",
id: "weather-1",
transient: true,
})
})
it("chains tool events through the handle", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer
.tool("getWeather", { input: { city: "SF" } })
.sleep(50)
.output({ temperature: 72 })
expect(events.map((event) => event.kind)).toEqual([
"tool-input",
"sleep",
"tool-output",
])
expect(events[0]).toMatchObject({
kind: "tool-input",
name: "getWeather",
toolCallId: "call-1",
input: { city: "SF" },
})
expect(events[2]).toMatchObject({
kind: "tool-output",
toolCallId: "call-1",
output: { temperature: 72 },
})
})
it("reserves explicit tool ids and detaches tool values", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const context = createWriterContext()
const input = { city: "SF" }
const output = { temperature: 72 }
const writer = createEventWriter(events, context)
writer.tool("getWeather", { toolCallId: "call-2", input }).output(output)
writer.tool("getWeather", { input: { city: "NYC" } })
input.city = "mutated"
output.temperature = 0
expect(events).toMatchObject([
{ toolCallId: "call-2", input: { city: "SF" } },
{ toolCallId: "call-2", output: { temperature: 72 } },
{ toolCallId: "call-3" },
])
})
it("types tool inputs and outputs", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
if (false) {
writer.tool("getWeather", {
// @ts-expect-error getWeather input requires a string city.
input: { city: 72 },
})
writer.tool("getWeather", {
// @ts-expect-error getWeather output requires a numeric temperature.
output: { temperature: "warm" },
})
}
expect(events).toEqual([])
})
it("emits tool output and error from writer options", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer.tool("getWeather", {
input: { city: "SF" },
output: { temperature: 72 },
})
writer.tool("getWeather", {
input: { city: "SF" },
errorText: "Service down.",
})
expect(events.map((event) => event.kind)).toEqual([
"tool-input",
"tool-output",
"tool-input",
"tool-error",
])
})
it("records denied tool calls and defaults tool input to an empty object", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer.tool("getWeather").denied()
expect(events).toMatchObject([
{
kind: "tool-input",
input: {},
},
{
kind: "tool-denied",
toolCallId: "call-1",
},
])
})
it("records payload-backed events", () => {
const events: ChatEvent<TestData, TestTools>[] = []
const writer = createEventWriter(events, createWriterContext())
writer
.sourceUrl()
.sourceDocument()
.file({ filename: "notes.txt" })
.reasoningFile()
.custom("app.widget")
.stepStart()
.error()
expect(events.map((event) => event.kind)).toEqual([
"source-url",
"source-document",
"file",
"reasoning-file",
"custom",
"step-start",
"error",
])
expect(events[0]).toMatchObject({
part: { sourceId: "source-1" },
})
expect(events[2]).toMatchObject({
part: { filename: "notes.txt" },
})
})
})

View File

@@ -0,0 +1,303 @@
import type { ChatPayloads } from "./payloads"
import type {
ChatEvent,
ChatIds,
DataEventInput,
DataTypes,
FilePayload,
ReasoningFilePayload,
SourceDocumentPayload,
SourceUrlPayload,
StreamTextOptions,
ToolSet,
ToolWriterOptions,
} from "./types"
import {
cloneValue,
DEFAULT_REASONING,
DEFAULT_TEXT,
getDataPartName,
} from "./utils"
/**
* Lifecycle handle returned by `writer.tool()`. Chain `sleep()` between the
* input and its `output()`/`error()`/`denied()` resolution to script tool
* latency.
*/
export type ToolHandle<OUTPUT> = {
sleep(delayMs: number): ToolHandle<OUTPUT>
output(output: OUTPUT): ToolHandle<OUTPUT>
error(errorText?: string): ToolHandle<OUTPUT>
denied(): ToolHandle<OUTPUT>
}
/**
* The fluent writer DSL handed to `assistant(({ writer }) => …)`. Each call
* appends events to the turn's log in order; every method chains except
* `tool()`, which returns a {@link ToolHandle}.
*/
export type EventWriter<DATA extends DataTypes, TOOLS extends ToolSet> = {
text(text?: string, options?: StreamTextOptions): EventWriter<DATA, TOOLS>
reasoning(
text?: string,
options?: StreamTextOptions
): EventWriter<DATA, TOOLS>
sleep(delayMs: number): EventWriter<DATA, TOOLS>
data(part: DataEventInput<DATA>): EventWriter<DATA, TOOLS>
error(errorText?: string): EventWriter<DATA, TOOLS>
tool<NAME extends keyof TOOLS & string>(
name: NAME,
options?: ToolWriterOptions<TOOLS, NAME>
): ToolHandle<TOOLS[NAME]["output"]>
sourceUrl(overrides?: Partial<SourceUrlPayload>): EventWriter<DATA, TOOLS>
sourceDocument(
overrides?: Partial<SourceDocumentPayload>
): EventWriter<DATA, TOOLS>
file(overrides?: Partial<FilePayload>): EventWriter<DATA, TOOLS>
reasoningFile(
overrides?: Partial<ReasoningFilePayload>
): EventWriter<DATA, TOOLS>
custom(kind?: string): EventWriter<DATA, TOOLS>
stepStart(): EventWriter<DATA, TOOLS>
}
/**
* Creates an {@link EventWriter} that appends to `events`. Generated part ids
* are `<kind>-<events.length + 1>` at push time, matching the reference
* implementation this package was extracted from.
*/
export function createEventWriter<
DATA extends DataTypes,
TOOLS extends ToolSet,
>(
events: ChatEvent<DATA, TOOLS>[],
context: { ids: ChatIds; payloads: ChatPayloads }
): EventWriter<DATA, TOOLS> {
function nextPartId(kind: string) {
return `${kind}-${events.length + 1}`
}
const writer: EventWriter<DATA, TOOLS> = {
text(text = DEFAULT_TEXT, options: StreamTextOptions = {}) {
events.push({
kind: "text",
id: options.id ?? nextPartId("text"),
text,
options,
})
return writer
},
reasoning(text = DEFAULT_REASONING, options: StreamTextOptions = {}) {
events.push({
kind: "reasoning",
id: options.id ?? nextPartId("reasoning"),
text,
options,
})
return writer
},
sleep(delayMs: number) {
events.push({
kind: "sleep",
delayMs,
phase: "after-start",
})
return writer
},
data(part) {
const name = getDataPartName(part.type) as keyof DATA & string
const clonedPart = cloneValue(part)
events.push({
kind: "data",
name,
id: clonedPart.id,
data: clonedPart.data,
transient: clonedPart.transient,
} as ChatEvent<DATA, TOOLS>)
return writer
},
error(errorText = "An error occurred.") {
events.push({
kind: "error",
errorText,
})
return writer
},
tool<NAME extends keyof TOOLS & string>(
name: NAME,
options: ToolWriterOptions<TOOLS, NAME> = {}
) {
const clonedOptions = cloneValue(options)
const toolCallId =
clonedOptions.toolCallId ?? context.ids.nextToolCallId()
if (clonedOptions.toolCallId !== undefined) {
context.ids.reserveToolCallId(clonedOptions.toolCallId)
}
events.push({
kind: "tool-input",
name,
toolCallId,
title: clonedOptions.title,
toolMetadata: clonedOptions.toolMetadata,
providerExecuted: clonedOptions.providerExecuted,
dynamic: clonedOptions.dynamic,
input: clonedOptions.input ?? {},
})
function assertResolvable(method: string) {
if (clonedOptions.needsApproval) {
throw new Error(
`A needsApproval tool call resolves from the user's decision; remove the ${method}() call.`
)
}
}
const handle: ToolHandle<TOOLS[NAME]["output"]> = {
sleep(delayMs: number) {
events.push({
kind: "sleep",
delayMs,
phase: "after-start",
})
return handle
},
output(output: TOOLS[NAME]["output"]) {
assertResolvable("output")
events.push({
kind: "tool-output",
toolCallId,
output: cloneValue(output),
providerExecuted: clonedOptions.providerExecuted,
toolMetadata: clonedOptions.toolMetadata,
dynamic: clonedOptions.dynamic,
})
return handle
},
error(errorText = "Tool call failed.") {
assertResolvable("error")
events.push({
kind: "tool-error",
toolCallId,
errorText,
providerExecuted: clonedOptions.providerExecuted,
toolMetadata: clonedOptions.toolMetadata,
dynamic: clonedOptions.dynamic,
})
return handle
},
denied() {
assertResolvable("denied")
events.push({
kind: "tool-denied",
toolCallId,
})
return handle
},
}
if (clonedOptions.needsApproval) {
const approvalId =
clonedOptions.approvalId ?? context.ids.nextApprovalId()
if (clonedOptions.approvalId !== undefined) {
context.ids.reserveApprovalId(clonedOptions.approvalId)
}
events.push({
kind: "tool-approval-request",
toolCallId,
approvalId,
output: clonedOptions.output,
errorText: clonedOptions.errorText,
})
return handle
}
if (clonedOptions.output !== undefined) {
handle.output(clonedOptions.output)
}
if (clonedOptions.errorText !== undefined) {
handle.error(clonedOptions.errorText)
}
return handle
},
sourceUrl(overrides: Partial<SourceUrlPayload> = {}) {
events.push({
kind: "source-url",
part: context.payloads.sourceUrl(overrides),
})
return writer
},
sourceDocument(overrides: Partial<SourceDocumentPayload> = {}) {
events.push({
kind: "source-document",
part: context.payloads.sourceDocument(overrides),
})
return writer
},
file(overrides: Partial<FilePayload> = {}) {
events.push({
kind: "file",
part: context.payloads.file(overrides),
})
return writer
},
reasoningFile(overrides: Partial<ReasoningFilePayload> = {}) {
events.push({
kind: "reasoning-file",
part: context.payloads.reasoningFile(overrides),
})
return writer
},
custom(kind?: string) {
events.push({
kind: "custom",
part: context.payloads.custom(kind),
})
return writer
},
stepStart() {
events.push({
kind: "step-start",
})
return writer
},
}
return writer
}

View File

@@ -0,0 +1,103 @@
import type { UIMessage } from "@tanstack/ai-client"
import type { ClientTool } from "@tanstack/ai/client"
import { describe, expect, expectTypeOf, it } from "vitest"
import { testChatContract } from "../test/chat-contract"
import { createChat } from "./index"
import { weatherOutput } from "./test-utils"
type TestSchema<T> = {
readonly "~standard": {
readonly version: 1
readonly vendor: "test"
readonly types: {
readonly input: T
readonly output: T
}
readonly jsonSchema: {
readonly input: () => Record<string, unknown>
readonly output: () => Record<string, unknown>
}
}
}
type WeatherTools = readonly [
ClientTool<
TestSchema<{ city: string }>,
TestSchema<typeof weatherOutput>,
"getWeather"
>,
]
testChatContract("TanStack AI", () =>
createChat().user("One").assistant("A").user("Two").assistant("B")
)
describe("TanStack chat", () => {
it("returns native messages and exposes only supported writer operations", () => {
const chat = createChat<WeatherTools, { summary: string }>().assistant(
({ writer }) => {
writer
.tool("getWeather", { input: { city: "San Francisco" } })
.output(weatherOutput)
if (false) {
// @ts-expect-error TanStack's AG-UI transport has no data-part equivalent.
writer.data({ type: "data-weather", data: {} })
// @ts-expect-error TanStack's AG-UI transport has no source-part equivalent.
writer.sourceUrl()
// @ts-expect-error Only tools declared in the client tool tuple are accepted.
writer.tool("missing")
writer.tool("getWeather", {
// @ts-expect-error The declared input requires a string city.
input: { city: 72 },
})
// @ts-expect-error TanStack messages do not preserve text part IDs.
writer.text("Hello", { id: "text-1" })
writer.tool("getWeather", {
// @ts-expect-error TanStack messages do not preserve neutral tool titles.
title: "Weather",
})
}
}
)
const messages = chat.get()
expectTypeOf(messages).toEqualTypeOf<
Array<UIMessage<WeatherTools, { summary: string }>>
>()
expect(messages[0].parts[0]).toMatchObject({
type: "tool-call",
name: "getWeather",
})
if (false) {
// @ts-expect-error TanStack AI does not expose source parts.
createChat({ sourceIdPrefix: "source" })
}
})
it("hydrates a chat from an existing transcript", () => {
const source = createChat()
.user("What's the weather?")
.assistant(({ writer }) => {
writer
.tool("getWeather", {
input: { city: "San Francisco" },
})
.output(weatherOutput)
writer.text("It's sunny.", { mode: "instant" })
})
.user("Thanks!")
.assistant("Anytime.")
const recorded = source.get()
const replay = createChat({ messages: recorded })
expect(replay.get()).toEqual(recorded)
expect(replay.next(recorded.slice(0, 2))).toMatchObject({
id: recorded[2].id,
role: "user",
})
})
})

View File

@@ -0,0 +1,61 @@
import type {
AnyClientTool,
UIMessage as ClientUIMessage,
ConnectConnectionAdapter,
MessagePart,
} from "@tanstack/ai-client"
import type { StreamChunk } from "@tanstack/ai/client"
import { createChatRuntime } from "../core"
import type { Chat, ChatOptions } from "../core"
import { createTanStackFormat } from "./format"
import type {
TanStackEventData,
TanStackMessageMetadata,
TanStackToolSet,
TanStackWriter,
} from "./types"
/** Options for creating a TanStack AI chat, optionally hydrated from existing messages. */
export type CreateChatOptions<
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
DATA = unknown,
> = Omit<ChatOptions, "sourceIdPrefix"> & {
messages?: Array<ClientUIMessage<TOOLS, DATA>>
}
/** The chat type returned by the TanStack AI adapter's `createChat`. */
export type TanStackChat<
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
DATA = unknown,
> = Chat<
ClientUIMessage<TOOLS, DATA>,
MessagePart<TOOLS, DATA>,
ConnectConnectionAdapter,
TanStackMessageMetadata,
TanStackEventData,
TanStackToolSet<TOOLS>,
TanStackWriter<TOOLS>
>
/** Creates a TanStack AI chat, optionally hydrated from existing messages. */
export function createChat<
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
DATA = unknown,
>(options: CreateChatOptions<TOOLS, DATA> = {}) {
const { messages, ...chatOptions } = options
return createChatRuntime<
ClientUIMessage<TOOLS, DATA>,
MessagePart<TOOLS, DATA>,
StreamChunk,
ConnectConnectionAdapter,
TanStackMessageMetadata,
TanStackEventData,
TanStackToolSet<TOOLS>,
TanStackWriter<TOOLS>
>(createTanStackFormat<TOOLS, DATA>(), {
...chatOptions,
messages,
})
}

View File

@@ -0,0 +1,230 @@
import { StreamProcessor } from "@tanstack/ai/client"
import { describe, expect, it } from "vitest"
import { createChat } from "./index"
import { getMessageText } from "./parts"
import { collectChunks, createWeatherChat, weatherOutput } from "./test-utils"
describe("TanStack transport", () => {
it("streams the golden AG-UI event sequence with runContext ids", async () => {
const chat = createWeatherChat()
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const chunks = await collectChunks(
transport.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"RUN_STARTED",
"REASONING_MESSAGE_START",
"REASONING_MESSAGE_CONTENT",
"REASONING_MESSAGE_END",
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"TOOL_CALL_RESULT",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"RUN_FINISHED",
])
expect(chunks[0]).toMatchObject({
type: "RUN_STARTED",
threadId: "thread-1",
runId: "run-1",
})
expect(chunks[4]).toMatchObject({
type: "TOOL_CALL_START",
toolCallId: "call-1",
toolCallName: "getWeather",
parentMessageId: "msg-2",
})
expect(chunks[7]).toMatchObject({
type: "TOOL_CALL_RESULT",
toolCallId: "call-1",
content: JSON.stringify(weatherOutput),
})
expect(chunks.at(-1)).toMatchObject({
type: "RUN_FINISHED",
threadId: "thread-1",
runId: "run-1",
finishReason: "stop",
})
})
it("uses chat ids when runContext is omitted", async () => {
const chat = createChat().user("Hello").assistant("Hi")
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const chunks = await collectChunks(transport.connect(initialMessages))
expect(chunks[0]).toMatchObject({
type: "RUN_STARTED",
threadId: "thread-chat",
runId: "run-chat",
})
expect(chunks.at(-1)).toMatchObject({
type: "RUN_FINISHED",
threadId: "thread-chat",
runId: "run-chat",
})
})
it("round-trips through the real TanStack StreamProcessor", async () => {
const chat = createWeatherChat()
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const processor = new StreamProcessor()
processor.setMessages(initialMessages)
processor.prepareAssistantMessage()
await processor.process(
transport.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
const messages = processor.getMessages()
const assistantMessage = messages[messages.length - 1]
expect(messages.length).toBeGreaterThanOrEqual(2)
expect(assistantMessage.role).toBe("assistant")
expect(getMessageText(assistantMessage)).toBe("It's sunny.")
const toolCallPart = assistantMessage.parts.find(
(part) => part.type === "tool-call"
)
if (!toolCallPart || toolCallPart.type !== "tool-call") {
throw new Error("Expected a tool-call part.")
}
// The installed StreamProcessor (0.40.0) keeps the accumulated
// `arguments` JSON string on the final part; it does not persist a
// parsed `input` field.
expect(toolCallPart).toMatchObject({
name: "getWeather",
state: "complete",
arguments: JSON.stringify({ city: "San Francisco" }),
output: weatherOutput,
})
})
it("streams an app-level error as RUN_ERROR", async () => {
const chat = createChat().user("Hello").error("Model unavailable.")
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const chunks = await collectChunks(
transport.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
expect(chunks.map((chunk) => chunk.type)).toEqual([
"RUN_STARTED",
"RUN_ERROR",
])
expect(chunks.at(-1)).toMatchObject({
type: "RUN_ERROR",
message: "Model unavailable.",
})
})
it("throws when no scripted assistant turn matches", () => {
const chat = createChat().user("Hello")
const transport = chat.transport()
expect(() =>
transport.connect(chat.get(), undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
).toThrowError("No assistant response found for this transcript.")
})
it("honors scripted before-start sleeps", async () => {
const chat = createChat()
.user("Hello")
.sleep(50)
.assistant(({ writer }) => {
writer.text("Hey.", {
mode: "instant",
})
})
const initialMessages = chat.get(1)
const transport = chat.transport({ delayMs: 0 })
const startedAt = Date.now()
const iterator = transport
.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
[Symbol.asyncIterator]()
const first = await iterator.next()
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(35)
expect(first.value).toMatchObject({
type: "RUN_STARTED",
})
while (!(await iterator.next()).done) {
// Drain the remaining chunks.
}
})
it("streams hydrated assistant turns through connect", async () => {
const source = createChat().user("Hello").assistant("Hey there.")
const replay = createChat({ messages: source.get() })
const initialMessages = replay.get(1)
const chunks = await collectChunks(
replay
.transport({ delayMs: 0 })
.connect(initialMessages, undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
expect(chunks[0]).toMatchObject({
type: "RUN_STARTED",
threadId: "thread-1",
runId: "run-1",
})
expect(chunks.at(-1)).toMatchObject({
type: "RUN_FINISHED",
})
})
it("streams a fallback response when the script is exhausted", async () => {
const chat = createChat().user("Hello").assistant("Hey.")
const [unknownMessage] = createChat()
.user("Something unscripted", { id: "unknown-1" })
.get()
const chunks = await collectChunks(
chat
.transport({ delayMs: 0, fallback: "This demo is scripted." })
.connect([...chat.get(), unknownMessage], undefined, undefined, {
threadId: "thread-1",
runId: "run-1",
})
)
expect(chunks.map((chunk) => chunk.type)).toContain("TEXT_MESSAGE_CONTENT")
expect(
chunks
.filter((chunk) => chunk.type === "TEXT_MESSAGE_CONTENT")
.map((chunk) => ("delta" in chunk ? chunk.delta : ""))
.join("")
).toBe("This demo is scripted.")
expect(chunks.at(-1)).toMatchObject({
type: "RUN_FINISHED",
})
})
})

View File

@@ -0,0 +1,325 @@
import type {
AnyClientTool,
UIMessage as ClientUIMessage,
ConnectConnectionAdapter,
MessagePart,
RunAgentInputContext,
} from "@tanstack/ai-client"
import type { ModelMessage, StreamChunk, UIMessage } from "@tanstack/ai/client"
import { assertNever } from "../core"
import type {
ChatFormat,
ChunkEncoder,
DataTypes,
JsonRecord,
MessageRole,
ToolSet,
TransportContext,
TurnStreamOptions,
} from "../core"
import { eventsFromParts, getMessageText, materializeParts } from "./parts"
import type {
TanStackEventData,
TanStackMessageMetadata,
TanStackToolSet,
} from "./types"
/** Ids stamped onto every AG-UI event of one run. Taken from `runContext` when the client connects. */
export type TanStackRunIds = {
threadId: string
runId: string
messageId: string
}
// AG-UI event discriminants are typed as @ag-ui/core enum members
// (nominal), so plain string literals are not assignable even though the
// runtime values are identical. This is the single cast point.
function asStreamChunk(chunk: JsonRecord): StreamChunk {
return chunk as unknown as StreamChunk
}
/**
* Creates a `NeutralChunk` → AG-UI event encoder scoped to one run. Chunks
* with no AG-UI equivalent (`abort`, files, sources, `start-step`,
* `tool-output-denied`) encode to `null`; data and custom parts become
* `CUSTOM` events.
*/
export function createRunEncoder<
DATA extends DataTypes = DataTypes,
TOOLS extends ToolSet = ToolSet,
>(
runIds: TanStackRunIds
): ChunkEncoder<StreamChunk, TanStackMessageMetadata, DATA, TOOLS> {
const { threadId, runId, messageId } = runIds
return (chunk) => {
switch (chunk.type) {
case "start":
return asStreamChunk({
type: "RUN_STARTED",
threadId,
runId,
})
case "finish":
return asStreamChunk({
type: "RUN_FINISHED",
threadId,
runId,
finishReason: "stop",
})
case "error":
return asStreamChunk({
type: "RUN_ERROR",
message: chunk.errorText,
})
case "text-start":
return asStreamChunk({
type: "TEXT_MESSAGE_START",
messageId,
role: "assistant",
})
case "text-delta":
return asStreamChunk({
type: "TEXT_MESSAGE_CONTENT",
messageId,
delta: chunk.delta,
})
case "text-end":
return asStreamChunk({
type: "TEXT_MESSAGE_END",
messageId,
})
case "reasoning-start":
return asStreamChunk({
type: "REASONING_MESSAGE_START",
messageId,
role: "reasoning",
})
case "reasoning-delta":
return asStreamChunk({
type: "REASONING_MESSAGE_CONTENT",
messageId,
delta: chunk.delta,
})
case "reasoning-end":
return asStreamChunk({
type: "REASONING_MESSAGE_END",
messageId,
})
case "tool-input-available":
return [
asStreamChunk({
type: "TOOL_CALL_START",
toolCallId: chunk.toolCallId,
toolCallName: chunk.toolName,
toolName: chunk.toolName,
parentMessageId: messageId,
}),
asStreamChunk({
type: "TOOL_CALL_ARGS",
toolCallId: chunk.toolCallId,
delta: JSON.stringify(chunk.input ?? {}),
}),
asStreamChunk({
type: "TOOL_CALL_END",
toolCallId: chunk.toolCallId,
toolCallName: chunk.toolName,
input: chunk.input,
}),
]
case "tool-output-available":
return asStreamChunk({
type: "TOOL_CALL_RESULT",
messageId: `${chunk.toolCallId}-result`,
toolCallId: chunk.toolCallId,
content: JSON.stringify(chunk.output ?? {}),
role: "tool",
})
case "tool-output-error":
return asStreamChunk({
type: "TOOL_CALL_RESULT",
messageId: `${chunk.toolCallId}-result`,
toolCallId: chunk.toolCallId,
content: chunk.errorText,
role: "tool",
state: "output-error",
})
case "data":
return asStreamChunk({
type: "CUSTOM",
name: `data-${chunk.name}`,
value: {
id: chunk.id,
data: chunk.data,
transient: chunk.transient,
},
})
case "custom":
return asStreamChunk({
type: "CUSTOM",
name: chunk.part.kind,
value: chunk.part,
})
case "abort":
case "tool-approval-request":
case "tool-output-denied":
case "start-step":
case "file":
case "reasoning-file":
case "source-url":
case "source-document":
// These chunks have no AG-UI streaming equivalent.
return null
default:
return assertNever(chunk)
}
}
}
/**
* Wraps a `ReadableStream` as an `AsyncIterable` via `getReader()` (Safari
* lacks native stream iteration). Returned synchronously, as TanStack's
* connection contract requires; early `for await` exits cancel the reader.
*/
export function streamToAsyncIterable<T>(
stream: ReadableStream<T>
): AsyncIterable<T> {
return {
[Symbol.asyncIterator]() {
const reader = stream.getReader()
return {
async next(): Promise<IteratorResult<T>> {
const { done, value } = await reader.read()
if (done) {
return { done: true, value: undefined }
}
return { done: false, value }
},
async return(): Promise<IteratorResult<T>> {
await reader.cancel()
return { done: true, value: undefined }
},
}
},
}
}
/**
* The TanStack AI `ChatFormat` plugin. Its transport is a
* `ConnectConnectionAdapter` for `useChat({ connection })` and throws
* `"No assistant response found for this transcript."` when no assistant
* response remains.
*/
export function createTanStackFormat<
TOOLS extends ReadonlyArray<AnyClientTool> = AnyClientTool[],
DATA = unknown,
>(): ChatFormat<
ClientUIMessage<TOOLS, DATA>,
MessagePart<TOOLS, DATA>,
StreamChunk,
ConnectConnectionAdapter,
TanStackMessageMetadata,
TanStackEventData,
TanStackToolSet<TOOLS>
> {
return {
materializeParts(events) {
return materializeParts<TOOLS, DATA>(events)
},
eventsFromParts(parts) {
return eventsFromParts<TOOLS, DATA>(parts)
},
createMessage(input) {
const message: ClientUIMessage<TOOLS, DATA> = {
id: input.id,
role: input.role,
parts: input.parts,
}
const createdAt = input.metadata.createdAt
if (typeof createdAt === "string" || createdAt instanceof Date) {
message.createdAt = new Date(createdAt)
}
return message
},
getMessageId(message) {
return message.id
},
getMessageRole(message) {
return message.role as MessageRole
},
getMessageText(message) {
return getMessageText(message)
},
getMessageParts(message) {
return message.parts
},
getMessageMetadata(message) {
return {
createdAt: message.createdAt,
}
},
createTransport(context, options = {}) {
return createConnectionAdapter<TOOLS, DATA>(context, options)
},
}
}
function createConnectionAdapter<
TOOLS extends ReadonlyArray<AnyClientTool>,
DATA,
>(
context: TransportContext<
ClientUIMessage<TOOLS, DATA>,
StreamChunk,
TanStackMessageMetadata,
TanStackEventData,
TanStackToolSet<TOOLS>
>,
options: TurnStreamOptions
): ConnectConnectionAdapter {
return {
connect(
messages: UIMessage[] | ModelMessage[],
_data?: Record<string, unknown>,
abortSignal?: AbortSignal,
runContext?: RunAgentInputContext
) {
// The chat client hands custom adapters the full UIMessage history.
const turn = context.resolveTurn(
messages as ClientUIMessage<TOOLS, DATA>[]
)
if (!turn) {
throw new Error("No assistant response found for this transcript.")
}
const encode = createRunEncoder<
TanStackEventData,
TanStackToolSet<TOOLS>
>({
threadId: runContext?.threadId ?? "thread-chat",
runId: runContext?.runId ?? "run-chat",
messageId: turn.message.id,
})
return streamToAsyncIterable(
context.streamTurn(turn, encode, options, abortSignal)
)
},
}
}

View File

@@ -0,0 +1,7 @@
export { createChat } from "./chat"
export type { CreateChatOptions, TanStackChat } from "./chat"
export type {
TanStackMessageMetadata,
TanStackToolHandle,
TanStackWriter,
} from "./types"

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest"
import { createWeatherChat, weatherOutput } from "./test-utils"
describe("TanStack parts", () => {
it("materializes writer events into TanStack message parts", () => {
const chat = createWeatherChat()
const [userMessage, assistantMessage] = chat.get()
expect(userMessage).toMatchObject({
id: "msg-1",
role: "user",
parts: [
{
type: "text",
content: "What's the weather?",
},
],
})
expect(assistantMessage.parts.map((part) => part.type)).toEqual([
"thinking",
"tool-call",
"tool-result",
"text",
])
expect(assistantMessage.parts[1]).toMatchObject({
type: "tool-call",
id: "call-1",
name: "getWeather",
state: "complete",
input: {
city: "San Francisco",
},
output: weatherOutput,
})
expect(assistantMessage.parts[2]).toMatchObject({
type: "tool-result",
toolCallId: "call-1",
state: "complete",
})
})
})

View File

@@ -0,0 +1,347 @@
import type { AnyClientTool, MessagePart, UIMessage } from "@tanstack/ai-client"
import { assertNever } from "../core"
import type { ChatEvent, FilePayload } from "../core"
import type { TanStackEventData, TanStackToolSet } from "./types"
type TextPart = Extract<MessagePart, { type: "text" }>
type ToolCallPart<TOOLS extends ReadonlyArray<AnyClientTool>> = Extract<
MessagePart<TOOLS>,
{ type: "tool-call" }
>
type ToolResultPart = Extract<MessagePart, { type: "tool-result" }>
/** Options for a static TanStack `tool-result` part. */
export type ToolResultPartOptions = {
output?: unknown
errorText?: string
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text)
} catch {
return {}
}
}
/** Concatenates the `content` of every `text` part in a message. */
export function getMessageText<
TOOLS extends ReadonlyArray<AnyClientTool>,
DATA,
>(message: Pick<UIMessage<TOOLS, DATA>, "parts">) {
return message.parts
.filter((part): part is TextPart => part.type === "text")
.map((part) => part.content)
.join("")
}
/** Converts a neutral file payload into a TanStack media part (`image`/`audio`/`video`/`document` by media type). */
export function mediaPartFromFile(file: FilePayload): MessagePart {
const source = {
type: "url" as const,
value: file.url,
mimeType: file.mediaType,
}
if (file.mediaType.startsWith("image/")) {
return {
type: "image",
source,
}
}
if (file.mediaType.startsWith("audio/")) {
return {
type: "audio",
source,
}
}
if (file.mediaType.startsWith("video/")) {
return {
type: "video",
source,
}
}
return {
type: "document",
source,
}
}
function fileFromMediaPart(part: MessagePart): FilePayload | null {
if (
part.type !== "image" &&
part.type !== "audio" &&
part.type !== "video" &&
part.type !== "document"
) {
return null
}
if (part.source.type !== "url") {
return null
}
const fallbackMediaType =
part.type === "image"
? "image/png"
: part.type === "audio"
? "audio/mpeg"
: part.type === "video"
? "video/mp4"
: "application/octet-stream"
return {
mediaType: part.source.mimeType ?? fallbackMediaType,
url: part.source.value,
}
}
export function createToolResultPart(
toolCallId: string,
options: ToolResultPartOptions = {}
): ToolResultPart {
if (options.errorText !== undefined) {
return {
type: "tool-result",
toolCallId,
content: options.errorText,
state: "error",
error: options.errorText,
}
}
return {
type: "tool-result",
toolCallId,
content: JSON.stringify(options.output ?? {}),
state: "complete",
}
}
/**
* Materializes an event log into final TanStack message parts, mirroring the
* end state the real StreamProcessor produces (tool outputs update the
* `tool-call` part and add a sibling `tool-result`). Data, source, custom,
* and step events have no TanStack part and are skipped.
*/
export function materializeParts<
TOOLS extends ReadonlyArray<AnyClientTool>,
DATA,
>(
events: ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[]
): Array<MessagePart<TOOLS, DATA>> {
const parts: Array<MessagePart<TOOLS, DATA>> = []
function findToolCallPart(toolCallId: string) {
const index = parts.findIndex(
(part) => part.type === "tool-call" && part.id === toolCallId
)
return index === -1 ? null : (parts[index] as ToolCallPart<TOOLS>)
}
for (const event of events) {
switch (event.kind) {
case "text": {
parts.push({
type: "text",
content: event.text,
})
break
}
case "reasoning": {
parts.push({
type: "thinking",
content: event.text,
})
break
}
case "tool-input": {
parts.push({
type: "tool-call",
id: event.toolCallId,
name: event.name,
arguments: JSON.stringify(event.input ?? {}),
input: event.input ?? {},
state: "input-complete",
} as MessagePart<TOOLS, DATA>)
break
}
case "tool-output": {
const toolCallPart = findToolCallPart(event.toolCallId)
if (toolCallPart) {
toolCallPart.state = "complete"
toolCallPart.output = event.output
}
parts.push(
createToolResultPart(event.toolCallId, {
output: event.output,
}) as MessagePart<TOOLS, DATA>
)
break
}
case "tool-error": {
const toolCallPart = findToolCallPart(event.toolCallId)
if (toolCallPart) {
toolCallPart.state = "error"
}
parts.push(
createToolResultPart(event.toolCallId, {
errorText: event.errorText,
}) as MessagePart<TOOLS, DATA>
)
break
}
case "tool-denied": {
const toolCallPart = findToolCallPart(event.toolCallId)
if (toolCallPart) {
toolCallPart.approval = {
id: `${event.toolCallId}-approval`,
needsApproval: true,
approved: false,
}
}
break
}
case "file": {
parts.push(mediaPartFromFile(event.part) as MessagePart<TOOLS, DATA>)
break
}
case "sleep":
case "data":
case "error":
case "source-url":
case "source-document":
case "reasoning-file":
case "custom":
case "step-start":
case "tool-approval-request":
// These events have no TanStack message part.
break
default:
assertNever(event)
}
}
return parts
}
/** Parses TanStack message parts back into an event log so static parts can stream. */
export function eventsFromParts<
TOOLS extends ReadonlyArray<AnyClientTool>,
DATA,
>(
parts: Array<MessagePart<TOOLS, DATA>>
): ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[] {
const events: ChatEvent<TanStackEventData, TanStackToolSet<TOOLS>>[] = []
const resolvedToolCallIds = new Set<string>()
for (const part of parts) {
// Framework-owned part unions are open, so parsing is non-exhaustive.
if (part.type === "text") {
events.push({
kind: "text",
id: `text-${events.length + 1}`,
text: part.content,
options: {
mode: "instant",
},
})
}
if (part.type === "thinking") {
events.push({
kind: "reasoning",
id: `reasoning-${events.length + 1}`,
text: part.content,
options: {
mode: "instant",
},
})
}
if (part.type === "tool-call") {
const input =
part.input !== undefined ? part.input : parseJson(part.arguments)
events.push({
kind: "tool-input",
name: part.name as keyof TanStackToolSet<TOOLS> & string,
toolCallId: part.id,
input,
})
if (part.state === "error") {
resolvedToolCallIds.add(part.id)
events.push({
kind: "tool-error",
toolCallId: part.id,
errorText: "Tool call failed.",
})
} else if (part.state === "complete" || part.output !== undefined) {
resolvedToolCallIds.add(part.id)
events.push({
kind: "tool-output",
toolCallId: part.id,
output: part.output,
})
}
}
if (part.type === "tool-result") {
if (resolvedToolCallIds.has(part.toolCallId)) {
continue
}
resolvedToolCallIds.add(part.toolCallId)
if (part.state === "error") {
events.push({
kind: "tool-error",
toolCallId: part.toolCallId,
errorText:
part.error ??
(typeof part.content === "string"
? part.content
: "Tool call failed."),
})
} else {
events.push({
kind: "tool-output",
toolCallId: part.toolCallId,
output:
typeof part.content === "string"
? parseJson(part.content)
: part.content,
})
}
}
if (
part.type === "image" ||
part.type === "audio" ||
part.type === "video" ||
part.type === "document"
) {
const file = fileFromMediaPart(part)
if (file) {
events.push({
kind: "file",
part: file,
})
}
}
}
return events
}

View File

@@ -0,0 +1,46 @@
import type { StreamChunk } from "@tanstack/ai/client"
import { createChat } from "./index"
export type WeatherOutput = {
city: string
temperature: number
condition: string
}
export const weatherOutput: WeatherOutput = {
city: "San Francisco",
temperature: 72,
condition: "sunny",
}
export async function collectChunks(iterable: AsyncIterable<StreamChunk>) {
const chunks: StreamChunk[] = []
for await (const chunk of iterable) {
chunks.push(chunk)
}
return chunks
}
export function createWeatherChat() {
return createChat()
.user("What's the weather?")
.assistant(({ writer }) => {
writer.reasoning("I need to call the weather tool.", {
mode: "instant",
})
const weather = writer.tool("getWeather", {
input: {
city: "San Francisco",
},
})
weather.output(weatherOutput)
writer.text("It's sunny.", {
mode: "instant",
})
})
}

View File

@@ -0,0 +1,54 @@
import type {
AnyClientTool,
ExtractToolInput,
ExtractToolOutput,
} from "@tanstack/ai-client"
import type { StreamTextOptions, ToolWriterOptions } from "../core"
type TanStackStreamTextOptions = Omit<StreamTextOptions, "id">
type TanStackToolWriterOptions<
TOOLS extends ReadonlyArray<AnyClientTool>,
NAME extends keyof TanStackToolSet<TOOLS>,
> = Pick<
ToolWriterOptions<TanStackToolSet<TOOLS>, NAME>,
"errorText" | "input" | "output" | "toolCallId"
>
export type TanStackMessageMetadata = {
createdAt?: Date | string
}
export type TanStackEventData = Record<never, never>
export type TanStackToolSet<TOOLS extends ReadonlyArray<AnyClientTool>> = {
[NAME in TOOLS[number]["name"]]: {
input: ExtractToolInput<TOOLS, NAME>
output: ExtractToolOutput<TOOLS, NAME>
}
}
export type TanStackToolHandle<OUTPUT> = {
sleep(delayMs: number): TanStackToolHandle<OUTPUT>
output(output: OUTPUT): TanStackToolHandle<OUTPUT>
error(errorText?: string): TanStackToolHandle<OUTPUT>
}
/** Writer operations that TanStack AI can represent end-to-end over AG-UI. */
export type TanStackWriter<TOOLS extends ReadonlyArray<AnyClientTool>> = {
text(
text?: string,
options?: TanStackStreamTextOptions
): TanStackWriter<TOOLS>
reasoning(
text?: string,
options?: TanStackStreamTextOptions
): TanStackWriter<TOOLS>
sleep(delayMs: number): TanStackWriter<TOOLS>
error(errorText?: string): TanStackWriter<TOOLS>
tool<NAME extends keyof TanStackToolSet<TOOLS> & string>(
name: NAME,
options?: TanStackToolWriterOptions<TOOLS, NAME>
): TanStackToolHandle<TanStackToolSet<TOOLS>[NAME]["output"]>
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest"
type ContractMessage = {
id: string
role: string
parts: unknown[]
}
type ContractChat<MESSAGE extends ContractMessage> = {
get(count?: number): MESSAGE[]
next(messages: readonly MESSAGE[]): MESSAGE | null
}
function mutateNestedValue(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some(mutateNestedValue)
}
if (value === null || typeof value !== "object") {
return false
}
for (const [key, child] of Object.entries(value)) {
if (typeof child === "string") {
const object = value as Record<string, unknown>
object[key] = "mutated"
return true
}
if (mutateNestedValue(child)) {
return true
}
}
return false
}
export function testChatContract<MESSAGE extends ContractMessage>(
adapter: string,
createFixture: () => ContractChat<MESSAGE>
) {
describe(`${adapter} chat contract`, () => {
it("reads the full transcript or a prefix without advancing", () => {
const chat = createFixture()
const messages = chat.get()
expect(messages.map((message) => message.role)).toEqual([
"user",
"assistant",
"user",
"assistant",
])
expect(chat.get(2)).toEqual(messages.slice(0, 2))
expect(chat.get()).toEqual(messages)
})
it("finds the next user message from the supplied transcript", () => {
const chat = createFixture()
const messages = chat.get()
expect(chat.next(messages.slice(0, 2))?.id).toBe(messages[2].id)
expect(chat.next(messages.slice(0, 2))?.id).toBe(messages[2].id)
expect(chat.next(messages)).toBeNull()
})
it("returns detached transcript snapshots", () => {
const chat = createFixture()
const expected = chat.get()
const snapshot = chat.get()
expect(mutateNestedValue(snapshot[0].parts)).toBe(true)
expect(chat.get()).toEqual(expected)
})
})
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": false,
"baseUrl": "."
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from "tsup"
export default defineConfig((options) => ({
clean: !options.watch,
dts: true,
entry: {
"ai-sdk/index": "src/ai-sdk/index.ts",
"tanstack-ai/index": "src/tanstack-ai/index.ts",
},
format: ["esm"],
sourcemap: false,
minify: true,
target: "esnext",
outDir: "dist",
treeshake: true,
}))

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "node",
testTimeout: 8000,
},
})