snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
2
packages/helpers/.gitignore
vendored
Normal file
2
packages/helpers/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
.turbo
|
||||
15
packages/helpers/CHANGELOG.md
Normal file
15
packages/helpers/CHANGELOG.md
Normal 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
|
||||
94
packages/helpers/README.md
Normal file
94
packages/helpers/README.md
Normal 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).
|
||||
81
packages/helpers/package.json
Normal file
81
packages/helpers/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
390
packages/helpers/src/ai-sdk/chat.test.ts
Normal file
390
packages/helpers/src/ai-sdk/chat.test.ts
Normal 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",
|
||||
])
|
||||
})
|
||||
})
|
||||
143
packages/helpers/src/ai-sdk/chat.ts
Normal file
143
packages/helpers/src/ai-sdk/chat.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
931
packages/helpers/src/ai-sdk/format.test.ts
Normal file
931
packages/helpers/src/ai-sdk/format.test.ts
Normal 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." })
|
||||
})
|
||||
})
|
||||
276
packages/helpers/src/ai-sdk/format.ts
Normal file
276
packages/helpers/src/ai-sdk/format.ts
Normal 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
|
||||
}
|
||||
2
packages/helpers/src/ai-sdk/index.ts
Normal file
2
packages/helpers/src/ai-sdk/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createChat } from "./chat"
|
||||
export type { AiSdkChat, CreateChatOptions } from "./chat"
|
||||
270
packages/helpers/src/ai-sdk/parts.test.ts
Normal file
270
packages/helpers/src/ai-sdk/parts.test.ts
Normal 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" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
478
packages/helpers/src/ai-sdk/parts.ts
Normal file
478
packages/helpers/src/ai-sdk/parts.ts
Normal 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
|
||||
}
|
||||
83
packages/helpers/src/ai-sdk/test-utils.ts
Normal file
83
packages/helpers/src/ai-sdk/test-utils.ts
Normal 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
|
||||
}
|
||||
621
packages/helpers/src/core/chat.test.ts
Normal file
621
packages/helpers/src/core/chat.test.ts
Normal 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/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
814
packages/helpers/src/core/chat.ts
Normal file
814
packages/helpers/src/core/chat.ts
Normal 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
|
||||
}
|
||||
41
packages/helpers/src/core/ids.test.ts
Normal file
41
packages/helpers/src/core/ids.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
94
packages/helpers/src/core/ids.ts
Normal file
94
packages/helpers/src/core/ids.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
8
packages/helpers/src/core/index.ts
Normal file
8
packages/helpers/src/core/index.ts
Normal 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"
|
||||
218
packages/helpers/src/core/lower.test.ts
Normal file
218
packages/helpers/src/core/lower.test.ts
Normal 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",
|
||||
])
|
||||
})
|
||||
})
|
||||
298
packages/helpers/src/core/lower.ts
Normal file
298
packages/helpers/src/core/lower.ts
Normal 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
|
||||
}
|
||||
73
packages/helpers/src/core/payloads.test.ts
Normal file
73
packages/helpers/src/core/payloads.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
106
packages/helpers/src/core/payloads.ts
Normal file
106
packages/helpers/src/core/payloads.ts
Normal 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
|
||||
}
|
||||
}
|
||||
182
packages/helpers/src/core/stream.test.ts
Normal file
182
packages/helpers/src/core/stream.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
116
packages/helpers/src/core/stream.ts
Normal file
116
packages/helpers/src/core/stream.ts
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
231
packages/helpers/src/core/test-utils.ts
Normal file
231
packages/helpers/src/core/test-utils.ts
Normal 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
|
||||
)
|
||||
}
|
||||
404
packages/helpers/src/core/types.ts
Normal file
404
packages/helpers/src/core/types.ts
Normal 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
|
||||
}
|
||||
55
packages/helpers/src/core/utils.ts
Normal file
55
packages/helpers/src/core/utils.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
214
packages/helpers/src/core/writer.test.ts
Normal file
214
packages/helpers/src/core/writer.test.ts
Normal 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" },
|
||||
})
|
||||
})
|
||||
})
|
||||
303
packages/helpers/src/core/writer.ts
Normal file
303
packages/helpers/src/core/writer.ts
Normal 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
|
||||
}
|
||||
103
packages/helpers/src/tanstack-ai/chat.test.ts
Normal file
103
packages/helpers/src/tanstack-ai/chat.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
61
packages/helpers/src/tanstack-ai/chat.ts
Normal file
61
packages/helpers/src/tanstack-ai/chat.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
230
packages/helpers/src/tanstack-ai/format.test.ts
Normal file
230
packages/helpers/src/tanstack-ai/format.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
325
packages/helpers/src/tanstack-ai/format.ts
Normal file
325
packages/helpers/src/tanstack-ai/format.ts
Normal 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)
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
7
packages/helpers/src/tanstack-ai/index.ts
Normal file
7
packages/helpers/src/tanstack-ai/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { createChat } from "./chat"
|
||||
export type { CreateChatOptions, TanStackChat } from "./chat"
|
||||
export type {
|
||||
TanStackMessageMetadata,
|
||||
TanStackToolHandle,
|
||||
TanStackWriter,
|
||||
} from "./types"
|
||||
42
packages/helpers/src/tanstack-ai/parts.test.ts
Normal file
42
packages/helpers/src/tanstack-ai/parts.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
347
packages/helpers/src/tanstack-ai/parts.ts
Normal file
347
packages/helpers/src/tanstack-ai/parts.ts
Normal 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
|
||||
}
|
||||
46
packages/helpers/src/tanstack-ai/test-utils.ts
Normal file
46
packages/helpers/src/tanstack-ai/test-utils.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
}
|
||||
54
packages/helpers/src/tanstack-ai/types.ts
Normal file
54
packages/helpers/src/tanstack-ai/types.ts
Normal 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"]>
|
||||
}
|
||||
76
packages/helpers/src/test/chat-contract.ts
Normal file
76
packages/helpers/src/test/chat-contract.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
13
packages/helpers/tsconfig.json
Normal file
13
packages/helpers/tsconfig.json
Normal 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"]
|
||||
}
|
||||
16
packages/helpers/tsup.config.ts
Normal file
16
packages/helpers/tsup.config.ts
Normal 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,
|
||||
}))
|
||||
8
packages/helpers/vitest.config.ts
Normal file
8
packages/helpers/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
testTimeout: 8000,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user