From 741c983e0fd38cd35b36004cf912cfb7c55df0f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:40:39 +0000 Subject: [PATCH 01/16] 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 | 218 ++++++++++++++++++++++++----- 7 files changed, 633 insertions(+), 31 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 0000000000..c6cee5d728 --- /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 0000000000..f58c1d1ffa --- /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 0000000000..1a5789c96b --- /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 0000000000..81f1c6dc9b --- /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 0000000000..2e47a88682 --- /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 0000000000..ec09e52a40 --- /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 f504496dd1..99bf66add7 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)(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 @@ -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': @@ -11147,9 +11172,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: @@ -11173,9 +11212,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==} @@ -14242,11 +14287,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==} @@ -19760,6 +19806,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} @@ -19827,6 +19878,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} @@ -20439,7 +20515,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: @@ -22384,7 +22460,7 @@ snapshots: '@babel/traverse': 7.24.7 '@babel/types': 7.24.0 convert-source-map: 1.9.0 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -22672,7 +22748,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 '@babel/parser': 7.27.5 '@babel/types': 7.27.3 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -23653,7 +23729,7 @@ snapshots: '@eslint/eslintrc@1.4.1': dependencies: ajv: 6.12.6 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) espree: 9.6.0 globals: 13.19.0 ignore: 5.2.4 @@ -23887,7 +23963,7 @@ snapshots: '@humanwhocodes/config-array@0.11.8': dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -23903,7 +23979,7 @@ snapshots: '@antfu/install-pkg': 1.1.0 '@antfu/utils': 9.3.0 '@iconify/types': 2.0.0 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) globals: 15.15.0 kolorist: 1.8.0 local-pkg: 1.1.2 @@ -31494,6 +31570,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 @@ -31501,6 +31584,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 @@ -31527,12 +31618,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 @@ -31810,7 +31911,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -32306,7 +32407,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -32320,7 +32421,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.0.0) http-errors: 2.0.0 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -33318,9 +33419,11 @@ snapshots: optionalDependencies: supports-color: 10.0.0 - debug@4.4.3: + debug@4.4.3(supports-color@10.0.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.0.0 decamelize-keys@1.1.1: dependencies: @@ -33464,7 +33567,7 @@ snapshots: docker-modem@5.0.6: dependencies: - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) readable-stream: 3.6.0 split-ca: 1.0.1 ssh2: 1.16.0 @@ -34161,12 +34264,12 @@ snapshots: eslint-import-resolver-typescript@3.5.5(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.7)(eslint-plugin-import@2.29.1)(eslint@8.31.0): dependencies: - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) enhanced-resolve: 5.15.0 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 @@ -34643,7 +34746,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.1 cookie-signature: 1.2.2 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -34856,7 +34959,7 @@ snapshots: finalhandler@2.1.0(supports-color@10.0.0): dependencies: - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -35572,7 +35675,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) transitivePeerDependencies: - supports-color @@ -37240,7 +37343,7 @@ snapshots: micromark@3.1.0: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.0.6 micromark-factory-space: 1.0.0 @@ -37262,7 +37365,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -39164,7 +39267,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 @@ -39201,8 +39304,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 @@ -39770,7 +39873,7 @@ snapshots: remix-auth-oauth2@1.11.0(@remix-run/server-runtime@2.1.0(typescript@5.5.4))(remix-auth@3.6.0(@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))): dependencies: '@remix-run/server-runtime': 2.1.0(typescript@5.5.4) - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) remix-auth: 3.6.0(@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)) transitivePeerDependencies: - supports-color @@ -39965,7 +40068,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -40119,7 +40222,7 @@ snapshots: send@1.1.0(supports-color@10.0.0): dependencies: - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -40136,7 +40239,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.0.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -40402,7 +40505,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) @@ -40431,7 +40534,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 @@ -41954,7 +42057,7 @@ snapshots: vite-node@0.28.5(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1): dependencies: cac: 6.7.14 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) mlly: 1.7.4 pathe: 1.1.2 picocolors: 1.1.1 @@ -41971,10 +42074,28 @@ 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 - debug: 4.4.1(supports-color@10.0.0) + debug: 4.4.3(supports-color@10.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 5.4.21(@types/node@20.14.14)(lightningcss@1.29.2)(terser@5.44.1) @@ -42020,6 +42141,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 8354e2a6d44a1678ed94e7eaad023721aa0fb415 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:42:30 +0000 Subject: [PATCH 02/16] 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 0000000000..eac1434eab --- /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 0000000000..c497b8ec97 --- /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 b6448fbf515b90236df76ca0da29c5e104175d7f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:43:47 +0000 Subject: [PATCH 03/16] 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 1a5789c96b..b9df702b3a 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 81f1c6dc9b..bbffb50247 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 68bd584337a9df2dc362cbc1b4f1ab60fc6e2ee2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:46:56 +0000 Subject: [PATCH 04/16] 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 eac1434eab..cc946596c2 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 4a828fe427708d631723d7377f81a40400912b85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:47:40 +0000 Subject: [PATCH 05/16] 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 0000000000..a24dcdc195 --- /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 97811bb597260610b571c0c03aa2436020675562 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:49:57 +0000 Subject: [PATCH 06/16] 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 f58c1d1ffa..7e673894ff 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 a17cad9d0c6f5ae139aeeb0da9f0e7da1f3adb8e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:52:56 +0000 Subject: [PATCH 07/16] 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 cc946596c2..53d3ab8686 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 b9df702b3a..d785a471c1 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 bbffb50247..88bf431735 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 c3656a55ac745b5bbe7f890c83a152a2f845fba2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 11:55:47 +0000 Subject: [PATCH 08/16] 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 d785a471c1..ff4b2c47a3 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 0ca459d85896425a997b1a92eb82980052d276a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 12:54:20 +0000 Subject: [PATCH 09/16] 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 0000000000..7f3eb92616 --- /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 0000000000..885951c59c --- /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 88bf431735..91ae993888 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 9ee58f6598..81ab56cbe4 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 59afa2fe21..9e79df22b8 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 0000000000..5a7872c101 --- /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 6dd87fdccaf38c4e875a54ddff473afe8830916e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 12:55:31 +0000 Subject: [PATCH 10/16] 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 0000000000..86a4ba9ad5 --- /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 d9ef611fbd52d268bef60749d7d3492b1c422bb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 12:56:17 +0000 Subject: [PATCH 11/16] =?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 c6cee5d728..0000000000 --- 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 7f3eb92616..0000000000 --- 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 7e673894ff..0000000000 --- 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 885951c59c..0000000000 --- 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 53d3ab8686..0000000000 --- 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 ff4b2c47a3..0000000000 --- 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 91ae993888..0000000000 --- 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 2e47a88682..0000000000 --- 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 ec09e52a40..0000000000 --- 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 c497b8ec97..0000000000 --- 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 99bf66add7..2738d8674e 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': @@ -11172,23 +11147,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: @@ -11212,15 +11173,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==} @@ -19806,11 +19761,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} @@ -19878,31 +19828,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} @@ -23197,7 +23122,7 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: '@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) + '@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 @@ -23952,7 +23877,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.5.11) ws: 8.18.3(bufferutil@4.0.9) @@ -31570,13 +31495,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 @@ -31584,14 +31502,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 @@ -31618,22 +31528,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 @@ -39267,7 +39167,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 @@ -39304,8 +39204,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 @@ -40505,7 +40405,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) @@ -40534,7 +40434,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 @@ -42074,24 +41974,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 @@ -42141,41 +42023,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 e4c30b0058b7d338e9457077a8e8fc22f802c4ff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 12:56:49 +0000 Subject: [PATCH 12/16] 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 a24dcdc195..f5cdb9187d 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 eb2ccc0c03ce35d0e32ebd4709c2c2f43d3b8505 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 13:06:08 +0000 Subject: [PATCH 13/16] 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 86a4ba9ad5..ae89f28a8a 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 5a7872c101..77378bded0 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 3f8bc26583dcca4c4ab74a00233adeb07362cd55 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 13:15:30 +0000 Subject: [PATCH 14/16] 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 5c2bddede0..4694355e5d 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 0000000000..e549226b14 --- /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 2329a10f1c9f0c376304dc3e7946ae41b7b6d816 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 13:24:32 +0000 Subject: [PATCH 15/16] 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 0000000000..cb651cdc00 --- /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 0000000000..228a09015d --- /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 0000000000..79bcf135dc --- /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 0000000000..f1d8c73cdc --- /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 0000000000..f507028583 --- /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 0000000000..16f01282c8 --- /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 0000000000..34c68d8ba7 --- /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 0000000000..27a4002397 --- /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 0000000000..4412bfc932 --- /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 0000000000..c1334095f8 --- /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 7badb14c5d37d19637ee3e3a3711978fa9dfe77b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 13:27:14 +0000 Subject: [PATCH 16/16] 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 2738d8674e..14cea433e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2099,6 +2099,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': @@ -2858,6 +2907,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'} @@ -2888,6 +2943,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'} @@ -2936,6 +2997,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'} @@ -2960,6 +3027,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'} @@ -2992,6 +3063,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'} @@ -5911,6 +5988,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==} @@ -5935,6 +6015,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'} @@ -5965,6 +6051,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'} @@ -5998,6 +6090,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'} @@ -6033,6 +6132,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'} @@ -6068,6 +6174,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'} @@ -6103,6 +6216,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'} @@ -6135,6 +6255,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'} @@ -6177,6 +6303,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'} @@ -11117,6 +11249,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'} @@ -11464,6 +11600,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: @@ -16253,6 +16395,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} @@ -17203,10 +17367,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} @@ -20256,6 +20416,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 @@ -20286,6 +20453,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 @@ -20340,6 +20513,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 @@ -20364,6 +20544,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) @@ -20394,6 +20578,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 @@ -24400,6 +24594,8 @@ snapshots: '@next/env@15.2.4': {} + '@next/env@15.3.3': {} + '@next/env@15.4.8': {} '@next/env@15.5.6': {} @@ -24413,6 +24609,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 @@ -24428,6 +24627,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 @@ -24443,6 +24645,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 @@ -24458,6 +24663,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 @@ -24473,6 +24681,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 @@ -24488,6 +24699,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 @@ -24503,6 +24717,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 @@ -24524,6 +24741,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 @@ -30569,7 +30789,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)': @@ -31112,7 +31332,7 @@ snapshots: '@types/react@19.0.12': dependencies: - csstype: 3.1.3 + csstype: 3.2.0 '@types/readable-stream@4.0.14': dependencies: @@ -31451,6 +31671,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 @@ -31894,6 +32116,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 @@ -33677,7 +33907,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: @@ -37624,6 +37854,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 @@ -38672,12 +38929,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 @@ -40903,6 +41154,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 @@ -41845,6 +42102,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 228a09015d..b373eb364d 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 27a4002397..8c77bbeebc 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), }); }, });