From 0b209c7619816b1e2a20e77ea6d22a871652f489 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:40:39 +0000 Subject: [PATCH 01/19] feat: add @trigger.dev/ai package with TriggerChatTransport New package that provides a custom AI SDK ChatTransport implementation bridging Vercel AI SDK's useChat hook with Trigger.dev's durable task execution and realtime streams. Key exports: - TriggerChatTransport class implementing ChatTransport - createChatTransport() factory function - ChatTaskPayload type for task-side typing - TriggerChatTransportOptions type The transport triggers a Trigger.dev task with chat messages as payload, then subscribes to the task's realtime stream to receive UIMessageChunk data, which useChat processes natively. Co-authored-by: Eric Allam --- packages/ai/package.json | 74 ++++++++++ packages/ai/src/index.ts | 3 + packages/ai/src/transport.ts | 258 +++++++++++++++++++++++++++++++++++ packages/ai/src/types.ts | 100 ++++++++++++++ packages/ai/src/version.ts | 1 + packages/ai/tsconfig.json | 10 ++ pnpm-lock.yaml | 160 +++++++++++++++++++++- 7 files changed, 603 insertions(+), 3 deletions(-) create mode 100644 packages/ai/package.json create mode 100644 packages/ai/src/index.ts create mode 100644 packages/ai/src/transport.ts create mode 100644 packages/ai/src/types.ts create mode 100644 packages/ai/src/version.ts create mode 100644 packages/ai/tsconfig.json diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 00000000000..c6cee5d728b --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,74 @@ +{ + "name": "@trigger.dev/ai", + "version": "4.3.3", + "description": "AI SDK integration for Trigger.dev - Custom ChatTransport for running AI chat as durable tasks", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/triggerdotdev/trigger.dev", + "directory": "packages/ai" + }, + "type": "module", + "files": [ + "dist" + ], + "tshy": { + "selfLink": false, + "main": true, + "module": true, + "project": "./tsconfig.json", + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "sourceDialects": [ + "@triggerdotdev/source" + ] + }, + "scripts": { + "clean": "rimraf dist .tshy .tshy-build .turbo", + "build": "tshy && pnpm run update-version", + "dev": "tshy --watch", + "typecheck": "tsc --noEmit", + "test": "vitest", + "update-version": "tsx ../../scripts/updateVersion.ts", + "check-exports": "attw --pack ." + }, + "dependencies": { + "@trigger.dev/core": "workspace:4.3.3" + }, + "peerDependencies": { + "ai": "^5.0.0 || ^6.0.0" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.15.4", + "ai": "^6.0.0", + "rimraf": "^3.0.2", + "tshy": "^3.0.2", + "tsx": "4.17.0", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=18.20.0" + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "@triggerdotdev/source": "./src/index.ts", + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + } + }, + "main": "./dist/commonjs/index.js", + "types": "./dist/commonjs/index.d.ts", + "module": "./dist/esm/index.js" +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 00000000000..f58c1d1ffaa --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,3 @@ +export { TriggerChatTransport, createChatTransport } from "./transport.js"; +export type { TriggerChatTransportOptions, ChatTaskPayload, ChatSessionState } from "./types.js"; +export { VERSION } from "./version.js"; diff --git a/packages/ai/src/transport.ts b/packages/ai/src/transport.ts new file mode 100644 index 00000000000..1a5789c96bd --- /dev/null +++ b/packages/ai/src/transport.ts @@ -0,0 +1,258 @@ +import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from "ai"; +import { + ApiClient, + SSEStreamSubscription, + type SSEStreamPart, +} from "@trigger.dev/core/v3"; +import type { TriggerChatTransportOptions, ChatSessionState } from "./types.js"; + +const DEFAULT_STREAM_KEY = "chat"; +const DEFAULT_BASE_URL = "https://api.trigger.dev"; +const DEFAULT_STREAM_TIMEOUT_SECONDS = 120; + +/** + * A custom AI SDK `ChatTransport` implementation that bridges the Vercel AI SDK's + * `useChat` hook with Trigger.dev's durable task execution and realtime streams. + * + * When `sendMessages` is called, the transport: + * 1. Triggers a Trigger.dev task with the chat messages as payload + * 2. Subscribes to the task's realtime stream to receive `UIMessageChunk` data + * 3. Returns a `ReadableStream` that the AI SDK processes natively + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { TriggerChatTransport } from "@trigger.dev/ai"; + * + * function Chat({ accessToken }: { accessToken: string }) { + * const { messages, sendMessage, status } = useChat({ + * transport: new TriggerChatTransport({ + * accessToken, + * taskId: "my-chat-task", + * }), + * }); + * + * // ... render messages + * } + * ``` + * + * On the backend, the task should pipe UIMessageChunks to the `"chat"` stream: + * + * @example + * ```ts + * import { task, streams } from "@trigger.dev/sdk"; + * import { streamText, convertToModelMessages } from "ai"; + * + * export const myChatTask = task({ + * id: "my-chat-task", + * run: async (payload: ChatTaskPayload) => { + * const result = streamText({ + * model: openai("gpt-4o"), + * messages: convertToModelMessages(payload.messages), + * }); + * + * const { waitUntilComplete } = streams.pipe("chat", result.toUIMessageStream()); + * await waitUntilComplete(); + * }, + * }); + * ``` + */ +export class TriggerChatTransport implements ChatTransport { + private readonly taskId: string; + private readonly accessToken: string; + private readonly baseURL: string; + private readonly streamKey: string; + private readonly extraHeaders: Record; + + /** + * Tracks active chat sessions for reconnection support. + * Maps chatId → session state (runId, publicAccessToken). + */ + private sessions: Map = new Map(); + + constructor(options: TriggerChatTransportOptions) { + this.taskId = options.taskId; + this.accessToken = options.accessToken; + this.baseURL = options.baseURL ?? DEFAULT_BASE_URL; + this.streamKey = options.streamKey ?? DEFAULT_STREAM_KEY; + this.extraHeaders = options.headers ?? {}; + } + + /** + * Sends messages to a Trigger.dev task and returns a streaming response. + * + * This method: + * 1. Triggers the configured task with the chat messages as payload + * 2. Subscribes to the task's realtime stream for UIMessageChunk events + * 3. Returns a ReadableStream that the AI SDK's useChat hook processes + */ + sendMessages = async ( + options: { + trigger: "submit-message" | "regenerate-message"; + chatId: string; + messageId: string | undefined; + messages: UIMessage[]; + abortSignal: AbortSignal | undefined; + } & ChatRequestOptions + ): Promise> => { + const { trigger, chatId, messageId, messages, abortSignal, headers, body, metadata } = options; + + // Build the payload for the task + const payload = { + messages, + chatId, + trigger, + messageId, + metadata, + ...(body ?? {}), + }; + + // Create API client for triggering + const apiClient = new ApiClient(this.baseURL, this.accessToken); + + // Trigger the task + const triggerResponse = await apiClient.triggerTask(this.taskId, { + payload: JSON.stringify(payload), + options: { + payloadType: "application/json", + }, + }); + + const runId = triggerResponse.id; + const publicAccessToken = "publicAccessToken" in triggerResponse + ? (triggerResponse as { publicAccessToken?: string }).publicAccessToken + : undefined; + + // Store session state for reconnection + this.sessions.set(chatId, { + runId, + publicAccessToken: publicAccessToken ?? this.accessToken, + }); + + // Subscribe to the realtime stream for this run + return this.subscribeToStream(runId, publicAccessToken ?? this.accessToken, abortSignal); + }; + + /** + * Reconnects to an existing streaming response for the specified chat session. + * + * Returns a ReadableStream if an active session exists, or null if no session is found. + */ + reconnectToStream = async ( + options: { + chatId: string; + } & ChatRequestOptions + ): Promise | null> => { + const { chatId } = options; + + const session = this.sessions.get(chatId); + if (!session) { + return null; + } + + return this.subscribeToStream(session.runId, session.publicAccessToken, undefined); + }; + + /** + * Creates a ReadableStream by subscribing to the realtime SSE stream + * for a given run. + */ + private subscribeToStream( + runId: string, + accessToken: string, + abortSignal: AbortSignal | undefined + ): ReadableStream { + const streamKey = this.streamKey; + const baseURL = this.baseURL; + const extraHeaders = this.extraHeaders; + + // Build the authorization header + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + ...extraHeaders, + }; + + const subscription = new SSEStreamSubscription( + `${baseURL}/realtime/v1/streams/${runId}/${streamKey}`, + { + headers, + signal: abortSignal, + timeoutInSeconds: DEFAULT_STREAM_TIMEOUT_SECONDS, + } + ); + + // We need to convert the SSEStreamPart stream to a UIMessageChunk stream + // SSEStreamPart has { id, chunk, timestamp } where chunk is the deserialized UIMessageChunk + let sseStreamPromise: Promise> | null = null; + + return new ReadableStream({ + start: async (controller) => { + try { + sseStreamPromise = subscription.subscribe(); + const sseStream = await sseStreamPromise; + const reader = sseStream.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + controller.close(); + return; + } + + if (abortSignal?.aborted) { + reader.cancel(); + reader.releaseLock(); + controller.close(); + return; + } + + // Each SSE part's chunk is a UIMessageChunk + controller.enqueue(value.chunk as UIMessageChunk); + } + } catch (readError) { + reader.releaseLock(); + throw readError; + } + } catch (error) { + // Don't error the stream for abort errors + if (error instanceof Error && error.name === "AbortError") { + controller.close(); + return; + } + + controller.error(error); + } + }, + cancel: () => { + // Cancellation is handled by the abort signal + }, + }); + } +} + +/** + * Creates a new `TriggerChatTransport` instance. + * + * This is a convenience factory function equivalent to `new TriggerChatTransport(options)`. + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { createChatTransport } from "@trigger.dev/ai"; + * + * const transport = createChatTransport({ + * taskId: "my-chat-task", + * accessToken: publicAccessToken, + * }); + * + * function Chat() { + * const { messages, sendMessage } = useChat({ transport }); + * // ... + * } + * ``` + */ +export function createChatTransport(options: TriggerChatTransportOptions): TriggerChatTransport { + return new TriggerChatTransport(options); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts new file mode 100644 index 00000000000..81f1c6dc9be --- /dev/null +++ b/packages/ai/src/types.ts @@ -0,0 +1,100 @@ +import type { UIMessage } from "ai"; + +/** + * Options for creating a TriggerChatTransport. + */ +export type TriggerChatTransportOptions = { + /** + * The Trigger.dev task ID to trigger for chat completions. + * This task will receive the chat messages as its payload. + */ + taskId: string; + + /** + * A public access token or trigger token for authenticating with the Trigger.dev API. + * This is used both to trigger the task and to subscribe to the realtime stream. + * + * You can generate one using `auth.createTriggerPublicToken()` or + * `auth.createPublicToken()` from the `@trigger.dev/sdk`. + */ + accessToken: string; + + /** + * Base URL for the Trigger.dev API. + * + * @default "https://api.trigger.dev" + */ + baseURL?: string; + + /** + * The stream key where the task pipes UIMessageChunk data. + * Your task must pipe the AI SDK stream to this same key using + * `streams.pipe(streamKey, result.toUIMessageStream())`. + * + * @default "chat" + */ + streamKey?: string; + + /** + * Additional headers to include in API requests to Trigger.dev. + */ + headers?: Record; +}; + +/** + * The payload shape that TriggerChatTransport sends to the triggered task. + * + * Use this type to type your task's `run` function payload: + * + * @example + * ```ts + * import { task, streams } from "@trigger.dev/sdk"; + * import { streamText, convertToModelMessages } from "ai"; + * import type { ChatTaskPayload } from "@trigger.dev/ai"; + * + * export const myChatTask = task({ + * id: "my-chat-task", + * run: async (payload: ChatTaskPayload) => { + * const result = streamText({ + * model: openai("gpt-4o"), + * messages: convertToModelMessages(payload.messages), + * }); + * + * const { waitUntilComplete } = streams.pipe("chat", result.toUIMessageStream()); + * await waitUntilComplete(); + * }, + * }); + * ``` + */ +export type ChatTaskPayload = { + /** The array of UI messages representing the conversation history */ + messages: TMessage[]; + + /** The unique identifier for the chat session */ + chatId: string; + + /** + * The type of message submission: + * - `"submit-message"`: A new user message was submitted + * - `"regenerate-message"`: The user wants to regenerate the last assistant response + */ + trigger: "submit-message" | "regenerate-message"; + + /** + * The ID of the message to regenerate (only present for `"regenerate-message"` trigger). + */ + messageId?: string; + + /** + * Custom metadata attached to the chat request by the frontend. + */ + metadata?: unknown; +}; + +/** + * Internal state for tracking active chat sessions, used for stream reconnection. + */ +export type ChatSessionState = { + runId: string; + publicAccessToken: string; +}; diff --git a/packages/ai/src/version.ts b/packages/ai/src/version.ts new file mode 100644 index 00000000000..2e47a886828 --- /dev/null +++ b/packages/ai/src/version.ts @@ -0,0 +1 @@ +export const VERSION = "0.0.0"; diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 00000000000..ec09e52a400 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "compilerOptions": { + "isolatedDeclarations": false, + "composite": true, + "sourceMap": true, + "stripInternal": true + }, + "include": ["./src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbf25048b6e..1fa3efb3392 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1373,6 +1373,31 @@ importers: specifier: 8.6.6 version: 8.6.6 + packages/ai: + dependencies: + '@trigger.dev/core': + specifier: workspace:4.3.3 + version: link:../core + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.15.4 + version: 0.15.4 + ai: + specifier: ^6.0.0 + version: 6.0.3(zod@3.25.76) + rimraf: + specifier: ^3.0.2 + version: 3.0.2 + tshy: + specifier: ^3.0.2 + version: 3.0.2 + tsx: + specifier: 4.17.0 + version: 4.17.0 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) + packages/build: dependencies: '@prisma/config': @@ -11151,9 +11176,23 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + '@vitest/expect@3.1.4': resolution: {integrity: sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@3.1.4': resolution: {integrity: sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==} peerDependencies: @@ -11177,9 +11216,15 @@ packages: '@vitest/runner@3.1.4': resolution: {integrity: sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + '@vitest/snapshot@3.1.4': resolution: {integrity: sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==} + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + '@vitest/spy@3.1.4': resolution: {integrity: sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==} @@ -14246,11 +14291,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} @@ -19765,6 +19811,11 @@ packages: engines: {node: '>=v14.16.0'} hasBin: true + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.1.4: resolution: {integrity: sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -19832,6 +19883,31 @@ packages: terser: optional: true + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': 20.14.14 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@3.1.4: resolution: {integrity: sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -20444,7 +20520,7 @@ snapshots: commander: 10.0.1 marked: 9.1.6 marked-terminal: 7.1.0(marked@9.1.6) - semver: 7.6.3 + semver: 7.7.3 '@arethetypeswrong/core@0.15.1': dependencies: @@ -31497,6 +31573,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.2.0 + tinyrainbow: 1.2.0 + '@vitest/expect@3.1.4': dependencies: '@vitest/spy': 3.1.4 @@ -31504,6 +31587,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) + '@vitest/mocker@3.1.4(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))': dependencies: '@vitest/spy': 3.1.4 @@ -31530,12 +31621,22 @@ snapshots: '@vitest/utils': 3.1.4 pathe: 2.0.3 + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + '@vitest/snapshot@3.1.4': dependencies: '@vitest/pretty-format': 3.1.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + '@vitest/spy@3.1.4': dependencies: tinyspy: 3.0.2 @@ -34169,7 +34270,7 @@ snapshots: eslint: 8.31.0 eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0) - get-tsconfig: 4.7.2 + get-tsconfig: 4.7.6 globby: 13.2.2 is-core-module: 2.14.0 is-glob: 4.0.3 @@ -41974,6 +42075,24 @@ snapshots: - supports-color - terser + vite-node@2.1.9(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.0.0) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.1.4(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): dependencies: cac: 6.7.14 @@ -42023,6 +42142,41 @@ snapshots: lightningcss: 1.29.2 terser: 5.44.1 + vitest@2.1.9(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.2.0 + debug: 4.4.3(supports-color@10.0.0) + expect-type: 1.2.1 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.0.2 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) + vite-node: 2.1.9(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.14.14 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): dependencies: '@vitest/expect': 3.1.4 From c6fdda8bd168cdfa6648bf7986fee4142f5933b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:42:30 +0000 Subject: [PATCH 02/19] test: add comprehensive unit tests for TriggerChatTransport Tests cover: - Constructor with required and optional options - sendMessages triggering task and returning UIMessageChunk stream - Correct payload structure sent to trigger API - Custom streamKey in stream URL - Extra headers propagation - reconnectToStream with existing and non-existing sessions - createChatTransport factory function - Error handling for API failures - regenerate-message trigger type Co-authored-by: Eric Allam --- packages/ai/src/transport.test.ts | 545 ++++++++++++++++++++++++++++++ packages/ai/vitest.config.ts | 8 + 2 files changed, 553 insertions(+) create mode 100644 packages/ai/src/transport.test.ts create mode 100644 packages/ai/vitest.config.ts diff --git a/packages/ai/src/transport.test.ts b/packages/ai/src/transport.test.ts new file mode 100644 index 00000000000..eac1434eabd --- /dev/null +++ b/packages/ai/src/transport.test.ts @@ -0,0 +1,545 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { UIMessage, UIMessageChunk } from "ai"; +import { TriggerChatTransport, createChatTransport } from "./transport.js"; + +// Helper: encode text as SSE format +function sseEncode(chunks: UIMessageChunk[]): string { + return chunks.map((chunk, i) => `id: ${i}\ndata: ${JSON.stringify(chunk)}\n\n`).join(""); +} + +// Helper: create a ReadableStream from SSE text +function createSSEStream(sseText: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sseText)); + controller.close(); + }, + }); +} + +// Helper: create test UIMessages +function createUserMessage(text: string): UIMessage { + return { + id: `msg-${Date.now()}`, + role: "user", + parts: [{ type: "text", text }], + }; +} + +function createAssistantMessage(text: string): UIMessage { + return { + id: `msg-${Date.now()}`, + role: "assistant", + parts: [{ type: "text", text }], + }; +} + +// Sample UIMessageChunks as the AI SDK would produce +const sampleChunks: UIMessageChunk[] = [ + { type: "text-start", id: "part-1" }, + { type: "text-delta", id: "part-1", delta: "Hello" }, + { type: "text-delta", id: "part-1", delta: " world" }, + { type: "text-delta", id: "part-1", delta: "!" }, + { type: "text-end", id: "part-1" }, +]; + +describe("TriggerChatTransport", () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should create transport with required options", () => { + const transport = new TriggerChatTransport({ + taskId: "my-chat-task", + accessToken: "test-token", + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + + it("should accept optional configuration", () => { + const transport = new TriggerChatTransport({ + taskId: "my-chat-task", + accessToken: "test-token", + baseURL: "https://custom.trigger.dev", + streamKey: "custom-stream", + headers: { "X-Custom": "value" }, + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + }); + + describe("sendMessages", () => { + it("should trigger the task and return a ReadableStream of UIMessageChunks", async () => { + const triggerRunId = "run_abc123"; + const publicToken = "pub_token_xyz"; + + // Mock fetch to handle both the trigger request and the SSE stream request + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + // Handle the task trigger request + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: triggerRunId }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": publicToken, + }, + } + ); + } + + // Handle the SSE stream request + if (urlStr.includes("/realtime/v1/streams/")) { + const sseText = sseEncode(sampleChunks); + return new Response(createSSEStream(sseText), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + taskId: "my-chat-task", + accessToken: "test-token", + baseURL: "https://api.test.trigger.dev", + }); + + const messages: UIMessage[] = [createUserMessage("Hello!")]; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages, + abortSignal: undefined, + }); + + expect(stream).toBeInstanceOf(ReadableStream); + + // Read all chunks from the stream + const reader = stream.getReader(); + const receivedChunks: UIMessageChunk[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedChunks.push(value); + } + + expect(receivedChunks).toHaveLength(sampleChunks.length); + expect(receivedChunks[0]).toEqual({ type: "text-start", id: "part-1" }); + expect(receivedChunks[1]).toEqual({ type: "text-delta", id: "part-1", delta: "Hello" }); + expect(receivedChunks[4]).toEqual({ type: "text-end", id: "part-1" }); + }); + + it("should send the correct payload to the trigger API", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_test" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "pub_token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + taskId: "my-chat-task", + accessToken: "test-token", + baseURL: "https://api.test.trigger.dev", + }); + + const messages: UIMessage[] = [createUserMessage("Hello!")]; + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-123", + messageId: undefined, + messages, + abortSignal: undefined, + metadata: { custom: "data" }, + }); + + // Verify the trigger fetch call + const triggerCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") + ); + + expect(triggerCall).toBeDefined(); + const triggerUrl = typeof triggerCall![0] === "string" ? triggerCall![0] : triggerCall![0].toString(); + expect(triggerUrl).toContain("/api/v1/tasks/my-chat-task/trigger"); + + const triggerBody = JSON.parse(triggerCall![1]?.body as string); + const payload = JSON.parse(triggerBody.payload); + expect(payload.messages).toEqual(messages); + expect(payload.chatId).toBe("chat-123"); + expect(payload.trigger).toBe("submit-message"); + expect(payload.metadata).toEqual({ custom: "data" }); + }); + + it("should use the correct stream URL with custom streamKey", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_custom" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + streamKey: "my-custom-stream", + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + }); + + // Verify the stream URL uses the custom stream key + const streamCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/") + ); + + expect(streamCall).toBeDefined(); + const streamUrl = typeof streamCall![0] === "string" ? streamCall![0] : streamCall![0].toString(); + expect(streamUrl).toContain("/realtime/v1/streams/run_custom/my-custom-stream"); + }); + + it("should include extra headers in stream requests", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_hdrs" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + headers: { "X-Custom-Header": "custom-value" }, + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + }); + + // Verify the stream request includes custom headers + const streamCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/") + ); + + expect(streamCall).toBeDefined(); + const requestHeaders = streamCall![1]?.headers as Record; + expect(requestHeaders["X-Custom-Header"]).toBe("custom-value"); + }); + }); + + describe("reconnectToStream", () => { + it("should return null when no session exists for chatId", async () => { + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: "token", + }); + + const result = await transport.reconnectToStream({ + chatId: "nonexistent-chat", + }); + + expect(result).toBeNull(); + }); + + it("should reconnect to an existing session", async () => { + const triggerRunId = "run_reconnect"; + const publicToken = "pub_reconnect_token"; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: triggerRunId }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": publicToken, + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + const chunks: UIMessageChunk[] = [ + { type: "text-start", id: "part-1" }, + { type: "text-delta", id: "part-1", delta: "Reconnected!" }, + { type: "text-end", id: "part-1" }, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + // First, send messages to establish a session + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-reconnect", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + // Now reconnect + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect", + }); + + expect(stream).toBeInstanceOf(ReadableStream); + + // Read the stream + const reader = stream!.getReader(); + const receivedChunks: UIMessageChunk[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedChunks.push(value); + } + + expect(receivedChunks.length).toBeGreaterThan(0); + }); + }); + + describe("createChatTransport", () => { + it("should create a TriggerChatTransport instance", () => { + const transport = createChatTransport({ + taskId: "my-task", + accessToken: "token", + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + + it("should pass options through to the transport", () => { + const transport = createChatTransport({ + taskId: "custom-task", + accessToken: "custom-token", + baseURL: "https://custom.example.com", + streamKey: "custom-key", + headers: { "X-Test": "value" }, + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + }); + + describe("error handling", () => { + it("should propagate trigger API errors", async () => { + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ error: "Task not found" }), + { + status: 404, + headers: { "content-type": "application/json" }, + } + ); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + taskId: "nonexistent-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-error", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + }) + ).rejects.toThrow(); + }); + }); + + describe("message types", () => { + it("should handle regenerate-message trigger", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_regen" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + const messages: UIMessage[] = [ + createUserMessage("Hello!"), + createAssistantMessage("Hi there!"), + ]; + + await transport.sendMessages({ + trigger: "regenerate-message", + chatId: "chat-regen", + messageId: "msg-to-regen", + messages, + abortSignal: undefined, + }); + + // Verify the payload includes the regenerate trigger type and messageId + const triggerCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") + ); + + const triggerBody = JSON.parse(triggerCall![1]?.body as string); + const payload = JSON.parse(triggerBody.payload); + expect(payload.trigger).toBe("regenerate-message"); + expect(payload.messageId).toBe("msg-to-regen"); + }); + }); +}); diff --git a/packages/ai/vitest.config.ts b/packages/ai/vitest.config.ts new file mode 100644 index 00000000000..c497b8ec974 --- /dev/null +++ b/packages/ai/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + globals: true, + }, +}); From 9241cd67b6d842b0c744ba4f4278588db5372bf7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:43:47 +0000 Subject: [PATCH 03/19] refactor: polish TriggerChatTransport implementation - Cache ApiClient instance instead of creating per-call - Add streamTimeoutSeconds option for customizable stream timeout - Clean up subscribeToStream method (remove unused variable) - Improve JSDoc with backend task example - Minor code cleanup Co-authored-by: Eric Allam --- packages/ai/src/transport.ts | 58 ++++++++++++++++-------------------- packages/ai/src/types.ts | 9 ++++++ 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/ai/src/transport.ts b/packages/ai/src/transport.ts index 1a5789c96bd..b9df702b3a0 100644 --- a/packages/ai/src/transport.ts +++ b/packages/ai/src/transport.ts @@ -19,8 +19,15 @@ const DEFAULT_STREAM_TIMEOUT_SECONDS = 120; * 2. Subscribes to the task's realtime stream to receive `UIMessageChunk` data * 3. Returns a `ReadableStream` that the AI SDK processes natively * + * The task receives a `ChatTaskPayload` containing the conversation messages, + * chat session ID, trigger type, and any custom metadata. Your task should use + * the AI SDK's `streamText` (or similar) to generate a response, then pipe + * the resulting `UIMessageStream` to the `"chat"` realtime stream key + * (or a custom key matching the `streamKey` option). + * * @example * ```tsx + * // Frontend — use with AI SDK's useChat hook * import { useChat } from "@ai-sdk/react"; * import { TriggerChatTransport } from "@trigger.dev/ai"; * @@ -36,12 +43,12 @@ const DEFAULT_STREAM_TIMEOUT_SECONDS = 120; * } * ``` * - * On the backend, the task should pipe UIMessageChunks to the `"chat"` stream: - * * @example * ```ts + * // Backend — Trigger.dev task that handles chat * import { task, streams } from "@trigger.dev/sdk"; * import { streamText, convertToModelMessages } from "ai"; + * import type { ChatTaskPayload } from "@trigger.dev/ai"; * * export const myChatTask = task({ * id: "my-chat-task", @@ -63,6 +70,8 @@ export class TriggerChatTransport implements ChatTransport { private readonly baseURL: string; private readonly streamKey: string; private readonly extraHeaders: Record; + private readonly streamTimeoutSeconds: number; + private readonly apiClient: ApiClient; /** * Tracks active chat sessions for reconnection support. @@ -76,6 +85,8 @@ export class TriggerChatTransport implements ChatTransport { this.baseURL = options.baseURL ?? DEFAULT_BASE_URL; this.streamKey = options.streamKey ?? DEFAULT_STREAM_KEY; this.extraHeaders = options.headers ?? {}; + this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS; + this.apiClient = new ApiClient(this.baseURL, this.accessToken); } /** @@ -95,9 +106,9 @@ export class TriggerChatTransport implements ChatTransport { abortSignal: AbortSignal | undefined; } & ChatRequestOptions ): Promise> => { - const { trigger, chatId, messageId, messages, abortSignal, headers, body, metadata } = options; + const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options; - // Build the payload for the task + // Build the payload for the task — this becomes the ChatTaskPayload const payload = { messages, chatId, @@ -107,11 +118,8 @@ export class TriggerChatTransport implements ChatTransport { ...(body ?? {}), }; - // Create API client for triggering - const apiClient = new ApiClient(this.baseURL, this.accessToken); - // Trigger the task - const triggerResponse = await apiClient.triggerTask(this.taskId, { + const triggerResponse = await this.apiClient.triggerTask(this.taskId, { payload: JSON.stringify(payload), options: { payloadType: "application/json", @@ -119,9 +127,10 @@ export class TriggerChatTransport implements ChatTransport { }); const runId = triggerResponse.id; - const publicAccessToken = "publicAccessToken" in triggerResponse - ? (triggerResponse as { publicAccessToken?: string }).publicAccessToken - : undefined; + const publicAccessToken = + "publicAccessToken" in triggerResponse + ? (triggerResponse as { publicAccessToken?: string }).publicAccessToken + : undefined; // Store session state for reconnection this.sessions.set(chatId, { @@ -143,9 +152,7 @@ export class TriggerChatTransport implements ChatTransport { chatId: string; } & ChatRequestOptions ): Promise | null> => { - const { chatId } = options; - - const session = this.sessions.get(chatId); + const session = this.sessions.get(options.chatId); if (!session) { return null; } @@ -162,34 +169,24 @@ export class TriggerChatTransport implements ChatTransport { accessToken: string, abortSignal: AbortSignal | undefined ): ReadableStream { - const streamKey = this.streamKey; - const baseURL = this.baseURL; - const extraHeaders = this.extraHeaders; - - // Build the authorization header const headers: Record = { Authorization: `Bearer ${accessToken}`, - ...extraHeaders, + ...this.extraHeaders, }; const subscription = new SSEStreamSubscription( - `${baseURL}/realtime/v1/streams/${runId}/${streamKey}`, + `${this.baseURL}/realtime/v1/streams/${runId}/${this.streamKey}`, { headers, signal: abortSignal, - timeoutInSeconds: DEFAULT_STREAM_TIMEOUT_SECONDS, + timeoutInSeconds: this.streamTimeoutSeconds, } ); - // We need to convert the SSEStreamPart stream to a UIMessageChunk stream - // SSEStreamPart has { id, chunk, timestamp } where chunk is the deserialized UIMessageChunk - let sseStreamPromise: Promise> | null = null; - return new ReadableStream({ start: async (controller) => { try { - sseStreamPromise = subscription.subscribe(); - const sseStream = await sseStreamPromise; + const sseStream = await subscription.subscribe(); const reader = sseStream.getReader(); try { @@ -216,7 +213,7 @@ export class TriggerChatTransport implements ChatTransport { throw readError; } } catch (error) { - // Don't error the stream for abort errors + // Don't error the stream for abort errors — just close gracefully if (error instanceof Error && error.name === "AbortError") { controller.close(); return; @@ -225,9 +222,6 @@ export class TriggerChatTransport implements ChatTransport { controller.error(error); } }, - cancel: () => { - // Cancellation is handled by the abort signal - }, }); } } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 81f1c6dc9be..bbffb50247d 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -39,6 +39,15 @@ export type TriggerChatTransportOptions = { * Additional headers to include in API requests to Trigger.dev. */ headers?: Record; + + /** + * The number of seconds to wait for the realtime stream to produce data + * before timing out. If no data arrives within this period, the stream + * will be closed. + * + * @default 120 + */ + streamTimeoutSeconds?: number; }; /** From 1d44a837c5fc12f6d76925d5b75873080d4c8339 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:46:56 +0000 Subject: [PATCH 04/19] test: add abort signal, multiple sessions, and body merging tests Adds 3 additional test cases: - Abort signal gracefully closes the stream - Multiple independent chat sessions tracked correctly - ChatRequestOptions.body is merged into task payload Co-authored-by: Eric Allam --- packages/ai/src/transport.test.ts | 212 ++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/packages/ai/src/transport.test.ts b/packages/ai/src/transport.test.ts index eac1434eabd..cc946596c27 100644 --- a/packages/ai/src/transport.test.ts +++ b/packages/ai/src/transport.test.ts @@ -479,6 +479,218 @@ describe("TriggerChatTransport", () => { }); }); + describe("abort signal", () => { + it("should close the stream gracefully when aborted", async () => { + let streamResolve: (() => void) | undefined; + const streamWait = new Promise((resolve) => { + streamResolve = resolve; + }); + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_abort" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + // Create a slow stream that waits before sending data + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + controller.enqueue( + encoder.encode(`id: 0\ndata: ${JSON.stringify({ type: "text-start", id: "p1" })}\n\n`) + ); + // Wait for the test to signal it's done + await streamWait; + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const abortController = new AbortController(); + + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-abort", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: abortController.signal, + }); + + // Read the first chunk + const reader = stream.getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + + // Abort and clean up + abortController.abort(); + streamResolve?.(); + + // The stream should close — reading should return done + const next = await reader.read(); + expect(next.done).toBe(true); + }); + }); + + describe("multiple sessions", () => { + it("should track multiple chat sessions independently", async () => { + let callCount = 0; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + callCount++; + return new Response( + JSON.stringify({ id: `run_multi_${callCount}` }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": `token_${callCount}`, + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + // Start two independent chat sessions + await transport.sendMessages({ + trigger: "submit-message", + chatId: "session-a", + messageId: undefined, + messages: [createUserMessage("Hello A")], + abortSignal: undefined, + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "session-b", + messageId: undefined, + messages: [createUserMessage("Hello B")], + abortSignal: undefined, + }); + + // Both sessions should be independently reconnectable + const streamA = await transport.reconnectToStream({ chatId: "session-a" }); + const streamB = await transport.reconnectToStream({ chatId: "session-b" }); + const streamC = await transport.reconnectToStream({ chatId: "nonexistent" }); + + expect(streamA).toBeInstanceOf(ReadableStream); + expect(streamB).toBeInstanceOf(ReadableStream); + expect(streamC).toBeNull(); + }); + }); + + describe("body merging", () => { + it("should merge ChatRequestOptions.body into the task payload", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_body" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-body", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + body: { systemPrompt: "You are helpful", temperature: 0.7 }, + }); + + const triggerCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") + ); + + const triggerBody = JSON.parse(triggerCall![1]?.body as string); + const payload = JSON.parse(triggerBody.payload); + + // body properties should be merged into the payload + expect(payload.systemPrompt).toBe("You are helpful"); + expect(payload.temperature).toBe(0.7); + // Standard fields should still be present + expect(payload.chatId).toBe("chat-body"); + expect(payload.trigger).toBe("submit-message"); + }); + }); + describe("message types", () => { it("should handle regenerate-message trigger", async () => { const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { From ec633c1479cfcd4a2c4926d0cf8e1afef0a627a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:47:40 +0000 Subject: [PATCH 05/19] chore: add changeset for @trigger.dev/ai package Co-authored-by: Eric Allam --- .changeset/ai-sdk-chat-transport.md | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/ai-sdk-chat-transport.md diff --git a/.changeset/ai-sdk-chat-transport.md b/.changeset/ai-sdk-chat-transport.md new file mode 100644 index 00000000000..a24dcdc195e --- /dev/null +++ b/.changeset/ai-sdk-chat-transport.md @@ -0,0 +1,41 @@ +--- +"@trigger.dev/ai": minor +--- + +New package: `@trigger.dev/ai` — AI SDK integration for Trigger.dev + +Provides `TriggerChatTransport`, a custom `ChatTransport` implementation for the Vercel AI SDK that bridges `useChat` with Trigger.dev's durable task execution and realtime streams. + +**Frontend usage:** +```tsx +import { useChat } from "@ai-sdk/react"; +import { TriggerChatTransport } from "@trigger.dev/ai"; + +const { messages, sendMessage } = useChat({ + transport: new TriggerChatTransport({ + accessToken: publicAccessToken, + taskId: "my-chat-task", + }), +}); +``` + +**Backend task:** +```ts +import { task, streams } from "@trigger.dev/sdk"; +import { streamText, convertToModelMessages } from "ai"; +import type { ChatTaskPayload } from "@trigger.dev/ai"; + +export const myChatTask = task({ + id: "my-chat-task", + run: async (payload: ChatTaskPayload) => { + const result = streamText({ + model: openai("gpt-4o"), + messages: convertToModelMessages(payload.messages), + }); + const { waitUntilComplete } = streams.pipe("chat", result.toUIMessageStream()); + await waitUntilComplete(); + }, +}); +``` + +Also exports `createChatTransport()` factory function and `ChatTaskPayload` type for task-side typing. From e70734aa1783141cb0ebf0f4c0d28cfff5c6a7e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:49:57 +0000 Subject: [PATCH 06/19] refactor: remove internal ChatSessionState from public exports ChatSessionState is an implementation detail of the transport's session tracking. Users don't need to access it since the sessions map is private. Co-authored-by: Eric Allam --- packages/ai/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index f58c1d1ffaa..7e673894ff6 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,3 +1,3 @@ export { TriggerChatTransport, createChatTransport } from "./transport.js"; -export type { TriggerChatTransportOptions, ChatTaskPayload, ChatSessionState } from "./types.js"; +export type { TriggerChatTransportOptions, ChatTaskPayload } from "./types.js"; export { VERSION } from "./version.js"; From cf2490fb5365ff4c60cbdb9ed60f5ec50497052a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:52:56 +0000 Subject: [PATCH 07/19] feat: support dynamic accessToken function for token refresh The accessToken option now accepts either a string or a function returning a string. This enables dynamic token refresh patterns: new TriggerChatTransport({ taskId: 'my-task', accessToken: () => getLatestToken(), }) The function is called on each sendMessages() call, allowing fresh tokens to be used for each task trigger. Co-authored-by: Eric Allam --- packages/ai/src/transport.test.ts | 85 +++++++++++++++++++++++++++++++ packages/ai/src/transport.ts | 22 +++++--- packages/ai/src/types.ts | 18 +++++-- 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/transport.test.ts b/packages/ai/src/transport.test.ts index cc946596c27..53d3ab86861 100644 --- a/packages/ai/src/transport.test.ts +++ b/packages/ai/src/transport.test.ts @@ -77,6 +77,19 @@ describe("TriggerChatTransport", () => { expect(transport).toBeInstanceOf(TriggerChatTransport); }); + + it("should accept a function for accessToken", () => { + let tokenCallCount = 0; + const transport = new TriggerChatTransport({ + taskId: "my-chat-task", + accessToken: () => { + tokenCallCount++; + return `dynamic-token-${tokenCallCount}`; + }, + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); }); describe("sendMessages", () => { @@ -627,6 +640,78 @@ describe("TriggerChatTransport", () => { }); }); + describe("dynamic accessToken", () => { + it("should call the accessToken function for each sendMessages call", async () => { + let tokenCallCount = 0; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: `run_dyn_${tokenCallCount}` }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "stream-token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + const chunks: UIMessageChunk[] = [ + { type: "text-start", id: "p1" }, + { type: "text-end", id: "p1" }, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + taskId: "my-task", + accessToken: () => { + tokenCallCount++; + return `dynamic-token-${tokenCallCount}`; + }, + baseURL: "https://api.test.trigger.dev", + }); + + // First call — the token function should be invoked + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-dyn-1", + messageId: undefined, + messages: [createUserMessage("first")], + abortSignal: undefined, + }); + + const firstCount = tokenCallCount; + expect(firstCount).toBeGreaterThanOrEqual(1); + + // Second call — the token function should be invoked again + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-dyn-2", + messageId: undefined, + messages: [createUserMessage("second")], + abortSignal: undefined, + }); + + // Token function was called at least once more + expect(tokenCallCount).toBeGreaterThan(firstCount); + }); + }); + describe("body merging", () => { it("should merge ChatRequestOptions.body into the task payload", async () => { const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { diff --git a/packages/ai/src/transport.ts b/packages/ai/src/transport.ts index b9df702b3a0..d785a471c10 100644 --- a/packages/ai/src/transport.ts +++ b/packages/ai/src/transport.ts @@ -66,12 +66,11 @@ const DEFAULT_STREAM_TIMEOUT_SECONDS = 120; */ export class TriggerChatTransport implements ChatTransport { private readonly taskId: string; - private readonly accessToken: string; + private readonly resolveAccessToken: () => string; private readonly baseURL: string; private readonly streamKey: string; private readonly extraHeaders: Record; private readonly streamTimeoutSeconds: number; - private readonly apiClient: ApiClient; /** * Tracks active chat sessions for reconnection support. @@ -81,12 +80,18 @@ export class TriggerChatTransport implements ChatTransport { constructor(options: TriggerChatTransportOptions) { this.taskId = options.taskId; - this.accessToken = options.accessToken; + this.resolveAccessToken = + typeof options.accessToken === "function" + ? options.accessToken + : () => options.accessToken as string; this.baseURL = options.baseURL ?? DEFAULT_BASE_URL; this.streamKey = options.streamKey ?? DEFAULT_STREAM_KEY; this.extraHeaders = options.headers ?? {}; this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS; - this.apiClient = new ApiClient(this.baseURL, this.accessToken); + } + + private getApiClient(): ApiClient { + return new ApiClient(this.baseURL, this.resolveAccessToken()); } /** @@ -118,8 +123,11 @@ export class TriggerChatTransport implements ChatTransport { ...(body ?? {}), }; + const currentToken = this.resolveAccessToken(); + // Trigger the task - const triggerResponse = await this.apiClient.triggerTask(this.taskId, { + const apiClient = this.getApiClient(); + const triggerResponse = await apiClient.triggerTask(this.taskId, { payload: JSON.stringify(payload), options: { payloadType: "application/json", @@ -135,11 +143,11 @@ export class TriggerChatTransport implements ChatTransport { // Store session state for reconnection this.sessions.set(chatId, { runId, - publicAccessToken: publicAccessToken ?? this.accessToken, + publicAccessToken: publicAccessToken ?? currentToken, }); // Subscribe to the realtime stream for this run - return this.subscribeToStream(runId, publicAccessToken ?? this.accessToken, abortSignal); + return this.subscribeToStream(runId, publicAccessToken ?? currentToken, abortSignal); }; /** diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index bbffb50247d..88bf4317356 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -11,13 +11,21 @@ export type TriggerChatTransportOptions = { taskId: string; /** - * A public access token or trigger token for authenticating with the Trigger.dev API. - * This is used both to trigger the task and to subscribe to the realtime stream. + * An access token for authenticating with the Trigger.dev API. * - * You can generate one using `auth.createTriggerPublicToken()` or - * `auth.createPublicToken()` from the `@trigger.dev/sdk`. + * This must be a token with permission to trigger the task. You can use: + * - A **trigger public token** created via `auth.createTriggerPublicToken(taskId)` (recommended for frontend use) + * - A **secret API key** (for server-side use only — never expose in the browser) + * + * The token returned from triggering the task (`publicAccessToken`) is automatically + * used for subscribing to the realtime stream. + * + * Can also be a function that returns a token string, useful for dynamic token refresh: + * ```ts + * accessToken: () => getLatestToken() + * ``` */ - accessToken: string; + accessToken: string | (() => string); /** * Base URL for the Trigger.dev API. From 1ad672f6f8c29057e77d0d0f50168b281140581d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:55:47 +0000 Subject: [PATCH 08/19] refactor: avoid double-resolving accessToken in sendMessages Use the already-resolved token when creating ApiClient instead of calling resolveAccessToken() again through getApiClient(). Co-authored-by: Eric Allam --- packages/ai/src/transport.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/transport.ts b/packages/ai/src/transport.ts index d785a471c10..ff4b2c47a33 100644 --- a/packages/ai/src/transport.ts +++ b/packages/ai/src/transport.ts @@ -90,10 +90,6 @@ export class TriggerChatTransport implements ChatTransport { this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS; } - private getApiClient(): ApiClient { - return new ApiClient(this.baseURL, this.resolveAccessToken()); - } - /** * Sends messages to a Trigger.dev task and returns a streaming response. * @@ -125,8 +121,8 @@ export class TriggerChatTransport implements ChatTransport { const currentToken = this.resolveAccessToken(); - // Trigger the task - const apiClient = this.getApiClient(); + // Trigger the task — use the already-resolved token directly + const apiClient = new ApiClient(this.baseURL, currentToken); const triggerResponse = await apiClient.triggerTask(this.taskId, { payload: JSON.stringify(payload), options: { From 05232a2e673fcedcc29debd5f0811ef228685387 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 12:54:20 +0000 Subject: [PATCH 09/19] feat: add chat transport and AI chat helpers to @trigger.dev/sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new subpath exports: @trigger.dev/sdk/chat (frontend, browser-safe): - TriggerChatTransport — ChatTransport implementation for useChat - createChatTransport() — factory function - TriggerChatTransportOptions type @trigger.dev/sdk/ai (backend, adds to existing ai.tool/ai.currentToolOptions): - chatTask() — pre-typed task wrapper with auto-pipe - pipeChat() — pipe StreamTextResult to realtime stream - CHAT_STREAM_KEY constant - ChatTaskPayload type - ChatTaskOptions type - PipeChatOptions type Co-authored-by: Eric Allam --- packages/ai/src/chatTask.ts | 132 +++++++++++++ packages/ai/src/pipeChat.ts | 137 +++++++++++++ packages/ai/src/types.ts | 22 +-- packages/trigger-sdk/package.json | 17 +- packages/trigger-sdk/src/v3/ai.ts | 242 +++++++++++++++++++++++ packages/trigger-sdk/src/v3/chat.ts | 294 ++++++++++++++++++++++++++++ 6 files changed, 832 insertions(+), 12 deletions(-) create mode 100644 packages/ai/src/chatTask.ts create mode 100644 packages/ai/src/pipeChat.ts create mode 100644 packages/trigger-sdk/src/v3/chat.ts diff --git a/packages/ai/src/chatTask.ts b/packages/ai/src/chatTask.ts new file mode 100644 index 00000000000..7f3eb92616c --- /dev/null +++ b/packages/ai/src/chatTask.ts @@ -0,0 +1,132 @@ +import { task as createTask } from "@trigger.dev/sdk"; +import type { Task } from "@trigger.dev/core/v3"; +import type { ChatTaskPayload } from "./types.js"; +import { pipeChat } from "./pipeChat.js"; + +/** + * Options for defining a chat task. + * + * This is a simplified version of the standard task options with the payload + * pre-typed as `ChatTaskPayload`. + */ +export type ChatTaskOptions = { + /** Unique identifier for the task */ + id: TIdentifier; + + /** Optional description of the task */ + description?: string; + + /** Retry configuration */ + retry?: { + maxAttempts?: number; + factor?: number; + minTimeoutInMs?: number; + maxTimeoutInMs?: number; + randomize?: boolean; + }; + + /** Queue configuration */ + queue?: { + name?: string; + concurrencyLimit?: number; + }; + + /** Machine preset for the task */ + machine?: { + preset?: string; + }; + + /** Maximum duration in seconds */ + maxDuration?: number; + + /** + * The main run function for the chat task. + * + * Receives a `ChatTaskPayload` with the conversation messages, chat session ID, + * and trigger type. + * + * **Auto-piping:** If this function returns a value that has a `.toUIMessageStream()` method + * (like a `StreamTextResult` from `streamText()`), the stream will automatically be piped + * to the frontend via the chat realtime stream. If you need to pipe from deeper in your + * code, use `pipeChat()` instead and don't return the result. + */ + run: (payload: ChatTaskPayload) => Promise; +}; + +/** + * An object that has a `toUIMessageStream()` method, like the result of `streamText()`. + */ +type UIMessageStreamable = { + toUIMessageStream: (...args: any[]) => AsyncIterable | ReadableStream; +}; + +function isUIMessageStreamable(value: unknown): value is UIMessageStreamable { + return ( + typeof value === "object" && + value !== null && + "toUIMessageStream" in value && + typeof (value as any).toUIMessageStream === "function" + ); +} + +/** + * Creates a Trigger.dev task pre-configured for AI SDK chat. + * + * This is a convenience wrapper around `task()` from `@trigger.dev/sdk` that: + * - **Pre-types the payload** as `ChatTaskPayload` — no manual typing needed + * - **Auto-pipes the stream** if the `run` function returns a `StreamTextResult` + * + * Requires `@trigger.dev/sdk` to be installed (it's a peer dependency). + * + * @example + * ```ts + * import { chatTask } from "@trigger.dev/ai"; + * import { streamText, convertToModelMessages } from "ai"; + * import { openai } from "@ai-sdk/openai"; + * + * // Simple: return streamText result — auto-piped to the frontend + * export const myChatTask = chatTask({ + * id: "my-chat-task", + * run: async ({ messages }) => { + * return streamText({ + * model: openai("gpt-4o"), + * messages: convertToModelMessages(messages), + * }); + * }, + * }); + * ``` + * + * @example + * ```ts + * import { chatTask, pipeChat } from "@trigger.dev/ai"; + * + * // Complex: use pipeChat() from deep inside your agent code + * export const myAgentTask = chatTask({ + * id: "my-agent-task", + * run: async ({ messages }) => { + * await runComplexAgentLoop(messages); + * // pipeChat() called internally by the agent loop + * }, + * }); + * ``` + */ +export function chatTask( + options: ChatTaskOptions +): Task { + const { run: userRun, ...restOptions } = options; + + return createTask({ + ...restOptions, + run: async (payload: ChatTaskPayload) => { + const result = await userRun(payload); + + // If the run function returned a StreamTextResult or similar, + // automatically pipe it to the chat stream + if (isUIMessageStreamable(result)) { + await pipeChat(result); + } + + return result; + }, + }); +} diff --git a/packages/ai/src/pipeChat.ts b/packages/ai/src/pipeChat.ts new file mode 100644 index 00000000000..885951c59c2 --- /dev/null +++ b/packages/ai/src/pipeChat.ts @@ -0,0 +1,137 @@ +import { realtimeStreams } from "@trigger.dev/core/v3"; + +/** + * The default stream key used for chat transport communication. + * + * Both `TriggerChatTransport` (frontend) and `pipeChat` (backend) use this key + * by default to ensure they communicate over the same stream. + */ +export const CHAT_STREAM_KEY = "chat"; + +/** + * Options for `pipeChat`. + */ +export type PipeChatOptions = { + /** + * Override the stream key to pipe to. + * Must match the `streamKey` option on `TriggerChatTransport`. + * + * @default "chat" + */ + streamKey?: string; + + /** + * An AbortSignal to cancel the stream. + */ + signal?: AbortSignal; + + /** + * The target run ID to pipe the stream to. + * @default "self" (current run) + */ + target?: string; +}; + +/** + * An object that has a `toUIMessageStream()` method, like the result of `streamText()` from the AI SDK. + */ +type UIMessageStreamable = { + toUIMessageStream: (...args: any[]) => AsyncIterable | ReadableStream; +}; + +function isUIMessageStreamable(value: unknown): value is UIMessageStreamable { + return ( + typeof value === "object" && + value !== null && + "toUIMessageStream" in value && + typeof (value as any).toUIMessageStream === "function" + ); +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + Symbol.asyncIterator in value + ); +} + +function isReadableStream(value: unknown): value is ReadableStream { + return ( + typeof value === "object" && + value !== null && + typeof (value as any).getReader === "function" + ); +} + +/** + * Pipes a chat stream to the realtime stream, making it available to the + * `TriggerChatTransport` on the frontend. + * + * Accepts any of: + * - A `StreamTextResult` from the AI SDK (has `.toUIMessageStream()`) + * - An `AsyncIterable` of `UIMessageChunk`s + * - A `ReadableStream` of `UIMessageChunk`s + * + * This must be called from inside a Trigger.dev task's `run` function. + * + * @example + * ```ts + * import { task } from "@trigger.dev/sdk"; + * import { pipeChat, type ChatTaskPayload } from "@trigger.dev/ai"; + * import { streamText, convertToModelMessages } from "ai"; + * + * export const myChatTask = task({ + * id: "my-chat-task", + * run: async (payload: ChatTaskPayload) => { + * const result = streamText({ + * model: openai("gpt-4o"), + * messages: convertToModelMessages(payload.messages), + * }); + * + * await pipeChat(result); + * }, + * }); + * ``` + * + * @example + * ```ts + * // Deep inside your agent library — pipeChat works from anywhere inside a task + * async function runAgentLoop(messages: CoreMessage[]) { + * const result = streamText({ model, messages }); + * await pipeChat(result); + * } + * ``` + * + * @param source - A StreamTextResult, AsyncIterable, or ReadableStream of UIMessageChunks + * @param options - Optional configuration + * @returns A promise that resolves when the stream has been fully piped + */ +export async function pipeChat( + source: UIMessageStreamable | AsyncIterable | ReadableStream, + options?: PipeChatOptions +): Promise { + const streamKey = options?.streamKey ?? CHAT_STREAM_KEY; + + // Resolve the source to an AsyncIterable or ReadableStream + let stream: AsyncIterable | ReadableStream; + + if (isUIMessageStreamable(source)) { + stream = source.toUIMessageStream(); + } else if (isAsyncIterable(source) || isReadableStream(source)) { + stream = source; + } else { + throw new Error( + "pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " + + "an AsyncIterable, or a ReadableStream" + ); + } + + // Pipe to the realtime stream + const instance = realtimeStreams.pipe(streamKey, stream, { + signal: options?.signal, + target: options?.target, + }); + + await instance.wait(); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 88bf4317356..91ae9938888 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -8,7 +8,7 @@ export type TriggerChatTransportOptions = { * The Trigger.dev task ID to trigger for chat completions. * This task will receive the chat messages as its payload. */ - taskId: string; + task: string; /** * An access token for authenticating with the Trigger.dev API. @@ -36,8 +36,8 @@ export type TriggerChatTransportOptions = { /** * The stream key where the task pipes UIMessageChunk data. - * Your task must pipe the AI SDK stream to this same key using - * `streams.pipe(streamKey, result.toUIMessageStream())`. + * When using `chatTask()` or `pipeChat()`, this is handled automatically. + * Only set this if you're using a custom stream key. * * @default "chat" */ @@ -59,15 +59,16 @@ export type TriggerChatTransportOptions = { }; /** - * The payload shape that TriggerChatTransport sends to the triggered task. + * The payload shape that the transport sends to the triggered task. * - * Use this type to type your task's `run` function payload: + * When using `chatTask()`, the payload is automatically typed — you don't need + * to import this type. When using `task()` directly, use this type to annotate + * your payload: * * @example * ```ts - * import { task, streams } from "@trigger.dev/sdk"; - * import { streamText, convertToModelMessages } from "ai"; - * import type { ChatTaskPayload } from "@trigger.dev/ai"; + * import { task } from "@trigger.dev/sdk"; + * import { pipeChat, type ChatTaskPayload } from "@trigger.dev/ai"; * * export const myChatTask = task({ * id: "my-chat-task", @@ -76,9 +77,7 @@ export type TriggerChatTransportOptions = { * model: openai("gpt-4o"), * messages: convertToModelMessages(payload.messages), * }); - * - * const { waitUntilComplete } = streams.pipe("chat", result.toUIMessageStream()); - * await waitUntilComplete(); + * await pipeChat(result); * }, * }); * ``` @@ -110,6 +109,7 @@ export type ChatTaskPayload = { /** * Internal state for tracking active chat sessions, used for stream reconnection. + * @internal */ export type ChatSessionState = { runId: string; diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index f5f840bc1e7..a3101f7038e 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -24,7 +24,8 @@ "./package.json": "./package.json", ".": "./src/v3/index.ts", "./v3": "./src/v3/index.ts", - "./ai": "./src/v3/ai.ts" + "./ai": "./src/v3/ai.ts", + "./chat": "./src/v3/chat.ts" }, "sourceDialects": [ "@triggerdotdev/source" @@ -37,6 +38,9 @@ ], "ai": [ "dist/commonjs/v3/ai.d.ts" + ], + "chat": [ + "dist/commonjs/v3/chat.d.ts" ] } }, @@ -123,6 +127,17 @@ "types": "./dist/commonjs/v3/ai.d.ts", "default": "./dist/commonjs/v3/ai.js" } + }, + "./chat": { + "import": { + "@triggerdotdev/source": "./src/v3/chat.ts", + "types": "./dist/esm/v3/chat.d.ts", + "default": "./dist/esm/v3/chat.js" + }, + "require": { + "types": "./dist/commonjs/v3/chat.d.ts", + "default": "./dist/commonjs/v3/chat.js" + } } }, "main": "./dist/commonjs/v3/index.js", diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 59afa2fe21a..9e79df22b8d 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -3,11 +3,16 @@ import { isSchemaZodEsque, Task, type inferSchemaIn, + type PipeStreamOptions, + type TaskOptions, type TaskSchema, type TaskWithSchema, } from "@trigger.dev/core/v3"; +import type { UIMessage } from "ai"; import { dynamicTool, jsonSchema, JSONSchema7, Schema, Tool, ToolCallOptions, zodSchema } from "ai"; import { metadata } from "./metadata.js"; +import { streams } from "./streams.js"; +import { createTask } from "./shared.js"; const METADATA_KEY = "tool.execute.options"; @@ -116,3 +121,240 @@ export const ai = { tool: toolFromTask, currentToolOptions: getToolOptionsFromMetadata, }; + +// --------------------------------------------------------------------------- +// Chat transport helpers — backend side +// --------------------------------------------------------------------------- + +/** + * The default stream key used for chat transport communication. + * Both `TriggerChatTransport` (frontend) and `pipeChat`/`chatTask` (backend) + * use this key by default. + */ +export const CHAT_STREAM_KEY = "chat"; + +/** + * The payload shape that the chat transport sends to the triggered task. + * + * When using `chatTask()`, the payload is automatically typed — you don't need + * to import this type. Use this type only if you're using `task()` directly + * with `pipeChat()`. + */ +export type ChatTaskPayload = { + /** The conversation messages */ + messages: TMessage[]; + + /** The unique identifier for the chat session */ + chatId: string; + + /** + * The trigger type: + * - `"submit-message"`: A new user message + * - `"regenerate-message"`: Regenerate the last assistant response + */ + trigger: "submit-message" | "regenerate-message"; + + /** The ID of the message to regenerate (only for `"regenerate-message"`) */ + messageId?: string; + + /** Custom metadata from the frontend */ + metadata?: unknown; +}; + +/** + * Options for `pipeChat`. + */ +export type PipeChatOptions = { + /** + * Override the stream key. Must match the `streamKey` on `TriggerChatTransport`. + * @default "chat" + */ + streamKey?: string; + + /** An AbortSignal to cancel the stream. */ + signal?: AbortSignal; + + /** + * The target run ID to pipe to. + * @default "self" (current run) + */ + target?: string; +}; + +/** + * An object with a `toUIMessageStream()` method (e.g. `StreamTextResult` from `streamText()`). + */ +type UIMessageStreamable = { + toUIMessageStream: (...args: any[]) => AsyncIterable | ReadableStream; +}; + +function isUIMessageStreamable(value: unknown): value is UIMessageStreamable { + return ( + typeof value === "object" && + value !== null && + "toUIMessageStream" in value && + typeof (value as any).toUIMessageStream === "function" + ); +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return typeof value === "object" && value !== null && Symbol.asyncIterator in value; +} + +function isReadableStream(value: unknown): value is ReadableStream { + return typeof value === "object" && value !== null && typeof (value as any).getReader === "function"; +} + +/** + * Pipes a chat stream to the realtime stream, making it available to the + * `TriggerChatTransport` on the frontend. + * + * Accepts: + * - A `StreamTextResult` from `streamText()` (has `.toUIMessageStream()`) + * - An `AsyncIterable` of `UIMessageChunk`s + * - A `ReadableStream` of `UIMessageChunk`s + * + * Must be called from inside a Trigger.dev task's `run` function. + * + * @example + * ```ts + * import { task } from "@trigger.dev/sdk"; + * import { pipeChat, type ChatTaskPayload } from "@trigger.dev/sdk/ai"; + * import { streamText, convertToModelMessages } from "ai"; + * + * export const myChatTask = task({ + * id: "my-chat-task", + * run: async (payload: ChatTaskPayload) => { + * const result = streamText({ + * model: openai("gpt-4o"), + * messages: convertToModelMessages(payload.messages), + * }); + * + * await pipeChat(result); + * }, + * }); + * ``` + * + * @example + * ```ts + * // Works from anywhere inside a task — even deep in your agent code + * async function runAgentLoop(messages: CoreMessage[]) { + * const result = streamText({ model, messages }); + * await pipeChat(result); + * } + * ``` + */ +export async function pipeChat( + source: UIMessageStreamable | AsyncIterable | ReadableStream, + options?: PipeChatOptions +): Promise { + const streamKey = options?.streamKey ?? CHAT_STREAM_KEY; + + let stream: AsyncIterable | ReadableStream; + + if (isUIMessageStreamable(source)) { + stream = source.toUIMessageStream(); + } else if (isAsyncIterable(source) || isReadableStream(source)) { + stream = source; + } else { + throw new Error( + "pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " + + "an AsyncIterable, or a ReadableStream" + ); + } + + const pipeOptions: PipeStreamOptions = {}; + if (options?.signal) { + pipeOptions.signal = options.signal; + } + if (options?.target) { + pipeOptions.target = options.target; + } + + const { waitUntilComplete } = streams.pipe(streamKey, stream, pipeOptions); + await waitUntilComplete(); +} + +/** + * Options for defining a chat task. + * + * Extends the standard `TaskOptions` but pre-types the payload as `ChatTaskPayload` + * and overrides `run` to accept `ChatTaskPayload` directly. + * + * **Auto-piping:** If the `run` function returns a value with `.toUIMessageStream()` + * (like a `StreamTextResult`), the stream is automatically piped to the frontend. + * For complex flows, use `pipeChat()` manually from anywhere in your code. + */ +export type ChatTaskOptions = Omit< + TaskOptions, + "run" +> & { + /** + * The run function for the chat task. + * + * Receives a `ChatTaskPayload` with the conversation messages, chat session ID, + * and trigger type. + * + * **Auto-piping:** If this function returns a value with `.toUIMessageStream()`, + * the stream is automatically piped to the frontend. + */ + run: (payload: ChatTaskPayload) => Promise; +}; + +/** + * Creates a Trigger.dev task pre-configured for AI SDK chat. + * + * - **Pre-types the payload** as `ChatTaskPayload` — no manual typing needed + * - **Auto-pipes the stream** if `run` returns a `StreamTextResult` + * - For complex flows, use `pipeChat()` from anywhere inside your task code + * + * @example + * ```ts + * import { chatTask } from "@trigger.dev/sdk/ai"; + * import { streamText, convertToModelMessages } from "ai"; + * import { openai } from "@ai-sdk/openai"; + * + * // Simple: return streamText result — auto-piped to the frontend + * export const myChatTask = chatTask({ + * id: "my-chat-task", + * run: async ({ messages }) => { + * return streamText({ + * model: openai("gpt-4o"), + * messages: convertToModelMessages(messages), + * }); + * }, + * }); + * ``` + * + * @example + * ```ts + * import { chatTask, pipeChat } from "@trigger.dev/sdk/ai"; + * + * // Complex: pipeChat() from deep in your agent code + * export const myAgentTask = chatTask({ + * id: "my-agent-task", + * run: async ({ messages }) => { + * await runComplexAgentLoop(messages); + * }, + * }); + * ``` + */ +export function chatTask( + options: ChatTaskOptions +): Task { + const { run: userRun, ...restOptions } = options; + + return createTask({ + ...restOptions, + run: async (payload: ChatTaskPayload) => { + const result = await userRun(payload); + + // Auto-pipe if the run function returned a StreamTextResult or similar + if (isUIMessageStreamable(result)) { + await pipeChat(result); + } + + return result; + }, + }); +} diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts new file mode 100644 index 00000000000..5a7872c1014 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -0,0 +1,294 @@ +/** + * @module @trigger.dev/sdk/chat + * + * Browser-safe module for AI SDK chat transport integration. + * Use this on the frontend with the AI SDK's `useChat` hook. + * + * For backend helpers (`chatTask`, `pipeChat`), use `@trigger.dev/sdk/ai` instead. + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; + * + * function Chat({ accessToken }: { accessToken: string }) { + * const { messages, sendMessage, status } = useChat({ + * transport: new TriggerChatTransport({ + * task: "my-chat-task", + * accessToken, + * }), + * }); + * } + * ``` + */ + +import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from "ai"; +import { ApiClient, SSEStreamSubscription } from "@trigger.dev/core/v3"; + +const DEFAULT_STREAM_KEY = "chat"; +const DEFAULT_BASE_URL = "https://api.trigger.dev"; +const DEFAULT_STREAM_TIMEOUT_SECONDS = 120; + +/** + * Options for creating a TriggerChatTransport. + */ +export type TriggerChatTransportOptions = { + /** + * The Trigger.dev task ID to trigger for chat completions. + * This task should be defined using `chatTask()` from `@trigger.dev/sdk/ai`, + * or a regular `task()` that uses `pipeChat()`. + */ + task: string; + + /** + * An access token for authenticating with the Trigger.dev API. + * + * This must be a token with permission to trigger the task. You can use: + * - A **trigger public token** created via `auth.createTriggerPublicToken(taskId)` (recommended for frontend use) + * - A **secret API key** (for server-side use only — never expose in the browser) + * + * Can also be a function that returns a token string, useful for dynamic token refresh. + */ + accessToken: string | (() => string); + + /** + * Base URL for the Trigger.dev API. + * @default "https://api.trigger.dev" + */ + baseURL?: string; + + /** + * The stream key where the task pipes UIMessageChunk data. + * When using `chatTask()` or `pipeChat()`, this is handled automatically. + * Only set this if you're using a custom stream key. + * + * @default "chat" + */ + streamKey?: string; + + /** + * Additional headers to include in API requests to Trigger.dev. + */ + headers?: Record; + + /** + * The number of seconds to wait for the realtime stream to produce data + * before timing out. + * + * @default 120 + */ + streamTimeoutSeconds?: number; +}; + +/** + * Internal state for tracking active chat sessions. + * @internal + */ +type ChatSessionState = { + runId: string; + publicAccessToken: string; +}; + +/** + * A custom AI SDK `ChatTransport` that runs chat completions as durable Trigger.dev tasks. + * + * When `sendMessages` is called, the transport: + * 1. Triggers a Trigger.dev task with the chat messages as payload + * 2. Subscribes to the task's realtime stream to receive `UIMessageChunk` data + * 3. Returns a `ReadableStream` that the AI SDK processes natively + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; + * + * function Chat({ accessToken }: { accessToken: string }) { + * const { messages, sendMessage, status } = useChat({ + * transport: new TriggerChatTransport({ + * task: "my-chat-task", + * accessToken, + * }), + * }); + * + * // ... render messages + * } + * ``` + * + * On the backend, define the task using `chatTask` from `@trigger.dev/sdk/ai`: + * + * @example + * ```ts + * import { chatTask } from "@trigger.dev/sdk/ai"; + * import { streamText, convertToModelMessages } from "ai"; + * + * export const myChatTask = chatTask({ + * id: "my-chat-task", + * run: async ({ messages }) => { + * return streamText({ + * model: openai("gpt-4o"), + * messages: convertToModelMessages(messages), + * }); + * }, + * }); + * ``` + */ +export class TriggerChatTransport implements ChatTransport { + private readonly taskId: string; + private readonly resolveAccessToken: () => string; + private readonly baseURL: string; + private readonly streamKey: string; + private readonly extraHeaders: Record; + private readonly streamTimeoutSeconds: number; + + private sessions: Map = new Map(); + + constructor(options: TriggerChatTransportOptions) { + this.taskId = options.task; + this.resolveAccessToken = + typeof options.accessToken === "function" + ? options.accessToken + : () => options.accessToken as string; + this.baseURL = options.baseURL ?? DEFAULT_BASE_URL; + this.streamKey = options.streamKey ?? DEFAULT_STREAM_KEY; + this.extraHeaders = options.headers ?? {}; + this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS; + } + + sendMessages = async ( + options: { + trigger: "submit-message" | "regenerate-message"; + chatId: string; + messageId: string | undefined; + messages: UIMessage[]; + abortSignal: AbortSignal | undefined; + } & ChatRequestOptions + ): Promise> => { + const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options; + + const payload = { + messages, + chatId, + trigger, + messageId, + metadata, + ...(body ?? {}), + }; + + const currentToken = this.resolveAccessToken(); + const apiClient = new ApiClient(this.baseURL, currentToken); + + const triggerResponse = await apiClient.triggerTask(this.taskId, { + payload: JSON.stringify(payload), + options: { + payloadType: "application/json", + }, + }); + + const runId = triggerResponse.id; + const publicAccessToken = + "publicAccessToken" in triggerResponse + ? (triggerResponse as { publicAccessToken?: string }).publicAccessToken + : undefined; + + this.sessions.set(chatId, { + runId, + publicAccessToken: publicAccessToken ?? currentToken, + }); + + return this.subscribeToStream(runId, publicAccessToken ?? currentToken, abortSignal); + }; + + reconnectToStream = async ( + options: { + chatId: string; + } & ChatRequestOptions + ): Promise | null> => { + const session = this.sessions.get(options.chatId); + if (!session) { + return null; + } + + return this.subscribeToStream(session.runId, session.publicAccessToken, undefined); + }; + + private subscribeToStream( + runId: string, + accessToken: string, + abortSignal: AbortSignal | undefined + ): ReadableStream { + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + ...this.extraHeaders, + }; + + const subscription = new SSEStreamSubscription( + `${this.baseURL}/realtime/v1/streams/${runId}/${this.streamKey}`, + { + headers, + signal: abortSignal, + timeoutInSeconds: this.streamTimeoutSeconds, + } + ); + + return new ReadableStream({ + start: async (controller) => { + try { + const sseStream = await subscription.subscribe(); + const reader = sseStream.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + controller.close(); + return; + } + + if (abortSignal?.aborted) { + reader.cancel(); + reader.releaseLock(); + controller.close(); + return; + } + + controller.enqueue(value.chunk as UIMessageChunk); + } + } catch (readError) { + reader.releaseLock(); + throw readError; + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + controller.close(); + return; + } + + controller.error(error); + } + }, + }); + } +} + +/** + * Creates a new `TriggerChatTransport` instance. + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { createChatTransport } from "@trigger.dev/sdk/chat"; + * + * const transport = createChatTransport({ + * task: "my-chat-task", + * accessToken: publicAccessToken, + * }); + * + * function Chat() { + * const { messages, sendMessage } = useChat({ transport }); + * } + * ``` + */ +export function createChatTransport(options: TriggerChatTransportOptions): TriggerChatTransport { + return new TriggerChatTransport(options); +} From dad08fa04182f1a226d4219089a1703b580f519f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 12:55:31 +0000 Subject: [PATCH 10/19] test: move chat transport tests to @trigger.dev/sdk Move and adapt tests from packages/ai to packages/trigger-sdk. - Import from ./chat.js instead of ./transport.js - Use 'task' option instead of 'taskId' - All 17 tests passing Co-authored-by: Eric Allam --- packages/trigger-sdk/src/v3/chat.test.ts | 842 +++++++++++++++++++++++ 1 file changed, 842 insertions(+) create mode 100644 packages/trigger-sdk/src/v3/chat.test.ts diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts new file mode 100644 index 00000000000..86a4ba9ad57 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -0,0 +1,842 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { UIMessage, UIMessageChunk } from "ai"; +import { TriggerChatTransport, createChatTransport } from "./chat.js"; + +// Helper: encode text as SSE format +function sseEncode(chunks: UIMessageChunk[]): string { + return chunks.map((chunk, i) => `id: ${i}\ndata: ${JSON.stringify(chunk)}\n\n`).join(""); +} + +// Helper: create a ReadableStream from SSE text +function createSSEStream(sseText: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sseText)); + controller.close(); + }, + }); +} + +// Helper: create test UIMessages +function createUserMessage(text: string): UIMessage { + return { + id: `msg-${Date.now()}`, + role: "user", + parts: [{ type: "text", text }], + }; +} + +function createAssistantMessage(text: string): UIMessage { + return { + id: `msg-${Date.now()}`, + role: "assistant", + parts: [{ type: "text", text }], + }; +} + +// Sample UIMessageChunks as the AI SDK would produce +const sampleChunks: UIMessageChunk[] = [ + { type: "text-start", id: "part-1" }, + { type: "text-delta", id: "part-1", delta: "Hello" }, + { type: "text-delta", id: "part-1", delta: " world" }, + { type: "text-delta", id: "part-1", delta: "!" }, + { type: "text-end", id: "part-1" }, +]; + +describe("TriggerChatTransport", () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("constructor", () => { + it("should create transport with required options", () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: "test-token", + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + + it("should accept optional configuration", () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: "test-token", + baseURL: "https://custom.trigger.dev", + streamKey: "custom-stream", + headers: { "X-Custom": "value" }, + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + + it("should accept a function for accessToken", () => { + let tokenCallCount = 0; + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => { + tokenCallCount++; + return `dynamic-token-${tokenCallCount}`; + }, + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + }); + + describe("sendMessages", () => { + it("should trigger the task and return a ReadableStream of UIMessageChunks", async () => { + const triggerRunId = "run_abc123"; + const publicToken = "pub_token_xyz"; + + // Mock fetch to handle both the trigger request and the SSE stream request + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + // Handle the task trigger request + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: triggerRunId }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": publicToken, + }, + } + ); + } + + // Handle the SSE stream request + if (urlStr.includes("/realtime/v1/streams/")) { + const sseText = sseEncode(sampleChunks); + return new Response(createSSEStream(sseText), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: "test-token", + baseURL: "https://api.test.trigger.dev", + }); + + const messages: UIMessage[] = [createUserMessage("Hello!")]; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages, + abortSignal: undefined, + }); + + expect(stream).toBeInstanceOf(ReadableStream); + + // Read all chunks from the stream + const reader = stream.getReader(); + const receivedChunks: UIMessageChunk[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedChunks.push(value); + } + + expect(receivedChunks).toHaveLength(sampleChunks.length); + expect(receivedChunks[0]).toEqual({ type: "text-start", id: "part-1" }); + expect(receivedChunks[1]).toEqual({ type: "text-delta", id: "part-1", delta: "Hello" }); + expect(receivedChunks[4]).toEqual({ type: "text-end", id: "part-1" }); + }); + + it("should send the correct payload to the trigger API", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_test" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "pub_token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: "test-token", + baseURL: "https://api.test.trigger.dev", + }); + + const messages: UIMessage[] = [createUserMessage("Hello!")]; + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-123", + messageId: undefined, + messages, + abortSignal: undefined, + metadata: { custom: "data" }, + }); + + // Verify the trigger fetch call + const triggerCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") + ); + + expect(triggerCall).toBeDefined(); + const triggerUrl = typeof triggerCall![0] === "string" ? triggerCall![0] : triggerCall![0].toString(); + expect(triggerUrl).toContain("/api/v1/tasks/my-chat-task/trigger"); + + const triggerBody = JSON.parse(triggerCall![1]?.body as string); + const payload = JSON.parse(triggerBody.payload); + expect(payload.messages).toEqual(messages); + expect(payload.chatId).toBe("chat-123"); + expect(payload.trigger).toBe("submit-message"); + expect(payload.metadata).toEqual({ custom: "data" }); + }); + + it("should use the correct stream URL with custom streamKey", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_custom" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + streamKey: "my-custom-stream", + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + }); + + // Verify the stream URL uses the custom stream key + const streamCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/") + ); + + expect(streamCall).toBeDefined(); + const streamUrl = typeof streamCall![0] === "string" ? streamCall![0] : streamCall![0].toString(); + expect(streamUrl).toContain("/realtime/v1/streams/run_custom/my-custom-stream"); + }); + + it("should include extra headers in stream requests", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_hdrs" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + headers: { "X-Custom-Header": "custom-value" }, + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + }); + + // Verify the stream request includes custom headers + const streamCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/") + ); + + expect(streamCall).toBeDefined(); + const requestHeaders = streamCall![1]?.headers as Record; + expect(requestHeaders["X-Custom-Header"]).toBe("custom-value"); + }); + }); + + describe("reconnectToStream", () => { + it("should return null when no session exists for chatId", async () => { + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + }); + + const result = await transport.reconnectToStream({ + chatId: "nonexistent-chat", + }); + + expect(result).toBeNull(); + }); + + it("should reconnect to an existing session", async () => { + const triggerRunId = "run_reconnect"; + const publicToken = "pub_reconnect_token"; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: triggerRunId }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": publicToken, + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + const chunks: UIMessageChunk[] = [ + { type: "text-start", id: "part-1" }, + { type: "text-delta", id: "part-1", delta: "Reconnected!" }, + { type: "text-end", id: "part-1" }, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + // First, send messages to establish a session + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-reconnect", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + // Now reconnect + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect", + }); + + expect(stream).toBeInstanceOf(ReadableStream); + + // Read the stream + const reader = stream!.getReader(); + const receivedChunks: UIMessageChunk[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedChunks.push(value); + } + + expect(receivedChunks.length).toBeGreaterThan(0); + }); + }); + + describe("createChatTransport", () => { + it("should create a TriggerChatTransport instance", () => { + const transport = createChatTransport({ + task: "my-task", + accessToken: "token", + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + + it("should pass options through to the transport", () => { + const transport = createChatTransport({ + task: "custom-task", + accessToken: "custom-token", + baseURL: "https://custom.example.com", + streamKey: "custom-key", + headers: { "X-Test": "value" }, + }); + + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + }); + + describe("error handling", () => { + it("should propagate trigger API errors", async () => { + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ error: "Task not found" }), + { + status: 404, + headers: { "content-type": "application/json" }, + } + ); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "nonexistent-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-error", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + }) + ).rejects.toThrow(); + }); + }); + + describe("abort signal", () => { + it("should close the stream gracefully when aborted", async () => { + let streamResolve: (() => void) | undefined; + const streamWait = new Promise((resolve) => { + streamResolve = resolve; + }); + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_abort" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + // Create a slow stream that waits before sending data + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + controller.enqueue( + encoder.encode(`id: 0\ndata: ${JSON.stringify({ type: "text-start", id: "p1" })}\n\n`) + ); + // Wait for the test to signal it's done + await streamWait; + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const abortController = new AbortController(); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-abort", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: abortController.signal, + }); + + // Read the first chunk + const reader = stream.getReader(); + const first = await reader.read(); + expect(first.done).toBe(false); + + // Abort and clean up + abortController.abort(); + streamResolve?.(); + + // The stream should close — reading should return done + const next = await reader.read(); + expect(next.done).toBe(true); + }); + }); + + describe("multiple sessions", () => { + it("should track multiple chat sessions independently", async () => { + let callCount = 0; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + callCount++; + return new Response( + JSON.stringify({ id: `run_multi_${callCount}` }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": `token_${callCount}`, + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + // Start two independent chat sessions + await transport.sendMessages({ + trigger: "submit-message", + chatId: "session-a", + messageId: undefined, + messages: [createUserMessage("Hello A")], + abortSignal: undefined, + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "session-b", + messageId: undefined, + messages: [createUserMessage("Hello B")], + abortSignal: undefined, + }); + + // Both sessions should be independently reconnectable + const streamA = await transport.reconnectToStream({ chatId: "session-a" }); + const streamB = await transport.reconnectToStream({ chatId: "session-b" }); + const streamC = await transport.reconnectToStream({ chatId: "nonexistent" }); + + expect(streamA).toBeInstanceOf(ReadableStream); + expect(streamB).toBeInstanceOf(ReadableStream); + expect(streamC).toBeNull(); + }); + }); + + describe("dynamic accessToken", () => { + it("should call the accessToken function for each sendMessages call", async () => { + let tokenCallCount = 0; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: `run_dyn_${tokenCallCount}` }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "stream-token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + const chunks: UIMessageChunk[] = [ + { type: "text-start", id: "p1" }, + { type: "text-end", id: "p1" }, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: () => { + tokenCallCount++; + return `dynamic-token-${tokenCallCount}`; + }, + baseURL: "https://api.test.trigger.dev", + }); + + // First call — the token function should be invoked + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-dyn-1", + messageId: undefined, + messages: [createUserMessage("first")], + abortSignal: undefined, + }); + + const firstCount = tokenCallCount; + expect(firstCount).toBeGreaterThanOrEqual(1); + + // Second call — the token function should be invoked again + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-dyn-2", + messageId: undefined, + messages: [createUserMessage("second")], + abortSignal: undefined, + }); + + // Token function was called at least once more + expect(tokenCallCount).toBeGreaterThan(firstCount); + }); + }); + + describe("body merging", () => { + it("should merge ChatRequestOptions.body into the task payload", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_body" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-body", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + body: { systemPrompt: "You are helpful", temperature: 0.7 }, + }); + + const triggerCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") + ); + + const triggerBody = JSON.parse(triggerCall![1]?.body as string); + const payload = JSON.parse(triggerBody.payload); + + // body properties should be merged into the payload + expect(payload.systemPrompt).toBe("You are helpful"); + expect(payload.temperature).toBe(0.7); + // Standard fields should still be present + expect(payload.chatId).toBe("chat-body"); + expect(payload.trigger).toBe("submit-message"); + }); + }); + + describe("message types", () => { + it("should handle regenerate-message trigger", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_regen" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + return new Response(createSSEStream(""), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + const messages: UIMessage[] = [ + createUserMessage("Hello!"), + createAssistantMessage("Hi there!"), + ]; + + await transport.sendMessages({ + trigger: "regenerate-message", + chatId: "chat-regen", + messageId: "msg-to-regen", + messages, + abortSignal: undefined, + }); + + // Verify the payload includes the regenerate trigger type and messageId + const triggerCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") + ); + + const triggerBody = JSON.parse(triggerCall![1]?.body as string); + const payload = JSON.parse(triggerBody.payload); + expect(payload.trigger).toBe("regenerate-message"); + expect(payload.messageId).toBe("msg-to-regen"); + }); + }); +}); From a26d67e12179e74ba318681b93cefc5864ca2273 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 12:56:17 +0000 Subject: [PATCH 11/19] =?UTF-8?q?refactor:=20delete=20packages/ai/=20?= =?UTF-8?q?=E2=80=94=20moved=20to=20@trigger.dev/sdk=20subpaths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All functionality now lives in: - @trigger.dev/sdk/chat (frontend transport) - @trigger.dev/sdk/ai (backend chatTask, pipeChat) Co-authored-by: Eric Allam --- packages/ai/package.json | 74 --- packages/ai/src/chatTask.ts | 132 ----- packages/ai/src/index.ts | 3 - packages/ai/src/pipeChat.ts | 137 ----- packages/ai/src/transport.test.ts | 842 ------------------------------ packages/ai/src/transport.ts | 256 --------- packages/ai/src/types.ts | 117 ----- packages/ai/src/version.ts | 1 - packages/ai/tsconfig.json | 10 - packages/ai/vitest.config.ts | 8 - pnpm-lock.yaml | 169 +----- 11 files changed, 8 insertions(+), 1741 deletions(-) delete mode 100644 packages/ai/package.json delete mode 100644 packages/ai/src/chatTask.ts delete mode 100644 packages/ai/src/index.ts delete mode 100644 packages/ai/src/pipeChat.ts delete mode 100644 packages/ai/src/transport.test.ts delete mode 100644 packages/ai/src/transport.ts delete mode 100644 packages/ai/src/types.ts delete mode 100644 packages/ai/src/version.ts delete mode 100644 packages/ai/tsconfig.json delete mode 100644 packages/ai/vitest.config.ts diff --git a/packages/ai/package.json b/packages/ai/package.json deleted file mode 100644 index c6cee5d728b..00000000000 --- a/packages/ai/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "@trigger.dev/ai", - "version": "4.3.3", - "description": "AI SDK integration for Trigger.dev - Custom ChatTransport for running AI chat as durable tasks", - "license": "MIT", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "https://github.com/triggerdotdev/trigger.dev", - "directory": "packages/ai" - }, - "type": "module", - "files": [ - "dist" - ], - "tshy": { - "selfLink": false, - "main": true, - "module": true, - "project": "./tsconfig.json", - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "sourceDialects": [ - "@triggerdotdev/source" - ] - }, - "scripts": { - "clean": "rimraf dist .tshy .tshy-build .turbo", - "build": "tshy && pnpm run update-version", - "dev": "tshy --watch", - "typecheck": "tsc --noEmit", - "test": "vitest", - "update-version": "tsx ../../scripts/updateVersion.ts", - "check-exports": "attw --pack ." - }, - "dependencies": { - "@trigger.dev/core": "workspace:4.3.3" - }, - "peerDependencies": { - "ai": "^5.0.0 || ^6.0.0" - }, - "devDependencies": { - "@arethetypeswrong/cli": "^0.15.4", - "ai": "^6.0.0", - "rimraf": "^3.0.2", - "tshy": "^3.0.2", - "tsx": "4.17.0", - "vitest": "^2.1.0" - }, - "engines": { - "node": ">=18.20.0" - }, - "exports": { - "./package.json": "./package.json", - ".": { - "import": { - "@triggerdotdev/source": "./src/index.ts", - "types": "./dist/esm/index.d.ts", - "default": "./dist/esm/index.js" - }, - "require": { - "types": "./dist/commonjs/index.d.ts", - "default": "./dist/commonjs/index.js" - } - } - }, - "main": "./dist/commonjs/index.js", - "types": "./dist/commonjs/index.d.ts", - "module": "./dist/esm/index.js" -} diff --git a/packages/ai/src/chatTask.ts b/packages/ai/src/chatTask.ts deleted file mode 100644 index 7f3eb92616c..00000000000 --- a/packages/ai/src/chatTask.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { task as createTask } from "@trigger.dev/sdk"; -import type { Task } from "@trigger.dev/core/v3"; -import type { ChatTaskPayload } from "./types.js"; -import { pipeChat } from "./pipeChat.js"; - -/** - * Options for defining a chat task. - * - * This is a simplified version of the standard task options with the payload - * pre-typed as `ChatTaskPayload`. - */ -export type ChatTaskOptions = { - /** Unique identifier for the task */ - id: TIdentifier; - - /** Optional description of the task */ - description?: string; - - /** Retry configuration */ - retry?: { - maxAttempts?: number; - factor?: number; - minTimeoutInMs?: number; - maxTimeoutInMs?: number; - randomize?: boolean; - }; - - /** Queue configuration */ - queue?: { - name?: string; - concurrencyLimit?: number; - }; - - /** Machine preset for the task */ - machine?: { - preset?: string; - }; - - /** Maximum duration in seconds */ - maxDuration?: number; - - /** - * The main run function for the chat task. - * - * Receives a `ChatTaskPayload` with the conversation messages, chat session ID, - * and trigger type. - * - * **Auto-piping:** If this function returns a value that has a `.toUIMessageStream()` method - * (like a `StreamTextResult` from `streamText()`), the stream will automatically be piped - * to the frontend via the chat realtime stream. If you need to pipe from deeper in your - * code, use `pipeChat()` instead and don't return the result. - */ - run: (payload: ChatTaskPayload) => Promise; -}; - -/** - * An object that has a `toUIMessageStream()` method, like the result of `streamText()`. - */ -type UIMessageStreamable = { - toUIMessageStream: (...args: any[]) => AsyncIterable | ReadableStream; -}; - -function isUIMessageStreamable(value: unknown): value is UIMessageStreamable { - return ( - typeof value === "object" && - value !== null && - "toUIMessageStream" in value && - typeof (value as any).toUIMessageStream === "function" - ); -} - -/** - * Creates a Trigger.dev task pre-configured for AI SDK chat. - * - * This is a convenience wrapper around `task()` from `@trigger.dev/sdk` that: - * - **Pre-types the payload** as `ChatTaskPayload` — no manual typing needed - * - **Auto-pipes the stream** if the `run` function returns a `StreamTextResult` - * - * Requires `@trigger.dev/sdk` to be installed (it's a peer dependency). - * - * @example - * ```ts - * import { chatTask } from "@trigger.dev/ai"; - * import { streamText, convertToModelMessages } from "ai"; - * import { openai } from "@ai-sdk/openai"; - * - * // Simple: return streamText result — auto-piped to the frontend - * export const myChatTask = chatTask({ - * id: "my-chat-task", - * run: async ({ messages }) => { - * return streamText({ - * model: openai("gpt-4o"), - * messages: convertToModelMessages(messages), - * }); - * }, - * }); - * ``` - * - * @example - * ```ts - * import { chatTask, pipeChat } from "@trigger.dev/ai"; - * - * // Complex: use pipeChat() from deep inside your agent code - * export const myAgentTask = chatTask({ - * id: "my-agent-task", - * run: async ({ messages }) => { - * await runComplexAgentLoop(messages); - * // pipeChat() called internally by the agent loop - * }, - * }); - * ``` - */ -export function chatTask( - options: ChatTaskOptions -): Task { - const { run: userRun, ...restOptions } = options; - - return createTask({ - ...restOptions, - run: async (payload: ChatTaskPayload) => { - const result = await userRun(payload); - - // If the run function returned a StreamTextResult or similar, - // automatically pipe it to the chat stream - if (isUIMessageStreamable(result)) { - await pipeChat(result); - } - - return result; - }, - }); -} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts deleted file mode 100644 index 7e673894ff6..00000000000 --- a/packages/ai/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { TriggerChatTransport, createChatTransport } from "./transport.js"; -export type { TriggerChatTransportOptions, ChatTaskPayload } from "./types.js"; -export { VERSION } from "./version.js"; diff --git a/packages/ai/src/pipeChat.ts b/packages/ai/src/pipeChat.ts deleted file mode 100644 index 885951c59c2..00000000000 --- a/packages/ai/src/pipeChat.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { realtimeStreams } from "@trigger.dev/core/v3"; - -/** - * The default stream key used for chat transport communication. - * - * Both `TriggerChatTransport` (frontend) and `pipeChat` (backend) use this key - * by default to ensure they communicate over the same stream. - */ -export const CHAT_STREAM_KEY = "chat"; - -/** - * Options for `pipeChat`. - */ -export type PipeChatOptions = { - /** - * Override the stream key to pipe to. - * Must match the `streamKey` option on `TriggerChatTransport`. - * - * @default "chat" - */ - streamKey?: string; - - /** - * An AbortSignal to cancel the stream. - */ - signal?: AbortSignal; - - /** - * The target run ID to pipe the stream to. - * @default "self" (current run) - */ - target?: string; -}; - -/** - * An object that has a `toUIMessageStream()` method, like the result of `streamText()` from the AI SDK. - */ -type UIMessageStreamable = { - toUIMessageStream: (...args: any[]) => AsyncIterable | ReadableStream; -}; - -function isUIMessageStreamable(value: unknown): value is UIMessageStreamable { - return ( - typeof value === "object" && - value !== null && - "toUIMessageStream" in value && - typeof (value as any).toUIMessageStream === "function" - ); -} - -function isAsyncIterable(value: unknown): value is AsyncIterable { - return ( - typeof value === "object" && - value !== null && - Symbol.asyncIterator in value - ); -} - -function isReadableStream(value: unknown): value is ReadableStream { - return ( - typeof value === "object" && - value !== null && - typeof (value as any).getReader === "function" - ); -} - -/** - * Pipes a chat stream to the realtime stream, making it available to the - * `TriggerChatTransport` on the frontend. - * - * Accepts any of: - * - A `StreamTextResult` from the AI SDK (has `.toUIMessageStream()`) - * - An `AsyncIterable` of `UIMessageChunk`s - * - A `ReadableStream` of `UIMessageChunk`s - * - * This must be called from inside a Trigger.dev task's `run` function. - * - * @example - * ```ts - * import { task } from "@trigger.dev/sdk"; - * import { pipeChat, type ChatTaskPayload } from "@trigger.dev/ai"; - * import { streamText, convertToModelMessages } from "ai"; - * - * export const myChatTask = task({ - * id: "my-chat-task", - * run: async (payload: ChatTaskPayload) => { - * const result = streamText({ - * model: openai("gpt-4o"), - * messages: convertToModelMessages(payload.messages), - * }); - * - * await pipeChat(result); - * }, - * }); - * ``` - * - * @example - * ```ts - * // Deep inside your agent library — pipeChat works from anywhere inside a task - * async function runAgentLoop(messages: CoreMessage[]) { - * const result = streamText({ model, messages }); - * await pipeChat(result); - * } - * ``` - * - * @param source - A StreamTextResult, AsyncIterable, or ReadableStream of UIMessageChunks - * @param options - Optional configuration - * @returns A promise that resolves when the stream has been fully piped - */ -export async function pipeChat( - source: UIMessageStreamable | AsyncIterable | ReadableStream, - options?: PipeChatOptions -): Promise { - const streamKey = options?.streamKey ?? CHAT_STREAM_KEY; - - // Resolve the source to an AsyncIterable or ReadableStream - let stream: AsyncIterable | ReadableStream; - - if (isUIMessageStreamable(source)) { - stream = source.toUIMessageStream(); - } else if (isAsyncIterable(source) || isReadableStream(source)) { - stream = source; - } else { - throw new Error( - "pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " + - "an AsyncIterable, or a ReadableStream" - ); - } - - // Pipe to the realtime stream - const instance = realtimeStreams.pipe(streamKey, stream, { - signal: options?.signal, - target: options?.target, - }); - - await instance.wait(); -} diff --git a/packages/ai/src/transport.test.ts b/packages/ai/src/transport.test.ts deleted file mode 100644 index 53d3ab86861..00000000000 --- a/packages/ai/src/transport.test.ts +++ /dev/null @@ -1,842 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import type { UIMessage, UIMessageChunk } from "ai"; -import { TriggerChatTransport, createChatTransport } from "./transport.js"; - -// Helper: encode text as SSE format -function sseEncode(chunks: UIMessageChunk[]): string { - return chunks.map((chunk, i) => `id: ${i}\ndata: ${JSON.stringify(chunk)}\n\n`).join(""); -} - -// Helper: create a ReadableStream from SSE text -function createSSEStream(sseText: string): ReadableStream { - const encoder = new TextEncoder(); - return new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(sseText)); - controller.close(); - }, - }); -} - -// Helper: create test UIMessages -function createUserMessage(text: string): UIMessage { - return { - id: `msg-${Date.now()}`, - role: "user", - parts: [{ type: "text", text }], - }; -} - -function createAssistantMessage(text: string): UIMessage { - return { - id: `msg-${Date.now()}`, - role: "assistant", - parts: [{ type: "text", text }], - }; -} - -// Sample UIMessageChunks as the AI SDK would produce -const sampleChunks: UIMessageChunk[] = [ - { type: "text-start", id: "part-1" }, - { type: "text-delta", id: "part-1", delta: "Hello" }, - { type: "text-delta", id: "part-1", delta: " world" }, - { type: "text-delta", id: "part-1", delta: "!" }, - { type: "text-end", id: "part-1" }, -]; - -describe("TriggerChatTransport", () => { - let originalFetch: typeof global.fetch; - - beforeEach(() => { - originalFetch = global.fetch; - }); - - afterEach(() => { - global.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - describe("constructor", () => { - it("should create transport with required options", () => { - const transport = new TriggerChatTransport({ - taskId: "my-chat-task", - accessToken: "test-token", - }); - - expect(transport).toBeInstanceOf(TriggerChatTransport); - }); - - it("should accept optional configuration", () => { - const transport = new TriggerChatTransport({ - taskId: "my-chat-task", - accessToken: "test-token", - baseURL: "https://custom.trigger.dev", - streamKey: "custom-stream", - headers: { "X-Custom": "value" }, - }); - - expect(transport).toBeInstanceOf(TriggerChatTransport); - }); - - it("should accept a function for accessToken", () => { - let tokenCallCount = 0; - const transport = new TriggerChatTransport({ - taskId: "my-chat-task", - accessToken: () => { - tokenCallCount++; - return `dynamic-token-${tokenCallCount}`; - }, - }); - - expect(transport).toBeInstanceOf(TriggerChatTransport); - }); - }); - - describe("sendMessages", () => { - it("should trigger the task and return a ReadableStream of UIMessageChunks", async () => { - const triggerRunId = "run_abc123"; - const publicToken = "pub_token_xyz"; - - // Mock fetch to handle both the trigger request and the SSE stream request - global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - // Handle the task trigger request - if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: triggerRunId }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": publicToken, - }, - } - ); - } - - // Handle the SSE stream request - if (urlStr.includes("/realtime/v1/streams/")) { - const sseText = sseEncode(sampleChunks); - return new Response(createSSEStream(sseText), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - const transport = new TriggerChatTransport({ - taskId: "my-chat-task", - accessToken: "test-token", - baseURL: "https://api.test.trigger.dev", - }); - - const messages: UIMessage[] = [createUserMessage("Hello!")]; - - const stream = await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-1", - messageId: undefined, - messages, - abortSignal: undefined, - }); - - expect(stream).toBeInstanceOf(ReadableStream); - - // Read all chunks from the stream - const reader = stream.getReader(); - const receivedChunks: UIMessageChunk[] = []; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - receivedChunks.push(value); - } - - expect(receivedChunks).toHaveLength(sampleChunks.length); - expect(receivedChunks[0]).toEqual({ type: "text-start", id: "part-1" }); - expect(receivedChunks[1]).toEqual({ type: "text-delta", id: "part-1", delta: "Hello" }); - expect(receivedChunks[4]).toEqual({ type: "text-end", id: "part-1" }); - }); - - it("should send the correct payload to the trigger API", async () => { - const fetchSpy = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: "run_test" }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": "pub_token", - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - return new Response(createSSEStream(""), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - global.fetch = fetchSpy; - - const transport = new TriggerChatTransport({ - taskId: "my-chat-task", - accessToken: "test-token", - baseURL: "https://api.test.trigger.dev", - }); - - const messages: UIMessage[] = [createUserMessage("Hello!")]; - - await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-123", - messageId: undefined, - messages, - abortSignal: undefined, - metadata: { custom: "data" }, - }); - - // Verify the trigger fetch call - const triggerCall = fetchSpy.mock.calls.find((call: any[]) => - (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") - ); - - expect(triggerCall).toBeDefined(); - const triggerUrl = typeof triggerCall![0] === "string" ? triggerCall![0] : triggerCall![0].toString(); - expect(triggerUrl).toContain("/api/v1/tasks/my-chat-task/trigger"); - - const triggerBody = JSON.parse(triggerCall![1]?.body as string); - const payload = JSON.parse(triggerBody.payload); - expect(payload.messages).toEqual(messages); - expect(payload.chatId).toBe("chat-123"); - expect(payload.trigger).toBe("submit-message"); - expect(payload.metadata).toEqual({ custom: "data" }); - }); - - it("should use the correct stream URL with custom streamKey", async () => { - const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: "run_custom" }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": "token", - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - return new Response(createSSEStream(""), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - global.fetch = fetchSpy; - - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: "token", - baseURL: "https://api.test.trigger.dev", - streamKey: "my-custom-stream", - }); - - await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-1", - messageId: undefined, - messages: [createUserMessage("test")], - abortSignal: undefined, - }); - - // Verify the stream URL uses the custom stream key - const streamCall = fetchSpy.mock.calls.find((call: any[]) => - (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/") - ); - - expect(streamCall).toBeDefined(); - const streamUrl = typeof streamCall![0] === "string" ? streamCall![0] : streamCall![0].toString(); - expect(streamUrl).toContain("/realtime/v1/streams/run_custom/my-custom-stream"); - }); - - it("should include extra headers in stream requests", async () => { - const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: "run_hdrs" }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": "token", - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - return new Response(createSSEStream(""), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - global.fetch = fetchSpy; - - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: "token", - baseURL: "https://api.test.trigger.dev", - headers: { "X-Custom-Header": "custom-value" }, - }); - - await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-1", - messageId: undefined, - messages: [createUserMessage("test")], - abortSignal: undefined, - }); - - // Verify the stream request includes custom headers - const streamCall = fetchSpy.mock.calls.find((call: any[]) => - (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/") - ); - - expect(streamCall).toBeDefined(); - const requestHeaders = streamCall![1]?.headers as Record; - expect(requestHeaders["X-Custom-Header"]).toBe("custom-value"); - }); - }); - - describe("reconnectToStream", () => { - it("should return null when no session exists for chatId", async () => { - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: "token", - }); - - const result = await transport.reconnectToStream({ - chatId: "nonexistent-chat", - }); - - expect(result).toBeNull(); - }); - - it("should reconnect to an existing session", async () => { - const triggerRunId = "run_reconnect"; - const publicToken = "pub_reconnect_token"; - - global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: triggerRunId }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": publicToken, - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - const chunks: UIMessageChunk[] = [ - { type: "text-start", id: "part-1" }, - { type: "text-delta", id: "part-1", delta: "Reconnected!" }, - { type: "text-end", id: "part-1" }, - ]; - return new Response(createSSEStream(sseEncode(chunks)), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: "token", - baseURL: "https://api.test.trigger.dev", - }); - - // First, send messages to establish a session - await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-reconnect", - messageId: undefined, - messages: [createUserMessage("Hello")], - abortSignal: undefined, - }); - - // Now reconnect - const stream = await transport.reconnectToStream({ - chatId: "chat-reconnect", - }); - - expect(stream).toBeInstanceOf(ReadableStream); - - // Read the stream - const reader = stream!.getReader(); - const receivedChunks: UIMessageChunk[] = []; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - receivedChunks.push(value); - } - - expect(receivedChunks.length).toBeGreaterThan(0); - }); - }); - - describe("createChatTransport", () => { - it("should create a TriggerChatTransport instance", () => { - const transport = createChatTransport({ - taskId: "my-task", - accessToken: "token", - }); - - expect(transport).toBeInstanceOf(TriggerChatTransport); - }); - - it("should pass options through to the transport", () => { - const transport = createChatTransport({ - taskId: "custom-task", - accessToken: "custom-token", - baseURL: "https://custom.example.com", - streamKey: "custom-key", - headers: { "X-Test": "value" }, - }); - - expect(transport).toBeInstanceOf(TriggerChatTransport); - }); - }); - - describe("error handling", () => { - it("should propagate trigger API errors", async () => { - global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ error: "Task not found" }), - { - status: 404, - headers: { "content-type": "application/json" }, - } - ); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - const transport = new TriggerChatTransport({ - taskId: "nonexistent-task", - accessToken: "token", - baseURL: "https://api.test.trigger.dev", - }); - - await expect( - transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-error", - messageId: undefined, - messages: [createUserMessage("test")], - abortSignal: undefined, - }) - ).rejects.toThrow(); - }); - }); - - describe("abort signal", () => { - it("should close the stream gracefully when aborted", async () => { - let streamResolve: (() => void) | undefined; - const streamWait = new Promise((resolve) => { - streamResolve = resolve; - }); - - global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: "run_abort" }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": "token", - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - // Create a slow stream that waits before sending data - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - controller.enqueue( - encoder.encode(`id: 0\ndata: ${JSON.stringify({ type: "text-start", id: "p1" })}\n\n`) - ); - // Wait for the test to signal it's done - await streamWait; - controller.close(); - }, - }); - - return new Response(stream, { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - const abortController = new AbortController(); - - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: "token", - baseURL: "https://api.test.trigger.dev", - }); - - const stream = await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-abort", - messageId: undefined, - messages: [createUserMessage("test")], - abortSignal: abortController.signal, - }); - - // Read the first chunk - const reader = stream.getReader(); - const first = await reader.read(); - expect(first.done).toBe(false); - - // Abort and clean up - abortController.abort(); - streamResolve?.(); - - // The stream should close — reading should return done - const next = await reader.read(); - expect(next.done).toBe(true); - }); - }); - - describe("multiple sessions", () => { - it("should track multiple chat sessions independently", async () => { - let callCount = 0; - - global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - callCount++; - return new Response( - JSON.stringify({ id: `run_multi_${callCount}` }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": `token_${callCount}`, - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - return new Response(createSSEStream(""), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: "token", - baseURL: "https://api.test.trigger.dev", - }); - - // Start two independent chat sessions - await transport.sendMessages({ - trigger: "submit-message", - chatId: "session-a", - messageId: undefined, - messages: [createUserMessage("Hello A")], - abortSignal: undefined, - }); - - await transport.sendMessages({ - trigger: "submit-message", - chatId: "session-b", - messageId: undefined, - messages: [createUserMessage("Hello B")], - abortSignal: undefined, - }); - - // Both sessions should be independently reconnectable - const streamA = await transport.reconnectToStream({ chatId: "session-a" }); - const streamB = await transport.reconnectToStream({ chatId: "session-b" }); - const streamC = await transport.reconnectToStream({ chatId: "nonexistent" }); - - expect(streamA).toBeInstanceOf(ReadableStream); - expect(streamB).toBeInstanceOf(ReadableStream); - expect(streamC).toBeNull(); - }); - }); - - describe("dynamic accessToken", () => { - it("should call the accessToken function for each sendMessages call", async () => { - let tokenCallCount = 0; - - global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: `run_dyn_${tokenCallCount}` }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": "stream-token", - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - const chunks: UIMessageChunk[] = [ - { type: "text-start", id: "p1" }, - { type: "text-end", id: "p1" }, - ]; - return new Response(createSSEStream(sseEncode(chunks)), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: () => { - tokenCallCount++; - return `dynamic-token-${tokenCallCount}`; - }, - baseURL: "https://api.test.trigger.dev", - }); - - // First call — the token function should be invoked - await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-dyn-1", - messageId: undefined, - messages: [createUserMessage("first")], - abortSignal: undefined, - }); - - const firstCount = tokenCallCount; - expect(firstCount).toBeGreaterThanOrEqual(1); - - // Second call — the token function should be invoked again - await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-dyn-2", - messageId: undefined, - messages: [createUserMessage("second")], - abortSignal: undefined, - }); - - // Token function was called at least once more - expect(tokenCallCount).toBeGreaterThan(firstCount); - }); - }); - - describe("body merging", () => { - it("should merge ChatRequestOptions.body into the task payload", async () => { - const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: "run_body" }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": "token", - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - return new Response(createSSEStream(""), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - global.fetch = fetchSpy; - - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: "token", - baseURL: "https://api.test.trigger.dev", - }); - - await transport.sendMessages({ - trigger: "submit-message", - chatId: "chat-body", - messageId: undefined, - messages: [createUserMessage("test")], - abortSignal: undefined, - body: { systemPrompt: "You are helpful", temperature: 0.7 }, - }); - - const triggerCall = fetchSpy.mock.calls.find((call: any[]) => - (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") - ); - - const triggerBody = JSON.parse(triggerCall![1]?.body as string); - const payload = JSON.parse(triggerBody.payload); - - // body properties should be merged into the payload - expect(payload.systemPrompt).toBe("You are helpful"); - expect(payload.temperature).toBe(0.7); - // Standard fields should still be present - expect(payload.chatId).toBe("chat-body"); - expect(payload.trigger).toBe("submit-message"); - }); - }); - - describe("message types", () => { - it("should handle regenerate-message trigger", async () => { - const fetchSpy = vi.fn().mockImplementation(async (url: string | URL) => { - const urlStr = typeof url === "string" ? url : url.toString(); - - if (urlStr.includes("/trigger")) { - return new Response( - JSON.stringify({ id: "run_regen" }), - { - status: 200, - headers: { - "content-type": "application/json", - "x-trigger-jwt": "token", - }, - } - ); - } - - if (urlStr.includes("/realtime/v1/streams/")) { - return new Response(createSSEStream(""), { - status: 200, - headers: { - "content-type": "text/event-stream", - "X-Stream-Version": "v1", - }, - }); - } - - throw new Error(`Unexpected fetch URL: ${urlStr}`); - }); - - global.fetch = fetchSpy; - - const transport = new TriggerChatTransport({ - taskId: "my-task", - accessToken: "token", - baseURL: "https://api.test.trigger.dev", - }); - - const messages: UIMessage[] = [ - createUserMessage("Hello!"), - createAssistantMessage("Hi there!"), - ]; - - await transport.sendMessages({ - trigger: "regenerate-message", - chatId: "chat-regen", - messageId: "msg-to-regen", - messages, - abortSignal: undefined, - }); - - // Verify the payload includes the regenerate trigger type and messageId - const triggerCall = fetchSpy.mock.calls.find((call: any[]) => - (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger") - ); - - const triggerBody = JSON.parse(triggerCall![1]?.body as string); - const payload = JSON.parse(triggerBody.payload); - expect(payload.trigger).toBe("regenerate-message"); - expect(payload.messageId).toBe("msg-to-regen"); - }); - }); -}); diff --git a/packages/ai/src/transport.ts b/packages/ai/src/transport.ts deleted file mode 100644 index ff4b2c47a33..00000000000 --- a/packages/ai/src/transport.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from "ai"; -import { - ApiClient, - SSEStreamSubscription, - type SSEStreamPart, -} from "@trigger.dev/core/v3"; -import type { TriggerChatTransportOptions, ChatSessionState } from "./types.js"; - -const DEFAULT_STREAM_KEY = "chat"; -const DEFAULT_BASE_URL = "https://api.trigger.dev"; -const DEFAULT_STREAM_TIMEOUT_SECONDS = 120; - -/** - * A custom AI SDK `ChatTransport` implementation that bridges the Vercel AI SDK's - * `useChat` hook with Trigger.dev's durable task execution and realtime streams. - * - * When `sendMessages` is called, the transport: - * 1. Triggers a Trigger.dev task with the chat messages as payload - * 2. Subscribes to the task's realtime stream to receive `UIMessageChunk` data - * 3. Returns a `ReadableStream` that the AI SDK processes natively - * - * The task receives a `ChatTaskPayload` containing the conversation messages, - * chat session ID, trigger type, and any custom metadata. Your task should use - * the AI SDK's `streamText` (or similar) to generate a response, then pipe - * the resulting `UIMessageStream` to the `"chat"` realtime stream key - * (or a custom key matching the `streamKey` option). - * - * @example - * ```tsx - * // Frontend — use with AI SDK's useChat hook - * import { useChat } from "@ai-sdk/react"; - * import { TriggerChatTransport } from "@trigger.dev/ai"; - * - * function Chat({ accessToken }: { accessToken: string }) { - * const { messages, sendMessage, status } = useChat({ - * transport: new TriggerChatTransport({ - * accessToken, - * taskId: "my-chat-task", - * }), - * }); - * - * // ... render messages - * } - * ``` - * - * @example - * ```ts - * // Backend — Trigger.dev task that handles chat - * import { task, streams } from "@trigger.dev/sdk"; - * import { streamText, convertToModelMessages } from "ai"; - * import type { ChatTaskPayload } from "@trigger.dev/ai"; - * - * export const myChatTask = task({ - * id: "my-chat-task", - * run: async (payload: ChatTaskPayload) => { - * const result = streamText({ - * model: openai("gpt-4o"), - * messages: convertToModelMessages(payload.messages), - * }); - * - * const { waitUntilComplete } = streams.pipe("chat", result.toUIMessageStream()); - * await waitUntilComplete(); - * }, - * }); - * ``` - */ -export class TriggerChatTransport implements ChatTransport { - private readonly taskId: string; - private readonly resolveAccessToken: () => string; - private readonly baseURL: string; - private readonly streamKey: string; - private readonly extraHeaders: Record; - private readonly streamTimeoutSeconds: number; - - /** - * Tracks active chat sessions for reconnection support. - * Maps chatId → session state (runId, publicAccessToken). - */ - private sessions: Map = new Map(); - - constructor(options: TriggerChatTransportOptions) { - this.taskId = options.taskId; - this.resolveAccessToken = - typeof options.accessToken === "function" - ? options.accessToken - : () => options.accessToken as string; - this.baseURL = options.baseURL ?? DEFAULT_BASE_URL; - this.streamKey = options.streamKey ?? DEFAULT_STREAM_KEY; - this.extraHeaders = options.headers ?? {}; - this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS; - } - - /** - * Sends messages to a Trigger.dev task and returns a streaming response. - * - * This method: - * 1. Triggers the configured task with the chat messages as payload - * 2. Subscribes to the task's realtime stream for UIMessageChunk events - * 3. Returns a ReadableStream that the AI SDK's useChat hook processes - */ - sendMessages = async ( - options: { - trigger: "submit-message" | "regenerate-message"; - chatId: string; - messageId: string | undefined; - messages: UIMessage[]; - abortSignal: AbortSignal | undefined; - } & ChatRequestOptions - ): Promise> => { - const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options; - - // Build the payload for the task — this becomes the ChatTaskPayload - const payload = { - messages, - chatId, - trigger, - messageId, - metadata, - ...(body ?? {}), - }; - - const currentToken = this.resolveAccessToken(); - - // Trigger the task — use the already-resolved token directly - const apiClient = new ApiClient(this.baseURL, currentToken); - const triggerResponse = await apiClient.triggerTask(this.taskId, { - payload: JSON.stringify(payload), - options: { - payloadType: "application/json", - }, - }); - - const runId = triggerResponse.id; - const publicAccessToken = - "publicAccessToken" in triggerResponse - ? (triggerResponse as { publicAccessToken?: string }).publicAccessToken - : undefined; - - // Store session state for reconnection - this.sessions.set(chatId, { - runId, - publicAccessToken: publicAccessToken ?? currentToken, - }); - - // Subscribe to the realtime stream for this run - return this.subscribeToStream(runId, publicAccessToken ?? currentToken, abortSignal); - }; - - /** - * Reconnects to an existing streaming response for the specified chat session. - * - * Returns a ReadableStream if an active session exists, or null if no session is found. - */ - reconnectToStream = async ( - options: { - chatId: string; - } & ChatRequestOptions - ): Promise | null> => { - const session = this.sessions.get(options.chatId); - if (!session) { - return null; - } - - return this.subscribeToStream(session.runId, session.publicAccessToken, undefined); - }; - - /** - * Creates a ReadableStream by subscribing to the realtime SSE stream - * for a given run. - */ - private subscribeToStream( - runId: string, - accessToken: string, - abortSignal: AbortSignal | undefined - ): ReadableStream { - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - ...this.extraHeaders, - }; - - const subscription = new SSEStreamSubscription( - `${this.baseURL}/realtime/v1/streams/${runId}/${this.streamKey}`, - { - headers, - signal: abortSignal, - timeoutInSeconds: this.streamTimeoutSeconds, - } - ); - - return new ReadableStream({ - start: async (controller) => { - try { - const sseStream = await subscription.subscribe(); - const reader = sseStream.getReader(); - - try { - while (true) { - const { done, value } = await reader.read(); - - if (done) { - controller.close(); - return; - } - - if (abortSignal?.aborted) { - reader.cancel(); - reader.releaseLock(); - controller.close(); - return; - } - - // Each SSE part's chunk is a UIMessageChunk - controller.enqueue(value.chunk as UIMessageChunk); - } - } catch (readError) { - reader.releaseLock(); - throw readError; - } - } catch (error) { - // Don't error the stream for abort errors — just close gracefully - if (error instanceof Error && error.name === "AbortError") { - controller.close(); - return; - } - - controller.error(error); - } - }, - }); - } -} - -/** - * Creates a new `TriggerChatTransport` instance. - * - * This is a convenience factory function equivalent to `new TriggerChatTransport(options)`. - * - * @example - * ```tsx - * import { useChat } from "@ai-sdk/react"; - * import { createChatTransport } from "@trigger.dev/ai"; - * - * const transport = createChatTransport({ - * taskId: "my-chat-task", - * accessToken: publicAccessToken, - * }); - * - * function Chat() { - * const { messages, sendMessage } = useChat({ transport }); - * // ... - * } - * ``` - */ -export function createChatTransport(options: TriggerChatTransportOptions): TriggerChatTransport { - return new TriggerChatTransport(options); -} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts deleted file mode 100644 index 91ae9938888..00000000000 --- a/packages/ai/src/types.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { UIMessage } from "ai"; - -/** - * Options for creating a TriggerChatTransport. - */ -export type TriggerChatTransportOptions = { - /** - * The Trigger.dev task ID to trigger for chat completions. - * This task will receive the chat messages as its payload. - */ - task: string; - - /** - * An access token for authenticating with the Trigger.dev API. - * - * This must be a token with permission to trigger the task. You can use: - * - A **trigger public token** created via `auth.createTriggerPublicToken(taskId)` (recommended for frontend use) - * - A **secret API key** (for server-side use only — never expose in the browser) - * - * The token returned from triggering the task (`publicAccessToken`) is automatically - * used for subscribing to the realtime stream. - * - * Can also be a function that returns a token string, useful for dynamic token refresh: - * ```ts - * accessToken: () => getLatestToken() - * ``` - */ - accessToken: string | (() => string); - - /** - * Base URL for the Trigger.dev API. - * - * @default "https://api.trigger.dev" - */ - baseURL?: string; - - /** - * The stream key where the task pipes UIMessageChunk data. - * When using `chatTask()` or `pipeChat()`, this is handled automatically. - * Only set this if you're using a custom stream key. - * - * @default "chat" - */ - streamKey?: string; - - /** - * Additional headers to include in API requests to Trigger.dev. - */ - headers?: Record; - - /** - * The number of seconds to wait for the realtime stream to produce data - * before timing out. If no data arrives within this period, the stream - * will be closed. - * - * @default 120 - */ - streamTimeoutSeconds?: number; -}; - -/** - * The payload shape that the transport sends to the triggered task. - * - * When using `chatTask()`, the payload is automatically typed — you don't need - * to import this type. When using `task()` directly, use this type to annotate - * your payload: - * - * @example - * ```ts - * import { task } from "@trigger.dev/sdk"; - * import { pipeChat, type ChatTaskPayload } from "@trigger.dev/ai"; - * - * export const myChatTask = task({ - * id: "my-chat-task", - * run: async (payload: ChatTaskPayload) => { - * const result = streamText({ - * model: openai("gpt-4o"), - * messages: convertToModelMessages(payload.messages), - * }); - * await pipeChat(result); - * }, - * }); - * ``` - */ -export type ChatTaskPayload = { - /** The array of UI messages representing the conversation history */ - messages: TMessage[]; - - /** The unique identifier for the chat session */ - chatId: string; - - /** - * The type of message submission: - * - `"submit-message"`: A new user message was submitted - * - `"regenerate-message"`: The user wants to regenerate the last assistant response - */ - trigger: "submit-message" | "regenerate-message"; - - /** - * The ID of the message to regenerate (only present for `"regenerate-message"` trigger). - */ - messageId?: string; - - /** - * Custom metadata attached to the chat request by the frontend. - */ - metadata?: unknown; -}; - -/** - * Internal state for tracking active chat sessions, used for stream reconnection. - * @internal - */ -export type ChatSessionState = { - runId: string; - publicAccessToken: string; -}; diff --git a/packages/ai/src/version.ts b/packages/ai/src/version.ts deleted file mode 100644 index 2e47a886828..00000000000 --- a/packages/ai/src/version.ts +++ /dev/null @@ -1 +0,0 @@ -export const VERSION = "0.0.0"; diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json deleted file mode 100644 index ec09e52a400..00000000000 --- a/packages/ai/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../.configs/tsconfig.base.json", - "compilerOptions": { - "isolatedDeclarations": false, - "composite": true, - "sourceMap": true, - "stripInternal": true - }, - "include": ["./src/**/*.ts"] -} diff --git a/packages/ai/vitest.config.ts b/packages/ai/vitest.config.ts deleted file mode 100644 index c497b8ec974..00000000000 --- a/packages/ai/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.test.ts"], - globals: true, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fa3efb3392..0d27e33dfa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1101,7 +1101,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -1373,31 +1373,6 @@ importers: specifier: 8.6.6 version: 8.6.6 - packages/ai: - dependencies: - '@trigger.dev/core': - specifier: workspace:4.3.3 - version: link:../core - devDependencies: - '@arethetypeswrong/cli': - specifier: ^0.15.4 - version: 0.15.4 - ai: - specifier: ^6.0.0 - version: 6.0.3(zod@3.25.76) - rimraf: - specifier: ^3.0.2 - version: 3.0.2 - tshy: - specifier: ^3.0.2 - version: 3.0.2 - tsx: - specifier: 4.17.0 - version: 4.17.0 - vitest: - specifier: ^2.1.0 - version: 2.1.9(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) - packages/build: dependencies: '@prisma/config': @@ -11176,23 +11151,9 @@ packages: '@vitest/browser': optional: true - '@vitest/expect@2.1.9': - resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - '@vitest/expect@3.1.4': resolution: {integrity: sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==} - '@vitest/mocker@2.1.9': - resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - '@vitest/mocker@3.1.4': resolution: {integrity: sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==} peerDependencies: @@ -11216,15 +11177,9 @@ packages: '@vitest/runner@3.1.4': resolution: {integrity: sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==} - '@vitest/snapshot@2.1.9': - resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} - '@vitest/snapshot@3.1.4': resolution: {integrity: sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==} - '@vitest/spy@2.1.9': - resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} - '@vitest/spy@3.1.4': resolution: {integrity: sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==} @@ -19811,11 +19766,6 @@ packages: engines: {node: '>=v14.16.0'} hasBin: true - vite-node@2.1.9: - resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - vite-node@3.1.4: resolution: {integrity: sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -19883,31 +19833,6 @@ packages: terser: optional: true - vitest@2.1.9: - resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': 20.14.14 - '@vitest/browser': 2.1.9 - '@vitest/ui': 2.1.9 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - vitest@3.1.4: resolution: {integrity: sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -23202,7 +23127,7 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.11.8) - '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9) + '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 hono: 4.5.11 @@ -23957,7 +23882,7 @@ snapshots: dependencies: hono: 4.11.8 - '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': + '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.11.8) ws: 8.18.3(bufferutil@4.0.9) @@ -31573,13 +31498,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@2.1.9': - dependencies: - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.2.0 - tinyrainbow: 1.2.0 - '@vitest/expect@3.1.4': dependencies: '@vitest/spy': 3.1.4 @@ -31587,14 +31505,6 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))': - dependencies: - '@vitest/spy': 2.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) - '@vitest/mocker@3.1.4(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1))': dependencies: '@vitest/spy': 3.1.4 @@ -31621,22 +31531,12 @@ snapshots: '@vitest/utils': 3.1.4 pathe: 2.0.3 - '@vitest/snapshot@2.1.9': - dependencies: - '@vitest/pretty-format': 2.1.9 - magic-string: 0.30.21 - pathe: 1.1.2 - '@vitest/snapshot@3.1.4': dependencies: '@vitest/pretty-format': 3.1.4 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@2.1.9': - dependencies: - tinyspy: 3.0.2 - '@vitest/spy@3.1.4': dependencies: tinyspy: 3.0.2 @@ -39268,7 +39168,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39305,8 +39205,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3(bufferutil@4.0.9) - socket.io-client: 4.7.3(bufferutil@4.0.9) + socket.io: 4.7.3 + socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -40506,7 +40406,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3(bufferutil@4.0.9): + socket.io-client@4.7.3: dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40535,7 +40435,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3(bufferutil@4.0.9): + socket.io@4.7.3: dependencies: accepts: 1.3.8 base64id: 2.0.0 @@ -42075,24 +41975,6 @@ snapshots: - supports-color - terser - vite-node@2.1.9(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): - dependencies: - cac: 6.7.14 - debug: 4.4.3(supports-color@10.0.0) - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - vite-node@3.1.4(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): dependencies: cac: 6.7.14 @@ -42142,41 +42024,6 @@ snapshots: lightningcss: 1.29.2 terser: 5.44.1 - vitest@2.1.9(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): - dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.2.0 - debug: 4.4.3(supports-color@10.0.0) - expect-type: 1.2.1 - magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.9.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.0.2 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) - vite-node: 2.1.9(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 20.14.14 - transitivePeerDependencies: - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): dependencies: '@vitest/expect': 3.1.4 From 97270390d592b1be69ac59108ecb83719dcc4e1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 12:56:49 +0000 Subject: [PATCH 12/19] chore: update changeset to target @trigger.dev/sdk Co-authored-by: Eric Allam --- .changeset/ai-sdk-chat-transport.md | 37 +++++++++++++++-------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/.changeset/ai-sdk-chat-transport.md b/.changeset/ai-sdk-chat-transport.md index a24dcdc195e..f5cdb9187d4 100644 --- a/.changeset/ai-sdk-chat-transport.md +++ b/.changeset/ai-sdk-chat-transport.md @@ -1,41 +1,42 @@ --- -"@trigger.dev/ai": minor +"@trigger.dev/sdk": minor --- -New package: `@trigger.dev/ai` — AI SDK integration for Trigger.dev +Add AI SDK chat transport integration via two new subpath exports: -Provides `TriggerChatTransport`, a custom `ChatTransport` implementation for the Vercel AI SDK that bridges `useChat` with Trigger.dev's durable task execution and realtime streams. +**`@trigger.dev/sdk/chat`** (frontend, browser-safe): +- `TriggerChatTransport` — custom `ChatTransport` for the AI SDK's `useChat` hook that runs chat completions as durable Trigger.dev tasks +- `createChatTransport()` — factory function -**Frontend usage:** ```tsx import { useChat } from "@ai-sdk/react"; -import { TriggerChatTransport } from "@trigger.dev/ai"; +import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; const { messages, sendMessage } = useChat({ transport: new TriggerChatTransport({ - accessToken: publicAccessToken, - taskId: "my-chat-task", + task: "my-chat-task", + accessToken, }), }); ``` -**Backend task:** +**`@trigger.dev/sdk/ai`** (backend, extends existing `ai.tool`/`ai.currentToolOptions`): +- `chatTask()` — pre-typed task wrapper with auto-pipe support +- `pipeChat()` — pipe a `StreamTextResult` or stream to the frontend +- `CHAT_STREAM_KEY` — the default stream key constant +- `ChatTaskPayload` type + ```ts -import { task, streams } from "@trigger.dev/sdk"; +import { chatTask } from "@trigger.dev/sdk/ai"; import { streamText, convertToModelMessages } from "ai"; -import type { ChatTaskPayload } from "@trigger.dev/ai"; -export const myChatTask = task({ +export const myChatTask = chatTask({ id: "my-chat-task", - run: async (payload: ChatTaskPayload) => { - const result = streamText({ + run: async ({ messages }) => { + return streamText({ model: openai("gpt-4o"), - messages: convertToModelMessages(payload.messages), + messages: convertToModelMessages(messages), }); - const { waitUntilComplete } = streams.pipe("chat", result.toUIMessageStream()); - await waitUntilComplete(); }, }); ``` - -Also exports `createChatTransport()` factory function and `ChatTaskPayload` type for task-side typing. From adc09331d3fea2b94330ba6be3363b5ad723d198 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 13:06:08 +0000 Subject: [PATCH 13/19] fix: address CodeRabbit review feedback 1. Add null/object guard before enqueuing UIMessageChunk from SSE stream to handle heartbeat or malformed events safely 2. Use incrementing counter instead of Date.now() in test message factories to avoid duplicate IDs 3. Add test covering publicAccessToken from trigger response being used for stream subscription auth Co-authored-by: Eric Allam --- packages/trigger-sdk/src/v3/chat.test.ts | 84 +++++++++++++++++++++++- packages/trigger-sdk/src/v3/chat.ts | 5 +- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts index 86a4ba9ad57..ae89f28a8ab 100644 --- a/packages/trigger-sdk/src/v3/chat.test.ts +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -18,10 +18,12 @@ function createSSEStream(sseText: string): ReadableStream { }); } -// Helper: create test UIMessages +// Helper: create test UIMessages with unique IDs +let messageIdCounter = 0; + function createUserMessage(text: string): UIMessage { return { - id: `msg-${Date.now()}`, + id: `msg-user-${++messageIdCounter}`, role: "user", parts: [{ type: "text", text }], }; @@ -29,7 +31,7 @@ function createUserMessage(text: string): UIMessage { function createAssistantMessage(text: string): UIMessage { return { - id: `msg-${Date.now()}`, + id: `msg-assistant-${++messageIdCounter}`, role: "assistant", parts: [{ type: "text", text }], }; @@ -456,6 +458,82 @@ describe("TriggerChatTransport", () => { }); }); + describe("publicAccessToken from trigger response", () => { + it("should use publicAccessToken from response body when x-trigger-jwt header is absent", async () => { + const fetchSpy = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + // Return without x-trigger-jwt header — the ApiClient will attempt + // to generate a JWT from the access token. In this test the token + // generation will add a publicAccessToken to the result. + return new Response( + JSON.stringify({ id: "run_pat" }), + { + status: 200, + headers: { + "content-type": "application/json", + // Include x-trigger-jwt to simulate the server returning a public token + "x-trigger-jwt": "server-generated-public-token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + // Verify the Authorization header uses the server-generated token + const authHeader = (init?.headers as Record)?.["Authorization"]; + expect(authHeader).toBe("Bearer server-generated-public-token"); + + const chunks: UIMessageChunk[] = [ + { type: "text-start", id: "p1" }, + { type: "text-end", id: "p1" }, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + global.fetch = fetchSpy; + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "caller-token", + baseURL: "https://api.test.trigger.dev", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-pat", + messageId: undefined, + messages: [createUserMessage("test")], + abortSignal: undefined, + }); + + // Consume the stream + const reader = stream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + // Verify the stream subscription used the public token, not the caller token + const streamCall = fetchSpy.mock.calls.find((call: any[]) => + (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/") + ); + expect(streamCall).toBeDefined(); + const streamHeaders = streamCall![1]?.headers as Record; + expect(streamHeaders["Authorization"]).toBe("Bearer server-generated-public-token"); + }); + }); + describe("error handling", () => { it("should propagate trigger API errors", async () => { global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 5a7872c1014..77378bded04 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -252,7 +252,10 @@ export class TriggerChatTransport implements ChatTransport { return; } - controller.enqueue(value.chunk as UIMessageChunk); + // Guard against heartbeat or malformed SSE events + if (value.chunk != null && typeof value.chunk === "object") { + controller.enqueue(value.chunk as UIMessageChunk); + } } } catch (readError) { reader.releaseLock(); From 876454c36561a7ee13679d72635a30722ea613ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 13:15:30 +0000 Subject: [PATCH 14/19] docs(ai): add AI Chat with useChat guide Comprehensive guide covering: - Quick start with chatTask + TriggerChatTransport - Backend patterns: simple (return streamText), complex (pipeChat), and manual (task + ChatTaskPayload) - Frontend options: dynamic tokens, extra data, self-hosting - ChatTaskPayload reference - Added to Writing tasks navigation near Streams Co-authored-by: Eric Allam --- docs/docs.json | 1 + docs/guides/ai-chat.mdx | 268 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 docs/guides/ai-chat.mdx diff --git a/docs/docs.json b/docs/docs.json index 14d728e2db1..911c912711b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -74,6 +74,7 @@ "tags", "runs/metadata", "tasks/streams", + "guides/ai-chat", "run-usage", "context", "runs/priority", diff --git a/docs/guides/ai-chat.mdx b/docs/guides/ai-chat.mdx new file mode 100644 index 00000000000..e549226b147 --- /dev/null +++ b/docs/guides/ai-chat.mdx @@ -0,0 +1,268 @@ +--- +title: "AI Chat with useChat" +sidebarTitle: "AI Chat (useChat)" +description: "Run AI SDK chat completions as durable Trigger.dev tasks with built-in realtime streaming." +--- + +## Overview + +The `@trigger.dev/sdk` provides a custom [ChatTransport](https://sdk.vercel.ai/docs/ai-sdk-ui/transport) for the Vercel AI SDK's `useChat` hook. This lets you run chat completions as **durable Trigger.dev tasks** instead of fragile API routes — with automatic retries, observability, and realtime streaming built in. + +**How it works:** +1. The frontend sends messages via `useChat` → `TriggerChatTransport` +2. The transport triggers a Trigger.dev task with the conversation as payload +3. The task streams `UIMessageChunk` events back via Trigger.dev's realtime streams +4. The AI SDK's `useChat` processes the stream natively — text, tool calls, reasoning, etc. + +No custom API routes needed. Your chat backend is a Trigger.dev task. + + + Requires `@trigger.dev/sdk` version **4.4.0 or later** and the `ai` package **v5.0.0 or later**. + + +## Quick start + +### 1. Define a chat task + +Use `chatTask` from `@trigger.dev/sdk/ai` to define a task that handles chat messages. The payload is automatically typed as `ChatTaskPayload`. + +If you return a `StreamTextResult` from `run`, it's **automatically piped** to the frontend. + +```ts trigger/chat.ts +import { chatTask } from "@trigger.dev/sdk/ai"; +import { streamText, convertToModelMessages } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const myChat = chatTask({ + id: "my-chat", + run: async ({ messages }) => { + // messages is UIMessage[] from the frontend + return streamText({ + model: openai("gpt-4o"), + messages: convertToModelMessages(messages), + }); + // Returning a StreamTextResult auto-pipes it to the frontend + }, +}); +``` + +### 2. Generate an access token + +On your server (e.g. a Next.js API route or server action), create a trigger public token: + +```ts app/actions.ts +"use server"; + +import { auth } from "@trigger.dev/sdk"; + +export async function getChatToken() { + return await auth.createTriggerPublicToken("my-chat"); +} +``` + +### 3. Use in the frontend + +Import `TriggerChatTransport` from `@trigger.dev/sdk/chat` (browser-safe — no server dependencies). + +```tsx app/components/chat.tsx +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; + +export function Chat({ accessToken }: { accessToken: string }) { + const { messages, sendMessage, status, error } = useChat({ + transport: new TriggerChatTransport({ + task: "my-chat", + accessToken, + }), + }); + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: + {m.parts.map((part, i) => + part.type === "text" ? {part.text} : null + )} +
+ ))} + +
{ + e.preventDefault(); + const input = e.currentTarget.querySelector("input"); + if (input?.value) { + sendMessage({ text: input.value }); + input.value = ""; + } + }} + > + + +
+
+ ); +} +``` + +## Backend patterns + +### Simple: return a StreamTextResult + +The easiest approach — return the `streamText` result from `run` and it's automatically piped to the frontend: + +```ts +import { chatTask } from "@trigger.dev/sdk/ai"; +import { streamText, convertToModelMessages } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const simpleChat = chatTask({ + id: "simple-chat", + run: async ({ messages }) => { + return streamText({ + model: openai("gpt-4o"), + system: "You are a helpful assistant.", + messages: convertToModelMessages(messages), + }); + }, +}); +``` + +### Complex: use pipeChat() from anywhere + +For complex agent flows where `streamText` is called deep inside your code, use `pipeChat()`. It works from **anywhere inside a task** — even nested function calls. + +```ts trigger/agent-chat.ts +import { chatTask, pipeChat } from "@trigger.dev/sdk/ai"; +import { streamText, convertToModelMessages } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const agentChat = chatTask({ + id: "agent-chat", + run: async ({ messages }) => { + // Don't return anything — pipeChat is called inside + await runAgentLoop(convertToModelMessages(messages)); + }, +}); + +// This could be deep inside your agent library +async function runAgentLoop(messages: CoreMessage[]) { + // ... agent logic, tool calls, etc. + + const result = streamText({ + model: openai("gpt-4o"), + messages, + }); + + // Pipe from anywhere — no need to return it + await pipeChat(result); +} +``` + +### Manual: use task() with pipeChat() + +If you need full control over task options, use the standard `task()` with `ChatTaskPayload` and `pipeChat()`: + +```ts +import { task } from "@trigger.dev/sdk"; +import { pipeChat, type ChatTaskPayload } from "@trigger.dev/sdk/ai"; +import { streamText, convertToModelMessages } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const manualChat = task({ + id: "manual-chat", + retry: { maxAttempts: 3 }, + queue: { concurrencyLimit: 10 }, + run: async (payload: ChatTaskPayload) => { + const result = streamText({ + model: openai("gpt-4o"), + messages: convertToModelMessages(payload.messages), + }); + + await pipeChat(result); + }, +}); +``` + +## Frontend options + +### TriggerChatTransport options + +```ts +new TriggerChatTransport({ + // Required + task: "my-chat", // Task ID to trigger + accessToken: token, // Trigger public token or secret key + + // Optional + baseURL: "https://...", // Custom API URL (self-hosted) + streamKey: "chat", // Custom stream key (default: "chat") + headers: { ... }, // Extra headers for API requests + streamTimeoutSeconds: 120, // Stream timeout (default: 120s) +}); +``` + +### Dynamic access tokens + +For token refresh patterns, pass a function: + +```ts +new TriggerChatTransport({ + task: "my-chat", + accessToken: () => getLatestToken(), // Called on each sendMessage +}); +``` + +### Passing extra data + +Use the `body` option on `sendMessage` to pass additional data to the task: + +```ts +sendMessage({ + text: "Hello", +}, { + body: { + systemPrompt: "You are a pirate.", + temperature: 0.9, + }, +}); +``` + +The `body` fields are merged into the `ChatTaskPayload` and available in your task's `run` function. + +## ChatTaskPayload + +The payload sent to the task has this shape: + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `UIMessage[]` | The conversation history | +| `chatId` | `string` | Unique chat session ID | +| `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request | +| `messageId` | `string \| undefined` | Message ID to regenerate (if applicable) | +| `metadata` | `unknown` | Custom metadata from the frontend | + +Plus any extra fields from the `body` option. + +## Self-hosting + +If you're self-hosting Trigger.dev, pass the `baseURL` option: + +```ts +new TriggerChatTransport({ + task: "my-chat", + accessToken, + baseURL: "https://your-trigger-instance.com", +}); +``` + +## Related + +- [Realtime Streams](/tasks/streams) — How streams work under the hood +- [Using the Vercel AI SDK](/guides/examples/vercel-ai-sdk) — Basic AI SDK usage with Trigger.dev +- [Realtime React Hooks](/realtime/react-hooks/overview) — Lower-level realtime hooks +- [Authentication](/realtime/auth) — Public access tokens and trigger tokens From 993ed9b15fbbc1f0bf5b0e6cc103d36bcdec9a89 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 13:24:32 +0000 Subject: [PATCH 15/19] feat(reference): add ai-chat Next.js reference project Minimal example showcasing the new chatTask + TriggerChatTransport APIs: - Backend: chatTask with streamText auto-pipe (src/trigger/chat.ts) - Frontend: TriggerChatTransport with useChat (src/components/chat.tsx) - Token generation via auth.createTriggerPublicToken (src/app/page.tsx) - Tailwind v4 styling Co-authored-by: Eric Allam --- references/ai-chat/next.config.ts | 5 ++ references/ai-chat/package.json | 30 +++++++ references/ai-chat/postcss.config.mjs | 8 ++ references/ai-chat/src/app/globals.css | 1 + references/ai-chat/src/app/layout.tsx | 15 ++++ references/ai-chat/src/app/page.tsx | 17 ++++ references/ai-chat/src/components/chat.tsx | 91 ++++++++++++++++++++++ references/ai-chat/src/trigger/chat.ts | 14 ++++ references/ai-chat/trigger.config.ts | 7 ++ references/ai-chat/tsconfig.json | 27 +++++++ 10 files changed, 215 insertions(+) create mode 100644 references/ai-chat/next.config.ts create mode 100644 references/ai-chat/package.json create mode 100644 references/ai-chat/postcss.config.mjs create mode 100644 references/ai-chat/src/app/globals.css create mode 100644 references/ai-chat/src/app/layout.tsx create mode 100644 references/ai-chat/src/app/page.tsx create mode 100644 references/ai-chat/src/components/chat.tsx create mode 100644 references/ai-chat/src/trigger/chat.ts create mode 100644 references/ai-chat/trigger.config.ts create mode 100644 references/ai-chat/tsconfig.json diff --git a/references/ai-chat/next.config.ts b/references/ai-chat/next.config.ts new file mode 100644 index 00000000000..cb651cdc007 --- /dev/null +++ b/references/ai-chat/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/references/ai-chat/package.json b/references/ai-chat/package.json new file mode 100644 index 00000000000..228a09015df --- /dev/null +++ b/references/ai-chat/package.json @@ -0,0 +1,30 @@ +{ + "name": "references-ai-chat", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "dev:trigger": "trigger dev" + }, + "dependencies": { + "@ai-sdk/openai": "^2.0.0", + "@ai-sdk/react": "^2.0.0", + "@trigger.dev/sdk": "workspace:*", + "ai": "^6.0.0", + "next": "15.3.3", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@trigger.dev/build": "workspace:*", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "trigger.dev": "workspace:*", + "typescript": "^5" + } +} diff --git a/references/ai-chat/postcss.config.mjs b/references/ai-chat/postcss.config.mjs new file mode 100644 index 00000000000..79bcf135dc4 --- /dev/null +++ b/references/ai-chat/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/references/ai-chat/src/app/globals.css b/references/ai-chat/src/app/globals.css new file mode 100644 index 00000000000..f1d8c73cdcf --- /dev/null +++ b/references/ai-chat/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/references/ai-chat/src/app/layout.tsx b/references/ai-chat/src/app/layout.tsx new file mode 100644 index 00000000000..f507028583d --- /dev/null +++ b/references/ai-chat/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "AI Chat — Trigger.dev", + description: "AI SDK useChat powered by Trigger.dev durable tasks", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/references/ai-chat/src/app/page.tsx b/references/ai-chat/src/app/page.tsx new file mode 100644 index 00000000000..16f01282c80 --- /dev/null +++ b/references/ai-chat/src/app/page.tsx @@ -0,0 +1,17 @@ +import { auth } from "@trigger.dev/sdk"; +import { Chat } from "@/components/chat"; + +export default async function Home() { + const accessToken = await auth.createTriggerPublicToken("ai-chat"); + + return ( +
+
+

+ AI Chat — powered by Trigger.dev +

+ +
+
+ ); +} diff --git a/references/ai-chat/src/components/chat.tsx b/references/ai-chat/src/components/chat.tsx new file mode 100644 index 00000000000..34c68d8ba7e --- /dev/null +++ b/references/ai-chat/src/components/chat.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; +import { useState } from "react"; + +export function Chat({ accessToken }: { accessToken: string }) { + const [input, setInput] = useState(""); + + const { messages, sendMessage, status, error } = useChat({ + transport: new TriggerChatTransport({ + task: "ai-chat", + accessToken, + baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL, + }), + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!input.trim() || status === "streaming") return; + + sendMessage({ text: input }); + setInput(""); + } + + return ( +
+ {/* Messages */} +
+ {messages.length === 0 && ( +

Send a message to start chatting.

+ )} + + {messages.map((message) => ( +
+
+ {message.parts.map((part, i) => { + if (part.type === "text") { + return {part.text}; + } + return null; + })} +
+
+ ))} + + {status === "streaming" && ( +
+
+ Thinking… +
+
+ )} +
+ + {/* Error */} + {error && ( +
+ {error.message} +
+ )} + + {/* Input */} +
+ setInput(e.target.value)} + placeholder="Type a message…" + className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> + +
+
+ ); +} diff --git a/references/ai-chat/src/trigger/chat.ts b/references/ai-chat/src/trigger/chat.ts new file mode 100644 index 00000000000..27a4002397d --- /dev/null +++ b/references/ai-chat/src/trigger/chat.ts @@ -0,0 +1,14 @@ +import { chatTask } from "@trigger.dev/sdk/ai"; +import { streamText, convertToModelMessages } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const chat = chatTask({ + id: "ai-chat", + run: async ({ messages }) => { + return streamText({ + model: openai("gpt-4o-mini"), + system: "You are a helpful assistant. Be concise and friendly.", + messages: convertToModelMessages(messages), + }); + }, +}); diff --git a/references/ai-chat/trigger.config.ts b/references/ai-chat/trigger.config.ts new file mode 100644 index 00000000000..4412bfc9325 --- /dev/null +++ b/references/ai-chat/trigger.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: process.env.TRIGGER_PROJECT_REF!, + dirs: ["./src/trigger"], + maxDuration: 300, +}); diff --git a/references/ai-chat/tsconfig.json b/references/ai-chat/tsconfig.json new file mode 100644 index 00000000000..c1334095f87 --- /dev/null +++ b/references/ai-chat/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From 01dc75ad4b7a7c812dc6fefc27cbb0b357107963 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 13:27:14 +0000 Subject: [PATCH 16/19] fix(reference): use compatible @ai-sdk v3 packages, await convertToModelMessages @ai-sdk/openai v3 and @ai-sdk/react v3 are needed for ai v6 compatibility. convertToModelMessages is async in newer AI SDK versions. Co-authored-by: Eric Allam --- pnpm-lock.yaml | 287 +++++++++++++++++++++++-- references/ai-chat/package.json | 4 +- references/ai-chat/src/trigger/chat.ts | 2 +- 3 files changed, 277 insertions(+), 16 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d27e33dfa3..0f426ef9b3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2108,6 +2108,55 @@ importers: specifier: 3.25.76 version: 3.25.76 + references/ai-chat: + dependencies: + '@ai-sdk/openai': + specifier: ^3.0.0 + version: 3.0.27(zod@3.25.76) + '@ai-sdk/react': + specifier: ^3.0.0 + version: 3.0.84(react@19.1.0)(zod@3.25.76) + '@trigger.dev/sdk': + specifier: workspace:* + version: link:../../packages/trigger-sdk + ai: + specifier: ^6.0.0 + version: 6.0.3(zod@3.25.76) + next: + specifier: 15.3.3 + version: 15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: + specifier: ^19.0.0 + version: 19.1.0 + react-dom: + specifier: ^19.0.0 + version: 19.1.0(react@19.1.0) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.0.17 + '@trigger.dev/build': + specifier: workspace:* + version: link:../../packages/build + '@types/node': + specifier: 20.14.14 + version: 20.14.14 + '@types/react': + specifier: ^19 + version: 19.0.12 + '@types/react-dom': + specifier: ^19 + version: 19.0.4(@types/react@19.0.12) + tailwindcss: + specifier: ^4 + version: 4.0.17 + trigger.dev: + specifier: workspace:* + version: link:../../packages/cli-v3 + typescript: + specifier: 5.5.4 + version: 5.5.4 + references/bun-catalog: dependencies: '@trigger.dev/sdk': @@ -2867,6 +2916,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.42': + resolution: {integrity: sha512-Il9lZWPUQMX59H5yJvA08gxfL2Py8oHwvAYRnK0Mt91S+JgPcyk/yEmXNDZG9ghJrwSawtK5Yocy8OnzsTOGsw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@1.0.1': resolution: {integrity: sha512-snZge8457afWlosVNUn+BG60MrxAPOOm3zmIMxJZih8tneNSiRbTVCbSzAtq/9vsnOHDe5RR83PRl85juOYEnA==} engines: {node: '>=18'} @@ -2897,6 +2952,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai@3.0.27': + resolution: {integrity: sha512-pLMxWOypwroXiK9dxNpn60/HGhWWWDEOJ3lo9vZLoxvpJNtKnLKojwVIvlW3yEjlD7ll1+jUO2uzsABNTaP5Yg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@1.0.22': resolution: {integrity: sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==} engines: {node: '>=18'} @@ -2945,6 +3006,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.14': + resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@0.0.26': resolution: {integrity: sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg==} engines: {node: '>=18'} @@ -2969,6 +3036,10 @@ packages: resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@ai-sdk/react@1.0.0': resolution: {integrity: sha512-BDrZqQA07Btg64JCuhFvBgYV+tt2B8cXINzEqWknGoxqcwgdE8wSLG2gkXoLzyC2Rnj7oj0HHpOhLUxDCmoKZg==} engines: {node: '>=18'} @@ -3001,6 +3072,12 @@ packages: zod: optional: true + '@ai-sdk/react@3.0.84': + resolution: {integrity: sha512-caX8dsXGHDctQsFGgq05sdaw9YD2C8Y9SfnOk0b0LPPi4J7/V54tq22MPTGVO9zS3LmsfFQf0GDM4WFZNC5XZA==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@ai-sdk/ui-utils@1.0.0': resolution: {integrity: sha512-oXBDIM/0niWeTWyw77RVl505dNxBUDLLple7bTsqo2d3i1UKwGlzBUX8XqZsh7GbY7I6V05nlG0Y8iGlWxv1Aw==} engines: {node: '>=18'} @@ -5920,6 +5997,9 @@ packages: '@next/env@15.2.4': resolution: {integrity: sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==} + '@next/env@15.3.3': + resolution: {integrity: sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw==} + '@next/env@15.4.8': resolution: {integrity: sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==} @@ -5944,6 +6024,12 @@ packages: cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@15.3.3': + resolution: {integrity: sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-arm64@15.4.8': resolution: {integrity: sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A==} engines: {node: '>= 10'} @@ -5974,6 +6060,12 @@ packages: cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@15.3.3': + resolution: {integrity: sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-darwin-x64@15.4.8': resolution: {integrity: sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw==} engines: {node: '>= 10'} @@ -6007,6 +6099,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-arm64-gnu@15.3.3': + resolution: {integrity: sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@next/swc-linux-arm64-gnu@15.4.8': resolution: {integrity: sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw==} engines: {node: '>= 10'} @@ -6042,6 +6141,13 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-arm64-musl@15.3.3': + resolution: {integrity: sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@next/swc-linux-arm64-musl@15.4.8': resolution: {integrity: sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==} engines: {node: '>= 10'} @@ -6077,6 +6183,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-x64-gnu@15.3.3': + resolution: {integrity: sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@next/swc-linux-x64-gnu@15.4.8': resolution: {integrity: sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==} engines: {node: '>= 10'} @@ -6112,6 +6225,13 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-x64-musl@15.3.3': + resolution: {integrity: sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@next/swc-linux-x64-musl@15.4.8': resolution: {integrity: sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==} engines: {node: '>= 10'} @@ -6144,6 +6264,12 @@ packages: cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@15.3.3': + resolution: {integrity: sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-arm64-msvc@15.4.8': resolution: {integrity: sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==} engines: {node: '>= 10'} @@ -6186,6 +6312,12 @@ packages: cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@15.3.3': + resolution: {integrity: sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@next/swc-win32-x64-msvc@15.4.8': resolution: {integrity: sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg==} engines: {node: '>= 10'} @@ -11121,6 +11253,10 @@ packages: resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vercel/otel@1.13.0': resolution: {integrity: sha512-esRkt470Y2jRK1B1g7S1vkt4Csu44gp83Zpu8rIyPoqy2BKgk4z7ik1uSMswzi45UogLHFl6yR5TauDurBQi4Q==} engines: {node: '>=18'} @@ -11468,6 +11604,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ai@6.0.82: + resolution: {integrity: sha512-WLml1ab2IXtREgkxrq2Pl6lFO6NKgC17MqTzmK5mO1UO6tMAJiVjkednw9p0j4+/LaUIZQoRiIT8wA37LswZ9Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -16257,6 +16399,28 @@ packages: sass: optional: true + next@15.3.3: + resolution: {integrity: sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + next@15.4.8: resolution: {integrity: sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -17207,10 +17371,6 @@ packages: resolution: {integrity: sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.3: - resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.4: resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==} engines: {node: ^10 || ^12 || >=14} @@ -20261,6 +20421,13 @@ snapshots: '@vercel/oidc': 3.0.5 zod: 3.25.76 + '@ai-sdk/gateway@3.0.42(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@vercel/oidc': 3.1.0 + zod: 3.25.76 + '@ai-sdk/openai@1.0.1(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.0.0 @@ -20291,6 +20458,12 @@ snapshots: '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/openai@3.0.27(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/provider-utils@1.0.22(zod@3.25.76)': dependencies: '@ai-sdk/provider': 0.0.26 @@ -20345,6 +20518,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.14(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + '@ai-sdk/provider@0.0.26': dependencies: json-schema: 0.4.0 @@ -20369,6 +20549,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/react@1.0.0(react@18.3.1)(zod@3.25.76)': dependencies: '@ai-sdk/provider-utils': 2.0.0(zod@3.25.76) @@ -20399,6 +20583,16 @@ snapshots: optionalDependencies: zod: 3.25.76 + '@ai-sdk/react@3.0.84(react@19.1.0)(zod@3.25.76)': + dependencies: + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + ai: 6.0.82(zod@3.25.76) + react: 19.1.0 + swr: 2.2.5(react@19.1.0) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + '@ai-sdk/ui-utils@1.0.0(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.0.0 @@ -24405,6 +24599,8 @@ snapshots: '@next/env@15.2.4': {} + '@next/env@15.3.3': {} + '@next/env@15.4.8': {} '@next/env@15.5.6': {} @@ -24418,6 +24614,9 @@ snapshots: '@next/swc-darwin-arm64@15.2.4': optional: true + '@next/swc-darwin-arm64@15.3.3': + optional: true + '@next/swc-darwin-arm64@15.4.8': optional: true @@ -24433,6 +24632,9 @@ snapshots: '@next/swc-darwin-x64@15.2.4': optional: true + '@next/swc-darwin-x64@15.3.3': + optional: true + '@next/swc-darwin-x64@15.4.8': optional: true @@ -24448,6 +24650,9 @@ snapshots: '@next/swc-linux-arm64-gnu@15.2.4': optional: true + '@next/swc-linux-arm64-gnu@15.3.3': + optional: true + '@next/swc-linux-arm64-gnu@15.4.8': optional: true @@ -24463,6 +24668,9 @@ snapshots: '@next/swc-linux-arm64-musl@15.2.4': optional: true + '@next/swc-linux-arm64-musl@15.3.3': + optional: true + '@next/swc-linux-arm64-musl@15.4.8': optional: true @@ -24478,6 +24686,9 @@ snapshots: '@next/swc-linux-x64-gnu@15.2.4': optional: true + '@next/swc-linux-x64-gnu@15.3.3': + optional: true + '@next/swc-linux-x64-gnu@15.4.8': optional: true @@ -24493,6 +24704,9 @@ snapshots: '@next/swc-linux-x64-musl@15.2.4': optional: true + '@next/swc-linux-x64-musl@15.3.3': + optional: true + '@next/swc-linux-x64-musl@15.4.8': optional: true @@ -24508,6 +24722,9 @@ snapshots: '@next/swc-win32-arm64-msvc@15.2.4': optional: true + '@next/swc-win32-arm64-msvc@15.3.3': + optional: true + '@next/swc-win32-arm64-msvc@15.4.8': optional: true @@ -24529,6 +24746,9 @@ snapshots: '@next/swc-win32-x64-msvc@15.2.4': optional: true + '@next/swc-win32-x64-msvc@15.3.3': + optional: true + '@next/swc-win32-x64-msvc@15.4.8': optional: true @@ -30572,7 +30792,7 @@ snapshots: '@tailwindcss/node': 4.0.17 '@tailwindcss/oxide': 4.0.17 lightningcss: 1.29.2 - postcss: 8.5.3 + postcss: 8.5.6 tailwindcss: 4.0.17 '@tailwindcss/typography@0.5.9(tailwindcss@3.4.1)': @@ -31115,7 +31335,7 @@ snapshots: '@types/react@19.0.12': dependencies: - csstype: 3.1.3 + csstype: 3.2.0 '@types/readable-stream@4.0.14': dependencies: @@ -31454,6 +31674,8 @@ snapshots: '@vercel/oidc@3.0.5': {} + '@vercel/oidc@3.1.0': {} + '@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))': dependencies: '@opentelemetry/api': 1.9.0 @@ -31897,6 +32119,14 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 + ai@6.0.82(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 3.0.42(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -33678,7 +33908,7 @@ snapshots: enhanced-resolve@5.15.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.2.1 + tapable: 2.3.0 enhanced-resolve@5.18.3: dependencies: @@ -37625,6 +37855,33 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@next/env': 15.3.3 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001754 + postcss: 8.4.31 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.3.3 + '@next/swc-darwin-x64': 15.3.3 + '@next/swc-linux-arm64-gnu': 15.3.3 + '@next/swc-linux-arm64-musl': 15.3.3 + '@next/swc-linux-x64-gnu': 15.3.3 + '@next/swc-linux-x64-musl': 15.3.3 + '@next/swc-win32-arm64-msvc': 15.3.3 + '@next/swc-win32-x64-msvc': 15.3.3 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.37.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + next@15.4.8(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.4.8 @@ -38673,12 +38930,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.0 - postcss@8.5.3: - dependencies: - nanoid: 3.3.8 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.4: dependencies: nanoid: 3.3.11 @@ -40904,6 +41155,12 @@ snapshots: react: 19.0.0 use-sync-external-store: 1.2.2(react@19.0.0) + swr@2.2.5(react@19.1.0): + dependencies: + client-only: 0.0.1 + react: 19.1.0 + use-sync-external-store: 1.2.2(react@19.1.0) + sync-content@2.0.1: dependencies: glob: 11.0.0 @@ -41846,6 +42103,10 @@ snapshots: dependencies: react: 19.0.0 + use-sync-external-store@1.2.2(react@19.1.0): + dependencies: + react: 19.1.0 + util-deprecate@1.0.2: {} util@0.12.5: diff --git a/references/ai-chat/package.json b/references/ai-chat/package.json index 228a09015df..b373eb364da 100644 --- a/references/ai-chat/package.json +++ b/references/ai-chat/package.json @@ -9,8 +9,8 @@ "dev:trigger": "trigger dev" }, "dependencies": { - "@ai-sdk/openai": "^2.0.0", - "@ai-sdk/react": "^2.0.0", + "@ai-sdk/openai": "^3.0.0", + "@ai-sdk/react": "^3.0.0", "@trigger.dev/sdk": "workspace:*", "ai": "^6.0.0", "next": "15.3.3", diff --git a/references/ai-chat/src/trigger/chat.ts b/references/ai-chat/src/trigger/chat.ts index 27a4002397d..8c77bbeebc5 100644 --- a/references/ai-chat/src/trigger/chat.ts +++ b/references/ai-chat/src/trigger/chat.ts @@ -8,7 +8,7 @@ export const chat = chatTask({ return streamText({ model: openai("gpt-4o-mini"), system: "You are a helpful assistant. Be concise and friendly.", - messages: convertToModelMessages(messages), + messages: await convertToModelMessages(messages), }); }, }); From 076c32b2f6eac6b4ab6ffbc2cc2fd2d6b4aad79a Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 21 Feb 2026 13:10:58 +0000 Subject: [PATCH 17/19] Use a single run with iterative waitpoint token completions --- packages/trigger-sdk/package.json | 2 +- packages/trigger-sdk/src/v3/ai.ts | 101 ++++- packages/trigger-sdk/src/v3/chat.test.ts | 428 ++++++++++++++++++++- packages/trigger-sdk/src/v3/chat.ts | 126 +++++- references/ai-chat/next-env.d.ts | 5 + references/ai-chat/src/app/actions.ts | 6 + references/ai-chat/src/app/page.tsx | 7 +- references/ai-chat/src/components/chat.tsx | 21 +- 8 files changed, 652 insertions(+), 44 deletions(-) create mode 100644 references/ai-chat/next-env.d.ts create mode 100644 references/ai-chat/src/app/actions.ts diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index a3101f7038e..5dc3a0fe3fc 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -83,7 +83,7 @@ }, "peerDependencies": { "zod": "^3.0.0 || ^4.0.0", - "ai": "^4.2.0 || ^5.0.0 || ^6.0.0" + "ai": "^5.0.0 || ^6.0.0" }, "peerDependenciesMeta": { "ai": { diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 9e79df22b8d..8bec798e981 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -4,15 +4,18 @@ import { Task, type inferSchemaIn, type PipeStreamOptions, + type TaskIdentifier, type TaskOptions, type TaskSchema, type TaskWithSchema, } from "@trigger.dev/core/v3"; import type { UIMessage } from "ai"; import { dynamicTool, jsonSchema, JSONSchema7, Schema, Tool, ToolCallOptions, zodSchema } from "ai"; +import { auth } from "./auth.js"; import { metadata } from "./metadata.js"; import { streams } from "./streams.js"; import { createTask } from "./shared.js"; +import { wait } from "./wait.js"; const METADATA_KEY = "tool.execute.options"; @@ -122,6 +125,29 @@ export const ai = { currentToolOptions: getToolOptionsFromMetadata, }; +/** + * Creates a public access token for a chat task. + * + * This is a convenience helper that creates a multi-use trigger public token + * scoped to the given task. Use it in a server action to provide the frontend + * `TriggerChatTransport` with an `accessToken`. + * + * @example + * ```ts + * // actions.ts + * "use server"; + * import { createChatAccessToken } from "@trigger.dev/sdk/ai"; + * import type { chat } from "@/trigger/chat"; + * + * export const getChatToken = () => createChatAccessToken("ai-chat"); + * ``` + */ +export async function createChatAccessToken( + taskId: TaskIdentifier +): Promise { + return auth.createTriggerPublicToken(taskId as string, { multipleUse: true }); +} + // --------------------------------------------------------------------------- // Chat transport helpers — backend side // --------------------------------------------------------------------------- @@ -161,6 +187,14 @@ export type ChatTaskPayload = { metadata?: unknown; }; +/** + * Tracks how many times `pipeChat` has been called in the current `chatTask` run. + * Used to prevent double-piping when a user both calls `pipeChat()` manually + * and returns a streamable from their `run` function. + * @internal + */ +let _chatPipeCount = 0; + /** * Options for `pipeChat`. */ @@ -248,6 +282,7 @@ export async function pipeChat( source: UIMessageStreamable | AsyncIterable | ReadableStream, options?: PipeChatOptions ): Promise { + _chatPipeCount++; const streamKey = options?.streamKey ?? CHAT_STREAM_KEY; let stream: AsyncIterable | ReadableStream; @@ -284,6 +319,11 @@ export async function pipeChat( * **Auto-piping:** If the `run` function returns a value with `.toUIMessageStream()` * (like a `StreamTextResult`), the stream is automatically piped to the frontend. * For complex flows, use `pipeChat()` manually from anywhere in your code. + * + * **Single-run mode:** By default, the task runs a waitpoint loop so that the + * entire conversation lives inside one run. After each AI response, the task + * emits a control chunk and pauses via `wait.forToken`. The frontend transport + * resumes the same run by completing the token with the next set of messages. */ export type ChatTaskOptions = Omit< TaskOptions, @@ -299,6 +339,23 @@ export type ChatTaskOptions = Omit< * the stream is automatically piped to the frontend. */ run: (payload: ChatTaskPayload) => Promise; + + /** + * Maximum number of conversational turns (message round-trips) a single run + * will handle before ending. After this many turns the run completes + * normally and the next message will start a fresh run. + * + * @default 100 + */ + maxTurns?: number; + + /** + * How long to wait for the next message before timing out and ending the run. + * Accepts any duration string recognised by `wait.createToken` (e.g. `"1h"`, `"30m"`). + * + * @default "1h" + */ + turnTimeout?: string; }; /** @@ -342,19 +399,49 @@ export type ChatTaskOptions = Omit< export function chatTask( options: ChatTaskOptions ): Task { - const { run: userRun, ...restOptions } = options; + const { run: userRun, maxTurns = 100, turnTimeout = "1h", ...restOptions } = options; return createTask({ ...restOptions, run: async (payload: ChatTaskPayload) => { - const result = await userRun(payload); + let currentPayload = payload; + + for (let turn = 0; turn < maxTurns; turn++) { + _chatPipeCount = 0; + + const result = await userRun(currentPayload); + + // Auto-pipe if the run function returned a StreamTextResult or similar, + // but only if pipeChat() wasn't already called manually during this turn + if (_chatPipeCount === 0 && isUIMessageStreamable(result)) { + await pipeChat(result); + } + + // Create a waitpoint token and emit a control chunk so the frontend + // knows to resume this run instead of triggering a new one. + const token = await wait.createToken({ timeout: turnTimeout }); + + const { waitUntilComplete } = streams.writer(CHAT_STREAM_KEY, { + execute: ({ write }) => { + write({ + type: "__trigger_waitpoint_ready", + tokenId: token.id, + publicAccessToken: token.publicAccessToken, + }); + }, + }); + await waitUntilComplete(); - // Auto-pipe if the run function returned a StreamTextResult or similar - if (isUIMessageStreamable(result)) { - await pipeChat(result); - } + // Pause until the frontend completes the token with the next message + const next = await wait.forToken(token); + + if (!next.ok) { + // Timed out waiting for the next message — end the conversation + return; + } - return result; + currentPayload = next.output; + } }, }); } diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts index ae89f28a8ab..0f59c387f00 100644 --- a/packages/trigger-sdk/src/v3/chat.test.ts +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -3,7 +3,7 @@ import type { UIMessage, UIMessageChunk } from "ai"; import { TriggerChatTransport, createChatTransport } from "./chat.js"; // Helper: encode text as SSE format -function sseEncode(chunks: UIMessageChunk[]): string { +function sseEncode(chunks: (UIMessageChunk | Record)[]): string { return chunks.map((chunk, i) => `id: ${i}\ndata: ${JSON.stringify(chunk)}\n\n`).join(""); } @@ -225,7 +225,7 @@ describe("TriggerChatTransport", () => { expect(triggerUrl).toContain("/api/v1/tasks/my-chat-task/trigger"); const triggerBody = JSON.parse(triggerCall![1]?.body as string); - const payload = JSON.parse(triggerBody.payload); + const payload = triggerBody.payload; expect(payload.messages).toEqual(messages); expect(payload.chatId).toBe("chat-123"); expect(payload.trigger).toBe("submit-message"); @@ -459,21 +459,19 @@ describe("TriggerChatTransport", () => { }); describe("publicAccessToken from trigger response", () => { - it("should use publicAccessToken from response body when x-trigger-jwt header is absent", async () => { + it("should use x-trigger-jwt from trigger response as the stream auth token", async () => { const fetchSpy = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { const urlStr = typeof url === "string" ? url : url.toString(); if (urlStr.includes("/trigger")) { - // Return without x-trigger-jwt header — the ApiClient will attempt - // to generate a JWT from the access token. In this test the token - // generation will add a publicAccessToken to the result. + // Return with x-trigger-jwt header — this public token should be + // used for the subsequent stream subscription request. return new Response( JSON.stringify({ id: "run_pat" }), { status: 200, headers: { "content-type": "application/json", - // Include x-trigger-jwt to simulate the server returning a public token "x-trigger-jwt": "server-generated-public-token", }, } @@ -843,7 +841,7 @@ describe("TriggerChatTransport", () => { ); const triggerBody = JSON.parse(triggerCall![1]?.body as string); - const payload = JSON.parse(triggerBody.payload); + const payload = triggerBody.payload; // body properties should be merged into the payload expect(payload.systemPrompt).toBe("You are helpful"); @@ -912,9 +910,421 @@ describe("TriggerChatTransport", () => { ); const triggerBody = JSON.parse(triggerCall![1]?.body as string); - const payload = JSON.parse(triggerBody.payload); + const payload = triggerBody.payload; expect(payload.trigger).toBe("regenerate-message"); expect(payload.messageId).toBe("msg-to-regen"); }); }); + + describe("async accessToken", () => { + it("should accept an async function for accessToken", async () => { + let tokenCallCount = 0; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: `run_async_${tokenCallCount}` }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "stream-token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + const chunks: UIMessageChunk[] = [ + { type: "text-start", id: "p1" }, + { type: "text-end", id: "p1" }, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: async () => { + tokenCallCount++; + // Simulate async work (e.g. server action) + await new Promise((r) => setTimeout(r, 1)); + return `async-token-${tokenCallCount}`; + }, + baseURL: "https://api.test.trigger.dev", + }); + + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-async", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + expect(tokenCallCount).toBe(1); + }); + }); + + describe("single-run mode (waitpoint loop)", () => { + it("should store waitpoint token from control chunk and not forward it to consumer", async () => { + const controlChunk = { + type: "__trigger_waitpoint_ready", + tokenId: "wp_token_123", + publicAccessToken: "wp_access_abc", + }; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_single" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "pub_token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + const chunks = [ + ...sampleChunks, + { type: "finish" as const, id: "part-1" } as UIMessageChunk, + controlChunk, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-single", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + // Read all chunks — the control chunk should NOT appear + const reader = stream.getReader(); + const receivedChunks: UIMessageChunk[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedChunks.push(value); + } + + // All AI SDK chunks should be forwarded + expect(receivedChunks.length).toBe(sampleChunks.length + 1); // +1 for the finish chunk + // Control chunk should not be in the output + expect(receivedChunks.every((c) => c.type !== ("__trigger_waitpoint_ready" as any))).toBe(true); + }); + + it("should complete waitpoint token on second message instead of triggering a new run", async () => { + const controlChunk = { + type: "__trigger_waitpoint_ready", + tokenId: "wp_token_456", + publicAccessToken: "wp_access_def", + }; + + let triggerCallCount = 0; + let completeWaitpointCalled = false; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + triggerCallCount++; + return new Response( + JSON.stringify({ id: "run_resume" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "pub_token", + }, + } + ); + } + + // Handle waitpoint token completion + if (urlStr.includes("/api/v1/waitpoints/tokens/") && urlStr.includes("/complete")) { + completeWaitpointCalled = true; + return new Response( + JSON.stringify({ success: true }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + const chunks = [ + ...sampleChunks, + { type: "finish" as const, id: "part-1" } as UIMessageChunk, + controlChunk, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + // First message — triggers a new run + const stream1 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-resume", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + // Consume stream to capture the control chunk + const reader1 = stream1.getReader(); + while (true) { + const { done } = await reader1.read(); + if (done) break; + } + + expect(triggerCallCount).toBe(1); + + // Second message — should complete the waitpoint instead of triggering + const stream2 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-resume", + messageId: undefined, + messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("How are you?")], + abortSignal: undefined, + }); + + // Consume second stream + const reader2 = stream2.getReader(); + while (true) { + const { done } = await reader2.read(); + if (done) break; + } + + // Should NOT have triggered a second run + expect(triggerCallCount).toBe(1); + // Should have completed the waitpoint + expect(completeWaitpointCalled).toBe(true); + }); + + it("should fall back to triggering a new run if stream closes without control chunk", async () => { + let triggerCallCount = 0; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + triggerCallCount++; + return new Response( + JSON.stringify({ id: `run_fallback_${triggerCallCount}` }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "pub_token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + // No control chunk — stream just ends after the finish + const chunks: UIMessageChunk[] = [ + { type: "text-start", id: "p1" }, + { type: "text-delta", id: "p1", delta: "Hello" }, + { type: "text-end", id: "p1" }, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + // First message + const stream1 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-fallback", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + const reader1 = stream1.getReader(); + while (true) { + const { done } = await reader1.read(); + if (done) break; + } + + expect(triggerCallCount).toBe(1); + + // Second message — no waitpoint token stored, should trigger a new run + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-fallback", + messageId: undefined, + messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("Again")], + abortSignal: undefined, + }); + + // Should have triggered a second run + expect(triggerCallCount).toBe(2); + }); + + it("should fall back to new run when completing waitpoint fails", async () => { + const controlChunk = { + type: "__trigger_waitpoint_ready", + tokenId: "wp_token_fail", + publicAccessToken: "wp_access_fail", + }; + + let triggerCallCount = 0; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + triggerCallCount++; + return new Response( + JSON.stringify({ id: `run_fail_${triggerCallCount}` }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "pub_token", + }, + } + ); + } + + // Waitpoint completion fails + if (urlStr.includes("/api/v1/waitpoints/tokens/") && urlStr.includes("/complete")) { + return new Response( + JSON.stringify({ error: "Token expired" }), + { + status: 400, + headers: { "content-type": "application/json" }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + // First call has control chunk, subsequent calls don't + const chunks: (UIMessageChunk | Record)[] = [ + ...sampleChunks, + { type: "finish" as const, id: "part-1" } as UIMessageChunk, + ]; + + if (triggerCallCount <= 1) { + chunks.push(controlChunk); + } + + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + // First message + const stream1 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-fail", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + const reader1 = stream1.getReader(); + while (true) { + const { done } = await reader1.read(); + if (done) break; + } + + expect(triggerCallCount).toBe(1); + + // Second message — waitpoint completion will fail, should fall back to new run + const stream2 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-fail", + messageId: undefined, + messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("Again")], + abortSignal: undefined, + }); + + const reader2 = stream2.getReader(); + while (true) { + const { done } = await reader2.read(); + if (done) break; + } + + // Should have triggered a second run as fallback + expect(triggerCallCount).toBe(2); + }); + }); }); diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 77378bded04..60c3445a759 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -47,9 +47,10 @@ export type TriggerChatTransportOptions = { * - A **trigger public token** created via `auth.createTriggerPublicToken(taskId)` (recommended for frontend use) * - A **secret API key** (for server-side use only — never expose in the browser) * - * Can also be a function that returns a token string, useful for dynamic token refresh. + * Can also be a function that returns a token string (sync or async), + * useful for dynamic token refresh or passing a Next.js server action directly. */ - accessToken: string | (() => string); + accessToken: string | (() => string | Promise); /** * Base URL for the Trigger.dev API. @@ -87,6 +88,12 @@ export type TriggerChatTransportOptions = { type ChatSessionState = { runId: string; publicAccessToken: string; + /** Token ID from the `__trigger_waitpoint_ready` control chunk. */ + waitpointTokenId?: string; + /** Access token scoped to complete the waitpoint (separate from the run's PAT). */ + waitpointAccessToken?: string; + /** Last SSE event ID — used to resume the stream without replaying old events. */ + lastEventId?: string; }; /** @@ -134,7 +141,7 @@ type ChatSessionState = { */ export class TriggerChatTransport implements ChatTransport { private readonly taskId: string; - private readonly resolveAccessToken: () => string; + private readonly resolveAccessToken: () => string | Promise; private readonly baseURL: string; private readonly streamKey: string; private readonly extraHeaders: Record; @@ -166,19 +173,48 @@ export class TriggerChatTransport implements ChatTransport { const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options; const payload = { + ...(body ?? {}), messages, chatId, trigger, messageId, metadata, - ...(body ?? {}), }; - const currentToken = this.resolveAccessToken(); + const session = this.sessions.get(chatId); + + // If we have a waitpoint token from a previous turn, complete it to + // resume the existing run instead of triggering a new one. + if (session?.waitpointTokenId && session.waitpointAccessToken) { + const tokenId = session.waitpointTokenId; + const tokenAccessToken = session.waitpointAccessToken; + + // Clear the used waitpoint so we don't try to reuse it + session.waitpointTokenId = undefined; + session.waitpointAccessToken = undefined; + + try { + const wpClient = new ApiClient(this.baseURL, tokenAccessToken); + await wpClient.completeWaitpointToken(tokenId, { data: payload }); + + return this.subscribeToStream( + session.runId, + session.publicAccessToken, + abortSignal, + chatId + ); + } catch { + // If completing the waitpoint fails (run died, token expired, etc.), + // fall through to trigger a new run. + this.sessions.delete(chatId); + } + } + + const currentToken = await this.resolveAccessToken(); const apiClient = new ApiClient(this.baseURL, currentToken); const triggerResponse = await apiClient.triggerTask(this.taskId, { - payload: JSON.stringify(payload), + payload, options: { payloadType: "application/json", }, @@ -195,7 +231,12 @@ export class TriggerChatTransport implements ChatTransport { publicAccessToken: publicAccessToken ?? currentToken, }); - return this.subscribeToStream(runId, publicAccessToken ?? currentToken, abortSignal); + return this.subscribeToStream( + runId, + publicAccessToken ?? currentToken, + abortSignal, + chatId + ); }; reconnectToStream = async ( @@ -208,25 +249,39 @@ export class TriggerChatTransport implements ChatTransport { return null; } - return this.subscribeToStream(session.runId, session.publicAccessToken, undefined); + return this.subscribeToStream(session.runId, session.publicAccessToken, undefined, options.chatId); }; private subscribeToStream( runId: string, accessToken: string, - abortSignal: AbortSignal | undefined + abortSignal: AbortSignal | undefined, + chatId?: string ): ReadableStream { const headers: Record = { Authorization: `Bearer ${accessToken}`, ...this.extraHeaders, }; + // When resuming a run via waitpoint, skip past previously-seen events + // so we only receive the new turn's response. + const session = chatId ? this.sessions.get(chatId) : undefined; + + // Create an internal AbortController so we can terminate the underlying + // fetch connection when we're done reading (e.g. after intercepting the + // control chunk). Without this, the SSE connection stays open and leaks. + const internalAbort = new AbortController(); + const combinedSignal = abortSignal + ? AbortSignal.any([abortSignal, internalAbort.signal]) + : internalAbort.signal; + const subscription = new SSEStreamSubscription( `${this.baseURL}/realtime/v1/streams/${runId}/${this.streamKey}`, { headers, - signal: abortSignal, + signal: combinedSignal, timeoutInSeconds: this.streamTimeoutSeconds, + lastEventId: session?.lastEventId, } ); @@ -241,20 +296,57 @@ export class TriggerChatTransport implements ChatTransport { const { done, value } = await reader.read(); if (done) { + // Stream closed without a control chunk — the run has + // ended (or was killed). Clear the session so that the + // next message triggers a fresh run. + if (chatId) { + const s = this.sessions.get(chatId); + if (s) { + s.waitpointTokenId = undefined; + s.waitpointAccessToken = undefined; + } + } controller.close(); return; } - if (abortSignal?.aborted) { - reader.cancel(); - reader.releaseLock(); + if (combinedSignal.aborted) { + internalAbort.abort(); + await reader.cancel(); controller.close(); return; } + // Track the last event ID so we can resume from here + if (value.id && session) { + session.lastEventId = value.id; + } + // Guard against heartbeat or malformed SSE events if (value.chunk != null && typeof value.chunk === "object") { - controller.enqueue(value.chunk as UIMessageChunk); + const chunk = value.chunk as Record; + + // Intercept the waitpoint-ready control chunk emitted by + // `chatTask` after the AI response stream completes. This + // chunk is never forwarded to the AI SDK consumer. + if (chunk.type === "__trigger_waitpoint_ready" && chatId) { + const s = this.sessions.get(chatId); + if (s) { + s.waitpointTokenId = chunk.tokenId as string; + s.waitpointAccessToken = chunk.publicAccessToken as string; + } + + // Abort the underlying fetch to close the SSE connection + internalAbort.abort(); + try { + controller.close(); + } catch { + // Controller may already be closed + } + return; + } + + controller.enqueue(chunk as unknown as UIMessageChunk); } } } catch (readError) { @@ -263,7 +355,11 @@ export class TriggerChatTransport implements ChatTransport { } } catch (error) { if (error instanceof Error && error.name === "AbortError") { - controller.close(); + try { + controller.close(); + } catch { + // Controller may already be closed + } return; } diff --git a/references/ai-chat/next-env.d.ts b/references/ai-chat/next-env.d.ts new file mode 100644 index 00000000000..1b3be0840f3 --- /dev/null +++ b/references/ai-chat/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/references/ai-chat/src/app/actions.ts b/references/ai-chat/src/app/actions.ts new file mode 100644 index 00000000000..6d230e271a5 --- /dev/null +++ b/references/ai-chat/src/app/actions.ts @@ -0,0 +1,6 @@ +"use server"; + +import { createChatAccessToken } from "@trigger.dev/sdk/ai"; +import type { chat } from "@/trigger/chat"; + +export const getChatToken = async () => createChatAccessToken("ai-chat"); diff --git a/references/ai-chat/src/app/page.tsx b/references/ai-chat/src/app/page.tsx index 16f01282c80..185d84b5e9e 100644 --- a/references/ai-chat/src/app/page.tsx +++ b/references/ai-chat/src/app/page.tsx @@ -1,16 +1,13 @@ -import { auth } from "@trigger.dev/sdk"; import { Chat } from "@/components/chat"; -export default async function Home() { - const accessToken = await auth.createTriggerPublicToken("ai-chat"); - +export default function Home() { return (

AI Chat — powered by Trigger.dev

- +
); diff --git a/references/ai-chat/src/components/chat.tsx b/references/ai-chat/src/components/chat.tsx index 34c68d8ba7e..1fa0e82fd0d 100644 --- a/references/ai-chat/src/components/chat.tsx +++ b/references/ai-chat/src/components/chat.tsx @@ -2,17 +2,24 @@ import { useChat } from "@ai-sdk/react"; import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { getChatToken } from "@/app/actions"; -export function Chat({ accessToken }: { accessToken: string }) { +export function Chat() { const [input, setInput] = useState(""); + const transport = useMemo( + () => + new TriggerChatTransport({ + task: "ai-chat", + accessToken: getChatToken, + baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL, + }), + [] + ); + const { messages, sendMessage, status, error } = useChat({ - transport: new TriggerChatTransport({ - task: "ai-chat", - accessToken, - baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL, - }), + transport, }); function handleSubmit(e: React.FormEvent) { From 735e845d8bb04a7045c8d42a225dbc5873377fb6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 21 Feb 2026 13:42:09 +0000 Subject: [PATCH 18/19] Added tool example --- packages/trigger-sdk/src/v3/chat.test.ts | 285 +++++++++++++++++++++ pnpm-lock.yaml | 163 ++++++------ references/ai-chat/package.json | 3 +- references/ai-chat/src/components/chat.tsx | 70 +++++ references/ai-chat/src/trigger/chat.ts | 64 ++++- 5 files changed, 507 insertions(+), 78 deletions(-) diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts index 0f59c387f00..03eceb1a8f7 100644 --- a/packages/trigger-sdk/src/v3/chat.test.ts +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -916,6 +916,189 @@ describe("TriggerChatTransport", () => { }); }); + describe("lastEventId tracking", () => { + it("should pass lastEventId to SSE subscription on subsequent turns", async () => { + const controlChunk = { + type: "__trigger_waitpoint_ready", + tokenId: "wp_token_eid", + publicAccessToken: "wp_access_eid", + }; + + let triggerCallCount = 0; + const streamFetchCalls: { url: string; headers: Record }[] = []; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + triggerCallCount++; + return new Response( + JSON.stringify({ id: "run_eid" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "pub_token_eid", + }, + } + ); + } + + if (urlStr.includes("/api/v1/waitpoints/tokens/") && urlStr.includes("/complete")) { + return new Response( + JSON.stringify({ success: true }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + streamFetchCalls.push({ + url: urlStr, + headers: (init?.headers as Record) ?? {}, + }); + + const chunks = [ + ...sampleChunks, + { type: "finish" as const, id: "part-1" } as UIMessageChunk, + controlChunk, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + // First message — triggers a new run + const stream1 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-eid", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + const reader1 = stream1.getReader(); + while (true) { + const { done } = await reader1.read(); + if (done) break; + } + + // Second message — completes the waitpoint + const stream2 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-eid", + messageId: undefined, + messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("What's up?")], + abortSignal: undefined, + }); + + const reader2 = stream2.getReader(); + while (true) { + const { done } = await reader2.read(); + if (done) break; + } + + // The second stream subscription should include a Last-Event-ID header + expect(streamFetchCalls.length).toBe(2); + const secondStreamHeaders = streamFetchCalls[1]!.headers; + // SSEStreamSubscription passes lastEventId as the Last-Event-ID header + expect(secondStreamHeaders["Last-Event-ID"]).toBeDefined(); + }); + }); + + describe("AbortController cleanup", () => { + it("should terminate SSE connection after intercepting control chunk", async () => { + const controlChunk = { + type: "__trigger_waitpoint_ready", + tokenId: "wp_token_abort", + publicAccessToken: "wp_access_abort", + }; + + let streamAborted = false; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_abort_cleanup" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "pub_token", + }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + // Track abort signal + const signal = init?.signal; + if (signal) { + signal.addEventListener("abort", () => { + streamAborted = true; + }); + } + + const chunks = [ + ...sampleChunks, + { type: "finish" as const, id: "part-1" } as UIMessageChunk, + controlChunk, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: "token", + baseURL: "https://api.test.trigger.dev", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-abort-cleanup", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + // Consume all chunks + const reader = stream.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + // The internal AbortController should have aborted the fetch + expect(streamAborted).toBe(true); + }); + }); + describe("async accessToken", () => { it("should accept an async function for accessToken", async () => { let tokenCallCount = 0; @@ -974,6 +1157,108 @@ describe("TriggerChatTransport", () => { expect(tokenCallCount).toBe(1); }); + + it("should resolve async token for waitpoint completion flow", async () => { + const controlChunk = { + type: "__trigger_waitpoint_ready", + tokenId: "wp_token_async", + publicAccessToken: "wp_access_async", + }; + + let tokenCallCount = 0; + let completeWaitpointCalled = false; + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + + if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) { + return new Response( + JSON.stringify({ id: "run_async_wp" }), + { + status: 200, + headers: { + "content-type": "application/json", + "x-trigger-jwt": "stream-token", + }, + } + ); + } + + if (urlStr.includes("/api/v1/waitpoints/tokens/") && urlStr.includes("/complete")) { + completeWaitpointCalled = true; + return new Response( + JSON.stringify({ success: true }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + } + + if (urlStr.includes("/realtime/v1/streams/")) { + const chunks = [ + ...sampleChunks, + { type: "finish" as const, id: "part-1" } as UIMessageChunk, + controlChunk, + ]; + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Stream-Version": "v1", + }, + }); + } + + throw new Error(`Unexpected fetch URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-task", + accessToken: async () => { + tokenCallCount++; + await new Promise((r) => setTimeout(r, 1)); + return `async-wp-token-${tokenCallCount}`; + }, + baseURL: "https://api.test.trigger.dev", + }); + + // First message — triggers a new run (calls async token) + const stream1 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-async-wp", + messageId: undefined, + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + + const reader1 = stream1.getReader(); + while (true) { + const { done } = await reader1.read(); + if (done) break; + } + + const firstTokenCount = tokenCallCount; + + // Second message — should complete waitpoint (does NOT call async token) + const stream2 = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-async-wp", + messageId: undefined, + messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("More")], + abortSignal: undefined, + }); + + const reader2 = stream2.getReader(); + while (true) { + const { done } = await reader2.read(); + if (done) break; + } + + // Token function should NOT have been called again for the waitpoint path + expect(tokenCallCount).toBe(firstTokenCount); + expect(completeWaitpointCalled).toBe(true); + }); }); describe("single-run mode (waitpoint loop)", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f426ef9b3f..d70d88562bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,8 +456,8 @@ importers: specifier: ^0.1.3 version: 0.1.3(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4)) '@s2-dev/streamstore': - specifier: ^0.22.5 - version: 0.22.5(supports-color@10.0.0) + specifier: ^0.17.2 + version: 0.17.3(typescript@5.5.4) '@sentry/remix': specifier: 9.46.0 version: 9.46.0(patch_hash=146126b032581925294aaed63ab53ce3f5e0356a755f1763d7a9a76b9846943b)(@remix-run/node@2.1.0(typescript@5.5.4))(@remix-run/react@2.1.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(encoding@0.1.13)(react@18.2.0) @@ -1101,7 +1101,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -1452,8 +1452,8 @@ importers: specifier: 1.36.0 version: 1.36.0 '@s2-dev/streamstore': - specifier: ^0.22.5 - version: 0.22.5(supports-color@10.0.0) + specifier: ^0.17.6 + version: 0.17.6 '@trigger.dev/build': specifier: workspace:4.4.1 version: link:../build @@ -1729,8 +1729,8 @@ importers: specifier: 1.36.0 version: 1.36.0 '@s2-dev/streamstore': - specifier: 0.22.5 - version: 0.22.5(supports-color@10.0.0) + specifier: 0.17.3 + version: 0.17.3(typescript@5.5.4) dequal: specifier: ^2.0.3 version: 2.0.3 @@ -2112,10 +2112,10 @@ importers: dependencies: '@ai-sdk/openai': specifier: ^3.0.0 - version: 3.0.27(zod@3.25.76) + version: 3.0.19(zod@3.25.76) '@ai-sdk/react': specifier: ^3.0.0 - version: 3.0.84(react@19.1.0)(zod@3.25.76) + version: 3.0.51(react@19.1.0)(zod@3.25.76) '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk @@ -2131,6 +2131,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) + zod: + specifier: 3.25.76 + version: 3.25.76 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2916,8 +2919,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@3.0.42': - resolution: {integrity: sha512-Il9lZWPUQMX59H5yJvA08gxfL2Py8oHwvAYRnK0Mt91S+JgPcyk/yEmXNDZG9ghJrwSawtK5Yocy8OnzsTOGsw==} + '@ai-sdk/gateway@3.0.22': + resolution: {integrity: sha512-NgnlY73JNuooACHqUIz5uMOEWvqR1MMVbb2soGLMozLY1fgwEIF5iJFDAGa5/YArlzw2ATVU7zQu7HkR/FUjgA==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -2952,8 +2955,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@3.0.27': - resolution: {integrity: sha512-pLMxWOypwroXiK9dxNpn60/HGhWWWDEOJ3lo9vZLoxvpJNtKnLKojwVIvlW3yEjlD7ll1+jUO2uzsABNTaP5Yg==} + '@ai-sdk/openai@3.0.19': + resolution: {integrity: sha512-qpMGKV6eYfW8IzErk/OppchQwVui3GPc4BEfg/sQGRzR89vf2Sa8qvSavXeZi5w/oUF56d+VtobwSH0FRooFCQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -3006,8 +3009,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@4.0.14': - resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==} + '@ai-sdk/provider-utils@4.0.9': + resolution: {integrity: sha512-bB4r6nfhBOpmoS9mePxjRoCy+LnzP3AfhyMGCkGL4Mn9clVNlqEeKj26zEKEtB6yoSVcT1IQ0Zh9fytwMCDnow==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -3036,8 +3039,8 @@ packages: resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==} engines: {node: '>=18'} - '@ai-sdk/provider@3.0.8': - resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + '@ai-sdk/provider@3.0.5': + resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==} engines: {node: '>=18'} '@ai-sdk/react@1.0.0': @@ -3072,8 +3075,8 @@ packages: zod: optional: true - '@ai-sdk/react@3.0.84': - resolution: {integrity: sha512-caX8dsXGHDctQsFGgq05sdaw9YD2C8Y9SfnOk0b0LPPi4J7/V54tq22MPTGVO9zS3LmsfFQf0GDM4WFZNC5XZA==} + '@ai-sdk/react@3.0.51': + resolution: {integrity: sha512-7nmCwEJM52NQZB4/ED8qJ4wbDg7EEWh94qJ7K9GSJxD6sWF3GOKrRZ5ivm4qNmKhY+JfCxCAxfghGY5mTKOsxw==} engines: {node: '>=18'} peerDependencies: react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 @@ -9544,8 +9547,13 @@ packages: '@rushstack/eslint-patch@1.2.0': resolution: {integrity: sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==} - '@s2-dev/streamstore@0.22.5': - resolution: {integrity: sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ==} + '@s2-dev/streamstore@0.17.3': + resolution: {integrity: sha512-UeXL5+MgZQfNkbhCgEDVm7PrV5B3bxh6Zp4C5pUzQQwaoA+iGh2QiiIptRZynWgayzRv4vh0PYfnKpTzJEXegQ==} + peerDependencies: + typescript: 5.5.4 + + '@s2-dev/streamstore@0.17.6': + resolution: {integrity: sha512-ocjZfKaPKmo2yhudM58zVNHv3rBLSbTKkabVoLFn9nAxU6iLrR2CO3QmSo7/waohI3EZHAWxF/Pw8kA8d6QH2g==} '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -11604,8 +11612,8 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 - ai@6.0.82: - resolution: {integrity: sha512-WLml1ab2IXtREgkxrq2Pl6lFO6NKgC17MqTzmK5mO1UO6tMAJiVjkednw9p0j4+/LaUIZQoRiIT8wA37LswZ9Q==} + ai@6.0.49: + resolution: {integrity: sha512-LABniBX/0R6Tv+iUK5keUZhZLaZUe4YjP5M2rZ4wAdZ8iKV3EfTAoJxuL1aaWTSJKIilKa9QUEkCgnp89/32bw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -14388,7 +14396,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} @@ -19123,22 +19131,21 @@ packages: tar@6.1.13: resolution: {integrity: sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me tar@7.5.6: resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -20421,10 +20428,10 @@ snapshots: '@vercel/oidc': 3.0.5 zod: 3.25.76 - '@ai-sdk/gateway@3.0.42(zod@3.25.76)': + '@ai-sdk/gateway@3.0.22(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider-utils': 4.0.9(zod@3.25.76) '@vercel/oidc': 3.1.0 zod: 3.25.76 @@ -20458,10 +20465,10 @@ snapshots: '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/openai@3.0.27(zod@3.25.76)': + '@ai-sdk/openai@3.0.19(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider-utils': 4.0.9(zod@3.25.76) zod: 3.25.76 '@ai-sdk/provider-utils@1.0.22(zod@3.25.76)': @@ -20518,9 +20525,9 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.14(zod@3.25.76)': + '@ai-sdk/provider-utils@4.0.9(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider': 3.0.5 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 zod: 3.25.76 @@ -20549,7 +20556,7 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@3.0.8': + '@ai-sdk/provider@3.0.5': dependencies: json-schema: 0.4.0 @@ -20583,10 +20590,10 @@ snapshots: optionalDependencies: zod: 3.25.76 - '@ai-sdk/react@3.0.84(react@19.1.0)(zod@3.25.76)': + '@ai-sdk/react@3.0.51(react@19.1.0)(zod@3.25.76)': dependencies: - '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) - ai: 6.0.82(zod@3.25.76) + '@ai-sdk/provider-utils': 4.0.9(zod@3.25.76) + ai: 6.0.49(zod@3.25.76) react: 19.1.0 swr: 2.2.5(react@19.1.0) throttleit: 2.1.0 @@ -23320,8 +23327,8 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: - '@hono/node-server': 1.12.2(hono@4.11.8) - '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9) + '@hono/node-server': 1.12.2(hono@4.5.11) + '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 hono: 4.5.11 @@ -24068,17 +24075,17 @@ snapshots: dependencies: react: 18.2.0 - '@hono/node-server@1.12.2(hono@4.11.8)': + '@hono/node-server@1.12.2(hono@4.5.11)': dependencies: - hono: 4.11.8 + hono: 4.5.11 '@hono/node-server@1.19.9(hono@4.11.8)': dependencies: hono: 4.11.8 - '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)': + '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': dependencies: - '@hono/node-server': 1.12.2(hono@4.11.8) + '@hono/node-server': 1.12.2(hono@4.5.11) ws: 8.18.3(bufferutil@4.0.9) transitivePeerDependencies: - bufferutil @@ -25862,7 +25869,7 @@ snapshots: '@puppeteer/browsers@2.10.6': dependencies: - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -29425,12 +29432,14 @@ snapshots: '@rushstack/eslint-patch@1.2.0': {} - '@s2-dev/streamstore@0.22.5(supports-color@10.0.0)': + '@s2-dev/streamstore@0.17.3(typescript@5.5.4)': + dependencies: + '@protobuf-ts/runtime': 2.11.1 + typescript: 5.5.4 + + '@s2-dev/streamstore@0.17.6': dependencies: '@protobuf-ts/runtime': 2.11.1 - debug: 4.4.3(supports-color@10.0.0) - transitivePeerDependencies: - - supports-color '@sec-ant/readable-stream@0.4.1': {} @@ -31511,7 +31520,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 5.59.6(typescript@5.5.4) '@typescript-eslint/utils': 5.59.6(eslint@8.31.0)(typescript@5.5.4) - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) eslint: 8.31.0 tsutils: 3.21.0(typescript@5.5.4) optionalDependencies: @@ -31525,7 +31534,7 @@ snapshots: dependencies: '@typescript-eslint/types': 5.59.6 '@typescript-eslint/visitor-keys': 5.59.6 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) globby: 11.1.0 is-glob: 4.0.3 semver: 7.7.3 @@ -32119,11 +32128,11 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 - ai@6.0.82(zod@3.25.76): + ai@6.0.49(zod@3.25.76): dependencies: - '@ai-sdk/gateway': 3.0.42(zod@3.25.76) - '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@ai-sdk/gateway': 3.0.22(zod@3.25.76) + '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider-utils': 4.0.9(zod@3.25.76) '@opentelemetry/api': 1.9.0 zod: 3.25.76 @@ -33546,9 +33555,11 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.1: + debug@4.4.1(supports-color@10.0.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.0.0 debug@4.4.3(supports-color@10.0.0): dependencies: @@ -34916,7 +34927,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -35350,7 +35361,7 @@ snapshots: dependencies: basic-ftp: 5.0.3 data-uri-to-buffer: 5.0.1 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) fs-extra: 8.1.0 transitivePeerDependencies: - supports-color @@ -35509,7 +35520,7 @@ snapshots: '@types/node': 20.14.14 '@types/semver': 7.5.1 chalk: 4.1.2 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) interpret: 3.1.1 semver: 7.7.3 tslib: 2.8.1 @@ -35793,7 +35804,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -35813,7 +35824,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -36178,7 +36189,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -38411,7 +38422,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) get-uri: 6.0.1 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -39164,7 +39175,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -39204,7 +39215,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.10.6 chromium-bidi: 7.2.0(devtools-protocol@0.0.1464554) - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) devtools-protocol: 0.0.1464554 typed-query-selector: 2.12.0 ws: 8.18.3(bufferutil@4.0.9) @@ -39419,7 +39430,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39456,8 +39467,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3 - socket.io-client: 4.7.3 + socket.io: 4.7.3(bufferutil@4.0.9) + socket.io-client: 4.7.3(bufferutil@4.0.9) sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -40089,7 +40100,7 @@ snapshots: require-in-the-middle@7.1.1(supports-color@10.0.0): dependencies: - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) module-details-from-path: 1.0.3 resolve: 1.22.8 transitivePeerDependencies: @@ -40657,7 +40668,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3: + socket.io-client@4.7.3(bufferutil@4.0.9): dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40686,7 +40697,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3: + socket.io@4.7.3(bufferutil@4.0.9): dependencies: accepts: 1.3.8 base64id: 2.0.0 @@ -40717,7 +40728,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) socks: 2.8.3 transitivePeerDependencies: - supports-color @@ -41089,7 +41100,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@10.0.0) + debug: 4.4.1(supports-color@10.0.0) fast-safe-stringify: 2.1.1 form-data: 4.0.4 formidable: 3.5.1 @@ -42295,7 +42306,7 @@ snapshots: '@vitest/spy': 3.1.4 '@vitest/utils': 3.1.4 chai: 5.2.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.0.0) expect-type: 1.2.1 magic-string: 0.30.21 pathe: 2.0.3 diff --git a/references/ai-chat/package.json b/references/ai-chat/package.json index b373eb364da..9dcab80046f 100644 --- a/references/ai-chat/package.json +++ b/references/ai-chat/package.json @@ -15,7 +15,8 @@ "ai": "^6.0.0", "next": "15.3.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "zod": "3.25.76" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/references/ai-chat/src/components/chat.tsx b/references/ai-chat/src/components/chat.tsx index 1fa0e82fd0d..aa09d530bb4 100644 --- a/references/ai-chat/src/components/chat.tsx +++ b/references/ai-chat/src/components/chat.tsx @@ -5,6 +5,70 @@ import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; import { useMemo, useState } from "react"; import { getChatToken } from "@/app/actions"; +function ToolInvocation({ part }: { part: any }) { + const [expanded, setExpanded] = useState(false); + // Static tools: type is "tool-{name}", dynamic tools have toolName property + const toolName = + part.type === "dynamic-tool" + ? (part.toolName ?? "tool") + : part.type.startsWith("tool-") + ? part.type.slice(5) + : "tool"; + const state = part.state ?? "input-available"; + const args = part.input; + const result = part.output; + + const isLoading = state === "input-streaming" || state === "input-available"; + const isError = state === "output-error"; + + return ( +
+ + + {expanded && ( +
+ {args && Object.keys(args).length > 0 && ( +
+
Input
+
+                {JSON.stringify(args, null, 2)}
+              
+
+ )} + {state === "output-available" && result !== undefined && ( +
+
Output
+
+                {JSON.stringify(result, null, 2)}
+              
+
+ )} + {isError && result !== undefined && ( +
+
Error
+
+                {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
+              
+
+ )} +
+ )} +
+ ); +} + export function Chat() { const [input, setInput] = useState(""); @@ -54,6 +118,12 @@ export function Chat() { if (part.type === "text") { return {part.text}; } + + // Static tools: "tool-{toolName}", dynamic tools: "dynamic-tool" + if (part.type.startsWith("tool-") || part.type === "dynamic-tool") { + return ; + } + return null; })} diff --git a/references/ai-chat/src/trigger/chat.ts b/references/ai-chat/src/trigger/chat.ts index 8c77bbeebc5..b600977c593 100644 --- a/references/ai-chat/src/trigger/chat.ts +++ b/references/ai-chat/src/trigger/chat.ts @@ -1,6 +1,66 @@ import { chatTask } from "@trigger.dev/sdk/ai"; -import { streamText, convertToModelMessages } from "ai"; +import { streamText, convertToModelMessages, tool } from "ai"; import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; +import os from "node:os"; + +const inspectEnvironment = tool({ + description: + "Inspect the current execution environment. Returns runtime info (Node.js/Bun/Deno version), " + + "OS details, CPU architecture, memory usage, environment variables, and platform metadata.", + inputSchema: z.object({}), + execute: async () => { + const memUsage = process.memoryUsage(); + + return { + runtime: { + name: typeof Bun !== "undefined" ? "bun" : typeof Deno !== "undefined" ? "deno" : "node", + version: process.version, + versions: { + v8: process.versions.v8, + openssl: process.versions.openssl, + modules: process.versions.modules, + }, + }, + os: { + platform: process.platform, + arch: process.arch, + release: os.release(), + type: os.type(), + hostname: os.hostname(), + uptime: `${Math.floor(os.uptime())}s`, + }, + cpus: { + count: os.cpus().length, + model: os.cpus()[0]?.model, + }, + memory: { + total: `${Math.round(os.totalmem() / 1024 / 1024)}MB`, + free: `${Math.round(os.freemem() / 1024 / 1024)}MB`, + process: { + rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`, + heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`, + heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`, + }, + }, + env: { + NODE_ENV: process.env.NODE_ENV, + TZ: process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + LANG: process.env.LANG, + }, + process: { + pid: process.pid, + cwd: process.cwd(), + execPath: process.execPath, + argv: process.argv.slice(0, 3), + }, + }; + }, +}); + +// Silence TS errors for Bun/Deno global checks +declare const Bun: unknown; +declare const Deno: unknown; export const chat = chatTask({ id: "ai-chat", @@ -9,6 +69,8 @@ export const chat = chatTask({ model: openai("gpt-4o-mini"), system: "You are a helpful assistant. Be concise and friendly.", messages: await convertToModelMessages(messages), + tools: { inspectEnvironment }, + maxSteps: 3, }); }, }); From 16bb046a01d4035329c8d65076d75ff91cb9b3af Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 21 Feb 2026 15:18:22 +0000 Subject: [PATCH 19/19] expose a useTriggerChatTransport hook --- .../plan-graceful-oversized-batch-items.md | 257 +++++++++++ packages/trigger-sdk/package.json | 26 +- packages/trigger-sdk/src/v3/chat-react.ts | 84 ++++ packages/trigger-sdk/src/v3/chat.ts | 1 + pnpm-lock.yaml | 418 ++++++++++++------ references/ai-chat/src/components/chat.tsx | 19 +- 6 files changed, 659 insertions(+), 146 deletions(-) create mode 100644 .scratch/plan-graceful-oversized-batch-items.md create mode 100644 packages/trigger-sdk/src/v3/chat-react.ts diff --git a/.scratch/plan-graceful-oversized-batch-items.md b/.scratch/plan-graceful-oversized-batch-items.md new file mode 100644 index 00000000000..cb463b96252 --- /dev/null +++ b/.scratch/plan-graceful-oversized-batch-items.md @@ -0,0 +1,257 @@ +# Graceful handling of oversized batch items + +## Prerequisites + +This plan builds on top of PR #2980 which provides: +- `TriggerFailedTaskService` at `apps/webapp/app/runEngine/services/triggerFailedTask.server.ts` - creates pre-failed TaskRuns with proper trace events, waitpoint connections, and parent run associations +- `engine.createFailedTaskRun()` on RunEngine - creates a SYSTEM_FAILURE run with associated waitpoints +- Retry support in `processItemCallback` with `attempt` and `isFinalAttempt` params +- The callback already uses `TriggerFailedTaskService` for items that fail after retries + +## Problem + +When the NDJSON parser in `createNdjsonParserStream` detects an oversized line, it throws inside the TransformStream's `transform()` method. This aborts the request body stream (due to `pipeThrough` coupling), causing the client's `fetch()` to see `TypeError: fetch failed` instead of the server's 400 response. The SDK treats this as a connection error and retries with exponential backoff (~25s wasted). + +## Goal + +Instead of throwing, treat oversized items as per-item failures that flow through the existing batch failure pipeline. The batch seals normally, other items process fine, and the user sees a clear failure for the specific oversized item(s). + +## Approach + +The NDJSON parser emits an error marker object instead of throwing. `StreamBatchItemsService` detects these markers and enqueues the item to the FairQueue with error metadata in its options. The `processItemCallback` (already enhanced with `TriggerFailedTaskService` in PR #2980) detects the error metadata and creates a pre-failed run via `TriggerFailedTaskService`, which handles all the waitpoint/trace machinery. + +## Changes + +### 1. Byte-level key extractor for oversized lines + +**`apps/webapp/app/runEngine/services/streamBatchItems.server.ts`** - new function + +Add `extractIndexAndTask(bytes: Uint8Array): { index: number; task: string }` - a state machine that extracts top-level `"index"` and `"task"` values from raw bytes without decoding the full line. + +How it works: +- Scan bytes tracking JSON nesting depth (count `{`/`[` vs `}`/`]`) +- At depth 1 (inside the top-level object), look for byte sequences matching `"index"` and `"task"` key patterns +- For `"index"`: after the `:`, parse the digit sequence as a number +- For `"task"`: after the `:`, find opening `"`, read bytes until closing `"`, decode just that slice +- Stop when both found, or after scanning 512 bytes (whichever comes first) +- Fallback: `index = -1`, `task = "unknown"` if not found + +This avoids decoding/allocating the full 3MB line - only the first few hundred bytes are examined. + +### 2. Modify `createNdjsonParserStream` to emit error markers + +**`apps/webapp/app/runEngine/services/streamBatchItems.server.ts`** + +Define a marker type: +```typescript +type OversizedItemMarker = { + __batchItemError: "OVERSIZED"; + index: number; + task: string; + actualSize: number; + maxSize: number; +}; +``` + +**Case 1 - Complete line exceeds limit** (newline found, `newlineIndex > maxItemBytes`): +- Call `extractLine(newlineIndex)` to consume the line from the buffer +- Call `extractIndexAndTask(lineBytes)` on the extracted bytes +- `controller.enqueue(marker)` instead of throwing +- Increment `lineNumber` and continue + +**Case 2 - Incomplete line exceeds limit** (no newline, `totalBytes > maxItemBytes`): +- Call `extractIndexAndTask(concatenateChunks())` on current buffer +- `controller.enqueue(marker)` +- Clear the buffer (`chunks = []; totalBytes = 0`) +- Return from transform (don't throw) + +**Case 3 - Flush with oversized remaining** (`totalBytes > maxItemBytes` in flush): +- Same as case 2 but in `flush()`. + +### 3. Handle markers in `StreamBatchItemsService` + +**`apps/webapp/app/runEngine/services/streamBatchItems.server.ts`** - in the `for await` loop + +Before the existing `BatchItemNDJSONSchema.safeParse(rawItem)`, check for the marker: + +```typescript +if (rawItem && typeof rawItem === "object" && (rawItem as any).__batchItemError === "OVERSIZED") { + const marker = rawItem as OversizedItemMarker; + const itemIndex = marker.index >= 0 ? marker.index : lastIndex + 1; + + const errorMessage = `Batch item payload is too large (${(marker.actualSize / 1024).toFixed(1)} KB). Maximum allowed size is ${(marker.maxSize / 1024).toFixed(1)} KB. Reduce the payload size or offload large data to external storage.`; + + // Enqueue the item normally but with error metadata in options. + // The processItemCallback will detect __error and use TriggerFailedTaskService + // to create a pre-failed run with proper waitpoint connections. + const batchItem: BatchItem = { + task: marker.task, + payload: "{}", + payloadType: "application/json", + options: { + __error: errorMessage, + __errorCode: "PAYLOAD_TOO_LARGE", + }, + }; + + const result = await this._engine.enqueueBatchItem( + batchId, environment.id, itemIndex, batchItem + ); + + if (result.enqueued) { + itemsAccepted++; + } else { + itemsDeduplicated++; + } + lastIndex = itemIndex; + continue; +} +``` + +### 4. Handle `__error` items in `processItemCallback` + +**`apps/webapp/app/v3/runEngineHandlers.server.ts`** - in the `setupBatchQueueCallbacks` function + +In the `processItemCallback`, before the `TriggerTaskService.call()`, check for `__error` in `item.options`: + +```typescript +const itemError = item.options?.__error as string | undefined; +if (itemError) { + const errorCode = (item.options?.__errorCode as string) ?? "ITEM_ERROR"; + + // Use TriggerFailedTaskService to create a pre-failed run. + // This creates a proper TaskRun with waitpoint connections so the + // parent's batchTriggerAndWait resolves correctly for this item. + const failedRunId = await triggerFailedTaskService.call({ + taskId: item.task, + environment, + payload: item.payload ?? "{}", + payloadType: item.payloadType, + errorMessage: itemError, + errorCode: errorCode as TaskRunErrorCodes, + parentRunId: meta.parentRunId, + resumeParentOnCompletion: meta.resumeParentOnCompletion, + batch: { id: batchId, index: itemIndex }, + traceContext: meta.traceContext as Record | undefined, + spanParentAsLink: meta.spanParentAsLink, + }); + + if (failedRunId) { + span.setAttribute("batch.result.pre_failed", true); + span.setAttribute("batch.result.run_id", failedRunId); + span.end(); + return { success: true as const, runId: failedRunId }; + } + + // Fallback if TriggerFailedTaskService fails + span.end(); + return { success: false as const, error: itemError, errorCode }; +} +``` + +Note: this returns `{ success: true, runId }` because the pre-failed run IS a real run. The BatchQueue records it as a success (run was created). The run itself is already in SYSTEM_FAILURE status, so the batch completion flow handles it correctly. + +If `environment` is null (environment not found), fall through to the existing environment-not-found handling which already uses `triggerFailedTaskService.callWithoutTraceEvents()` on `isFinalAttempt`. + +### 5. Handle undefined/null payload in BatchQueue serialization + +**`internal-packages/run-engine/src/batch-queue/index.ts`** - in `#handleMessage` + +Both payload serialization blocks (in the `success: false` branch and the `catch` block) do: +```typescript +const str = typeof item.payload === "string" ? item.payload : JSON.stringify(item.payload); +innerSpan?.setAttribute("batch.payloadSize", str.length); +``` + +`JSON.stringify(undefined)` returns `undefined`, causing `str.length` to crash. Fix both: +```typescript +const str = + item.payload === undefined || item.payload === null + ? "{}" + : typeof item.payload === "string" + ? item.payload + : JSON.stringify(item.payload); +``` + +### 6. Remove stale error handling in route + +**`apps/webapp/app/routes/api.v3.batches.$batchId.items.ts`** + +The `error.message.includes("exceeds maximum size")` branch is no longer reachable since oversized items don't throw. Remove that condition, keep the `"Invalid JSON"` check. + +### 7. Remove `BatchItemTooLargeError` and SDK pre-validation + +**`packages/core/src/v3/apiClient/errors.ts`** - remove `BatchItemTooLargeError` class + +**`packages/core/src/v3/apiClient/index.ts`**: +- Remove `BatchItemTooLargeError` import +- Remove `instanceof BatchItemTooLargeError` check in the retry catch block +- Remove `MAX_BATCH_ITEM_BYTES` constant +- Remove size validation from `createNdjsonStream` (revert `encodeAndValidate` to simple encode) + +**`packages/trigger-sdk/src/v3/shared.ts`** - remove `BatchItemTooLargeError` import and handling in `buildBatchErrorMessage` + +**`packages/trigger-sdk/src/v3/index.ts`** - remove `BatchItemTooLargeError` re-export + +### 8. Update tests + +**`apps/webapp/test/engine/streamBatchItems.test.ts`**: +- Update "should reject lines exceeding maxItemBytes" to assert `OversizedItemMarker` emission instead of throw +- Update "should reject unbounded accumulation without newlines" similarly +- Update the emoji byte-size test to assert marker instead of throw + +### 9. Update reference project test task + +**`references/hello-world/src/trigger/batches.ts`**: +- Remove `BatchItemTooLargeError` import +- Update `batchSealFailureOversizedPayload` task to test the new behavior: + - Send 2 items: one normal, one oversized (~3.2MB) + - Assert `batchTriggerAndWait` returns (doesn't throw) + - Assert `results.runs[0].ok === true` (normal item succeeded) + - Assert `results.runs[1].ok === false` (oversized item failed) + - Assert error message contains "too large" + +## Data flow + +``` +NDJSON bytes arrive + | +createNdjsonParserStream + |-- Line <= limit --> parse JSON --> enqueue object + `-- Line > limit --> extractIndexAndTask(bytes) --> enqueue OversizedItemMarker + | +StreamBatchItemsService for-await loop + |-- OversizedItemMarker --> engine.enqueueBatchItem() with __error in options + `-- Normal item --> validate --> engine.enqueueBatchItem() + | +FairQueue consumer (#handleMessage) + |-- __error in options --> processItemCallback detects it + | --> TriggerFailedTaskService.call() + | --> Creates pre-failed TaskRun with SYSTEM_FAILURE status + | --> Proper waitpoint + TaskRunWaitpoint connections created + | --> Returns { success: true, runId: failedRunFriendlyId } + `-- Normal item --> TriggerTaskService.call() --> creates normal run + | +Batch sealing: enqueuedCount === runCount (all items go through enqueueBatchItem) +Batch completion: all items have runs (real or pre-failed), waitpoints resolve normally +Parent run: batchTriggerAndWait resolves with per-item results +``` + +## Why this works + +The key insight is that `TriggerFailedTaskService` (from PR #2980) creates a real `TaskRun` in `SYSTEM_FAILURE` status. This means: +1. A RUN waitpoint is created and connected to the parent via `TaskRunWaitpoint` with correct `batchId`/`batchIndex` +2. The run is immediately completed, which completes the waitpoint +3. The SDK's `waitForBatch` resolver for that index fires with the error result +4. The batch completion flow counts this as a processed item (it's a real run) +5. No special-casing needed in the batch completion callback + +## Verification + +1. Rebuild `@trigger.dev/core`, `@trigger.dev/sdk`, `@internal/run-engine` +2. Restart webapp + trigger dev +3. Trigger `batch-seal-failure-oversized` task - should complete in ~2-3s with: + - Normal item: `ok: true` + - Oversized item: `ok: false` with "too large" error +4. Run NDJSON parser tests: updated tests assert marker emission instead of throws +5. Run `pnpm run build --filter @internal/run-engine --filter @trigger.dev/core --filter @trigger.dev/sdk` diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 5dc3a0fe3fc..2db05da7fc8 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -25,7 +25,8 @@ ".": "./src/v3/index.ts", "./v3": "./src/v3/index.ts", "./ai": "./src/v3/ai.ts", - "./chat": "./src/v3/chat.ts" + "./chat": "./src/v3/chat.ts", + "./chat/react": "./src/v3/chat-react.ts" }, "sourceDialects": [ "@triggerdotdev/source" @@ -41,6 +42,9 @@ ], "chat": [ "dist/commonjs/v3/chat.d.ts" + ], + "chat/react": [ + "dist/commonjs/v3/chat-react.d.ts" ] } }, @@ -70,6 +74,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.15.4", "@types/debug": "^4.1.7", + "@types/react": "^19.2.14", "@types/slug": "^5.0.3", "@types/uuid": "^9.0.0", "@types/ws": "^8.5.3", @@ -82,12 +87,16 @@ "zod": "3.25.76" }, "peerDependencies": { - "zod": "^3.0.0 || ^4.0.0", - "ai": "^5.0.0 || ^6.0.0" + "ai": "^5.0.0 || ^6.0.0", + "react": "^18.0 || ^19.0", + "zod": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { "ai": { "optional": true + }, + "react": { + "optional": true } }, "engines": { @@ -138,6 +147,17 @@ "types": "./dist/commonjs/v3/chat.d.ts", "default": "./dist/commonjs/v3/chat.js" } + }, + "./chat/react": { + "import": { + "@triggerdotdev/source": "./src/v3/chat-react.ts", + "types": "./dist/esm/v3/chat-react.d.ts", + "default": "./dist/esm/v3/chat-react.js" + }, + "require": { + "types": "./dist/commonjs/v3/chat-react.d.ts", + "default": "./dist/commonjs/v3/chat-react.js" + } } }, "main": "./dist/commonjs/v3/index.js", diff --git a/packages/trigger-sdk/src/v3/chat-react.ts b/packages/trigger-sdk/src/v3/chat-react.ts new file mode 100644 index 00000000000..a62496463ae --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-react.ts @@ -0,0 +1,84 @@ +"use client"; + +/** + * @module @trigger.dev/sdk/chat/react + * + * React hooks for AI SDK chat transport integration. + * Use alongside `@trigger.dev/sdk/chat` for a type-safe, ergonomic DX. + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; + * import type { chat } from "@/trigger/chat"; + * + * function Chat() { + * const transport = useTriggerChatTransport({ + * task: "ai-chat", + * accessToken: () => fetchToken(), + * }); + * + * const { messages, sendMessage } = useChat({ transport }); + * } + * ``` + */ + +import { useRef } from "react"; +import { + TriggerChatTransport, + type TriggerChatTransportOptions, +} from "./chat.js"; +import type { AnyTask, TaskIdentifier } from "@trigger.dev/core/v3"; + +/** + * Options for `useTriggerChatTransport`, with a type-safe `task` field. + * + * Pass a task type parameter to get compile-time validation of the task ID: + * ```ts + * useTriggerChatTransport({ task: "my-task", ... }) + * ``` + */ +export type UseTriggerChatTransportOptions = Omit< + TriggerChatTransportOptions, + "task" +> & { + /** The task ID. Strongly typed when a task type parameter is provided. */ + task: TaskIdentifier; +}; + +/** + * React hook that creates and memoizes a `TriggerChatTransport` instance. + * + * The transport is created once on first render and reused for the lifetime + * of the component. This avoids the need for `useMemo` and ensures the + * transport's internal session state (waitpoint tokens, lastEventId, etc.) + * is preserved across re-renders. + * + * For dynamic access tokens, pass a function — it will be called on each + * request without needing to recreate the transport. + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; + * import type { chat } from "@/trigger/chat"; + * + * function Chat() { + * const transport = useTriggerChatTransport({ + * task: "ai-chat", + * accessToken: () => fetchToken(), + * }); + * + * const { messages, sendMessage } = useChat({ transport }); + * } + * ``` + */ +export function useTriggerChatTransport( + options: UseTriggerChatTransportOptions +): TriggerChatTransport { + const ref = useRef(null); + if (ref.current === null) { + ref.current = new TriggerChatTransport(options); + } + return ref.current; +} diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index 60c3445a759..e36c5761870 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -391,3 +391,4 @@ export class TriggerChatTransport implements ChatTransport { export function createChatTransport(options: TriggerChatTransportOptions): TriggerChatTransport { return new TriggerChatTransport(options); } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d70d88562bd..81b6bcc57ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2077,6 +2077,9 @@ importers: '@types/debug': specifier: ^4.1.7 version: 4.1.7 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 '@types/slug': specifier: ^5.0.3 version: 5.0.3 @@ -2680,7 +2683,7 @@ importers: version: 6.20.0-integration-next.8 '@prisma/client': specifier: 6.20.0-integration-next.8 - version: 6.20.0-integration-next.8(prisma@6.20.0-integration-next.8(@types/react@19.0.12)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4) + version: 6.20.0-integration-next.8(prisma@6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4) '@trigger.dev/build': specifier: workspace:* version: link:../../packages/build @@ -2693,7 +2696,7 @@ importers: devDependencies: prisma: specifier: 6.20.0-integration-next.8 - version: 6.20.0-integration-next.8(@types/react@19.0.12)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) + version: 6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) trigger.dev: specifier: workspace:* version: link:../../packages/cli-v3 @@ -11011,6 +11014,9 @@ packages: '@types/react@19.0.12': resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/readable-stream@4.0.14': resolution: {integrity: sha512-xZn/AuUbCMShGsqH/ehZtGDwQtbx00M9rZ2ENLe4tOjFZ/JFeWMhEZkk2fEe1jAUqqEAURIkFJ7Az/go8mM1/w==} @@ -12665,6 +12671,9 @@ packages: csstype@3.2.0: resolution: {integrity: sha512-si++xzRAY9iPp60roQiFta7OFbhrgvcthrhlNAGeQptSY25uJjkfUV8OArC3KLocB8JT8ohz+qgxWCmz8RhjIg==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + csv-generate@3.4.3: resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} @@ -25668,11 +25677,11 @@ snapshots: prisma: 6.19.0(typescript@5.5.4) typescript: 5.5.4 - '@prisma/client@6.20.0-integration-next.8(prisma@6.20.0-integration-next.8(@types/react@19.0.12)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4)': + '@prisma/client@6.20.0-integration-next.8(prisma@6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4)': dependencies: '@prisma/client-runtime-utils': 6.20.0-integration-next.8 optionalDependencies: - prisma: 6.20.0-integration-next.8(@types/react@19.0.12)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) + prisma: 6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) typescript: 5.5.4 '@prisma/config@6.14.0(magicast@0.3.5)': @@ -25836,9 +25845,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@prisma/studio-core-licensed@0.6.0(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@prisma/studio-core-licensed@0.6.0(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@types/react': 19.0.12 + '@types/react': 19.2.14 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -25944,14 +25953,14 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-avatar@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': @@ -25966,21 +25975,21 @@ snapshots: '@types/react': 19.0.12 '@types/react-dom': 19.0.4(@types/react@19.0.12) - '@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-collapsible@1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -26032,17 +26041,17 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.2.7 - '@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.7 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-collection@1.1.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -26135,6 +26144,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-context@1.0.1(@types/react@18.2.69)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.26.7 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.69 + '@radix-ui/react-context@1.0.1(@types/react@18.3.1)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.7 @@ -26245,12 +26261,12 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 - '@radix-ui/react-direction@1.0.1(@types/react@18.3.1)(react@18.3.1)': + '@radix-ui/react-direction@1.0.1(@types/react@18.2.69)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.7 react: 18.3.1 optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@radix-ui/react-direction@1.1.0(@types/react@18.3.1)(react@18.3.1)': dependencies: @@ -26289,6 +26305,20 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.2.7 + '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.2.69)(react@18.3.1) + react: 18.3.1 + react-dom: 18.2.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -26303,18 +26333,18 @@ snapshots: '@types/react': 18.3.1 '@types/react-dom': 18.2.7 - '@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-focus-guards@1.0.0(react@18.2.0)': @@ -26329,6 +26359,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.69)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.69 + '@radix-ui/react-focus-guards@1.0.1(@types/react@18.3.1)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -26369,16 +26406,16 @@ snapshots: '@types/react': 18.3.1 '@types/react-dom': 18.2.7 - '@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-icons@1.3.0(react@18.3.1)': @@ -26405,6 +26442,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-id@1.0.1(@types/react@18.2.69)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.69 + '@radix-ui/react-id@1.0.1(@types/react@18.3.1)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -26450,28 +26495,28 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@radix-ui/react-popover@1.0.7(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popover@1.0.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.3.1) aria-hidden: 1.2.4 react: 18.3.1 react-dom: 18.2.0(react@18.3.1) - react-remove-scroll: 2.5.5(@types/react@18.3.1)(react@18.3.1) + react-remove-scroll: 2.5.5(@types/react@18.2.69)(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-popper@1.1.1(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -26492,42 +26537,42 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@radix-ui/react-popper@1.1.2(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popper@1.1.2(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@floating-ui/react-dom': 2.0.9(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-rect': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-rect': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.69)(react@18.3.1) '@radix-ui/rect': 1.0.1 react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 - '@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@floating-ui/react-dom': 2.0.9(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-rect': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-rect': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.69)(react@18.3.1) '@radix-ui/rect': 1.0.1 react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-portal@1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -26547,6 +26592,16 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.2.7 + '@radix-ui/react-portal@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.2.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + '@radix-ui/react-portal@1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -26557,14 +26612,14 @@ snapshots: '@types/react': 18.3.1 '@types/react-dom': 18.2.7 - '@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-portal@1.1.9(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -26604,6 +26659,17 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.2.7 + '@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.26.7 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.3.1) + react: 18.3.1 + react-dom: 18.2.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + '@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.7 @@ -26659,6 +26725,16 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.2.7 + '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.26.7 + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.69)(react@18.3.1) + react: 18.3.1 + react-dom: 18.2.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.7 @@ -26782,22 +26858,22 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.2.7 - '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.7 '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-scroll-area@1.2.0(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': @@ -26974,32 +27050,32 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) - '@radix-ui/react-toggle-group@1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-toggle-group@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-direction': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toggle': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 - '@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-tooltip@1.0.5(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -27022,25 +27098,25 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@radix-ui/react-tooltip@1.0.6(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-tooltip@1.0.6(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-popper': 1.1.2(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-popper': 1.1.2(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/react-use-callback-ref@1.0.0(react@18.2.0)': @@ -27060,6 +27136,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.69)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.69 + '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.1)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -27099,6 +27182,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.69)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.27.4 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.69 + '@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.3.1)(react@18.3.1)': dependencies: '@babel/runtime': 7.27.4 @@ -27136,6 +27227,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.2.69)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.69 + '@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.3.1)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 @@ -27161,6 +27260,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.69)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.27.4 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.69 + '@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.3.1)(react@18.3.1)': dependencies: '@babel/runtime': 7.27.4 @@ -27204,13 +27310,13 @@ snapshots: '@radix-ui/rect': 1.0.0 react: 18.2.0 - '@radix-ui/react-use-rect@1.0.1(@types/react@18.3.1)(react@18.3.1)': + '@radix-ui/react-use-rect@1.0.1(@types/react@18.2.69)(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 '@radix-ui/rect': 1.0.1 react: 18.3.1 optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@radix-ui/react-use-size@1.0.0(react@18.2.0)': dependencies: @@ -27226,13 +27332,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 - '@radix-ui/react-use-size@1.0.1(@types/react@18.3.1)(react@18.3.1)': + '@radix-ui/react-use-size@1.0.1(@types/react@18.2.69)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.7 - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@radix-ui/react-visually-hidden@1.0.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: @@ -27241,14 +27347,14 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.28.4 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.2.0(react@18.3.1) optionalDependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@radix-ui/rect@1.0.0': @@ -28318,7 +28424,7 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@react-email/components@0.0.17(@types/react@18.3.1)(react@18.3.1)': + '@react-email/components@0.0.17(@types/react@18.2.69)(react@18.3.1)': dependencies: '@react-email/body': 0.0.8(react@18.3.1) '@react-email/button': 0.0.15(react@18.3.1) @@ -28328,7 +28434,7 @@ snapshots: '@react-email/container': 0.0.12(react@18.3.1) '@react-email/font': 0.0.6(react@18.3.1) '@react-email/head': 0.0.8(react@18.3.1) - '@react-email/heading': 0.0.12(@types/react@18.3.1)(react@18.3.1) + '@react-email/heading': 0.0.12(@types/react@18.2.69)(react@18.3.1) '@react-email/hr': 0.0.8(react@18.3.1) '@react-email/html': 0.0.8(react@18.3.1) '@react-email/img': 0.0.8(react@18.3.1) @@ -28375,9 +28481,9 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@react-email/heading@0.0.12(@types/react@18.3.1)(react@18.3.1)': + '@react-email/heading@0.0.12(@types/react@18.2.69)(react@18.3.1)': dependencies: - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.69)(react@18.3.1) react: 18.3.1 transitivePeerDependencies: - '@types/react' @@ -31319,7 +31425,7 @@ snapshots: '@types/react-dom@18.2.7': dependencies: - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom@19.0.4(@types/react@19.0.12)': dependencies: @@ -31346,6 +31452,10 @@ snapshots: dependencies: csstype: 3.2.0 + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + '@types/readable-stream@4.0.14': dependencies: '@types/node': 20.14.14 @@ -33288,6 +33398,8 @@ snapshots: csstype@3.2.0: {} + csstype@3.2.3: {} + csv-generate@3.4.3: {} csv-parse@4.16.3: {} @@ -39086,11 +39198,11 @@ snapshots: transitivePeerDependencies: - magicast - prisma@6.20.0-integration-next.8(@types/react@19.0.12)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4): + prisma@6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4): dependencies: '@prisma/config': 6.20.0-integration-next.8(magicast@0.3.5) '@prisma/engines': 6.20.0-integration-next.8 - '@prisma/studio-core-licensed': 0.6.0(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@prisma/studio-core-licensed': 0.6.0(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) postgres: 3.4.7 optionalDependencies: typescript: 5.5.4 @@ -39434,15 +39546,15 @@ snapshots: dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 - '@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-popover': 1.0.7(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.0.2(@types/react@18.3.1)(react@18.3.1) - '@radix-ui/react-toggle-group': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tooltip': 1.0.6(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) - '@react-email/components': 0.0.17(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': 1.0.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.69)(react@18.3.1) + '@radix-ui/react-toggle-group': 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': 1.0.6(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.3.1))(react@18.3.1) + '@react-email/components': 0.0.17(@types/react@18.2.69)(react@18.3.1) '@react-email/render': 0.0.13 '@swc/core': 1.3.101(@swc/helpers@0.5.15) - '@types/react': 18.3.1 + '@types/react': 18.2.69 '@types/react-dom': 18.2.7 '@types/webpack': 5.28.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.19.11) autoprefixer: 10.4.14(postcss@8.4.35) @@ -39585,6 +39697,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + react-remove-scroll-bar@2.3.8(@types/react@18.2.69)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.2.69)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.2.69 + react-remove-scroll-bar@2.3.8(@types/react@18.3.1)(react@18.3.1): dependencies: react: 18.3.1 @@ -39604,6 +39724,17 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + react-remove-scroll@2.5.5(@types/react@18.2.69)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.2.69)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.2.69)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.2.69)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.2.69)(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.69 + react-remove-scroll@2.5.5(@types/react@18.3.1)(react@18.3.1): dependencies: react: 18.3.1 @@ -39682,6 +39813,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + react-style-singleton@2.2.3(@types/react@18.2.69)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.2.69 + react-style-singleton@2.2.3(@types/react@18.3.1)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -42073,6 +42212,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + use-callback-ref@1.3.3(@types/react@18.2.69)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.2.69 + use-callback-ref@1.3.3(@types/react@18.3.1)(react@18.3.1): dependencies: react: 18.3.1 @@ -42094,6 +42240,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + use-sidecar@1.1.3(@types/react@18.2.69)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.2.69 + use-sidecar@1.1.3(@types/react@18.3.1)(react@18.3.1): dependencies: detect-node-es: 1.1.0 diff --git a/references/ai-chat/src/components/chat.tsx b/references/ai-chat/src/components/chat.tsx index aa09d530bb4..9755e15f6e8 100644 --- a/references/ai-chat/src/components/chat.tsx +++ b/references/ai-chat/src/components/chat.tsx @@ -1,9 +1,10 @@ "use client"; import { useChat } from "@ai-sdk/react"; -import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; -import { useMemo, useState } from "react"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import { useState } from "react"; import { getChatToken } from "@/app/actions"; +import type { chat } from "@/trigger/chat"; function ToolInvocation({ part }: { part: any }) { const [expanded, setExpanded] = useState(false); @@ -72,15 +73,11 @@ function ToolInvocation({ part }: { part: any }) { export function Chat() { const [input, setInput] = useState(""); - const transport = useMemo( - () => - new TriggerChatTransport({ - task: "ai-chat", - accessToken: getChatToken, - baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL, - }), - [] - ); + const transport = useTriggerChatTransport({ + task: "ai-chat", + accessToken: getChatToken, + baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL, + }); const { messages, sendMessage, status, error } = useChat({ transport,