From 0f932eb6ef330d101c46a6dce245789dec88a971 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:08:17 +0000 Subject: [PATCH 001/217] Add @trigger.dev/ai package with chat transport and tests Co-authored-by: Eric Allam --- packages/ai/package.json | 75 +++++ packages/ai/src/ai.test.ts | 79 +++++ packages/ai/src/ai.ts | 131 ++++++++ packages/ai/src/chatTransport.test.ts | 338 ++++++++++++++++++++ packages/ai/src/chatTransport.ts | 442 ++++++++++++++++++++++++++ packages/ai/src/index.ts | 18 ++ packages/ai/src/types.ts | 81 +++++ packages/ai/tsconfig.json | 10 + pnpm-lock.yaml | 52 ++- 9 files changed, 1210 insertions(+), 16 deletions(-) create mode 100644 packages/ai/package.json create mode 100644 packages/ai/src/ai.test.ts create mode 100644 packages/ai/src/ai.ts create mode 100644 packages/ai/src/chatTransport.test.ts create mode 100644 packages/ai/src/chatTransport.ts create mode 100644 packages/ai/src/index.ts create mode 100644 packages/ai/src/types.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..c6cfdf062a --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,75 @@ +{ + "name": "@trigger.dev/ai", + "version": "4.3.3", + "description": "Trigger.dev AI SDK integrations and chat transport", + "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" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.15.4", + "ai": "^6.0.0", + "rimraf": "^3.0.2", + "tshy": "^3.0.2", + "tsx": "4.17.0", + "zod": "3.25.76" + }, + "peerDependencies": { + "ai": "^4.2.0 || ^5.0.0 || ^6.0.0", + "zod": "^3.0.0 || ^4.0.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/ai.test.ts b/packages/ai/src/ai.test.ts new file mode 100644 index 0000000000..b171674c7f --- /dev/null +++ b/packages/ai/src/ai.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { ai } from "./ai.js"; +import type { TaskWithSchema } from "@trigger.dev/core/v3"; + +describe("ai helper", function () { + it("creates a tool from a schema task and executes through triggerAndWait", async function () { + let receivedInput: unknown = undefined; + + const fakeTask = { + id: "fake-task", + description: "A fake task", + schema: z.object({ + name: z.string(), + }), + triggerAndWait: function (payload: { name: string }) { + receivedInput = payload; + const resultPromise = Promise.resolve({ + ok: true, + id: "run_123", + taskIdentifier: "fake-task", + output: { + greeting: `Hello ${payload.name}`, + }, + }); + + return Object.assign(resultPromise, { + unwrap: async function () { + return { + greeting: `Hello ${payload.name}`, + }; + }, + }); + }, + } as unknown as TaskWithSchema< + "fake-task", + z.ZodObject<{ name: z.ZodString }>, + { greeting: string } + >; + + const tool = ai.tool(fakeTask); + const result = await tool.execute?.( + { + name: "Ada", + }, + undefined as never + ); + + expect(receivedInput).toEqual({ + name: "Ada", + }); + expect(result).toEqual({ + greeting: "Hello Ada", + }); + }); + + it("throws when creating a tool from a task without schema", function () { + const fakeTask = { + id: "no-schema", + description: "No schema task", + schema: undefined, + triggerAndWait: async function () { + return { + unwrap: async function () { + return {}; + }, + }; + }, + } as unknown as TaskWithSchema<"no-schema", undefined, unknown>; + + expect(function () { + ai.tool(fakeTask); + }).toThrowError("task has no schema"); + }); + + it("returns undefined for current tool options outside task execution context", function () { + expect(ai.currentToolOptions()).toBeUndefined(); + }); +}); diff --git a/packages/ai/src/ai.ts b/packages/ai/src/ai.ts new file mode 100644 index 0000000000..59d3474779 --- /dev/null +++ b/packages/ai/src/ai.ts @@ -0,0 +1,131 @@ +import { + AnyTask, + isSchemaZodEsque, + runMetadata, + Task, + type inferSchemaIn, + type TaskSchema, + type TaskWithSchema, +} from "@trigger.dev/core/v3"; +import { + dynamicTool, + jsonSchema, + JSONSchema7, + Schema, + Tool, + ToolCallOptions, + zodSchema, +} from "ai"; + +const METADATA_KEY = "tool.execute.options"; + +export type ToolCallExecutionOptions = Omit; + +type ToolResultContent = Array< + | { + type: "text"; + text: string; + } + | { + type: "image"; + data: string; + mimeType?: string; + } +>; + +export type ToolOptions = { + experimental_toToolResultContent?: (result: TResult) => ToolResultContent; +}; + +function toolFromTask( + task: Task, + options?: ToolOptions +): Tool; +function toolFromTask< + TIdentifier extends string, + TTaskSchema extends TaskSchema | undefined = undefined, + TOutput = unknown, +>( + task: TaskWithSchema, + options?: ToolOptions +): Tool, TOutput>; +function toolFromTask< + TIdentifier extends string, + TTaskSchema extends TaskSchema | undefined = undefined, + TInput = void, + TOutput = unknown, +>( + task: TaskWithSchema | Task, + options?: ToolOptions +): TTaskSchema extends TaskSchema + ? Tool, TOutput> + : Tool { + if (("schema" in task && !task.schema) || ("jsonSchema" in task && !task.jsonSchema)) { + throw new Error( + "Cannot convert this task to to a tool because the task has no schema. Make sure to either use schemaTask or a task with an input jsonSchema." + ); + } + + const toolDefinition = dynamicTool({ + description: task.description, + inputSchema: convertTaskSchemaToToolParameters(task), + execute: async (input, options) => { + const serializedOptions = options ? JSON.parse(JSON.stringify(options)) : undefined; + + return await task + .triggerAndWait(input as inferSchemaIn, { + metadata: { + [METADATA_KEY]: serializedOptions, + }, + }) + .unwrap(); + }, + ...options, + }); + + return toolDefinition as TTaskSchema extends TaskSchema + ? Tool, TOutput> + : Tool; +} + +function getToolOptionsFromMetadata(): ToolCallExecutionOptions | undefined { + let tool: unknown; + try { + tool = runMetadata.getKey(METADATA_KEY); + } catch { + return undefined; + } + + if (!tool) { + return undefined; + } + + return tool as ToolCallExecutionOptions; +} + +function convertTaskSchemaToToolParameters( + task: AnyTask | TaskWithSchema +): Schema { + if ("schema" in task) { + if ("toJsonSchema" in task.schema && typeof task.schema.toJsonSchema === "function") { + return jsonSchema((task.schema as any).toJsonSchema()); + } + + if (isSchemaZodEsque(task.schema)) { + return zodSchema(task.schema as any); + } + } + + if ("jsonSchema" in task) { + return jsonSchema(task.jsonSchema as JSONSchema7); + } + + throw new Error( + "Cannot convert task to a tool. Make sure to use a task with a schema or jsonSchema." + ); +} + +export const ai = { + tool: toolFromTask, + currentToolOptions: getToolOptionsFromMetadata, +}; diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts new file mode 100644 index 0000000000..4e14927755 --- /dev/null +++ b/packages/ai/src/chatTransport.test.ts @@ -0,0 +1,338 @@ +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { + InMemoryTriggerChatRunStore, + TriggerChatTransport, +} from "./chatTransport.js"; +import type { UIMessage, UIMessageChunk } from "ai"; + +type TestServer = { + url: string; + close: () => Promise; +}; + +const activeServers: TestServer[] = []; + +afterEach(async function () { + while (activeServers.length > 0) { + const server = activeServers.pop(); + if (server) { + await server.close(); + } + } +}); + +describe("TriggerChatTransport", function () { + it("triggers task and streams chunks with rich default payload", async function () { + let receivedTriggerBody: Record | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + readJsonBody(req).then(function (body) { + receivedTriggerBody = body; + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_123", + }); + res.end(JSON.stringify({ id: "run_123" })); + }); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_123/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "msg_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-delta", id: "msg_1", delta: "Hello" }) + ); + writeSSE( + res, + "3-0", + JSON.stringify({ type: "text-end", id: "msg_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: undefined, + messages: [ + { + id: "usr_1", + role: "user", + parts: [{ type: "text", text: "Hello there" }], + } satisfies UIMessage, + ], + abortSignal: undefined, + headers: new Headers([["x-test-header", "abc123"]]), + body: { tenantId: "tenant_1" }, + metadata: { source: "unit-test" }, + }); + + const chunks = await readChunks(stream); + + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ + type: "text-start", + chunk: { type: "text-start", id: "msg_1" }, + }); + expect(chunks[1]).toMatchObject({ + type: "text-delta", + chunk: { type: "text-delta", id: "msg_1", delta: "Hello" }, + }); + expect(chunks[2]).toMatchObject({ + type: "text-end", + chunk: { type: "text-end", id: "msg_1" }, + }); + + expect(receivedTriggerBody).toBeDefined(); + + const options = (receivedTriggerBody?.options ?? {}) as Record; + expect(options.payloadType).toBe("application/super+json"); + + const payloadString = receivedTriggerBody?.payload as string; + const payload = (JSON.parse(payloadString) as { json: Record }).json; + + expect(payload.chatId).toBe("chat-1"); + expect(payload.trigger).toBe("submit-message"); + expect(payload.messageId).toBeNull(); + expect(payload.messages).toHaveLength(1); + expect(payload.request).toEqual({ + headers: { + "x-test-header": "abc123", + }, + body: { tenantId: "tenant_1" }, + metadata: { source: "unit-test" }, + }); + }); + + it("returns null on reconnect when no active run exists", async function () { + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev", + }); + + const stream = await transport.reconnectToStream({ + chatId: "missing-chat", + }); + + expect(stream).toBeNull(); + }); + + it("reconnects active streams using tracked lastEventId", async function () { + let reconnectLastEventId: string | undefined; + let firstStreamResponse: ServerResponse | undefined; + let firstStreamChunkSent = false; + const runStore = new InMemoryTriggerChatRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_456", + }); + res.end(JSON.stringify({ id: "run_456" })); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_456/chat-stream") { + const lastEventId = req.headers["last-event-id"]; + const normalizedLastEventId = Array.isArray(lastEventId) + ? lastEventId[0] + : lastEventId; + + if (typeof normalizedLastEventId === "string") { + reconnectLastEventId = normalizedLastEventId; + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-delta", id: "msg_2", delta: "world" }) + ); + writeSSE( + res, + "3-0", + JSON.stringify({ type: "text-end", id: "msg_2" }) + ); + res.end(); + return; + } + + firstStreamResponse = res; + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "msg_2" }) + ); + firstStreamChunkSent = true; + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + }); + + try { + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-2", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await waitForCondition(function () { + if (!firstStreamChunkSent) { + return false; + } + + const state = runStore.get("chat-2"); + return Boolean(state && state.lastEventId === "0"); + }); + + const reconnectStream = await transport.reconnectToStream({ + chatId: "chat-2", + }); + + expect(reconnectStream).not.toBeNull(); + + const reconnectChunks = await readChunks(reconnectStream!); + expect(reconnectLastEventId).toBe("0"); + expect(reconnectChunks).toHaveLength(2); + expect(reconnectChunks[0]).toMatchObject({ + chunk: { type: "text-delta", id: "msg_2", delta: "world" }, + }); + expect(reconnectChunks[1]).toMatchObject({ + chunk: { type: "text-end", id: "msg_2" }, + }); + } finally { + if (firstStreamResponse) { + firstStreamResponse.end(); + } + } + }); +}); + +async function startServer( + handler: (req: IncomingMessage, res: ServerResponse) => void +) { + const nodeServer = createServer(handler); + + await new Promise(function (resolve) { + nodeServer.listen(0, "127.0.0.1", function () { + resolve(); + }); + }); + + const address = nodeServer.address() as AddressInfo; + const server: TestServer = { + url: `http://127.0.0.1:${address.port}`, + close: function () { + if (typeof nodeServer.closeAllConnections === "function") { + nodeServer.closeAllConnections(); + } + + return new Promise(function (resolve, reject) { + nodeServer.close(function (error) { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + }, + }; + + activeServers.push(server); + + return server; +} + +function writeSSE(res: ServerResponse, id: string, data: string) { + res.write(`id: ${id}\n`); + res.write(`data: ${data}\n\n`); +} + +async function readJsonBody(req: IncomingMessage) { + const chunks: string[] = []; + for await (const chunk of req) { + chunks.push(chunk.toString()); + } + return JSON.parse(chunks.join("")) as Record; +} + +async function readChunks(stream: ReadableStream) { + const parts: Array<{ type: string; id?: string; chunk: UIMessageChunk }> = []; + for await (const chunk of stream) { + const part: { type: string; id?: string; chunk: UIMessageChunk } = { + type: chunk.type, + chunk, + }; + + if ("id" in chunk && typeof chunk.id === "string") { + part.id = chunk.id; + } + + parts.push(part); + } + + return parts; +} + +async function waitForCondition(condition: () => boolean, timeoutInMs = 5000) { + const start = Date.now(); + + while (Date.now() - start < timeoutInMs) { + if (condition()) { + return; + } + + await new Promise(function (resolve) { + setTimeout(resolve, 25); + }); + } + + throw new Error(`Condition was not met within ${timeoutInMs}ms`); +} diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts new file mode 100644 index 0000000000..117377848d --- /dev/null +++ b/packages/ai/src/chatTransport.ts @@ -0,0 +1,442 @@ +import { + ApiClient, + ApiRequestOptions, + makeIdempotencyKey, + stringifyIO, + TriggerOptions, +} from "@trigger.dev/core/v3"; +import type { + ChatRequestOptions, + ChatTransport, + InferUIMessageChunk, + UIMessage, + UIMessageChunk, +} from "ai"; +import type { + TriggerChatPayloadMapper, + TriggerChatRunState, + TriggerChatRunStore, + TriggerChatStream, + TriggerChatTaskContext, + TriggerChatTransportPayload, + TriggerChatTransportRequest, + TriggerChatTriggerOptionsResolver, +} from "./types.js"; + +type TriggerTaskResponse = { + id: string; + publicAccessToken: string; +}; + +type TriggerTaskRequestOptions = { + queue?: { + name: string; + }; + concurrencyKey?: string; + payloadType?: string; + idempotencyKey?: string; + idempotencyKeyTTL?: string; + delay?: string | Date; + ttl?: string | number; + tags?: string | string[]; + maxAttempts?: number; + metadata?: Record; + maxDuration?: number; + lockToVersion?: string; + priority?: number; + region?: string; + machine?: string; + debounce?: { + key: string; + delay: string; + mode?: "leading" | "trailing"; + maxDelay?: string; + }; +}; + +type TriggerTaskRequestBody = { + payload?: string; + options?: TriggerTaskRequestOptions; +}; + +type TriggerChatTransportCommonOptions< + UI_MESSAGE extends UIMessage = UIMessage, +> = { + task: string; + accessToken: string; + stream?: TriggerChatStream; + baseURL?: string; + previewBranch?: string; + requestOptions?: ApiRequestOptions; + timeoutInSeconds?: number; + triggerOptions?: + | TriggerOptions + | TriggerChatTriggerOptionsResolver; + runStore?: TriggerChatRunStore; + onTriggeredRun?: (state: TriggerChatRunState) => void; +}; + +type TriggerChatTransportMapperRequirement< + UI_MESSAGE extends UIMessage, + PAYLOAD, +> = PAYLOAD extends TriggerChatTransportPayload + ? { + payloadMapper?: TriggerChatPayloadMapper; + } + : { + payloadMapper: TriggerChatPayloadMapper; + }; + +export type TriggerChatTransportOptions< + UI_MESSAGE extends UIMessage = UIMessage, + PAYLOAD = TriggerChatTransportPayload, +> = TriggerChatTransportCommonOptions & + TriggerChatTransportMapperRequirement; + +export class InMemoryTriggerChatRunStore implements TriggerChatRunStore { + private readonly runs = new Map(); + + public get(chatId: string): TriggerChatRunState | undefined { + return this.runs.get(chatId); + } + + public set(state: TriggerChatRunState): void { + this.runs.set(state.chatId, state); + } + + public delete(chatId: string): void { + this.runs.delete(chatId); + } +} + +export class TriggerChatTransport< + UI_MESSAGE extends UIMessage = UIMessage, + PAYLOAD = TriggerChatTransportPayload, + > + implements ChatTransport +{ + private readonly task: string; + private readonly streamKey: string; + private readonly timeoutInSeconds: number; + private readonly payloadMapper: TriggerChatPayloadMapper; + private readonly triggerOptions?: + | TriggerOptions + | TriggerChatTriggerOptionsResolver; + private readonly runStore: TriggerChatRunStore; + private readonly triggerClient: ApiClient; + private readonly baseURL: string; + private readonly previewBranch: string | undefined; + private readonly requestOptions: ApiRequestOptions | undefined; + private readonly onTriggeredRun: ((state: TriggerChatRunState) => void) | undefined; + + constructor(options: TriggerChatTransportOptions) { + this.task = options.task; + this.streamKey = resolveStreamKey(options.stream); + this.timeoutInSeconds = options.timeoutInSeconds ?? 60; + this.payloadMapper = resolvePayloadMapper(options.payloadMapper); + this.triggerOptions = options.triggerOptions; + this.runStore = options.runStore ?? new InMemoryTriggerChatRunStore(); + this.baseURL = options.baseURL ?? "https://api.trigger.dev"; + this.previewBranch = options.previewBranch; + this.requestOptions = options.requestOptions; + this.triggerClient = new ApiClient( + this.baseURL, + options.accessToken, + this.previewBranch, + this.requestOptions + ); + this.onTriggeredRun = options.onTriggeredRun; + } + + public async sendMessages( + options: { + trigger: "submit-message" | "regenerate-message"; + chatId: string; + messageId: string | undefined; + messages: UI_MESSAGE[]; + abortSignal: AbortSignal | undefined; + } & ChatRequestOptions + ): Promise> { + const transportRequest = createTransportRequest(options); + const payload = this.payloadMapper(transportRequest); + const triggerOptions = await resolveTriggerOptions( + this.triggerOptions, + transportRequest + ); + const run = await this.triggerTask(payload, triggerOptions); + + const runState: TriggerChatRunState = { + chatId: options.chatId, + runId: run.id, + publicAccessToken: run.publicAccessToken, + streamKey: this.streamKey, + lastEventId: undefined, + isActive: true, + }; + + await this.runStore.set(runState); + + if (this.onTriggeredRun) { + this.onTriggeredRun(runState); + } + + const stream = await this.fetchRunStream(runState, options.abortSignal); + + return this.createTrackedStream(runState.chatId, stream); + } + + public async reconnectToStream( + options: { + chatId: string; + } & ChatRequestOptions + ): Promise | null> { + const runState = await this.runStore.get(options.chatId); + + if (!runState || !runState.isActive) { + return null; + } + + const stream = await this.fetchRunStream(runState, undefined, runState.lastEventId); + + return this.createTrackedStream(runState.chatId, stream); + } + + private async fetchRunStream( + runState: TriggerChatRunState, + abortSignal: AbortSignal | undefined, + lastEventId?: string + ): Promise> { + const streamClient = new ApiClient( + this.baseURL, + runState.publicAccessToken, + this.previewBranch, + this.requestOptions + ); + + const stream = await streamClient.fetchStream>( + runState.runId, + runState.streamKey, + { + signal: abortSignal, + timeoutInSeconds: this.timeoutInSeconds, + lastEventId, + } + ); + + return stream as unknown as ReadableStream; + } + + private createTrackedStream(chatId: string, stream: ReadableStream) { + const teeStreams = stream.tee(); + const trackingStream = teeStreams[0]; + const consumerStream = teeStreams[1]; + + this.consumeTrackingStream(chatId, trackingStream); + + return consumerStream; + } + + private async consumeTrackingStream(chatId: string, stream: ReadableStream) { + try { + for await (const _chunk of stream) { + const runState = await this.runStore.get(chatId); + + if (!runState) { + return; + } + + runState.lastEventId = incrementLastEventId(runState.lastEventId); + await this.runStore.set(runState); + } + + const runState = await this.runStore.get(chatId); + if (runState) { + runState.isActive = false; + await this.runStore.set(runState); + } + } catch { + const runState = await this.runStore.get(chatId); + if (runState) { + runState.isActive = false; + await this.runStore.set(runState); + } + } + } + + private async triggerTask(payload: PAYLOAD, options: TriggerOptions | undefined) { + const payloadPacket = await stringifyIO(payload); + const requestBody: TriggerTaskRequestBody = { + payload: payloadPacket.data, + options: await createTriggerTaskOptions(payloadPacket.dataType, options), + }; + + const handle = await this.triggerClient.triggerTask(this.task, requestBody as never); + + return handle as TriggerTaskResponse; + } +} + +export function createTriggerChatTransport< + UI_MESSAGE extends UIMessage = UIMessage, + PAYLOAD = TriggerChatTransportPayload, +>( + options: TriggerChatTransportOptions +) { + return new TriggerChatTransport(options); +} + +function resolvePayloadMapper< + UI_MESSAGE extends UIMessage, + PAYLOAD, +>(payloadMapper: TriggerChatPayloadMapper | undefined) { + if (payloadMapper) { + return payloadMapper; + } + + return createDefaultPayload as TriggerChatPayloadMapper; +} + +function createTransportRequest( + options: { + trigger: "submit-message" | "regenerate-message"; + chatId: string; + messageId: string | undefined; + messages: UI_MESSAGE[]; + abortSignal: AbortSignal | undefined; + } & ChatRequestOptions +): TriggerChatTransportRequest { + return { + chatId: options.chatId, + trigger: options.trigger, + messageId: options.messageId, + messages: options.messages, + request: { + headers: normalizeHeaders(options.headers), + body: options.body, + metadata: options.metadata, + }, + abortSignal: options.abortSignal, + }; +} + +function createDefaultPayload( + request: TriggerChatTransportRequest +): TriggerChatTransportPayload { + return { + chatId: request.chatId, + trigger: request.trigger, + messageId: request.messageId, + messages: request.messages, + request: { + headers: request.request.headers, + body: request.request.body, + metadata: request.request.metadata, + }, + }; +} + +function resolveStreamKey( + stream: TriggerChatStream | undefined +) { + if (!stream) { + return "default"; + } + + if (typeof stream === "string") { + return stream; + } + + return stream.id; +} + +function normalizeHeaders( + headers: Record | Headers | undefined +): Record | undefined { + if (!headers) { + return undefined; + } + + if (isHeadersInstance(headers)) { + const result: Record = {}; + for (const [key, value] of headers.entries()) { + result[key] = value; + } + return result; + } + + const headersRecord = headers as Record; + const result: Record = {}; + for (const key of Object.keys(headersRecord)) { + const value = headersRecord[key]; + if (typeof value === "string") { + result[key] = value; + } + } + + return result; +} + +function isHeadersInstance(headers: unknown): headers is Headers { + if (typeof Headers === "undefined") { + return false; + } + + return headers instanceof Headers; +} + +async function resolveTriggerOptions( + options: + | TriggerOptions + | TriggerChatTriggerOptionsResolver + | undefined, + request: TriggerChatTransportRequest +) { + if (!options) { + return undefined; + } + + if (typeof options === "function") { + return options(request); + } + + return options; +} + +async function createTriggerTaskOptions( + payloadType: string | undefined, + triggerOptions: TriggerOptions | undefined +): Promise { + return { + queue: triggerOptions?.queue ? { name: triggerOptions.queue } : undefined, + concurrencyKey: triggerOptions?.concurrencyKey, + payloadType, + idempotencyKey: await makeIdempotencyKey(triggerOptions?.idempotencyKey), + idempotencyKeyTTL: triggerOptions?.idempotencyKeyTTL, + delay: triggerOptions?.delay, + ttl: triggerOptions?.ttl, + tags: triggerOptions?.tags, + maxAttempts: triggerOptions?.maxAttempts, + metadata: triggerOptions?.metadata, + maxDuration: triggerOptions?.maxDuration, + lockToVersion: triggerOptions?.version, + priority: triggerOptions?.priority, + region: triggerOptions?.region, + machine: triggerOptions?.machine, + debounce: triggerOptions?.debounce, + }; +} + +function incrementLastEventId(lastEventId: string | undefined): string { + if (!lastEventId) { + return "0"; + } + + const numberValue = Number.parseInt(lastEventId, 10); + if (Number.isNaN(numberValue)) { + return "0"; + } + + return String(numberValue + 1); +} + +export type { TriggerChatTaskContext }; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 0000000000..8ea94e6394 --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,18 @@ +export { ai, type ToolCallExecutionOptions, type ToolOptions } from "./ai.js"; +export { + createTriggerChatTransport, + InMemoryTriggerChatRunStore, + TriggerChatTransport, + type TriggerChatTransportOptions, +} from "./chatTransport.js"; +export type { + TriggerChatPayloadMapper, + TriggerChatRunState, + TriggerChatRunStore, + TriggerChatStream, + TriggerChatTaskContext, + TriggerChatTransportPayload, + TriggerChatTransportRequest, + TriggerChatTransportTrigger, + TriggerChatTriggerOptionsResolver, +} from "./types.js"; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts new file mode 100644 index 0000000000..f539fa280a --- /dev/null +++ b/packages/ai/src/types.ts @@ -0,0 +1,81 @@ +import type { + ChatRequestOptions, + InferUIMessageChunk, + UIMessage, +} from "ai"; +import type { + RealtimeDefinedStream, + TriggerOptions, +} from "@trigger.dev/core/v3"; + +export type TriggerChatTransportTrigger = + | "submit-message" + | "regenerate-message"; + +export type TriggerChatTransportRequest< + UI_MESSAGE extends UIMessage = UIMessage, +> = { + chatId: string; + trigger: TriggerChatTransportTrigger; + messageId: string | undefined; + messages: UI_MESSAGE[]; + request: { + headers?: Record; + body?: ChatRequestOptions["body"]; + metadata?: ChatRequestOptions["metadata"]; + }; + abortSignal: AbortSignal | undefined; +}; + +export type TriggerChatTransportPayload< + UI_MESSAGE extends UIMessage = UIMessage, +> = { + chatId: string; + trigger: TriggerChatTransportTrigger; + messageId: string | undefined; + messages: UI_MESSAGE[]; + request: { + headers?: Record; + body?: ChatRequestOptions["body"]; + metadata?: ChatRequestOptions["metadata"]; + }; +}; + +export type TriggerChatTaskContext< + UI_MESSAGE extends UIMessage = UIMessage, +> = { + payload: TriggerChatTransportPayload; + streamKey: string; +}; + +export type TriggerChatPayloadMapper< + UI_MESSAGE extends UIMessage = UIMessage, + PAYLOAD = TriggerChatTransportPayload, +> = (request: TriggerChatTransportRequest) => PAYLOAD; + +export type TriggerChatTriggerOptionsResolver< + UI_MESSAGE extends UIMessage = UIMessage, +> = ( + request: TriggerChatTransportRequest +) => TriggerOptions | undefined; + +export type TriggerChatStream< + UI_MESSAGE extends UIMessage = UIMessage, +> = + | string + | RealtimeDefinedStream>; + +export type TriggerChatRunState = { + chatId: string; + runId: string; + publicAccessToken: string; + streamKey: string; + lastEventId: string | undefined; + isActive: boolean; +}; + +export interface TriggerChatRunStore { + get(chatId: string): Promise | TriggerChatRunState | undefined; + set(state: TriggerChatRunState): Promise | void; + delete(chatId: string): Promise | void; +} 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..09d30a98d5 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 + zod: + specifier: 3.25.76 + version: 3.25.76 + packages/build: dependencies: '@prisma/config': @@ -14242,11 +14267,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==} @@ -15464,10 +15490,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.0.0: - resolution: {integrity: sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==} - engines: {node: 20 || >=22} - lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} @@ -20439,7 +20461,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: @@ -34166,7 +34188,7 @@ snapshots: eslint: 8.31.0 eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@5.59.6(eslint@8.31.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.5.5)(eslint@8.31.0) - get-tsconfig: 4.7.2 + get-tsconfig: 4.7.6 globby: 13.2.2 is-core-module: 2.14.0 is-glob: 4.0.3 @@ -36426,8 +36448,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.0.0: {} - lru-cache@11.2.4: {} lru-cache@4.1.5: @@ -38258,7 +38278,7 @@ snapshots: path-scurry@2.0.0: dependencies: - lru-cache: 11.0.0 + lru-cache: 11.2.4 minipass: 7.1.2 path-to-regexp@0.1.10: {} @@ -39164,7 +39184,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 +39221,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 @@ -40402,7 +40422,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 +40451,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 From af510e668aa130102fc55cb95f2ee7fca2368cd9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:19:36 +0000 Subject: [PATCH 002/217] Document and demo @trigger.dev/ai useChat transport Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 10 ++ docs/tasks/schemaTask.mdx | 6 +- docs/tasks/streams.mdx | 89 +++++++++++ pnpm-lock.yaml | 128 +++++++++++----- references/realtime-streams/package.json | 4 +- .../realtime-streams/src/app/actions.ts | 6 + references/realtime-streams/src/app/page.tsx | 18 ++- .../src/components/ai-sdk-chat.tsx | 138 ++++++++++++++++++ .../realtime-streams/src/trigger/ai-chat.ts | 24 ++- 9 files changed, 365 insertions(+), 58 deletions(-) create mode 100644 .changeset/curly-radios-visit.md create mode 100644 references/realtime-streams/src/components/ai-sdk-chat.tsx diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md new file mode 100644 index 0000000000..eae5d349f3 --- /dev/null +++ b/.changeset/curly-radios-visit.md @@ -0,0 +1,10 @@ +--- +"@trigger.dev/ai": minor +--- + +Add a new `@trigger.dev/ai` package with: + +- `ai.tool(...)` and `ai.currentToolOptions()` helpers for AI SDK tool calling ergonomics +- a typed `TriggerChatTransport` that plugs into AI SDK UI `useChat()` and runs chat backends as Trigger.dev tasks +- rich default task payloads (`chatId`, trigger metadata, messages, request context) with optional payload mapping +- reconnect-aware stream handling on top of Trigger.dev Realtime Streams v2 diff --git a/docs/tasks/schemaTask.mdx b/docs/tasks/schemaTask.mdx index 3692d1d703..921f83d646 100644 --- a/docs/tasks/schemaTask.mdx +++ b/docs/tasks/schemaTask.mdx @@ -81,7 +81,7 @@ await myTask.trigger({ name: "Alice", age: 30, dob: "2020-01-01" }); // this is The `ai.tool` function allows you to create an AI tool from an existing `schemaTask` to use with the Vercel [AI SDK](https://vercel.com/docs/ai-sdk): ```ts -import { ai } from "@trigger.dev/sdk/ai"; +import { ai } from "@trigger.dev/ai"; import { schemaTask } from "@trigger.dev/sdk"; import { z } from "zod"; import { generateText } from "ai"; @@ -118,7 +118,7 @@ You can also pass the `experimental_toToolResultContent` option to the `ai.tool` ```ts import { openai } from "@ai-sdk/openai"; import { Sandbox } from "@e2b/code-interpreter"; -import { ai } from "@trigger.dev/sdk/ai"; +import { ai } from "@trigger.dev/ai"; import { schemaTask } from "@trigger.dev/sdk"; import { generateObject } from "ai"; import { z } from "zod"; @@ -183,7 +183,7 @@ export const chartTool = ai.tool(chartTask, { You can access the current tool execution options inside the task run function using the `ai.currentToolOptions()` function: ```ts -import { ai } from "@trigger.dev/sdk/ai"; +import { ai } from "@trigger.dev/ai"; import { schemaTask } from "@trigger.dev/sdk"; import { z } from "zod"; diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 2d494977a3..64b846459e 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -517,6 +517,95 @@ const { parts, error } = useRealtimeStream(streamDef, runId, { }); ``` +## AI SDK `useChat` transport with Trigger.dev tasks + +If you want to use AI SDK UI's `useChat()` on the frontend and run the backend as a Trigger.dev task, +use the `@trigger.dev/ai` transport. + +### Install + +```bash +npm add @trigger.dev/ai @ai-sdk/react ai +``` + +### Define a typed stream + +```ts +// app/streams.ts +import { streams } from "@trigger.dev/sdk"; +import { UIMessageChunk } from "ai"; + +export const aiStream = streams.define({ + id: "ai", +}); +``` + +### Create a task that accepts rich chat transport payload + +```ts +// trigger/chat-task.ts +import { openai } from "@ai-sdk/openai"; +import type { TriggerChatTransportPayload } from "@trigger.dev/ai"; +import { task } from "@trigger.dev/sdk"; +import { convertToModelMessages, streamText, UIMessage } from "ai"; +import { aiStream } from "@/app/streams"; + +type ChatPayload = TriggerChatTransportPayload; + +export const aiChatTask = task({ + id: "ai-chat", + run: async (payload: ChatPayload) => { + const result = streamText({ + model: openai("gpt-4o"), + messages: convertToModelMessages(payload.messages), + }); + + const { waitUntilComplete } = aiStream.pipe(result.toUIMessageStream()); + await waitUntilComplete(); + }, +}); +``` + +### Use `useChat()` with Trigger chat transport + +```tsx +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { TriggerChatTransport } from "@trigger.dev/ai"; +import { aiStream } from "@/app/streams"; + +export function Chat({ triggerToken }: { triggerToken: string }) { + const chat = useChat({ + transport: new TriggerChatTransport({ + task: "ai-chat", + stream: aiStream, + accessToken: triggerToken, + timeoutInSeconds: 120, + }), + }); + + return ( +
{ + event.preventDefault(); + chat.sendMessage({ text: "Hello!" }); + }} + > + +
+ ); +} +``` + +The default payload sent to your task is a rich, typed object that includes: + +- `chatId` +- `trigger` (`"submit-message"` or `"regenerate-message"`) +- `messageId` +- `messages` +- `request` (`headers`, `body`, and `metadata`) + ## Complete Example: AI Streaming ### Define the stream diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09d30a98d5..b3f39f4c3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2732,6 +2732,12 @@ importers: '@ai-sdk/openai': specifier: ^2.0.53 version: 2.0.53(zod@3.25.76) + '@ai-sdk/react': + specifier: ^3.0.83 + version: 3.0.83(react@19.1.0)(zod@3.25.76) + '@trigger.dev/ai': + specifier: workspace:* + version: link:../../packages/ai '@trigger.dev/react-hooks': specifier: workspace:* version: link:../../packages/react-hooks @@ -2739,8 +2745,8 @@ importers: specifier: workspace:* version: link:../../packages/trigger-sdk ai: - specifier: ^5.0.76 - version: 5.0.76(zod@3.25.76) + specifier: ^6.0.81 + version: 6.0.81(zod@3.25.76) next: specifier: 15.5.6 version: 15.5.6(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2871,14 +2877,14 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 - '@ai-sdk/gateway@2.0.0': - resolution: {integrity: sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g==} + '@ai-sdk/gateway@3.0.2': + resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/gateway@3.0.2': - resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==} + '@ai-sdk/gateway@3.0.41': + resolution: {integrity: sha512-dYNhtvEomccNNGSxfSP8f4g6yPcoDHyQ6Rb7dALFE0FvvVP9UqfFWi3D2dLIz0VVKaSkiNLQAJ7lsdTVlBdRrw==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -2961,6 +2967,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'} @@ -2985,6 +2997,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'} @@ -3017,6 +3033,12 @@ packages: zod: optional: true + '@ai-sdk/react@3.0.83': + resolution: {integrity: sha512-UsHr+/N0pqGY90BwPFFpWXhY5eVYjNgcWCvSeDMRrzfPvtcd2yqHXlwR7/bveRryVB4NmJ2z6sjyLyOgOHRQdw==} + 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'} @@ -11134,14 +11156,14 @@ packages: '@vanilla-extract/private@1.0.3': resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==} - '@vercel/oidc@3.0.3': - resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==} - engines: {node: '>= 20'} - '@vercel/oidc@3.0.5': 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'} @@ -11157,7 +11179,7 @@ packages: '@vercel/postgres@0.10.0': resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==} engines: {node: '>=18.14'} - deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide' + deprecated: '@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon''s SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide' '@vercel/sdk@1.19.1': resolution: {integrity: sha512-K4rmtUT6t1vX06tiY44ot8A7W1FKN7g/tMkE7yZghCgNQ8b30SzljBd4ni8RNp2pJzM/HrZmphRDeIArO7oZuw==} @@ -11477,14 +11499,14 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 - ai@5.0.76: - resolution: {integrity: sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg==} + ai@6.0.3: + resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 - ai@6.0.3: - resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==} + ai@6.0.81: + resolution: {integrity: sha512-F9EEhjl2dn1VGS5tbU64ldLbqRemV9X1WHghVblpJlPCWuyYj1xpPQsj+G0TRs/SyAGnbpG1yYpl9mkwfr1H8w==} engines: {node: '>=18'} peerDependencies: zod: ^3.25.76 || ^4.1.8 @@ -14249,20 +14271,24 @@ packages: glob@10.3.10: resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} 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 hasBin: true glob@10.3.4: resolution: {integrity: sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ==} 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 hasBin: true glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + 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 hasBin: true glob@11.0.0: resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} engines: {node: 20 || >=22} + 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 hasBin: true glob@7.2.3: @@ -18980,21 +19006,22 @@ packages: tar@6.1.13: resolution: {integrity: sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.5.6: resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -20263,13 +20290,6 @@ snapshots: '@ai-sdk/provider-utils': 3.0.3(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/gateway@2.0.0(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) - '@vercel/oidc': 3.0.3 - zod: 3.25.76 - '@ai-sdk/gateway@3.0.2(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.0 @@ -20277,6 +20297,13 @@ snapshots: '@vercel/oidc': 3.0.5 zod: 3.25.76 + '@ai-sdk/gateway@3.0.41(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 @@ -20361,6 +20388,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 @@ -20385,6 +20419,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) @@ -20415,6 +20453,16 @@ snapshots: optionalDependencies: zod: 3.25.76 + '@ai-sdk/react@3.0.83(react@19.1.0)(zod@3.25.76)': + dependencies: + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + ai: 6.0.81(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 @@ -31468,10 +31516,10 @@ snapshots: '@vanilla-extract/private@1.0.3': {} - '@vercel/oidc@3.0.3': {} - '@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 @@ -31899,14 +31947,6 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 - ai@5.0.76(zod@3.25.76): - dependencies: - '@ai-sdk/gateway': 2.0.0(zod@3.25.76) - '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) - '@opentelemetry/api': 1.9.0 - zod: 3.25.76 - ai@6.0.3(zod@3.25.76): dependencies: '@ai-sdk/gateway': 3.0.2(zod@3.25.76) @@ -31915,6 +31955,14 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 + ai@6.0.81(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 3.0.41(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 @@ -40920,6 +40968,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 @@ -41862,6 +41916,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/realtime-streams/package.json b/references/realtime-streams/package.json index 965443153f..37b624d4b5 100644 --- a/references/realtime-streams/package.json +++ b/references/realtime-streams/package.json @@ -11,9 +11,11 @@ }, "dependencies": { "@ai-sdk/openai": "^2.0.53", + "@ai-sdk/react": "^3.0.83", + "@trigger.dev/ai": "workspace:*", "@trigger.dev/react-hooks": "workspace:*", "@trigger.dev/sdk": "workspace:*", - "ai": "^5.0.76", + "ai": "^6.0.81", "next": "15.5.6", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/references/realtime-streams/src/app/actions.ts b/references/realtime-streams/src/app/actions.ts index 2c18d11e6c..32c16f127f 100644 --- a/references/realtime-streams/src/app/actions.ts +++ b/references/realtime-streams/src/app/actions.ts @@ -44,9 +44,15 @@ export async function triggerStreamTask( } export async function triggerAIChatTask(messages: UIMessage[]) { + const chatId = `chat_${Date.now()}`; + // Trigger the AI chat task const handle = await tasks.trigger("ai-chat", { + chatId, + trigger: "submit-message", + messageId: undefined, messages, + request: {}, }); console.log("Triggered AI chat run:", handle.id); diff --git a/references/realtime-streams/src/app/page.tsx b/references/realtime-streams/src/app/page.tsx index 76beed7a29..438c439507 100644 --- a/references/realtime-streams/src/app/page.tsx +++ b/references/realtime-streams/src/app/page.tsx @@ -1,7 +1,15 @@ +import { AISdkChat } from "@/components/ai-sdk-chat"; import { TriggerButton } from "@/components/trigger-button"; -import { AIChatButton } from "@/components/ai-chat-button"; +import { auth } from "@trigger.dev/sdk"; + +export const dynamic = "force-dynamic"; + +export default async function Home() { + const triggerToken = await auth.createTriggerPublicToken("ai-chat", { + multipleUse: true, + expirationTime: "1h", + }); -export default function Home() { return (
@@ -13,10 +21,8 @@ export default function Home() {

AI Chat Stream (AI SDK v5)

-

- Test AI SDK v5's streamText with toUIMessageStream() -

- +

Test useChat with Trigger.dev task transport

+
diff --git a/references/realtime-streams/src/components/ai-sdk-chat.tsx b/references/realtime-streams/src/components/ai-sdk-chat.tsx new file mode 100644 index 0000000000..a7fb9d76f0 --- /dev/null +++ b/references/realtime-streams/src/components/ai-sdk-chat.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { aiStream } from "@/app/streams"; +import { TriggerChatTransport } from "@trigger.dev/ai"; +import { useChat } from "@ai-sdk/react"; +import type { UIMessage } from "ai"; +import type { TriggerChatRunState } from "@trigger.dev/ai"; +import { Streamdown } from "streamdown"; +import { useMemo, useState } from "react"; + +export function AISdkChat({ triggerToken }: { triggerToken: string }) { + const [input, setInput] = useState(""); + const [lastRunId, setLastRunId] = useState(undefined); + + const transport = useMemo(function createTransport() { + return new TriggerChatTransport({ + task: "ai-chat", + stream: aiStream, + accessToken: triggerToken, + baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL, + timeoutInSeconds: 120, + onTriggeredRun: function onTriggeredRun(state: TriggerChatRunState) { + setLastRunId(state.runId); + }, + }); + }, [triggerToken]); + + const chat = useChat({ + transport, + }); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const trimmedInput = input.trim(); + if (!trimmedInput || chat.status === "submitted" || chat.status === "streaming") { + return; + } + + chat.sendMessage({ + text: trimmedInput, + }); + setInput(""); + } + + return ( +
+
+
+

AI SDK useChat + Trigger.dev task transport

+

+ This chat uses @trigger.dev/ai + Realtime Streams v2 +

+
+ + {chat.status} + +
+ + {lastRunId ? ( +
+ Latest run: {lastRunId} +
+ ) : null} + +
+ {chat.messages.length === 0 ? ( +

+ Ask anything to start. Messages are streamed through a Trigger.dev task. +

+ ) : ( + chat.messages.map(function renderMessage(message) { + const messageText = getMessageText(message); + + return ( +
+
+ {message.role} +
+ {messageText ? ( +
+ + {messageText} + +
+ ) : ( +

No text content

+ )} +
+ ); + }) + )} +
+ +
+ + +
+
+ ); +} + +function getMessageText(message: UIMessage): string { + let text = ""; + + for (const part of message.parts) { + if (part.type === "text") { + text += part.text; + continue; + } + + if (part.type === "reasoning") { + text += part.text; + } + } + + return text; +} diff --git a/references/realtime-streams/src/trigger/ai-chat.ts b/references/realtime-streams/src/trigger/ai-chat.ts index d5c681d07b..6635e30a5f 100644 --- a/references/realtime-streams/src/trigger/ai-chat.ts +++ b/references/realtime-streams/src/trigger/ai-chat.ts @@ -1,9 +1,9 @@ import { aiStream } from "@/app/streams"; import { openai } from "@ai-sdk/openai"; -import { logger, streams, task } from "@trigger.dev/sdk"; +import type { TriggerChatTransportPayload } from "@trigger.dev/ai"; +import { logger, task } from "@trigger.dev/sdk"; import { convertToModelMessages, - readUIMessageStream, stepCountIs, streamText, tool, @@ -11,22 +11,25 @@ import { } from "ai"; import { z } from "zod/v4"; -export type AIChatPayload = { - messages: UIMessage[]; -}; +export type AIChatPayload = TriggerChatTransportPayload; export const aiChatTask = task({ id: "ai-chat", run: async (payload: AIChatPayload) => { logger.info("Starting AI chat stream", { messageCount: payload.messages.length, + chatId: payload.chatId, + trigger: payload.trigger, + messageId: payload.messageId, }); + const modelMessages = await convertToModelMessages(payload.messages); + // Stream text from OpenAI const result = streamText({ model: openai("gpt-4o"), system: "You are a helpful assistant.", - messages: convertToModelMessages(payload.messages), + messages: modelMessages, stopWhen: stepCountIs(20), tools: { getCommonUseCases: tool({ @@ -58,13 +61,7 @@ export const aiChatTask = task({ const uiMessageStream = result.toUIMessageStream(); // Append the stream to metadata - const { waitUntilComplete, stream } = aiStream.pipe(uiMessageStream); - - for await (const uiMessage of readUIMessageStream({ - stream: stream, - })) { - logger.log("Current message state", { uiMessage }); - } + const { waitUntilComplete } = aiStream.pipe(uiMessageStream); // Wait for the stream to complete await waitUntilComplete(); @@ -74,6 +71,7 @@ export const aiChatTask = task({ return { message: "AI chat stream completed successfully", messageCount: payload.messages.length, + chatId: payload.chatId, }; }, }); From 092c2ba55cbf9863b48a0326feb1546b8d48d6cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:23:57 +0000 Subject: [PATCH 003/217] Add ai compatibility tests and preserve sdk behavior Co-authored-by: Eric Allam --- docs/tasks/schemaTask.mdx | 3 + packages/ai/package.json | 2 +- packages/ai/src/ai.test.ts | 6 +- packages/ai/src/ai.ts | 8 +-- packages/trigger-sdk/src/v3/ai.test.ts | 81 ++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 packages/trigger-sdk/src/v3/ai.test.ts diff --git a/docs/tasks/schemaTask.mdx b/docs/tasks/schemaTask.mdx index 921f83d646..3bf1f2b5d8 100644 --- a/docs/tasks/schemaTask.mdx +++ b/docs/tasks/schemaTask.mdx @@ -80,6 +80,9 @@ await myTask.trigger({ name: "Alice", age: 30, dob: "2020-01-01" }); // this is The `ai.tool` function allows you to create an AI tool from an existing `schemaTask` to use with the Vercel [AI SDK](https://vercel.com/docs/ai-sdk): +> `@trigger.dev/ai` is the recommended import path. For backwards compatibility, +> `@trigger.dev/sdk/ai` continues to work. + ```ts import { ai } from "@trigger.dev/ai"; import { schemaTask } from "@trigger.dev/sdk"; diff --git a/packages/ai/package.json b/packages/ai/package.json index c6cfdf062a..ebec165a0d 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -33,7 +33,7 @@ "build": "tshy && pnpm run update-version", "dev": "tshy --watch", "typecheck": "tsc --noEmit", - "test": "vitest", + "test": "vitest --exclude \"**/.tshy-build/**\"", "update-version": "tsx ../../scripts/updateVersion.ts", "check-exports": "attw --pack ." }, diff --git a/packages/ai/src/ai.test.ts b/packages/ai/src/ai.test.ts index b171674c7f..cddcbbacef 100644 --- a/packages/ai/src/ai.test.ts +++ b/packages/ai/src/ai.test.ts @@ -73,7 +73,9 @@ describe("ai helper", function () { }).toThrowError("task has no schema"); }); - it("returns undefined for current tool options outside task execution context", function () { - expect(ai.currentToolOptions()).toBeUndefined(); + it("throws for current tool options outside task execution context", function () { + expect(function () { + ai.currentToolOptions(); + }).toThrowError("Method not implemented."); }); }); diff --git a/packages/ai/src/ai.ts b/packages/ai/src/ai.ts index 59d3474779..3493440cff 100644 --- a/packages/ai/src/ai.ts +++ b/packages/ai/src/ai.ts @@ -89,13 +89,7 @@ function toolFromTask< } function getToolOptionsFromMetadata(): ToolCallExecutionOptions | undefined { - let tool: unknown; - try { - tool = runMetadata.getKey(METADATA_KEY); - } catch { - return undefined; - } - + const tool = runMetadata.getKey(METADATA_KEY); if (!tool) { return undefined; } diff --git a/packages/trigger-sdk/src/v3/ai.test.ts b/packages/trigger-sdk/src/v3/ai.test.ts new file mode 100644 index 0000000000..0d36f729a7 --- /dev/null +++ b/packages/trigger-sdk/src/v3/ai.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { ai } from "./ai.js"; +import type { TaskWithSchema } from "@trigger.dev/core/v3"; + +describe("@trigger.dev/sdk/ai compatibility", function () { + it("creates a tool from a schema task and executes triggerAndWait", async function () { + let receivedInput: unknown = undefined; + + const fakeTask = { + id: "fake-task", + description: "A fake task", + schema: z.object({ + name: z.string(), + }), + triggerAndWait: function (payload: { name: string }) { + receivedInput = payload; + const resultPromise = Promise.resolve({ + ok: true, + id: "run_123", + taskIdentifier: "fake-task", + output: { + greeting: `Hello ${payload.name}`, + }, + }); + + return Object.assign(resultPromise, { + unwrap: async function () { + return { + greeting: `Hello ${payload.name}`, + }; + }, + }); + }, + } as unknown as TaskWithSchema< + "fake-task", + z.ZodObject<{ name: z.ZodString }>, + { greeting: string } + >; + + const tool = ai.tool(fakeTask); + const result = await tool.execute?.( + { + name: "Ada", + }, + undefined as never + ); + + expect(receivedInput).toEqual({ + name: "Ada", + }); + expect(result).toEqual({ + greeting: "Hello Ada", + }); + }); + + it("throws when converting tasks without schema", function () { + const fakeTask = { + id: "no-schema", + description: "No schema task", + schema: undefined, + triggerAndWait: async function () { + return { + unwrap: async function () { + return {}; + }, + }; + }, + } as unknown as TaskWithSchema<"no-schema", undefined, unknown>; + + expect(function () { + ai.tool(fakeTask); + }).toThrowError("task has no schema"); + }); + + it("preserves currentToolOptions behavior outside task execution", function () { + expect(function () { + ai.currentToolOptions(); + }).toThrowError("Method not implemented."); + }); +}); From 7c0476cc1c178b5869972dcb396a5147570f921e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:25:33 +0000 Subject: [PATCH 004/217] Use function declaration style in ai.tool execute callback Co-authored-by: Eric Allam --- packages/ai/src/ai.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/src/ai.ts b/packages/ai/src/ai.ts index 3493440cff..e54f893082 100644 --- a/packages/ai/src/ai.ts +++ b/packages/ai/src/ai.ts @@ -69,7 +69,7 @@ function toolFromTask< const toolDefinition = dynamicTool({ description: task.description, inputSchema: convertTaskSchemaToToolParameters(task), - execute: async (input, options) => { + execute: async function execute(input, options) { const serializedOptions = options ? JSON.parse(JSON.stringify(options)) : undefined; return await task From babf007c10270cc0a79104203a6f326e9bf6f678 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:29:07 +0000 Subject: [PATCH 005/217] Add changelog for new @trigger.dev/ai package Co-authored-by: Eric Allam --- packages/ai/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 packages/ai/CHANGELOG.md diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md new file mode 100644 index 0000000000..690f886233 --- /dev/null +++ b/packages/ai/CHANGELOG.md @@ -0,0 +1,10 @@ +# @trigger.dev/ai + +## 4.3.3 + +### Added + +- Introduced a new `@trigger.dev/ai` package. +- Added `ai.tool(...)` and `ai.currentToolOptions()` helpers for AI SDK tool ergonomics. +- Added `TriggerChatTransport` / `createTriggerChatTransport(...)` for AI SDK `useChat()` integrations powered by Trigger.dev tasks and Realtime Streams v2. +- Added rich default chat payload typing (`chatId`, `trigger`, `messageId`, `messages`, request context) and mapper hooks for custom payloads. From 60f2a32d03592f9175c173bf4d2dbd7d0db6e9eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:30:53 +0000 Subject: [PATCH 006/217] Add custom mapper and trigger options transport test Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 133 ++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 4e14927755..a614ad53d0 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -146,6 +146,139 @@ describe("TriggerChatTransport", function () { expect(stream).toBeNull(); }); + it("supports custom payload mapping and trigger options resolver", async function () { + let receivedTriggerBody: Record | undefined; + let receivedResolverChatId: string | undefined; + let receivedResolverHeader: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + readJsonBody(req).then(function (body) { + receivedTriggerBody = body; + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_789", + }); + res.end(JSON.stringify({ id: "run_789" })); + }); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_789/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "mapped_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "mapped_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport< + UIMessage, + { + prompt: string; + chatId: string; + sourceHeader: string | undefined; + } + >({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + payloadMapper: function payloadMapper(request) { + const firstMessage = request.messages[0]; + const firstPart = firstMessage?.parts[0]; + const prompt = + firstPart && firstPart.type === "text" + ? firstPart.text + : ""; + + return { + prompt, + chatId: request.chatId, + sourceHeader: request.request.headers?.["x-source"], + }; + }, + triggerOptions: function triggerOptions(request) { + receivedResolverChatId = request.chatId; + receivedResolverHeader = request.request.headers?.["x-source"]; + + return { + queue: "chat-queue", + concurrencyKey: `chat-${request.chatId}`, + idempotencyKey: `idem-${request.chatId}`, + ttl: "30m", + tags: ["chat", "mapped"], + metadata: { + requester: request.request.headers?.["x-source"] ?? "unknown", + }, + priority: 50, + }; + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-mapped", + messageId: undefined, + messages: [ + { + id: "mapped-user", + role: "user", + parts: [{ type: "text", text: "Map me" }], + } satisfies UIMessage, + ], + abortSignal: undefined, + headers: { + "x-source": "sdk-test", + }, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(chunks[0]).toMatchObject({ + chunk: { type: "text-start", id: "mapped_1" }, + }); + expect(chunks[1]).toMatchObject({ + chunk: { type: "text-end", id: "mapped_1" }, + }); + + expect(receivedResolverChatId).toBe("chat-mapped"); + expect(receivedResolverHeader).toBe("sdk-test"); + + expect(receivedTriggerBody).toBeDefined(); + const payloadString = receivedTriggerBody?.payload as string; + const payload = (JSON.parse(payloadString) as { json: Record }).json; + expect(payload).toEqual({ + prompt: "Map me", + chatId: "chat-mapped", + sourceHeader: "sdk-test", + }); + + const options = (receivedTriggerBody?.options ?? {}) as Record; + expect(options.queue).toEqual({ name: "chat-queue" }); + expect(options.concurrencyKey).toBe("chat-chat-mapped"); + expect(options.ttl).toBe("30m"); + expect(options.tags).toEqual(["chat", "mapped"]); + expect(options.metadata).toEqual({ requester: "sdk-test" }); + expect(options.priority).toBe(50); + expect(typeof options.idempotencyKey).toBe("string"); + expect((options.idempotencyKey as string).length).toBe(64); + }); + it("reconnects active streams using tracked lastEventId", async function () { let reconnectLastEventId: string | undefined; let firstStreamResponse: ServerResponse | undefined; From b30a66b74515294a3b03eb64343c812cf96c7a3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:32:20 +0000 Subject: [PATCH 007/217] Add factory constructor coverage for chat transport Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index a614ad53d0..856d94dd0b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -3,6 +3,7 @@ import { AddressInfo } from "node:net"; import { afterEach, describe, expect, it } from "vitest"; import { InMemoryTriggerChatRunStore, + createTriggerChatTransport, TriggerChatTransport, } from "./chatTransport.js"; import type { UIMessage, UIMessageChunk } from "ai"; @@ -279,6 +280,64 @@ describe("TriggerChatTransport", function () { expect((options.idempotencyKey as string).length).toBe(64); }); + it("supports creating transport with factory function", async function () { + let observedRunId: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_factory", + }); + res.end(JSON.stringify({ id: "run_factory" })); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_factory/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "factory_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "factory_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = createTriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + onTriggeredRun: function onTriggeredRun(state) { + observedRunId = state.runId; + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-factory", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedRunId).toBe("run_factory"); + }); + it("reconnects active streams using tracked lastEventId", async function () { let reconnectLastEventId: string | undefined; let firstStreamResponse: ServerResponse | undefined; From 58b43ecf34599f6a5be3b52afd76c1ba8b4b5934 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:33:55 +0000 Subject: [PATCH 008/217] Track exact SSE event IDs for chat reconnect Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 4 +- packages/ai/src/chatTransport.ts | 57 ++++++++++++++++----------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 856d94dd0b..e707e00d66 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -419,7 +419,7 @@ describe("TriggerChatTransport", function () { } const state = runStore.get("chat-2"); - return Boolean(state && state.lastEventId === "0"); + return Boolean(state && state.lastEventId === "1-0"); }); const reconnectStream = await transport.reconnectToStream({ @@ -429,7 +429,7 @@ describe("TriggerChatTransport", function () { expect(reconnectStream).not.toBeNull(); const reconnectChunks = await readChunks(reconnectStream!); - expect(reconnectLastEventId).toBe("0"); + expect(reconnectLastEventId).toBe("1-0"); expect(reconnectChunks).toHaveLength(2); expect(reconnectChunks[0]).toMatchObject({ chunk: { type: "text-delta", id: "msg_2", delta: "world" }, diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 117377848d..34099edf43 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -2,6 +2,8 @@ import { ApiClient, ApiRequestOptions, makeIdempotencyKey, + SSEStreamPart, + SSEStreamSubscription, stringifyIO, TriggerOptions, } from "@trigger.dev/core/v3"; @@ -205,7 +207,7 @@ export class TriggerChatTransport< runState: TriggerChatRunState, abortSignal: AbortSignal | undefined, lastEventId?: string - ): Promise> { + ): Promise>>> { const streamClient = new ApiClient( this.baseURL, runState.publicAccessToken, @@ -213,39 +215,53 @@ export class TriggerChatTransport< this.requestOptions ); - const stream = await streamClient.fetchStream>( - runState.runId, - runState.streamKey, + const subscription = new SSEStreamSubscription( + this.createStreamUrl(runState.runId, runState.streamKey), { + headers: streamClient.getHeaders(), signal: abortSignal, timeoutInSeconds: this.timeoutInSeconds, lastEventId, } ); - return stream as unknown as ReadableStream; + return (await subscription.subscribe()) as ReadableStream< + SSEStreamPart> + >; } - private createTrackedStream(chatId: string, stream: ReadableStream) { + private createTrackedStream( + chatId: string, + stream: ReadableStream>> + ) { const teeStreams = stream.tee(); const trackingStream = teeStreams[0]; const consumerStream = teeStreams[1]; this.consumeTrackingStream(chatId, trackingStream); - return consumerStream; + return consumerStream.pipeThrough( + new TransformStream>, UIMessageChunk>({ + transform(part, controller) { + controller.enqueue(part.chunk as UIMessageChunk); + }, + }) + ); } - private async consumeTrackingStream(chatId: string, stream: ReadableStream) { + private async consumeTrackingStream( + chatId: string, + stream: ReadableStream>> + ) { try { - for await (const _chunk of stream) { + for await (const part of stream) { const runState = await this.runStore.get(chatId); if (!runState) { return; } - runState.lastEventId = incrementLastEventId(runState.lastEventId); + runState.lastEventId = part.id; await this.runStore.set(runState); } @@ -274,6 +290,14 @@ export class TriggerChatTransport< return handle as TriggerTaskResponse; } + + private createStreamUrl(runId: string, streamKey: string): string { + const normalizedBaseUrl = this.baseURL.replace(/\/$/, ""); + const encodedRunId = encodeURIComponent(runId); + const encodedStreamKey = encodeURIComponent(streamKey); + + return `${normalizedBaseUrl}/realtime/v1/streams/${encodedRunId}/${encodedStreamKey}`; + } } export function createTriggerChatTransport< @@ -426,17 +450,4 @@ async function createTriggerTaskOptions( }; } -function incrementLastEventId(lastEventId: string | undefined): string { - if (!lastEventId) { - return "0"; - } - - const numberValue = Number.parseInt(lastEventId, 10); - if (Number.isNaN(numberValue)) { - return "0"; - } - - return String(numberValue + 1); -} - export type { TriggerChatTaskContext }; From cf1b5c7ffe2978ea3eee2103e54f1328954bb35d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:35:34 +0000 Subject: [PATCH 009/217] Add compile-time DX tests for chat transport types Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.types.test.ts | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/ai/src/chatTransport.types.test.ts diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts new file mode 100644 index 0000000000..0926cddd0f --- /dev/null +++ b/packages/ai/src/chatTransport.types.test.ts @@ -0,0 +1,81 @@ +import { expectTypeOf, it } from "vitest"; +import type { InferUIMessageChunk, UIMessage } from "ai"; +import { + TriggerChatTransport, + TriggerChatTransportOptions, + type TriggerChatTransportPayload, + type TriggerChatTransportRequest, + type TriggerChatRunState, +} from "./index.js"; +import type { RealtimeDefinedStream } from "@trigger.dev/core/v3"; + +it("infers rich default payload contract", function () { + const transport = new TriggerChatTransport({ + task: "ai-chat", + accessToken: "pk_test", + stream: "chat-stream", + }); + + expectTypeOf(transport).toEqualTypeOf< + TriggerChatTransport> + >(); +}); + +it("requires payload mapper for custom payload types", function () { + // @ts-expect-error Custom payload generic requires payloadMapper + const invalidOptions: TriggerChatTransportOptions = { + task: "ai-chat", + accessToken: "pk_test", + stream: "chat-stream", + }; + + expectTypeOf(invalidOptions).toBeObject(); +}); + +it("types mapper input with rich request context", function () { + const options: TriggerChatTransportOptions< + UIMessage, + { prompt: string; chatId: string; source: string | undefined } + > = { + task: "ai-chat", + accessToken: "pk_test", + stream: "chat-stream", + payloadMapper: function payloadMapper(request: TriggerChatTransportRequest) { + const firstMessage = request.messages[0]; + const firstPart = firstMessage?.parts[0]; + const prompt = + firstPart && firstPart.type === "text" + ? firstPart.text + : ""; + + return { + prompt, + chatId: request.chatId, + source: request.request.headers?.["x-source"], + }; + }, + onTriggeredRun: function onTriggeredRun(state: TriggerChatRunState) { + expectTypeOf(state.chatId).toEqualTypeOf(); + expectTypeOf(state.publicAccessToken).toEqualTypeOf(); + }, + }; + + expectTypeOf(options.payloadMapper).toBeFunction(); +}); + +it("accepts typed stream definition objects", function () { + const typedStream = { + id: "chat-stream", + pipe: async function pipe() { + throw new Error("not used in type test"); + }, + } as unknown as RealtimeDefinedStream>; + + const transport = new TriggerChatTransport({ + task: "ai-chat", + accessToken: "pk_test", + stream: typedStream, + }); + + expectTypeOf(transport).toBeObject(); +}); From b51156664d97e5def3eaaf90346190353d176583 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:37:19 +0000 Subject: [PATCH 010/217] Cleanup run-store entries when chat streams finish Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 70 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 2 + 2 files changed, 72 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e707e00d66..4c258c58ef 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -338,6 +338,67 @@ describe("TriggerChatTransport", function () { expect(observedRunId).toBe("run_factory"); }); + it("cleans run store state when stream completes", async function () { + const trackedRunStore = new TrackedRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_cleanup", + }); + res.end(JSON.stringify({ id: "run_cleanup" })); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_cleanup/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "cleanup_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "cleanup_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore: trackedRunStore, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-cleanup", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + + await waitForCondition(function () { + return trackedRunStore.deleteCalls.includes("chat-cleanup"); + }); + + expect(trackedRunStore.get("chat-cleanup")).toBeUndefined(); + }); + it("reconnects active streams using tracked lastEventId", async function () { let reconnectLastEventId: string | undefined; let firstStreamResponse: ServerResponse | undefined; @@ -528,3 +589,12 @@ async function waitForCondition(condition: () => boolean, timeoutInMs = 5000) { throw new Error(`Condition was not met within ${timeoutInMs}ms`); } + +class TrackedRunStore extends InMemoryTriggerChatRunStore { + public readonly deleteCalls: string[] = []; + + public delete(chatId: string): void { + this.deleteCalls.push(chatId); + super.delete(chatId); + } +} diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 34099edf43..bc7a4fc749 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -269,12 +269,14 @@ export class TriggerChatTransport< if (runState) { runState.isActive = false; await this.runStore.set(runState); + await this.runStore.delete(chatId); } } catch { const runState = await this.runStore.get(chatId); if (runState) { runState.isActive = false; await this.runStore.set(runState); + await this.runStore.delete(chatId); } } } From d706534ad83173a32610588dd858b88f2541412b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:38:47 +0000 Subject: [PATCH 011/217] Test reconnect null behavior after completed streams Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 67 ++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 4c258c58ef..44c45ac87a 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -399,6 +399,66 @@ describe("TriggerChatTransport", function () { expect(trackedRunStore.get("chat-cleanup")).toBeUndefined(); }); + it("returns null from reconnect after stream completion cleanup", async function () { + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_done", + }); + res.end(JSON.stringify({ id: "run_done" })); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_done/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "done_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "done_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-done", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + + await waitForCondition(async function () { + const reconnect = await transport.reconnectToStream({ + chatId: "chat-done", + }); + + return reconnect === null; + }); + }); + it("reconnects active streams using tracked lastEventId", async function () { let reconnectLastEventId: string | undefined; let firstStreamResponse: ServerResponse | undefined; @@ -574,11 +634,14 @@ async function readChunks(stream: ReadableStream) { return parts; } -async function waitForCondition(condition: () => boolean, timeoutInMs = 5000) { +async function waitForCondition( + condition: () => boolean | Promise, + timeoutInMs = 5000 +) { const start = Date.now(); while (Date.now() - start < timeoutInMs) { - if (condition()) { + if (await condition()) { return; } From cc2be18789ba16f14a231aa785a74c749a6a19be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:40:36 +0000 Subject: [PATCH 012/217] Cover async run store implementations in chat transport tests Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 98 +++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 44c45ac87a..e1124dd355 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -7,6 +7,10 @@ import { TriggerChatTransport, } from "./chatTransport.js"; import type { UIMessage, UIMessageChunk } from "ai"; +import type { + TriggerChatRunState, + TriggerChatRunStore, +} from "./types.js"; type TestServer = { url: string; @@ -459,6 +463,69 @@ describe("TriggerChatTransport", function () { }); }); + it("supports async run store implementations", async function () { + const runStore = new AsyncTrackedRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_async", + }); + res.end(JSON.stringify({ id: "run_async" })); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_async/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "async_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "async_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-async", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + + await waitForCondition(function () { + return runStore.deleteCalls.includes("chat-async"); + }); + + expect(runStore.setCalls).toContain("chat-async"); + expect(runStore.getCalls).toContain("chat-async"); + await expect(runStore.get("chat-async")).resolves.toBeUndefined(); + }); + it("reconnects active streams using tracked lastEventId", async function () { let reconnectLastEventId: string | undefined; let firstStreamResponse: ServerResponse | undefined; @@ -661,3 +728,34 @@ class TrackedRunStore extends InMemoryTriggerChatRunStore { super.delete(chatId); } } + +class AsyncTrackedRunStore implements TriggerChatRunStore { + private readonly runs = new Map(); + public readonly getCalls: string[] = []; + public readonly setCalls: string[] = []; + public readonly deleteCalls: string[] = []; + + public async get(chatId: string): Promise { + this.getCalls.push(chatId); + await sleep(1); + return this.runs.get(chatId); + } + + public async set(state: TriggerChatRunState): Promise { + this.setCalls.push(state.chatId); + await sleep(1); + this.runs.set(state.chatId, state); + } + + public async delete(chatId: string): Promise { + this.deleteCalls.push(chatId); + await sleep(1); + this.runs.delete(chatId); + } +} + +async function sleep(timeoutInMs: number) { + await new Promise(function (resolve) { + setTimeout(resolve, timeoutInMs); + }); +} From 5d5a757235b21ecd95c1901d5b6200a8ce01532e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:42:05 +0000 Subject: [PATCH 013/217] Test default stream fallback path in chat transport Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 58 +++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e1124dd355..c821ac5e8d 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -29,6 +29,64 @@ afterEach(async function () { }); describe("TriggerChatTransport", function () { + it("uses default stream key when stream option is omitted", async function () { + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_default_stream", + }); + res.end(JSON.stringify({ id: "run_default_stream" })); + return; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_default_stream/default") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "default_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "default_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: server.url, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-default-stream", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedStreamPath).toBe("/realtime/v1/streams/run_default_stream/default"); + }); + it("triggers task and streams chunks with rich default payload", async function () { let receivedTriggerBody: Record | undefined; From b53b61b96b958a2b9c54c655fdde8ee91f9c099e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:43:23 +0000 Subject: [PATCH 014/217] Add package README for @trigger.dev/ai usage Co-authored-by: Eric Allam --- packages/ai/README.md | 80 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 packages/ai/README.md diff --git a/packages/ai/README.md b/packages/ai/README.md new file mode 100644 index 0000000000..8a974f4dc5 --- /dev/null +++ b/packages/ai/README.md @@ -0,0 +1,80 @@ +# @trigger.dev/ai + +AI SDK integrations for Trigger.dev. + +## What this package includes + +- `TriggerChatTransport` for wiring AI SDK `useChat()` to Trigger.dev tasks + Realtime Streams v2 +- `createTriggerChatTransport(...)` factory helper +- `ai.tool(...)` and `ai.currentToolOptions()` helpers for tool-calling flows + +## Install + +```bash +npm add @trigger.dev/ai ai +``` + +## `useChat()` transport example + +```tsx +import { useChat } from "@ai-sdk/react"; +import { TriggerChatTransport } from "@trigger.dev/ai"; +import { aiStream } from "@/app/streams"; + +export function Chat({ triggerToken }: { triggerToken: string }) { + const chat = useChat({ + transport: new TriggerChatTransport({ + task: "ai-chat", + stream: aiStream, + accessToken: triggerToken, + }), + }); + + return ( + + ); +} +``` + +## Task payload typing + +Use `TriggerChatTransportPayload` in your task for the default rich payload: + +- `chatId` +- `trigger` +- `messageId` +- `messages` +- `request` (`headers`, `body`, `metadata`) + +```ts +import type { TriggerChatTransportPayload } from "@trigger.dev/ai"; +import { UIMessage } from "ai"; + +type Payload = TriggerChatTransportPayload; +``` + +## `ai.tool(...)` example + +```ts +import { ai } from "@trigger.dev/ai"; +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +const searchTask = schemaTask({ + id: "search", + schema: z.object({ query: z.string() }), + run: async function run(payload) { + return { result: payload.query }; + }, +}); + +const tool = ai.tool(searchTask); +``` + +`@trigger.dev/sdk/ai` remains available for backwards compatibility, but `@trigger.dev/ai` is the recommended import path. From d02edc3a4c93b5b7c73f21011465e019f0d301c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:44:49 +0000 Subject: [PATCH 015/217] Deprecate sdk ai import path in favor of @trigger.dev/ai Co-authored-by: Eric Allam --- packages/trigger-sdk/src/v3/ai.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 59afa2fe21..02e823d851 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -61,7 +61,7 @@ function toolFromTask< const toolDefinition = dynamicTool({ description: task.description, inputSchema: convertTaskSchemaToToolParameters(task), - execute: async (input, options) => { + execute: async function execute(input, options) { const serializedOptions = options ? JSON.parse(JSON.stringify(options)) : undefined; return await task @@ -112,6 +112,10 @@ function convertTaskSchemaToToolParameters( ); } +/** + * @deprecated Use `ai` from `@trigger.dev/ai` for new code. + * `@trigger.dev/sdk/ai` is kept for backwards compatibility. + */ export const ai = { tool: toolFromTask, currentToolOptions: getToolOptionsFromMetadata, From 70d297cd7b3f5fe9e49221c2e9f16b1932d3a5fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:45:44 +0000 Subject: [PATCH 016/217] Test stream key URL encoding in chat transport Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index c821ac5e8d..9a431c6b31 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -87,6 +87,68 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_default_stream/default"); }); + it("encodes stream key values in stream URL paths", async function () { + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_encoded_stream", + }); + res.end(JSON.stringify({ id: "run_encoded_stream" })); + return; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_encoded_stream/chat%2Fspecial%20stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "encoded_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "encoded_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: server.url, + stream: "chat/special stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-encoded-stream", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedStreamPath).toBe("/realtime/v1/streams/run_encoded_stream/chat%2Fspecial%20stream"); + }); + it("triggers task and streams chunks with rich default payload", async function () { let receivedTriggerBody: Record | undefined; From 9974f22c7c0734fdecfafa33f9f6e164e7e43353 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:47:57 +0000 Subject: [PATCH 017/217] Guard reconnect against fetch setup failures Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index bc7a4fc749..a788a9ad14 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -198,7 +198,15 @@ export class TriggerChatTransport< return null; } - const stream = await this.fetchRunStream(runState, undefined, runState.lastEventId); + let stream: ReadableStream>>; + try { + stream = await this.fetchRunStream(runState, undefined, runState.lastEventId); + } catch { + runState.isActive = false; + await this.runStore.set(runState); + await this.runStore.delete(options.chatId); + return null; + } return this.createTrackedStream(runState.chatId, stream); } From 390e53cdf66800c2620c4bb9a8293eaa2b831700 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:49:08 +0000 Subject: [PATCH 018/217] Document custom payload mapping and run store options Co-authored-by: Eric Allam --- packages/ai/README.md | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/ai/README.md b/packages/ai/README.md index 8a974f4dc5..df0c6a2d08 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -59,6 +59,58 @@ import { UIMessage } from "ai"; type Payload = TriggerChatTransportPayload; ``` +## Custom payload mapping + +If your task expects a custom payload shape, provide `payloadMapper`: + +```ts +import { TriggerChatTransport } from "@trigger.dev/ai"; +import type { UIMessage } from "ai"; + +const transport = new TriggerChatTransport< + UIMessage, + { prompt: string; tenantId: string | undefined } +>({ + task: "ai-chat-custom", + accessToken: "pk_...", + payloadMapper: function payloadMapper(request) { + const firstPart = request.messages[0]?.parts[0]; + + return { + prompt: firstPart && firstPart.type === "text" ? firstPart.text : "", + tenantId: + typeof request.request.body === "object" && request.request.body + ? (request.request.body as Record).tenantId + : undefined, + }; + }, +}); +``` + +## Optional persistent run state + +`TriggerChatTransport` supports custom run stores (including async implementations) to persist reconnect state: + +```ts +import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; + +class MemoryStore implements TriggerChatRunStore { + private runs = new Map(); + + async get(chatId: string) { + return this.runs.get(chatId); + } + + async set(state: TriggerChatRunState) { + this.runs.set(state.chatId, state); + } + + async delete(chatId: string) { + this.runs.delete(chatId); + } +} +``` + ## `ai.tool(...)` example ```ts From ffb91a1a4b129492d744faae71a7a9483f09477a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:51:13 +0000 Subject: [PATCH 019/217] Support async payload and trigger option resolvers Co-authored-by: Eric Allam --- packages/ai/README.md | 9 ++++++-- packages/ai/src/chatTransport.test.ts | 8 +++++-- packages/ai/src/chatTransport.ts | 4 ++-- packages/ai/src/chatTransport.types.test.ts | 23 +++++++++++++++++++++ packages/ai/src/types.ts | 6 ++++-- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index df0c6a2d08..199ee61d45 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -61,7 +61,7 @@ type Payload = TriggerChatTransportPayload; ## Custom payload mapping -If your task expects a custom payload shape, provide `payloadMapper`: +If your task expects a custom payload shape, provide `payloadMapper` (sync or async): ```ts import { TriggerChatTransport } from "@trigger.dev/ai"; @@ -73,7 +73,9 @@ const transport = new TriggerChatTransport< >({ task: "ai-chat-custom", accessToken: "pk_...", - payloadMapper: function payloadMapper(request) { + payloadMapper: async function payloadMapper(request) { + await Promise.resolve(); + const firstPart = request.messages[0]?.parts[0]; return { @@ -87,6 +89,9 @@ const transport = new TriggerChatTransport< }); ``` +`triggerOptions` can also be a function (sync or async), which gives you access to +`chatId`, messages, and request context to compute queueing/idempotency options. + ## Optional persistent run state `TriggerChatTransport` supports custom run stores (including async implementations) to persist reconnect state: diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 9a431c6b31..611b9d6b1e 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -323,7 +323,9 @@ describe("TriggerChatTransport", function () { stream: "chat-stream", accessToken: "pk_trigger", baseURL: server.url, - payloadMapper: function payloadMapper(request) { + payloadMapper: async function payloadMapper(request) { + await sleep(1); + const firstMessage = request.messages[0]; const firstPart = firstMessage?.parts[0]; const prompt = @@ -337,7 +339,9 @@ describe("TriggerChatTransport", function () { sourceHeader: request.request.headers?.["x-source"], }; }, - triggerOptions: function triggerOptions(request) { + triggerOptions: async function triggerOptions(request) { + await sleep(1); + receivedResolverChatId = request.chatId; receivedResolverHeader = request.request.headers?.["x-source"]; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index a788a9ad14..d71572ed34 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -160,7 +160,7 @@ export class TriggerChatTransport< } & ChatRequestOptions ): Promise> { const transportRequest = createTransportRequest(options); - const payload = this.payloadMapper(transportRequest); + const payload = await this.payloadMapper(transportRequest); const triggerOptions = await resolveTriggerOptions( this.triggerOptions, transportRequest @@ -430,7 +430,7 @@ async function resolveTriggerOptions( } if (typeof options === "function") { - return options(request); + return await options(request); } return options; diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index 0926cddd0f..191b1c1045 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -63,6 +63,29 @@ it("types mapper input with rich request context", function () { expectTypeOf(options.payloadMapper).toBeFunction(); }); +it("accepts async payload mappers and trigger option resolvers", function () { + const options: TriggerChatTransportOptions< + UIMessage, + { prompt: string; chatId: string } + > = { + task: "ai-chat", + accessToken: "pk_test", + payloadMapper: async function payloadMapper(request) { + return { + prompt: request.chatId, + chatId: request.chatId, + }; + }, + triggerOptions: async function triggerOptions(request) { + return { + queue: `queue-${request.chatId}`, + }; + }, + }; + + expectTypeOf(options).toBeObject(); +}); + it("accepts typed stream definition objects", function () { const typedStream = { id: "chat-stream", diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index f539fa280a..d98a30be2f 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -48,16 +48,18 @@ export type TriggerChatTaskContext< streamKey: string; }; +type MaybePromise = T | Promise; + export type TriggerChatPayloadMapper< UI_MESSAGE extends UIMessage = UIMessage, PAYLOAD = TriggerChatTransportPayload, -> = (request: TriggerChatTransportRequest) => PAYLOAD; +> = (request: TriggerChatTransportRequest) => MaybePromise; export type TriggerChatTriggerOptionsResolver< UI_MESSAGE extends UIMessage = UIMessage, > = ( request: TriggerChatTransportRequest -) => TriggerOptions | undefined; +) => MaybePromise; export type TriggerChatStream< UI_MESSAGE extends UIMessage = UIMessage, From 7d17be7406c7a23d53dd67866bca44d304b67af2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:52:29 +0000 Subject: [PATCH 020/217] Document advanced chat transport mapping and run store options Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 64b846459e..2b9eab71a8 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -606,6 +606,34 @@ The default payload sent to your task is a rich, typed object that includes: - `messages` - `request` (`headers`, `body`, and `metadata`) +### Advanced transport options + +`TriggerChatTransport` also supports: + +- `payloadMapper` (sync or async) for custom task payload shapes +- `triggerOptions` as an object or resolver function (sync or async) +- `runStore` for custom reconnect-state persistence (including async stores) + +```ts +import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; + +class MemoryRunStore implements TriggerChatRunStore { + private runs = new Map(); + + async get(chatId: string) { + return this.runs.get(chatId); + } + + async set(state: TriggerChatRunState) { + this.runs.set(state.chatId, state); + } + + async delete(chatId: string) { + this.runs.delete(chatId); + } +} +``` + ## Complete Example: AI Streaming ### Define the stream From 889194ad94799d99a9aaadcf22e2a8c717e99940 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:54:03 +0000 Subject: [PATCH 021/217] Cover typed stream definition ids in transport tests Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 67 +++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 611b9d6b1e..06a2ac9b07 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -6,6 +6,7 @@ import { createTriggerChatTransport, TriggerChatTransport, } from "./chatTransport.js"; +import type { TriggerChatStream } from "./types.js"; import type { UIMessage, UIMessageChunk } from "ai"; import type { TriggerChatRunState, @@ -149,6 +150,72 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_encoded_stream/chat%2Fspecial%20stream"); }); + it("uses defined stream object id when provided", async function () { + let observedStreamPath: string | undefined; + + const streamDefinition = { + id: "typed-stream-id", + pipe: async function pipe() { + throw new Error("not used in this test"); + }, + } as unknown as TriggerChatStream; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_stream_object", + }); + res.end(JSON.stringify({ id: "run_stream_object" })); + return; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_stream_object/typed-stream-id") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "typed_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "typed_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: server.url, + stream: streamDefinition, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-typed-stream", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedStreamPath).toBe("/realtime/v1/streams/run_stream_object/typed-stream-id"); + }); + it("triggers task and streams chunks with rich default payload", async function () { let receivedTriggerBody: Record | undefined; From 63e98c2af94abdb7cdbc6f92b45b275fb364061d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:56:24 +0000 Subject: [PATCH 022/217] Add sdk parity test against @trigger.dev/ai helper Co-authored-by: Eric Allam --- packages/trigger-sdk/package.json | 1 + packages/trigger-sdk/src/v3/ai.test.ts | 61 ++++++++++++++++++++++++++ pnpm-lock.yaml | 19 ++++---- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 9ee58f6598..84b1ee693d 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -64,6 +64,7 @@ "ws": "^8.11.0" }, "devDependencies": { + "@trigger.dev/ai": "workspace:*", "@arethetypeswrong/cli": "^0.15.4", "@types/debug": "^4.1.7", "@types/slug": "^5.0.3", diff --git a/packages/trigger-sdk/src/v3/ai.test.ts b/packages/trigger-sdk/src/v3/ai.test.ts index 0d36f729a7..d383b8d8e8 100644 --- a/packages/trigger-sdk/src/v3/ai.test.ts +++ b/packages/trigger-sdk/src/v3/ai.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { ai } from "./ai.js"; +import { ai as packageAi } from "@trigger.dev/ai"; import type { TaskWithSchema } from "@trigger.dev/core/v3"; describe("@trigger.dev/sdk/ai compatibility", function () { @@ -78,4 +79,64 @@ describe("@trigger.dev/sdk/ai compatibility", function () { ai.currentToolOptions(); }).toThrowError("Method not implemented."); }); + + it("matches behavior with @trigger.dev/ai tool helper", async function () { + const fakeTask = createSchemaTask(); + + const sdkTool = ai.tool(fakeTask); + const packageTool = packageAi.tool(fakeTask); + + const sdkResult = await sdkTool.execute?.( + { + name: "Lin", + }, + undefined as never + ); + + const packageResult = await packageTool.execute?.( + { + name: "Lin", + }, + undefined as never + ); + + expect(sdkResult).toEqual(packageResult); + expect(sdkResult).toEqual({ + greeting: "Hello Lin", + }); + }); }); + +function createSchemaTask() { + const fakeTask = { + id: "fake-task", + description: "A fake task", + schema: z.object({ + name: z.string(), + }), + triggerAndWait: function triggerAndWait(payload: { name: string }) { + const resultPromise = Promise.resolve({ + ok: true, + id: "run_123", + taskIdentifier: "fake-task", + output: { + greeting: `Hello ${payload.name}`, + }, + }); + + return Object.assign(resultPromise, { + unwrap: async function unwrap() { + return { + greeting: `Hello ${payload.name}`, + }; + }, + }); + }, + } as unknown as TaskWithSchema< + "fake-task", + z.ZodObject<{ name: z.ZodString }>, + { greeting: string } + >; + + return fakeTask; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3f39f4c3d..4f7dc5030f 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 @@ -2090,6 +2090,9 @@ importers: '@arethetypeswrong/cli': specifier: ^0.15.4 version: 0.15.4 + '@trigger.dev/ai': + specifier: workspace:* + version: link:../ai '@types/debug': specifier: ^4.1.7 version: 4.1.7 @@ -23191,7 +23194,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 @@ -23946,7 +23949,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) @@ -39232,7 +39235,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 @@ -39269,8 +39272,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 @@ -40470,7 +40473,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) @@ -40499,7 +40502,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 From d79c5866f950f43c2c44852bf43cc62b8247233c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 14 Feb 2026 23:58:19 +0000 Subject: [PATCH 023/217] Cover static trigger options path in chat transport Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 74 +++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 06a2ac9b07..e0ab552bd1 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -475,6 +475,80 @@ describe("TriggerChatTransport", function () { expect((options.idempotencyKey as string).length).toBe(64); }); + it("supports static trigger options objects", async function () { + let receivedTriggerBody: Record | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + readJsonBody(req).then(function (body) { + receivedTriggerBody = body; + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_static_opts", + }); + res.end(JSON.stringify({ id: "run_static_opts" })); + }); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_static_opts/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "static_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "static_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + triggerOptions: { + queue: "static-queue", + concurrencyKey: "chat-static", + idempotencyKey: "static-idempotency", + metadata: { + mode: "static", + }, + maxAttempts: 2, + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-static-options", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + + const options = (receivedTriggerBody?.options ?? {}) as Record; + expect(options.queue).toEqual({ name: "static-queue" }); + expect(options.concurrencyKey).toBe("chat-static"); + expect(options.metadata).toEqual({ mode: "static" }); + expect(options.maxAttempts).toBe(2); + expect(typeof options.idempotencyKey).toBe("string"); + expect((options.idempotencyKey as string).length).toBe(64); + }); + it("supports creating transport with factory function", async function () { let observedRunId: string | undefined; From df877ee1f51fac87dd1b2b1c267e541d88896315 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:02:12 +0000 Subject: [PATCH 024/217] Cover mapper and trigger option resolver failure paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e0ab552bd1..978e36aaf4 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -549,6 +549,83 @@ describe("TriggerChatTransport", function () { expect((options.idempotencyKey as string).length).toBe(64); }); + it("surfaces payload mapper errors and does not trigger runs", async function () { + let triggerCalls = 0; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + triggerCalls++; + } + + res.writeHead(500, { + "content-type": "application/json", + }); + res.end(JSON.stringify({ error: "unexpected" })); + }); + + const transport = new TriggerChatTransport< + UIMessage, + { prompt: string } + >({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + payloadMapper: async function payloadMapper() { + throw new Error("mapper failed"); + }, + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-mapper-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("mapper failed"); + + expect(triggerCalls).toBe(0); + }); + + it("surfaces trigger options resolver errors and does not trigger runs", async function () { + let triggerCalls = 0; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + triggerCalls++; + } + + res.writeHead(500, { + "content-type": "application/json", + }); + res.end(JSON.stringify({ error: "unexpected" })); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + triggerOptions: async function triggerOptions() { + throw new Error("trigger options failed"); + }, + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trigger-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("trigger options failed"); + + expect(triggerCalls).toBe(0); + }); + it("supports creating transport with factory function", async function () { let observedRunId: string | undefined; From 3f1d98fd88f62552f5738dbe0193a30f4b8d67e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:05:00 +0000 Subject: [PATCH 025/217] Support async onTriggeredRun callbacks in chat transport Co-authored-by: Eric Allam --- packages/ai/README.md | 3 +++ packages/ai/src/chatTransport.test.ts | 6 +++++- packages/ai/src/chatTransport.ts | 7 ++++--- packages/ai/src/chatTransport.types.test.ts | 3 +++ packages/ai/src/index.ts | 1 + packages/ai/src/types.ts | 4 ++++ 6 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index 199ee61d45..3f3fc1833d 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -116,6 +116,9 @@ class MemoryStore implements TriggerChatRunStore { } ``` +`onTriggeredRun` can also be async, which is useful for persisting run IDs before +the chat stream is consumed. + ## `ai.tool(...)` example ```ts diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 978e36aaf4..391cfd1f06 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -628,6 +628,7 @@ describe("TriggerChatTransport", function () { it("supports creating transport with factory function", async function () { let observedRunId: string | undefined; + let callbackCompleted = false; const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { @@ -666,8 +667,10 @@ describe("TriggerChatTransport", function () { stream: "chat-stream", accessToken: "pk_trigger", baseURL: server.url, - onTriggeredRun: function onTriggeredRun(state) { + onTriggeredRun: async function onTriggeredRun(state) { + await sleep(1); observedRunId = state.runId; + callbackCompleted = true; }, }); @@ -682,6 +685,7 @@ describe("TriggerChatTransport", function () { const chunks = await readChunks(stream); expect(chunks).toHaveLength(2); expect(observedRunId).toBe("run_factory"); + expect(callbackCompleted).toBe(true); }); it("cleans run store state when stream completes", async function () { diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index d71572ed34..7186c9d392 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -15,6 +15,7 @@ import type { UIMessageChunk, } from "ai"; import type { + TriggerChatOnTriggeredRun, TriggerChatPayloadMapper, TriggerChatRunState, TriggerChatRunStore, @@ -75,7 +76,7 @@ type TriggerChatTransportCommonOptions< | TriggerOptions | TriggerChatTriggerOptionsResolver; runStore?: TriggerChatRunStore; - onTriggeredRun?: (state: TriggerChatRunState) => void; + onTriggeredRun?: TriggerChatOnTriggeredRun; }; type TriggerChatTransportMapperRequirement< @@ -129,7 +130,7 @@ export class TriggerChatTransport< private readonly baseURL: string; private readonly previewBranch: string | undefined; private readonly requestOptions: ApiRequestOptions | undefined; - private readonly onTriggeredRun: ((state: TriggerChatRunState) => void) | undefined; + private readonly onTriggeredRun: TriggerChatOnTriggeredRun | undefined; constructor(options: TriggerChatTransportOptions) { this.task = options.task; @@ -179,7 +180,7 @@ export class TriggerChatTransport< await this.runStore.set(runState); if (this.onTriggeredRun) { - this.onTriggeredRun(runState); + await this.onTriggeredRun(runState); } const stream = await this.fetchRunStream(runState, options.abortSignal); diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index 191b1c1045..d59cbd705a 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -81,6 +81,9 @@ it("accepts async payload mappers and trigger option resolvers", function () { queue: `queue-${request.chatId}`, }; }, + onTriggeredRun: async function onTriggeredRun(_state) { + return; + }, }; expectTypeOf(options).toBeObject(); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 8ea94e6394..15711a4c8e 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -7,6 +7,7 @@ export { } from "./chatTransport.js"; export type { TriggerChatPayloadMapper, + TriggerChatOnTriggeredRun, TriggerChatRunState, TriggerChatRunStore, TriggerChatStream, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d98a30be2f..59c8487861 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -61,6 +61,10 @@ export type TriggerChatTriggerOptionsResolver< request: TriggerChatTransportRequest ) => MaybePromise; +export type TriggerChatOnTriggeredRun = ( + state: TriggerChatRunState +) => MaybePromise; + export type TriggerChatStream< UI_MESSAGE extends UIMessage = UIMessage, > = From e8222f76b9c93f482cfdb67cfcae5098830144fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:05:32 +0000 Subject: [PATCH 026/217] Document onTriggeredRun callback option in streams guide Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 2b9eab71a8..c6c6b49bc8 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -613,6 +613,7 @@ The default payload sent to your task is a rich, typed object that includes: - `payloadMapper` (sync or async) for custom task payload shapes - `triggerOptions` as an object or resolver function (sync or async) - `runStore` for custom reconnect-state persistence (including async stores) +- `onTriggeredRun` callback (sync or async) to persist or observe run IDs ```ts import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; From bc1a10c28e4adbfe32cd65e86741da567bd22c21 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:06:51 +0000 Subject: [PATCH 027/217] Ignore onTriggeredRun callback failures during streaming Co-authored-by: Eric Allam --- packages/ai/README.md | 2 +- packages/ai/src/chatTransport.test.ts | 62 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 6 ++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index 3f3fc1833d..d642510a58 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -117,7 +117,7 @@ class MemoryStore implements TriggerChatRunStore { ``` `onTriggeredRun` can also be async, which is useful for persisting run IDs before -the chat stream is consumed. +the chat stream is consumed. Callback failures are ignored so chat streaming can continue. ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 391cfd1f06..ede31f1cdd 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -688,6 +688,68 @@ describe("TriggerChatTransport", function () { expect(callbackCompleted).toBe(true); }); + it("continues streaming when onTriggeredRun callback throws", async function () { + let callbackCalled = false; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_callback_error", + }); + res.end(JSON.stringify({ id: "run_callback_error" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_callback_error/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "callback_error_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "callback_error_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + onTriggeredRun: async function onTriggeredRun() { + callbackCalled = true; + throw new Error("callback failed"); + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-callback-error", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(callbackCalled).toBe(true); + expect(chunks).toHaveLength(2); + }); + it("cleans run store state when stream completes", async function () { const trackedRunStore = new TrackedRunStore(); diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 7186c9d392..d520e39ac6 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -180,7 +180,11 @@ export class TriggerChatTransport< await this.runStore.set(runState); if (this.onTriggeredRun) { - await this.onTriggeredRun(runState); + try { + await this.onTriggeredRun(runState); + } catch { + // Ignore callback errors so chat streaming can continue. + } } const stream = await this.fetchRunStream(runState, options.abortSignal); From bff60e11e462dc42acb20f55357bd542b6675e81 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:07:45 +0000 Subject: [PATCH 028/217] Clarify reconnect lifecycle behavior in transport docs Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 +++ packages/ai/README.md | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index c6c6b49bc8..706b3143ed 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -635,6 +635,9 @@ class MemoryRunStore implements TriggerChatRunStore { } ``` +`reconnectToStream()` only resumes active streams. When a stream completes or errors, +the transport clears stored run state and future reconnect attempts return `null`. + ## Complete Example: AI Streaming ### Define the stream diff --git a/packages/ai/README.md b/packages/ai/README.md index d642510a58..d9bffa39a3 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -119,6 +119,12 @@ class MemoryStore implements TriggerChatRunStore { `onTriggeredRun` can also be async, which is useful for persisting run IDs before the chat stream is consumed. Callback failures are ignored so chat streaming can continue. +## Reconnect semantics + +- `reconnectToStream({ chatId })` resumes only while a stream is still active. +- Once a stream completes or errors, its run state is cleaned up and reconnect returns `null`. +- Provide a custom `runStore` if you need state shared across processes/instances. + ## `ai.tool(...)` example ```ts From 3766c7319a349ef5f0b347b0b5edcff9dddae744 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:08:56 +0000 Subject: [PATCH 029/217] Recommend @trigger.dev/ai in trigger-sdk README Co-authored-by: Eric Allam --- packages/trigger-sdk/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/trigger-sdk/README.md b/packages/trigger-sdk/README.md index f82b525095..47ef54a8fa 100644 --- a/packages/trigger-sdk/README.md +++ b/packages/trigger-sdk/README.md @@ -49,6 +49,16 @@ There are two ways to get started: For more information on our SDK, refer to our [docs](https://trigger.dev/docs/introduction). +## AI integrations + +For AI SDK transport and tool helpers, prefer the dedicated package: + +```ts +import { TriggerChatTransport, ai } from "@trigger.dev/ai"; +``` + +`@trigger.dev/sdk/ai` remains available for backwards compatibility. + ## Support If you have any questions, please reach out to us on [Discord](https://trigger.dev/discord) and we'll be happy to help. From 302df5e7aec602098680104bf3bd75b2500f654c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:10:43 +0000 Subject: [PATCH 030/217] Normalize tuple-style headers in transport request mapping Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 68 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 14 +++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index ede31f1cdd..a0fb3a6250 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -323,6 +323,74 @@ describe("TriggerChatTransport", function () { }); }); + it("normalizes tuple header arrays into request headers", async function () { + let receivedTriggerBody: Record | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + readJsonBody(req).then(function (body) { + receivedTriggerBody = body; + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tuple_headers", + }); + res.end(JSON.stringify({ id: "run_tuple_headers" })); + }); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_tuple_headers/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "tuple_headers_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "tuple_headers_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tuple-headers", + messageId: undefined, + messages: [], + abortSignal: undefined, + headers: [["x-tuple-header", "tuple-value"]] as unknown as Record, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + + const payloadString = receivedTriggerBody?.payload as string; + const payload = (JSON.parse(payloadString) as { json: Record }).json; + expect(payload.request).toEqual({ + body: null, + headers: { + "x-tuple-header": "tuple-value", + }, + metadata: null, + }); + }); + it("returns null on reconnect when no active run exists", async function () { const transport = new TriggerChatTransport({ task: "chat-task", diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index d520e39ac6..4320321baf 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -389,12 +389,24 @@ function resolveStreamKey( } function normalizeHeaders( - headers: Record | Headers | undefined + headers: + | Record + | Headers + | Array<[string, string]> + | undefined ): Record | undefined { if (!headers) { return undefined; } + if (Array.isArray(headers)) { + const result: Record = {}; + for (const [key, value] of headers) { + result[key] = value; + } + return result; + } + if (isHeadersInstance(headers)) { const result: Record = {}; for (const [key, value] of headers.entries()) { From dc48c614f2762a9318a5765ca1e089e11ca89322 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:11:43 +0000 Subject: [PATCH 031/217] Add factory payload inference type-level coverage Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.types.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index d59cbd705a..d452c9b704 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -1,6 +1,7 @@ import { expectTypeOf, it } from "vitest"; import type { InferUIMessageChunk, UIMessage } from "ai"; import { + createTriggerChatTransport, TriggerChatTransport, TriggerChatTransportOptions, type TriggerChatTransportPayload, @@ -89,6 +90,22 @@ it("accepts async payload mappers and trigger option resolvers", function () { expectTypeOf(options).toBeObject(); }); +it("infers custom payload output from mapper in factory helper", function () { + const transport = createTriggerChatTransport({ + task: "ai-chat", + accessToken: "pk_test", + payloadMapper: function payloadMapper(request) { + return { + prompt: request.chatId, + }; + }, + }); + + expectTypeOf(transport).toEqualTypeOf< + TriggerChatTransport + >(); +}); + it("accepts typed stream definition objects", function () { const typedStream = { id: "chat-stream", From 19909ea8be59f4f01c821f55cc1239ad824197f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:14:15 +0000 Subject: [PATCH 032/217] Allow tuple header inputs in transport send options Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 2 +- packages/ai/src/chatTransport.ts | 10 ++++++++-- packages/ai/src/chatTransport.types.test.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index a0fb3a6250..0cb30779f9 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -374,7 +374,7 @@ describe("TriggerChatTransport", function () { messageId: undefined, messages: [], abortSignal: undefined, - headers: [["x-tuple-header", "tuple-value"]] as unknown as Record, + headers: [["x-tuple-header", "tuple-value"]], }); const chunks = await readChunks(stream); diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 4320321baf..8183cbfa92 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -62,6 +62,12 @@ type TriggerTaskRequestBody = { options?: TriggerTaskRequestOptions; }; +type TriggerChatRequestOptionsWithTupleHeaders = Omit & { + headers?: + | ChatRequestOptions["headers"] + | Array<[string, string]>; +}; + type TriggerChatTransportCommonOptions< UI_MESSAGE extends UIMessage = UIMessage, > = { @@ -158,7 +164,7 @@ export class TriggerChatTransport< messageId: string | undefined; messages: UI_MESSAGE[]; abortSignal: AbortSignal | undefined; - } & ChatRequestOptions + } & TriggerChatRequestOptionsWithTupleHeaders ): Promise> { const transportRequest = createTransportRequest(options); const payload = await this.payloadMapper(transportRequest); @@ -342,7 +348,7 @@ function createTransportRequest( messageId: string | undefined; messages: UI_MESSAGE[]; abortSignal: AbortSignal | undefined; - } & ChatRequestOptions + } & TriggerChatRequestOptionsWithTupleHeaders ): TriggerChatTransportRequest { return { chatId: options.chatId, diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index d452c9b704..6cfa6108a0 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -122,3 +122,15 @@ it("accepts typed stream definition objects", function () { expectTypeOf(transport).toBeObject(); }); + +it("accepts tuple-style headers in sendMessages options", function () { + const transport = new TriggerChatTransport({ + task: "ai-chat", + accessToken: "pk_test", + }); + + type SendMessagesParams = Parameters[0]; + const tupleHeaders: SendMessagesParams["headers"] = [["x-header", "x-value"]]; + expectTypeOf(transport.sendMessages).toBeFunction(); + void tupleHeaders; +}); From 5d24c69c4f7401bdd51d44c496460209d2bd5122 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:15:09 +0000 Subject: [PATCH 033/217] Document supported header input formats for transport Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 706b3143ed..01d763dd13 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -614,6 +614,7 @@ The default payload sent to your task is a rich, typed object that includes: - `triggerOptions` as an object or resolver function (sync or async) - `runStore` for custom reconnect-state persistence (including async stores) - `onTriggeredRun` callback (sync or async) to persist or observe run IDs +- headers passed through transport can be object, `Headers`, or tuple arrays ```ts import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; diff --git a/packages/ai/README.md b/packages/ai/README.md index d9bffa39a3..94a81b7e07 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -52,6 +52,8 @@ Use `TriggerChatTransportPayload` in your task for the default rich p - `messages` - `request` (`headers`, `body`, `metadata`) +Incoming `request.headers` can be supplied as a plain object, `Headers`, or tuple arrays. + ```ts import type { TriggerChatTransportPayload } from "@trigger.dev/ai"; import { UIMessage } from "ai"; From 7a3db86242373b31b5e65945072fc5a038adedad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:18:57 +0000 Subject: [PATCH 034/217] Assert onTriggeredRun receives initial run state snapshot Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 0cb30779f9..f972810eaa 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -697,6 +697,7 @@ describe("TriggerChatTransport", function () { it("supports creating transport with factory function", async function () { let observedRunId: string | undefined; let callbackCompleted = false; + let observedState: TriggerChatRunState | undefined; const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { @@ -738,6 +739,7 @@ describe("TriggerChatTransport", function () { onTriggeredRun: async function onTriggeredRun(state) { await sleep(1); observedRunId = state.runId; + observedState = { ...state }; callbackCompleted = true; }, }); @@ -754,6 +756,13 @@ describe("TriggerChatTransport", function () { expect(chunks).toHaveLength(2); expect(observedRunId).toBe("run_factory"); expect(callbackCompleted).toBe(true); + expect(observedState).toMatchObject({ + chatId: "chat-factory", + runId: "run_factory", + streamKey: "chat-stream", + lastEventId: undefined, + isActive: true, + }); }); it("continues streaming when onTriggeredRun callback throws", async function () { From b7c2b2ed2acd6757d5feac5f7cb84a6a4805f3b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:20:30 +0000 Subject: [PATCH 035/217] Expand ai package changelog with transport capabilities Co-authored-by: Eric Allam --- packages/ai/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 690f886233..d7c6d401a9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,3 +8,6 @@ - Added `ai.tool(...)` and `ai.currentToolOptions()` helpers for AI SDK tool ergonomics. - Added `TriggerChatTransport` / `createTriggerChatTransport(...)` for AI SDK `useChat()` integrations powered by Trigger.dev tasks and Realtime Streams v2. - Added rich default chat payload typing (`chatId`, `trigger`, `messageId`, `messages`, request context) and mapper hooks for custom payloads. +- Added support for async payload mappers, async trigger option resolvers, and async `onTriggeredRun` callbacks. +- Added support for tuple-style header input normalization and typing. +- Added reconnect lifecycle handling that cleans run state after completion/error and gracefully returns `null` when reconnect cannot be resumed. From f9dcafa80949a9b0c3d1aadd9b1a8c10903451cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:23:58 +0000 Subject: [PATCH 036/217] Export explicit send/reconnect option helper types Co-authored-by: Eric Allam --- packages/ai/README.md | 1 + packages/ai/src/chatTransport.ts | 36 ++++----------------- packages/ai/src/chatTransport.types.test.ts | 22 ++++++++++++- packages/ai/src/index.ts | 3 ++ packages/ai/src/types.ts | 23 +++++++++++++ 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index 94a81b7e07..2c2f7b28f5 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -7,6 +7,7 @@ AI SDK integrations for Trigger.dev. - `TriggerChatTransport` for wiring AI SDK `useChat()` to Trigger.dev tasks + Realtime Streams v2 - `createTriggerChatTransport(...)` factory helper - `ai.tool(...)` and `ai.currentToolOptions()` helpers for tool-calling flows +- helper types such as `TriggerChatSendMessagesOptions`, `TriggerChatReconnectOptions`, and `TriggerChatHeadersInput` ## Install diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 8183cbfa92..fec8c75f09 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -8,13 +8,15 @@ import { TriggerOptions, } from "@trigger.dev/core/v3"; import type { - ChatRequestOptions, ChatTransport, InferUIMessageChunk, UIMessage, UIMessageChunk, } from "ai"; import type { + TriggerChatHeadersInput, + TriggerChatReconnectOptions, + TriggerChatSendMessagesOptions, TriggerChatOnTriggeredRun, TriggerChatPayloadMapper, TriggerChatRunState, @@ -62,12 +64,6 @@ type TriggerTaskRequestBody = { options?: TriggerTaskRequestOptions; }; -type TriggerChatRequestOptionsWithTupleHeaders = Omit & { - headers?: - | ChatRequestOptions["headers"] - | Array<[string, string]>; -}; - type TriggerChatTransportCommonOptions< UI_MESSAGE extends UIMessage = UIMessage, > = { @@ -158,13 +154,7 @@ export class TriggerChatTransport< } public async sendMessages( - options: { - trigger: "submit-message" | "regenerate-message"; - chatId: string; - messageId: string | undefined; - messages: UI_MESSAGE[]; - abortSignal: AbortSignal | undefined; - } & TriggerChatRequestOptionsWithTupleHeaders + options: TriggerChatSendMessagesOptions ): Promise> { const transportRequest = createTransportRequest(options); const payload = await this.payloadMapper(transportRequest); @@ -199,9 +189,7 @@ export class TriggerChatTransport< } public async reconnectToStream( - options: { - chatId: string; - } & ChatRequestOptions + options: TriggerChatReconnectOptions ): Promise | null> { const runState = await this.runStore.get(options.chatId); @@ -342,13 +330,7 @@ function resolvePayloadMapper< } function createTransportRequest( - options: { - trigger: "submit-message" | "regenerate-message"; - chatId: string; - messageId: string | undefined; - messages: UI_MESSAGE[]; - abortSignal: AbortSignal | undefined; - } & TriggerChatRequestOptionsWithTupleHeaders + options: TriggerChatSendMessagesOptions ): TriggerChatTransportRequest { return { chatId: options.chatId, @@ -395,11 +377,7 @@ function resolveStreamKey( } function normalizeHeaders( - headers: - | Record - | Headers - | Array<[string, string]> - | undefined + headers: TriggerChatHeadersInput | undefined ): Record | undefined { if (!headers) { return undefined; diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index 6cfa6108a0..678ab59545 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -4,6 +4,9 @@ import { createTriggerChatTransport, TriggerChatTransport, TriggerChatTransportOptions, + type TriggerChatHeadersInput, + type TriggerChatReconnectOptions, + type TriggerChatSendMessagesOptions, type TriggerChatTransportPayload, type TriggerChatTransportRequest, type TriggerChatRunState, @@ -129,8 +132,25 @@ it("accepts tuple-style headers in sendMessages options", function () { accessToken: "pk_test", }); + const headersInput: TriggerChatHeadersInput = [["x-header", "x-value"]]; + + const sendOptions: TriggerChatSendMessagesOptions = { + trigger: "submit-message", + chatId: "chat_123", + messageId: undefined, + messages: [], + abortSignal: undefined, + headers: headersInput, + }; + + const reconnectOptions: TriggerChatReconnectOptions = { + chatId: "chat_123", + headers: headersInput, + }; + type SendMessagesParams = Parameters[0]; - const tupleHeaders: SendMessagesParams["headers"] = [["x-header", "x-value"]]; + const tupleHeaders: SendMessagesParams["headers"] = sendOptions.headers; + expectTypeOf(reconnectOptions).toBeObject(); expectTypeOf(transport.sendMessages).toBeFunction(); void tupleHeaders; }); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 15711a4c8e..fc5de1790b 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -6,10 +6,13 @@ export { type TriggerChatTransportOptions, } from "./chatTransport.js"; export type { + TriggerChatHeadersInput, TriggerChatPayloadMapper, TriggerChatOnTriggeredRun, + TriggerChatReconnectOptions, TriggerChatRunState, TriggerChatRunStore, + TriggerChatSendMessagesOptions, TriggerChatStream, TriggerChatTaskContext, TriggerChatTransportPayload, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 59c8487861..c9dbb4931e 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -12,6 +12,11 @@ export type TriggerChatTransportTrigger = | "submit-message" | "regenerate-message"; +export type TriggerChatHeadersInput = + | Record + | Headers + | Array<[string, string]>; + export type TriggerChatTransportRequest< UI_MESSAGE extends UIMessage = UIMessage, > = { @@ -85,3 +90,21 @@ export interface TriggerChatRunStore { set(state: TriggerChatRunState): Promise | void; delete(chatId: string): Promise | void; } + +export type TriggerChatSendMessagesOptions< + UI_MESSAGE extends UIMessage = UIMessage, +> = { + trigger: TriggerChatTransportTrigger; + chatId: string; + messageId: string | undefined; + messages: UI_MESSAGE[]; + abortSignal: AbortSignal | undefined; +} & Omit & { + headers?: TriggerChatHeadersInput; + }; + +export type TriggerChatReconnectOptions = { + chatId: string; +} & Omit & { + headers?: TriggerChatHeadersInput; + }; From c0fa99506aeb9dd68299c72970b135e7344ee92a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:26:17 +0000 Subject: [PATCH 037/217] Test preview branch and timeout header propagation Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index f972810eaa..278a1962f8 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -216,6 +216,76 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_stream_object/typed-stream-id"); }); + it("forwards preview branch and timeout headers to trigger and stream requests", async function () { + let triggerBranchHeader: string | undefined; + let streamBranchHeader: string | undefined; + let streamTimeoutHeader: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + const branchHeader = req.headers["x-trigger-branch"]; + triggerBranchHeader = Array.isArray(branchHeader) ? branchHeader[0] : branchHeader; + + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_headers", + }); + res.end(JSON.stringify({ id: "run_headers" })); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_headers/chat-stream") { + const branchHeader = req.headers["x-trigger-branch"]; + const timeoutHeader = req.headers["timeout-seconds"]; + + streamBranchHeader = Array.isArray(branchHeader) ? branchHeader[0] : branchHeader; + streamTimeoutHeader = Array.isArray(timeoutHeader) ? timeoutHeader[0] : timeoutHeader; + + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "headers_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "headers_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + previewBranch: "feature-preview", + timeoutInSeconds: 123, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-headers", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(triggerBranchHeader).toBe("feature-preview"); + expect(streamBranchHeader).toBe("feature-preview"); + expect(streamTimeoutHeader).toBe("123"); + }); + it("triggers task and streams chunks with rich default payload", async function () { let receivedTriggerBody: Record | undefined; From bc9e06c6744be60bb1e69aeb7b3256f8e7f45793 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:27:06 +0000 Subject: [PATCH 038/217] Strengthen async run store interaction assertions Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 278a1962f8..3782dbe8d6 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1078,6 +1078,9 @@ describe("TriggerChatTransport", function () { expect(runStore.setCalls).toContain("chat-async"); expect(runStore.getCalls).toContain("chat-async"); + expect(runStore.getCalls.length).toBeGreaterThan(0); + expect(runStore.setCalls.length).toBeGreaterThan(0); + expect(runStore.deleteCalls.length).toBeGreaterThan(0); await expect(runStore.get("chat-async")).resolves.toBeUndefined(); }); From 6843732f79b1b5072c395c2676cc2460a122f244 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:27:39 +0000 Subject: [PATCH 039/217] Document exported request option helper aliases Co-authored-by: Eric Allam --- packages/ai/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ai/README.md b/packages/ai/README.md index 2c2f7b28f5..9319c55a37 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -55,6 +55,12 @@ Use `TriggerChatTransportPayload` in your task for the default rich p Incoming `request.headers` can be supplied as a plain object, `Headers`, or tuple arrays. +Typed request option helper aliases are exported: + +- `TriggerChatSendMessagesOptions` +- `TriggerChatReconnectOptions` +- `TriggerChatHeadersInput` + ```ts import type { TriggerChatTransportPayload } from "@trigger.dev/ai"; import { UIMessage } from "ai"; From e69a1d25504c251751cf7c7222c001fc17dd3d53 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:29:00 +0000 Subject: [PATCH 040/217] Add sdk patch changeset for ai package guidance Co-authored-by: Eric Allam --- .changeset/chilled-cougars-relate.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chilled-cougars-relate.md diff --git a/.changeset/chilled-cougars-relate.md b/.changeset/chilled-cougars-relate.md new file mode 100644 index 0000000000..6a222444e8 --- /dev/null +++ b/.changeset/chilled-cougars-relate.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Document and guide AI users toward `@trigger.dev/ai` as the recommended package for AI SDK integrations, while keeping `@trigger.dev/sdk/ai` for backwards compatibility. From 5d492d6c9a540955fc98a06dbefac283ec5eec38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:30:36 +0000 Subject: [PATCH 041/217] Document exported transport option helper types in streams docs Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 01d763dd13..95976698a5 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -639,6 +639,12 @@ class MemoryRunStore implements TriggerChatRunStore { `reconnectToStream()` only resumes active streams. When a stream completes or errors, the transport clears stored run state and future reconnect attempts return `null`. +For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: + +- `TriggerChatHeadersInput` +- `TriggerChatSendMessagesOptions` +- `TriggerChatReconnectOptions` + ## Complete Example: AI Streaming ### Define the stream From 227290b3da16c8b5f40b3a71bf2a330ec544db2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:32:29 +0000 Subject: [PATCH 042/217] Pass immutable run-state snapshot to onTriggeredRun Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 2 +- packages/ai/src/chatTransport.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 3782dbe8d6..89f67451d9 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -809,7 +809,7 @@ describe("TriggerChatTransport", function () { onTriggeredRun: async function onTriggeredRun(state) { await sleep(1); observedRunId = state.runId; - observedState = { ...state }; + observedState = state; callbackCompleted = true; }, }); diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index fec8c75f09..d80b2fdebe 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -177,7 +177,9 @@ export class TriggerChatTransport< if (this.onTriggeredRun) { try { - await this.onTriggeredRun(runState); + await this.onTriggeredRun({ + ...runState, + }); } catch { // Ignore callback errors so chat streaming can continue. } From 5efba1394d261b3d899c36277098649fc9f65edf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:34:25 +0000 Subject: [PATCH 043/217] Cleanup inactive reconnect state entries on access Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 27 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 7 ++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 89f67451d9..1896ec1f15 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -476,6 +476,33 @@ describe("TriggerChatTransport", function () { expect(stream).toBeNull(); }); + it("removes inactive run entries during reconnect attempts", async function () { + const runStore = new TrackedRunStore(); + runStore.set({ + chatId: "chat-inactive", + runId: "run_inactive", + publicAccessToken: "pk_inactive", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive", + }); + + expect(stream).toBeNull(); + expect(runStore.deleteCalls).toContain("chat-inactive"); + expect(runStore.get("chat-inactive")).toBeUndefined(); + }); + it("supports custom payload mapping and trigger options resolver", async function () { let receivedTriggerBody: Record | undefined; let receivedResolverChatId: string | undefined; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index d80b2fdebe..c65f5b532d 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -195,7 +195,12 @@ export class TriggerChatTransport< ): Promise | null> { const runState = await this.runStore.get(options.chatId); - if (!runState || !runState.isActive) { + if (!runState) { + return null; + } + + if (!runState.isActive) { + await this.runStore.delete(options.chatId); return null; } From 1f49bc218b6491d0941d0aa06fe75a52e773c914 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:37:22 +0000 Subject: [PATCH 044/217] Add optional onError callback for non-fatal transport issues Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 4 ++ packages/ai/src/chatTransport.test.ts | 70 +++++++++++++++++++++ packages/ai/src/chatTransport.ts | 53 +++++++++++++++- packages/ai/src/chatTransport.types.test.ts | 4 ++ packages/ai/src/index.ts | 3 + packages/ai/src/types.ts | 16 +++++ 7 files changed, 149 insertions(+), 2 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 95976698a5..13589aecc7 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -614,6 +614,7 @@ The default payload sent to your task is a rich, typed object that includes: - `triggerOptions` as an object or resolver function (sync or async) - `runStore` for custom reconnect-state persistence (including async stores) - `onTriggeredRun` callback (sync or async) to persist or observe run IDs +- `onError` callback to observe non-fatal transport issues - headers passed through transport can be object, `Headers`, or tuple arrays ```ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 9319c55a37..b369ead671 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -60,6 +60,7 @@ Typed request option helper aliases are exported: - `TriggerChatSendMessagesOptions` - `TriggerChatReconnectOptions` - `TriggerChatHeadersInput` +- `TriggerChatTransportError` / `TriggerChatOnError` ```ts import type { TriggerChatTransportPayload } from "@trigger.dev/ai"; @@ -128,6 +129,9 @@ class MemoryStore implements TriggerChatRunStore { `onTriggeredRun` can also be async, which is useful for persisting run IDs before the chat stream is consumed. Callback failures are ignored so chat streaming can continue. +You can optionally provide `onError` to observe non-fatal transport errors +(for example callback failures or reconnect setup issues). + ## Reconnect semantics - `reconnectToStream({ chatId })` resumes only while a stream is still active. diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 1896ec1f15..8e265e1f43 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -9,6 +9,7 @@ import { import type { TriggerChatStream } from "./types.js"; import type { UIMessage, UIMessageChunk } from "ai"; import type { + TriggerChatTransportError, TriggerChatRunState, TriggerChatRunStore, } from "./types.js"; @@ -864,6 +865,7 @@ describe("TriggerChatTransport", function () { it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; + const errors: TriggerChatTransportError[] = []; const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { @@ -909,6 +911,9 @@ describe("TriggerChatTransport", function () { callbackCalled = true; throw new Error("callback failed"); }, + onError: function onError(error) { + errors.push(error); + }, }); const stream = await transport.sendMessages({ @@ -922,6 +927,71 @@ describe("TriggerChatTransport", function () { const chunks = await readChunks(stream); expect(callbackCalled).toBe(true); expect(chunks).toHaveLength(2); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "onTriggeredRun", + chatId: "chat-callback-error", + runId: "run_callback_error", + }); + expect(errors[0]?.error.message).toBe("callback failed"); + }); + + it("ignores failures from onError callback", async function () { + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_onerror_fail", + }); + res.end(JSON.stringify({ id: "run_onerror_fail" })); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_onerror_fail/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "onerror_fail_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "onerror_fail_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + onTriggeredRun: async function onTriggeredRun() { + throw new Error("callback failed"); + }, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-onerror-fail", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); }); it("cleans run store state when stream completes", async function () { diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index c65f5b532d..10c1248706 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -15,6 +15,7 @@ import type { } from "ai"; import type { TriggerChatHeadersInput, + TriggerChatOnError, TriggerChatReconnectOptions, TriggerChatSendMessagesOptions, TriggerChatOnTriggeredRun, @@ -79,6 +80,7 @@ type TriggerChatTransportCommonOptions< | TriggerChatTriggerOptionsResolver; runStore?: TriggerChatRunStore; onTriggeredRun?: TriggerChatOnTriggeredRun; + onError?: TriggerChatOnError; }; type TriggerChatTransportMapperRequirement< @@ -133,6 +135,7 @@ export class TriggerChatTransport< private readonly previewBranch: string | undefined; private readonly requestOptions: ApiRequestOptions | undefined; private readonly onTriggeredRun: TriggerChatOnTriggeredRun | undefined; + private readonly onError: TriggerChatOnError | undefined; constructor(options: TriggerChatTransportOptions) { this.task = options.task; @@ -151,6 +154,7 @@ export class TriggerChatTransport< this.requestOptions ); this.onTriggeredRun = options.onTriggeredRun; + this.onError = options.onError; } public async sendMessages( @@ -180,7 +184,13 @@ export class TriggerChatTransport< await this.onTriggeredRun({ ...runState, }); - } catch { + } catch (error) { + await this.reportError({ + phase: "onTriggeredRun", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); // Ignore callback errors so chat streaming can continue. } } @@ -207,10 +217,16 @@ export class TriggerChatTransport< let stream: ReadableStream>>; try { stream = await this.fetchRunStream(runState, undefined, runState.lastEventId); - } catch { + } catch (error) { runState.isActive = false; await this.runStore.set(runState); await this.runStore.delete(options.chatId); + await this.reportError({ + phase: "reconnect", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); return null; } @@ -291,6 +307,12 @@ export class TriggerChatTransport< runState.isActive = false; await this.runStore.set(runState); await this.runStore.delete(chatId); + await this.reportError({ + phase: "consumeTrackingStream", + chatId: runState.chatId, + runId: runState.runId, + error: new Error("Stream tracking failed"), + }); } } } @@ -314,6 +336,25 @@ export class TriggerChatTransport< return `${normalizedBaseUrl}/realtime/v1/streams/${encodedRunId}/${encodedStreamKey}`; } + + private async reportError( + event: { + phase: "onTriggeredRun" | "consumeTrackingStream" | "reconnect"; + chatId: string; + runId: string; + error: Error; + } + ) { + if (!this.onError) { + return; + } + + try { + await this.onError(event); + } catch { + // Never let error callbacks interfere with transport behavior. + } + } } export function createTriggerChatTransport< @@ -469,3 +510,11 @@ async function createTriggerTaskOptions( } export type { TriggerChatTaskContext }; + +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(String(error)); +} diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index 678ab59545..cdc60ab7f0 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -4,6 +4,7 @@ import { createTriggerChatTransport, TriggerChatTransport, TriggerChatTransportOptions, + type TriggerChatTransportError, type TriggerChatHeadersInput, type TriggerChatReconnectOptions, type TriggerChatSendMessagesOptions, @@ -88,6 +89,9 @@ it("accepts async payload mappers and trigger option resolvers", function () { onTriggeredRun: async function onTriggeredRun(_state) { return; }, + onError: async function onError(_error: TriggerChatTransportError) { + return; + }, }; expectTypeOf(options).toBeObject(); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index fc5de1790b..af095b2f19 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -7,6 +7,7 @@ export { } from "./chatTransport.js"; export type { TriggerChatHeadersInput, + TriggerChatOnError, TriggerChatPayloadMapper, TriggerChatOnTriggeredRun, TriggerChatReconnectOptions, @@ -15,6 +16,8 @@ export type { TriggerChatSendMessagesOptions, TriggerChatStream, TriggerChatTaskContext, + TriggerChatTransportError, + TriggerChatTransportErrorPhase, TriggerChatTransportPayload, TriggerChatTransportRequest, TriggerChatTransportTrigger, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c9dbb4931e..667f95cb52 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -70,6 +70,22 @@ export type TriggerChatOnTriggeredRun = ( state: TriggerChatRunState ) => MaybePromise; +export type TriggerChatTransportErrorPhase = + | "onTriggeredRun" + | "consumeTrackingStream" + | "reconnect"; + +export type TriggerChatTransportError = { + phase: TriggerChatTransportErrorPhase; + chatId: string; + runId: string; + error: Error; +}; + +export type TriggerChatOnError = ( + error: TriggerChatTransportError +) => MaybePromise; + export type TriggerChatStream< UI_MESSAGE extends UIMessage = UIMessage, > = From f235e1d9a62ce39acf70bedf5174c70d6a800cee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:38:56 +0000 Subject: [PATCH 045/217] Update ai changelog with latest transport enhancements Co-authored-by: Eric Allam --- packages/ai/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d7c6d401a9..fcf99b99fb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,3 +11,5 @@ - Added support for async payload mappers, async trigger option resolvers, and async `onTriggeredRun` callbacks. - Added support for tuple-style header input normalization and typing. - Added reconnect lifecycle handling that cleans run state after completion/error and gracefully returns `null` when reconnect cannot be resumed. +- Added explicit helper option types for chat send/reconnect request inputs. +- Added optional `onError` callback support for observing non-fatal transport issues. From 6ead0d0cecc8550a58988b8eab76e7954e7d813f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:41:36 +0000 Subject: [PATCH 046/217] Cover reconnect and tracking phases in onError callback tests Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 101 ++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 4 +- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 8e265e1f43..87d31304db 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -994,6 +994,107 @@ describe("TriggerChatTransport", function () { expect(chunks).toHaveLength(2); }); + it("reports consumeTrackingStream failures through onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new TrackedRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tracking_error", + }); + res.end(JSON.stringify({ id: "run_tracking_error" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + return new ReadableStream({ + start(controller) { + controller.error(new Error("tracking failed root cause")); + }, + }); + }; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tracking-error", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await expect(readChunks(stream)).rejects.toThrowError("tracking failed root cause"); + + await waitForCondition(function () { + return errors.length === 1; + }); + + expect(errors[0]).toMatchObject({ + phase: "consumeTrackingStream", + chatId: "chat-tracking-error", + runId: "run_tracking_error", + }); + expect(errors[0]?.error.message).toBe("tracking failed root cause"); + expect(runStore.get("chat-tracking-error")).toBeUndefined(); + }); + + it("reports reconnect failures through onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new InMemoryTriggerChatRunStore(); + runStore.set({ + chatId: "chat-reconnect-error", + runId: "run_reconnect_error", + publicAccessToken: "pk_reconnect_error", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("reconnect root cause"); + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect-error", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-reconnect-error", + runId: "run_reconnect_error", + }); + expect(errors[0]?.error.message).toBe("reconnect root cause"); + expect(runStore.get("chat-reconnect-error")).toBeUndefined(); + }); + it("cleans run store state when stream completes", async function () { const trackedRunStore = new TrackedRunStore(); diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 10c1248706..288a53687f 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -301,7 +301,7 @@ export class TriggerChatTransport< await this.runStore.set(runState); await this.runStore.delete(chatId); } - } catch { + } catch (error) { const runState = await this.runStore.get(chatId); if (runState) { runState.isActive = false; @@ -311,7 +311,7 @@ export class TriggerChatTransport< phase: "consumeTrackingStream", chatId: runState.chatId, runId: runState.runId, - error: new Error("Stream tracking failed"), + error: normalizeError(error), }); } } From a4bab4cac650ac46447f7e2f3972e0ba255531cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:42:57 +0000 Subject: [PATCH 047/217] Add type-level coverage for onError callback payload Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.types.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index cdc60ab7f0..8e20dd4781 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -4,6 +4,7 @@ import { createTriggerChatTransport, TriggerChatTransport, TriggerChatTransportOptions, + type TriggerChatOnError, type TriggerChatTransportError, type TriggerChatHeadersInput, type TriggerChatReconnectOptions, @@ -97,6 +98,23 @@ it("accepts async payload mappers and trigger option resolvers", function () { expectTypeOf(options).toBeObject(); }); +it("exposes strongly typed onError callback payloads", function () { + const onError = createTypedOnErrorCallback(); + + expectTypeOf(onError).toBeFunction(); +}); + +function createTypedOnErrorCallback(): TriggerChatOnError { + async function onError(error: TriggerChatTransportError) { + expectTypeOf(error.phase).toEqualTypeOf<"onTriggeredRun" | "consumeTrackingStream" | "reconnect">(); + expectTypeOf(error.chatId).toEqualTypeOf(); + expectTypeOf(error.runId).toEqualTypeOf(); + expectTypeOf(error.error).toEqualTypeOf(); + } + + return onError; +} + it("infers custom payload output from mapper in factory helper", function () { const transport = createTriggerChatTransport({ task: "ai-chat", From d870c1907591ac8ec47d87ce44f704c5b6ba820c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:43:58 +0000 Subject: [PATCH 048/217] Add type coverage for custom run store options Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.types.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index 8e20dd4781..1caa8b13a1 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -3,6 +3,7 @@ import type { InferUIMessageChunk, UIMessage } from "ai"; import { createTriggerChatTransport, TriggerChatTransport, + InMemoryTriggerChatRunStore, TriggerChatTransportOptions, type TriggerChatOnError, type TriggerChatTransportError, @@ -176,3 +177,14 @@ it("accepts tuple-style headers in sendMessages options", function () { expectTypeOf(transport.sendMessages).toBeFunction(); void tupleHeaders; }); + +it("accepts custom run store implementations via options typing", function () { + const runStore = new InMemoryTriggerChatRunStore(); + const transport = new TriggerChatTransport({ + task: "ai-chat", + accessToken: "pk_test", + runStore, + }); + + expectTypeOf(transport).toBeObject(); +}); From 677debcff00d61daa58cf0a6fb676f0a3e6403d5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:45:34 +0000 Subject: [PATCH 049/217] Export header normalization helper for advanced integrations Co-authored-by: Eric Allam --- packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 15 +++++++++++++++ packages/ai/src/chatTransport.ts | 12 ++++++++++++ packages/ai/src/chatTransport.types.test.ts | 9 +++++++++ packages/ai/src/index.ts | 1 + 5 files changed, 38 insertions(+) diff --git a/packages/ai/README.md b/packages/ai/README.md index b369ead671..b4be72ecbb 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -61,6 +61,7 @@ Typed request option helper aliases are exported: - `TriggerChatReconnectOptions` - `TriggerChatHeadersInput` - `TriggerChatTransportError` / `TriggerChatOnError` +- `normalizeTriggerChatHeaders(...)` ```ts import type { TriggerChatTransportPayload } from "@trigger.dev/ai"; diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 87d31304db..c9e1a08e7b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { InMemoryTriggerChatRunStore, createTriggerChatTransport, + normalizeTriggerChatHeaders, TriggerChatTransport, } from "./chatTransport.js"; import type { TriggerChatStream } from "./types.js"; @@ -462,6 +463,20 @@ describe("TriggerChatTransport", function () { }); }); + it("normalizes header helper input values consistently", function () { + expect(normalizeTriggerChatHeaders(undefined)).toBeUndefined(); + expect( + normalizeTriggerChatHeaders([["x-array", "array-value"]]) + ).toEqual({ + "x-array": "array-value", + }); + expect( + normalizeTriggerChatHeaders(new Headers([["x-headers", "headers-value"]])) + ).toEqual({ + "x-headers": "headers-value", + }); + }); + it("returns null on reconnect when no active run exists", async function () { const transport = new TriggerChatTransport({ task: "chat-task", diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 288a53687f..8aca046189 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -459,6 +459,18 @@ function normalizeHeaders( return result; } +/** + * Converts supported header input shapes into a normalized plain object. + * + * @deprecated This helper is primarily exposed for advanced integrations and tests. + * Most users should rely on `TriggerChatTransport` internals to normalize request headers. + */ +export function normalizeTriggerChatHeaders( + headers: TriggerChatHeadersInput | undefined +): Record | undefined { + return normalizeHeaders(headers); +} + function isHeadersInstance(headers: unknown): headers is Headers { if (typeof Headers === "undefined") { return false; diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index 1caa8b13a1..b038dbce67 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -4,6 +4,7 @@ import { createTriggerChatTransport, TriggerChatTransport, InMemoryTriggerChatRunStore, + normalizeTriggerChatHeaders, TriggerChatTransportOptions, type TriggerChatOnError, type TriggerChatTransportError, @@ -188,3 +189,11 @@ it("accepts custom run store implementations via options typing", function () { expectTypeOf(transport).toBeObject(); }); + +it("exports typed header normalization helper", function () { + const normalizedHeaders = normalizeTriggerChatHeaders({ + "x-header": "value", + }); + + expectTypeOf(normalizedHeaders).toEqualTypeOf | undefined>(); +}); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index af095b2f19..3a5e71613c 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -2,6 +2,7 @@ export { ai, type ToolCallExecutionOptions, type ToolOptions } from "./ai.js"; export { createTriggerChatTransport, InMemoryTriggerChatRunStore, + normalizeTriggerChatHeaders, TriggerChatTransport, type TriggerChatTransportOptions, } from "./chatTransport.js"; From 4cf0b970a22079a0229b0a0c4ff23ff4b7fb67d0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:46:37 +0000 Subject: [PATCH 050/217] Add type coverage for onError option wiring Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.types.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index b038dbce67..811bc76515 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -190,6 +190,19 @@ it("accepts custom run store implementations via options typing", function () { expectTypeOf(transport).toBeObject(); }); +it("accepts custom onError callbacks via options typing", function () { + const transport = new TriggerChatTransport({ + task: "ai-chat", + accessToken: "pk_test", + onError: function onError(error) { + expectTypeOf(error.chatId).toEqualTypeOf(); + expectTypeOf(error.runId).toEqualTypeOf(); + }, + }); + + expectTypeOf(transport).toBeObject(); +}); + it("exports typed header normalization helper", function () { const normalizedHeaders = normalizeTriggerChatHeaders({ "x-header": "value", From 6f4b9f0424c112b4ba9b1bb2309c91f527ee5875 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:48:13 +0000 Subject: [PATCH 051/217] Document onError callback payload fields Co-authored-by: Eric Allam --- packages/ai/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ai/README.md b/packages/ai/README.md index b4be72ecbb..af20b6376a 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -133,6 +133,13 @@ the chat stream is consumed. Callback failures are ignored so chat streaming can You can optionally provide `onError` to observe non-fatal transport errors (for example callback failures or reconnect setup issues). +The callback receives: + +- `phase`: `"onTriggeredRun" | "consumeTrackingStream" | "reconnect"` +- `chatId` +- `runId` +- `error` + ## Reconnect semantics - `reconnectToStream({ chatId })` resumes only while a stream is still active. From 8f5c16a7c521c42e037cafac3d3ba1e37bcc130f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:53:16 +0000 Subject: [PATCH 052/217] Report send-phase errors through onError callback Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 4 ++ packages/ai/README.md | 4 +- packages/ai/src/chatTransport.test.ts | 68 +++++++++++++++++++++ packages/ai/src/chatTransport.ts | 57 ++++++++++++++--- packages/ai/src/chatTransport.types.test.ts | 13 +++- packages/ai/src/types.ts | 5 +- 6 files changed, 137 insertions(+), 14 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 13589aecc7..aaaaa33acd 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -617,6 +617,10 @@ The default payload sent to your task is a rich, typed object that includes: - `onError` callback to observe non-fatal transport issues - headers passed through transport can be object, `Headers`, or tuple arrays +`onError` receives phase-aware details (`payloadMapper`, `triggerOptions`, `triggerTask`, +`onTriggeredRun`, `consumeTrackingStream`, `reconnect`) plus `chatId`, optional `runId`, +and the underlying `error`. + ```ts import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; diff --git a/packages/ai/README.md b/packages/ai/README.md index af20b6376a..8a0e185886 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -135,9 +135,9 @@ You can optionally provide `onError` to observe non-fatal transport errors The callback receives: -- `phase`: `"onTriggeredRun" | "consumeTrackingStream" | "reconnect"` +- `phase`: `"payloadMapper" | "triggerOptions" | "triggerTask" | "onTriggeredRun" | "consumeTrackingStream" | "reconnect"` - `chatId` -- `runId` +- `runId` (may be `undefined` before a run is created) - `error` ## Reconnect semantics diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index c9e1a08e7b..e88fddf439 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -732,6 +732,7 @@ describe("TriggerChatTransport", function () { it("surfaces payload mapper errors and does not trigger runs", async function () { let triggerCalls = 0; + const errors: TriggerChatTransportError[] = []; const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { @@ -755,6 +756,9 @@ describe("TriggerChatTransport", function () { payloadMapper: async function payloadMapper() { throw new Error("mapper failed"); }, + onError: function onError(error) { + errors.push(error); + }, }); await expect( @@ -768,10 +772,18 @@ describe("TriggerChatTransport", function () { ).rejects.toThrowError("mapper failed"); expect(triggerCalls).toBe(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "payloadMapper", + chatId: "chat-mapper-failure", + runId: undefined, + }); + expect(errors[0]?.error.message).toBe("mapper failed"); }); it("surfaces trigger options resolver errors and does not trigger runs", async function () { let triggerCalls = 0; + const errors: TriggerChatTransportError[] = []; const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { @@ -792,6 +804,9 @@ describe("TriggerChatTransport", function () { triggerOptions: async function triggerOptions() { throw new Error("trigger options failed"); }, + onError: function onError(error) { + errors.push(error); + }, }); await expect( @@ -805,6 +820,59 @@ describe("TriggerChatTransport", function () { ).rejects.toThrowError("trigger options failed"); expect(triggerCalls).toBe(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "triggerOptions", + chatId: "chat-trigger-failure", + runId: undefined, + }); + expect(errors[0]?.error.message).toBe("trigger options failed"); + }); + + it("reports trigger task request failures through onError", async function () { + const errors: TriggerChatTransportError[] = []; + const server = await startServer(function (_req, res) { + res.writeHead(500, { + "content-type": "application/json", + }); + res.end(JSON.stringify({ error: "task trigger failed" })); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + requestOptions: { + retry: { + maxAttempts: 1, + minTimeoutInMs: 1, + maxTimeoutInMs: 1, + factor: 1, + randomize: false, + }, + }, + onError: function onError(error) { + errors.push(error); + }, + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trigger-request-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("task trigger failed"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "triggerTask", + chatId: "chat-trigger-request-failure", + runId: undefined, + }); }); it("supports creating transport with factory function", async function () { diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 8aca046189..e0eff82de0 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -161,12 +161,47 @@ export class TriggerChatTransport< options: TriggerChatSendMessagesOptions ): Promise> { const transportRequest = createTransportRequest(options); - const payload = await this.payloadMapper(transportRequest); - const triggerOptions = await resolveTriggerOptions( - this.triggerOptions, - transportRequest - ); - const run = await this.triggerTask(payload, triggerOptions); + let payload: PAYLOAD; + try { + payload = await this.payloadMapper(transportRequest); + } catch (error) { + await this.reportError({ + phase: "payloadMapper", + chatId: options.chatId, + runId: undefined, + error: normalizeError(error), + }); + throw error; + } + + let triggerOptions: TriggerOptions | undefined; + try { + triggerOptions = await resolveTriggerOptions( + this.triggerOptions, + transportRequest + ); + } catch (error) { + await this.reportError({ + phase: "triggerOptions", + chatId: options.chatId, + runId: undefined, + error: normalizeError(error), + }); + throw error; + } + + let run: TriggerTaskResponse; + try { + run = await this.triggerTask(payload, triggerOptions); + } catch (error) { + await this.reportError({ + phase: "triggerTask", + chatId: options.chatId, + runId: undefined, + error: normalizeError(error), + }); + throw error; + } const runState: TriggerChatRunState = { chatId: options.chatId, @@ -339,9 +374,15 @@ export class TriggerChatTransport< private async reportError( event: { - phase: "onTriggeredRun" | "consumeTrackingStream" | "reconnect"; + phase: + | "payloadMapper" + | "triggerOptions" + | "triggerTask" + | "onTriggeredRun" + | "consumeTrackingStream" + | "reconnect"; chatId: string; - runId: string; + runId: string | undefined; error: Error; } ) { diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index 811bc76515..2d09b2fd08 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -108,9 +108,16 @@ it("exposes strongly typed onError callback payloads", function () { function createTypedOnErrorCallback(): TriggerChatOnError { async function onError(error: TriggerChatTransportError) { - expectTypeOf(error.phase).toEqualTypeOf<"onTriggeredRun" | "consumeTrackingStream" | "reconnect">(); + expectTypeOf(error.phase).toEqualTypeOf< + | "payloadMapper" + | "triggerOptions" + | "triggerTask" + | "onTriggeredRun" + | "consumeTrackingStream" + | "reconnect" + >(); expectTypeOf(error.chatId).toEqualTypeOf(); - expectTypeOf(error.runId).toEqualTypeOf(); + expectTypeOf(error.runId).toEqualTypeOf(); expectTypeOf(error.error).toEqualTypeOf(); } @@ -196,7 +203,7 @@ it("accepts custom onError callbacks via options typing", function () { accessToken: "pk_test", onError: function onError(error) { expectTypeOf(error.chatId).toEqualTypeOf(); - expectTypeOf(error.runId).toEqualTypeOf(); + expectTypeOf(error.runId).toEqualTypeOf(); }, }); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 667f95cb52..6ee994a8b1 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -71,6 +71,9 @@ export type TriggerChatOnTriggeredRun = ( ) => MaybePromise; export type TriggerChatTransportErrorPhase = + | "payloadMapper" + | "triggerOptions" + | "triggerTask" | "onTriggeredRun" | "consumeTrackingStream" | "reconnect"; @@ -78,7 +81,7 @@ export type TriggerChatTransportErrorPhase = export type TriggerChatTransportError = { phase: TriggerChatTransportErrorPhase; chatId: string; - runId: string; + runId: string | undefined; error: Error; }; From dc01fab1c111dd7eb481be60f2ef2ce79cb66140 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:54:08 +0000 Subject: [PATCH 053/217] Note phase-aware onError reporting in ai changelog Co-authored-by: Eric Allam --- packages/ai/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index fcf99b99fb..19600163dd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,3 +13,4 @@ - Added reconnect lifecycle handling that cleans run state after completion/error and gracefully returns `null` when reconnect cannot be resumed. - Added explicit helper option types for chat send/reconnect request inputs. - Added optional `onError` callback support for observing non-fatal transport issues. +- Added phase-aware `onError` reporting across send, reconnect, and stream-consumption paths. From add5ed304bc612779f36d141106785ebf9244b08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:55:07 +0000 Subject: [PATCH 054/217] Keep exported header normalization helper as supported API Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index e0eff82de0..62381c622e 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -502,9 +502,6 @@ function normalizeHeaders( /** * Converts supported header input shapes into a normalized plain object. - * - * @deprecated This helper is primarily exposed for advanced integrations and tests. - * Most users should rely on `TriggerChatTransport` internals to normalize request headers. */ export function normalizeTriggerChatHeaders( headers: TriggerChatHeadersInput | undefined From 8e1821c2f90557502d62442f99fbf4044abe34aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:56:09 +0000 Subject: [PATCH 055/217] Assert normalized object headers are copied immutably Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e88fddf439..fffdb6108d 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -464,7 +464,16 @@ describe("TriggerChatTransport", function () { }); it("normalizes header helper input values consistently", function () { + const originalHeaders = { + "x-object": "object-value", + }; + const normalizedObjectHeaders = normalizeTriggerChatHeaders(originalHeaders); + originalHeaders["x-object"] = "changed"; + expect(normalizeTriggerChatHeaders(undefined)).toBeUndefined(); + expect(normalizedObjectHeaders).toEqual({ + "x-object": "object-value", + }); expect( normalizeTriggerChatHeaders([["x-array", "array-value"]]) ).toEqual({ From 0e403ded4571d76cfc7aebb0103cf7e751d1cd96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:57:08 +0000 Subject: [PATCH 056/217] Verify duplicate header keys use last value on normalization Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index fffdb6108d..b4b1442706 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -479,6 +479,14 @@ describe("TriggerChatTransport", function () { ).toEqual({ "x-array": "array-value", }); + expect( + normalizeTriggerChatHeaders([ + ["x-dup", "first"], + ["x-dup", "second"], + ]) + ).toEqual({ + "x-dup": "second", + }); expect( normalizeTriggerChatHeaders(new Headers([["x-headers", "headers-value"]])) ).toEqual({ From 6100c6e247e693cf6ddc8a2bf95fbee334f4f6c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 00:59:02 +0000 Subject: [PATCH 057/217] Use shared transport error type in reportError Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 62381c622e..3b44bf3e52 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -24,6 +24,7 @@ import type { TriggerChatRunStore, TriggerChatStream, TriggerChatTaskContext, + TriggerChatTransportError, TriggerChatTransportPayload, TriggerChatTransportRequest, TriggerChatTriggerOptionsResolver, @@ -372,20 +373,7 @@ export class TriggerChatTransport< return `${normalizedBaseUrl}/realtime/v1/streams/${encodedRunId}/${encodedStreamKey}`; } - private async reportError( - event: { - phase: - | "payloadMapper" - | "triggerOptions" - | "triggerTask" - | "onTriggeredRun" - | "consumeTrackingStream" - | "reconnect"; - chatId: string; - runId: string | undefined; - error: Error; - } - ) { + private async reportError(event: TriggerChatTransportError) { if (!this.onError) { return; } From d0b31197f00449bbca85b83420e0acd586814b3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:00:30 +0000 Subject: [PATCH 058/217] Cover non-Error mapper failures in onError reporting Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index b4b1442706..80710efbf7 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -798,6 +798,43 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("mapper failed"); }); + it("normalizes non-Error mapper failures before reporting onError", async function () { + const errors: TriggerChatTransportError[] = []; + + const transport = new TriggerChatTransport< + UIMessage, + { prompt: string } + >({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + payloadMapper: async function payloadMapper() { + throw "string mapper failure"; + }, + onError: function onError(error) { + errors.push(error); + }, + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-mapper-string-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toBe("string mapper failure"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "payloadMapper", + chatId: "chat-mapper-string-failure", + runId: undefined, + }); + expect(errors[0]?.error.message).toBe("string mapper failure"); + }); + it("surfaces trigger options resolver errors and does not trigger runs", async function () { let triggerCalls = 0; const errors: TriggerChatTransportError[] = []; From a7cba369d97c8e4bef754aa7bd16abce61ab59c1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:01:38 +0000 Subject: [PATCH 059/217] Cover non-Error trigger option failures in onError reporting Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 80710efbf7..03496d84e1 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -883,6 +883,40 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("trigger options failed"); }); + it("normalizes non-Error trigger options failures before reporting onError", async function () { + const errors: TriggerChatTransportError[] = []; + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + triggerOptions: async function triggerOptions() { + throw "string trigger options failure"; + }, + onError: function onError(error) { + errors.push(error); + }, + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trigger-string-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toBe("string trigger options failure"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "triggerOptions", + chatId: "chat-trigger-string-failure", + runId: undefined, + }); + expect(errors[0]?.error.message).toBe("string trigger options failure"); + }); + it("reports trigger task request failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const server = await startServer(function (_req, res) { From d729344c32d21180d90433c9e74a634086eb39e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:02:43 +0000 Subject: [PATCH 060/217] Cover non-Error trigger task failures in onError reporting Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 03496d84e1..32462855ee 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -963,6 +963,41 @@ describe("TriggerChatTransport", function () { }); }); + it("normalizes non-Error trigger task failures before reporting onError", async function () { + const errors: TriggerChatTransportError[] = []; + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).triggerTask = async function triggerTask() { + throw "string trigger task failure"; + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trigger-task-string-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toBe("string trigger task failure"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "triggerTask", + chatId: "chat-trigger-task-string-failure", + runId: undefined, + }); + expect(errors[0]?.error.message).toBe("string trigger task failure"); + }); + it("supports creating transport with factory function", async function () { let observedRunId: string | undefined; let callbackCompleted = false; From b76c8b53a5037c03e90cca1aa2eaeaff594aec8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:03:49 +0000 Subject: [PATCH 061/217] Cover non-Error reconnect failures in onError reporting Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 32462855ee..5cf0004b4b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1301,6 +1301,47 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-reconnect-error")).toBeUndefined(); }); + it("normalizes non-Error reconnect failures before reporting onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new InMemoryTriggerChatRunStore(); + runStore.set({ + chatId: "chat-reconnect-string-failure", + runId: "run_reconnect_string_failure", + publicAccessToken: "pk_reconnect_string_failure", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw "reconnect string failure"; + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect-string-failure", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-reconnect-string-failure", + runId: "run_reconnect_string_failure", + }); + expect(errors[0]?.error.message).toBe("reconnect string failure"); + expect(runStore.get("chat-reconnect-string-failure")).toBeUndefined(); + }); + it("cleans run store state when stream completes", async function () { const trackedRunStore = new TrackedRunStore(); From afd9df51ce60fa03def0c03eeeb05698900b092c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:04:53 +0000 Subject: [PATCH 062/217] Cover non-Error onTriggeredRun failures in onError reporting Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 5cf0004b4b..52bb6e6842 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1142,6 +1142,76 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("callback failed"); }); + it("normalizes non-Error onTriggeredRun failures before reporting onError", async function () { + const errors: TriggerChatTransportError[] = []; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_callback_string", + }); + res.end(JSON.stringify({ id: "run_callback_string" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_callback_string/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "callback_string_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "callback_string_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + onTriggeredRun: async function onTriggeredRun() { + throw "callback string failure"; + }, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-callback-string", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "onTriggeredRun", + chatId: "chat-callback-string", + runId: "run_callback_string", + }); + expect(errors[0]?.error.message).toBe("callback string failure"); + }); + it("ignores failures from onError callback", async function () { const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { From 58b6dc3d865ed00629416db7168fe2dc8001060c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:05:39 +0000 Subject: [PATCH 063/217] Document non-Error normalization in onError changelog notes Co-authored-by: Eric Allam --- packages/ai/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 19600163dd..5b02573a38 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -14,3 +14,4 @@ - Added explicit helper option types for chat send/reconnect request inputs. - Added optional `onError` callback support for observing non-fatal transport issues. - Added phase-aware `onError` reporting across send, reconnect, and stream-consumption paths. +- Added normalization of non-Error throw values into Error instances before `onError` reporting. From 1c506719750d0448f812dbf28e8630b6a109b947 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:06:28 +0000 Subject: [PATCH 064/217] Clarify onError normalization behavior in streams docs Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index aaaaa33acd..1368ef2fe0 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -619,7 +619,7 @@ The default payload sent to your task is a rich, typed object that includes: `onError` receives phase-aware details (`payloadMapper`, `triggerOptions`, `triggerTask`, `onTriggeredRun`, `consumeTrackingStream`, `reconnect`) plus `chatId`, optional `runId`, -and the underlying `error`. +and the underlying `error` (non-Error throws are normalized to `Error` instances). ```ts import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; From 2236e0ffbfe50d48a92401e41b9419881f635a61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:07:45 +0000 Subject: [PATCH 065/217] Ensure onError failures never mask mapper errors Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 52bb6e6842..b6a69fe6e7 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -835,6 +835,33 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("string mapper failure"); }); + it("keeps original mapper failure when onError callback also fails", async function () { + const transport = new TriggerChatTransport< + UIMessage, + { prompt: string } + >({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + payloadMapper: async function payloadMapper() { + throw new Error("mapper failed root"); + }, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-mapper-onerror-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("mapper failed root"); + }); + it("surfaces trigger options resolver errors and does not trigger runs", async function () { let triggerCalls = 0; const errors: TriggerChatTransportError[] = []; From 065d9339bf7a08068e06e141d41af2baf5b74197 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:10:08 +0000 Subject: [PATCH 066/217] Ensure onError failures do not mask trigger option/task errors Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index b6a69fe6e7..2e631d8613 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -944,6 +944,30 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("string trigger options failure"); }); + it("keeps original trigger options failure when onError callback also fails", async function () { + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + triggerOptions: async function triggerOptions() { + throw new Error("trigger options failed root"); + }, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trigger-options-onerror-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("trigger options failed root"); + }); + it("reports trigger task request failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const server = await startServer(function (_req, res) { @@ -1025,6 +1049,31 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("string trigger task failure"); }); + it("keeps original trigger task failure when onError callback also fails", async function () { + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).triggerTask = async function triggerTask() { + throw new Error("trigger task failed root"); + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trigger-task-onerror-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("trigger task failed root"); + }); + it("supports creating transport with factory function", async function () { let observedRunId: string | undefined; let callbackCompleted = false; From ebd39aa79238909f8e279cd6e2105519c90ca601 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:11:41 +0000 Subject: [PATCH 067/217] Verify reconnect flow tolerates onError callback failures Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 2e631d8613..db1b437bf2 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1488,6 +1488,39 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-reconnect-string-failure")).toBeUndefined(); }); + it("ignores onError callback failures during reconnect error reporting", async function () { + const runStore = new InMemoryTriggerChatRunStore(); + runStore.set({ + chatId: "chat-reconnect-onerror-failure", + runId: "run_reconnect_onerror_failure", + publicAccessToken: "pk_reconnect_onerror_failure", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("reconnect root cause"); + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect-onerror-failure", + }); + + expect(stream).toBeNull(); + expect(runStore.get("chat-reconnect-onerror-failure")).toBeUndefined(); + }); + it("cleans run store state when stream completes", async function () { const trackedRunStore = new TrackedRunStore(); From 8e0dcd44bf3ac5c4460abcc8dc06a3bc2fe6ef0a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:14:11 +0000 Subject: [PATCH 068/217] Harden consumeTrackingStream error callback coverage Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 111 ++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index db1b437bf2..a61b3b5686 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1406,6 +1406,117 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-tracking-error")).toBeUndefined(); }); + it("normalizes non-Error consumeTrackingStream failures before reporting onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new TrackedRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tracking_string_error", + }); + res.end(JSON.stringify({ id: "run_tracking_string_error" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + return new ReadableStream({ + start(controller) { + controller.error("tracking string failure"); + }, + }); + }; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tracking-string-error", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await expect(readChunks(stream)).rejects.toBe("tracking string failure"); + + await waitForCondition(function () { + return errors.length === 1; + }); + + expect(errors[0]).toMatchObject({ + phase: "consumeTrackingStream", + chatId: "chat-tracking-string-error", + runId: "run_tracking_string_error", + }); + expect(errors[0]?.error.message).toBe("tracking string failure"); + expect(runStore.get("chat-tracking-string-error")).toBeUndefined(); + }); + + it("ignores onError callback failures during consumeTrackingStream errors", async function () { + const runStore = new TrackedRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tracking_onerror_failure", + }); + res.end(JSON.stringify({ id: "run_tracking_onerror_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + return new ReadableStream({ + start(controller) { + controller.error(new Error("tracking failed root cause")); + }, + }); + }; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tracking-onerror-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await expect(readChunks(stream)).rejects.toThrowError("tracking failed root cause"); + + await waitForCondition(function () { + return runStore.get("chat-tracking-onerror-failure") === undefined; + }); + }); + it("reports reconnect failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); From 4b4953cc28457e6d282d9c31cec4475e9e9dcf96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:17:06 +0000 Subject: [PATCH 069/217] Report stream subscribe failures through onError lifecycle Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 5 +- packages/ai/CHANGELOG.md | 2 +- packages/ai/README.md | 2 +- packages/ai/src/chatTransport.test.ts | 97 +++++++++++++++++++++ packages/ai/src/chatTransport.ts | 16 +++- packages/ai/src/chatTransport.types.test.ts | 1 + packages/ai/src/types.ts | 1 + 7 files changed, 119 insertions(+), 5 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 1368ef2fe0..1e395d50f1 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -618,8 +618,9 @@ The default payload sent to your task is a rich, typed object that includes: - headers passed through transport can be object, `Headers`, or tuple arrays `onError` receives phase-aware details (`payloadMapper`, `triggerOptions`, `triggerTask`, -`onTriggeredRun`, `consumeTrackingStream`, `reconnect`) plus `chatId`, optional `runId`, -and the underlying `error` (non-Error throws are normalized to `Error` instances). +`streamSubscribe`, `onTriggeredRun`, `consumeTrackingStream`, `reconnect`) plus `chatId`, +optional `runId`, and the underlying `error` (non-Error throws are normalized to `Error` +instances). ```ts import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5b02573a38..9ebd05dcb0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -13,5 +13,5 @@ - Added reconnect lifecycle handling that cleans run state after completion/error and gracefully returns `null` when reconnect cannot be resumed. - Added explicit helper option types for chat send/reconnect request inputs. - Added optional `onError` callback support for observing non-fatal transport issues. -- Added phase-aware `onError` reporting across send, reconnect, and stream-consumption paths. +- Added phase-aware `onError` reporting across send, stream-subscribe, reconnect, and stream-consumption paths. - Added normalization of non-Error throw values into Error instances before `onError` reporting. diff --git a/packages/ai/README.md b/packages/ai/README.md index 8a0e185886..b4e8d6f444 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -135,7 +135,7 @@ You can optionally provide `onError` to observe non-fatal transport errors The callback receives: -- `phase`: `"payloadMapper" | "triggerOptions" | "triggerTask" | "onTriggeredRun" | "consumeTrackingStream" | "reconnect"` +- `phase`: `"payloadMapper" | "triggerOptions" | "triggerTask" | "streamSubscribe" | "onTriggeredRun" | "consumeTrackingStream" | "reconnect"` - `chatId` - `runId` (may be `undefined` before a run is created) - `error` diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index a61b3b5686..536f17fc78 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1074,6 +1074,103 @@ describe("TriggerChatTransport", function () { ).rejects.toThrowError("trigger task failed root"); }); + it("reports stream subscription failures through onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new InMemoryTriggerChatRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_error", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_error" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("stream subscribe failed root"); + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-error", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("stream subscribe failed root"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "streamSubscribe", + chatId: "chat-stream-subscribe-error", + runId: "run_stream_subscribe_error", + }); + expect(runStore.get("chat-stream-subscribe-error")).toBeUndefined(); + }); + + it("keeps original stream subscription failure when onError callback also fails", async function () { + const runStore = new InMemoryTriggerChatRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_onerror_failure", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_onerror_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("stream subscribe failed root"); + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-onerror-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("stream subscribe failed root"); + + expect(runStore.get("chat-stream-subscribe-onerror-failure")).toBeUndefined(); + }); + it("supports creating transport with factory function", async function () { let observedRunId: string | undefined; let callbackCompleted = false; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 3b44bf3e52..cc27d975e9 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -231,7 +231,21 @@ export class TriggerChatTransport< } } - const stream = await this.fetchRunStream(runState, options.abortSignal); + let stream: ReadableStream>>; + try { + stream = await this.fetchRunStream(runState, options.abortSignal); + } catch (error) { + runState.isActive = false; + await this.runStore.set(runState); + await this.runStore.delete(runState.chatId); + await this.reportError({ + phase: "streamSubscribe", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); + throw error; + } return this.createTrackedStream(runState.chatId, stream); } diff --git a/packages/ai/src/chatTransport.types.test.ts b/packages/ai/src/chatTransport.types.test.ts index 2d09b2fd08..dc37c8420a 100644 --- a/packages/ai/src/chatTransport.types.test.ts +++ b/packages/ai/src/chatTransport.types.test.ts @@ -112,6 +112,7 @@ function createTypedOnErrorCallback(): TriggerChatOnError { | "payloadMapper" | "triggerOptions" | "triggerTask" + | "streamSubscribe" | "onTriggeredRun" | "consumeTrackingStream" | "reconnect" diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6ee994a8b1..0053f0de93 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -74,6 +74,7 @@ export type TriggerChatTransportErrorPhase = | "payloadMapper" | "triggerOptions" | "triggerTask" + | "streamSubscribe" | "onTriggeredRun" | "consumeTrackingStream" | "reconnect"; From 97909907ab1d10116a16032a6faa584eeb69a392 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:18:57 +0000 Subject: [PATCH 070/217] Cover non-Error stream subscription failures in onError reporting Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 536f17fc78..6e0040df9f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1123,9 +1123,63 @@ describe("TriggerChatTransport", function () { chatId: "chat-stream-subscribe-error", runId: "run_stream_subscribe_error", }); + expect(errors[0]?.error.message).toBe("stream subscribe failed root"); expect(runStore.get("chat-stream-subscribe-error")).toBeUndefined(); }); + it("normalizes non-Error stream subscription failures before reporting onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new InMemoryTriggerChatRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_string_error", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_string_error" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw "stream subscribe string failure"; + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-string-error", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toBe("stream subscribe string failure"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "streamSubscribe", + chatId: "chat-stream-subscribe-string-error", + runId: "run_stream_subscribe_string_error", + }); + expect(errors[0]?.error.message).toBe("stream subscribe string failure"); + expect(runStore.get("chat-stream-subscribe-string-error")).toBeUndefined(); + }); + it("keeps original stream subscription failure when onError callback also fails", async function () { const runStore = new InMemoryTriggerChatRunStore(); From a1d32c494d179b0b5eb8d86318e2727b9e906333 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:21:39 +0000 Subject: [PATCH 071/217] Assert stream subscribe cleanup transitions run state Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 36 +++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 6e0040df9f..23648ed65e 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1076,7 +1076,7 @@ describe("TriggerChatTransport", function () { it("reports stream subscription failures through onError", async function () { const errors: TriggerChatTransportError[] = []; - const runStore = new InMemoryTriggerChatRunStore(); + const runStore = new TrackedRunStore(); const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { @@ -1124,12 +1124,24 @@ describe("TriggerChatTransport", function () { runId: "run_stream_subscribe_error", }); expect(errors[0]?.error.message).toBe("stream subscribe failed root"); + expect(runStore.setSnapshots).toHaveLength(2); + expect(runStore.setSnapshots[0]).toMatchObject({ + chatId: "chat-stream-subscribe-error", + runId: "run_stream_subscribe_error", + isActive: true, + }); + expect(runStore.setSnapshots[1]).toMatchObject({ + chatId: "chat-stream-subscribe-error", + runId: "run_stream_subscribe_error", + isActive: false, + }); + expect(runStore.deleteCalls).toEqual(["chat-stream-subscribe-error"]); expect(runStore.get("chat-stream-subscribe-error")).toBeUndefined(); }); it("normalizes non-Error stream subscription failures before reporting onError", async function () { const errors: TriggerChatTransportError[] = []; - const runStore = new InMemoryTriggerChatRunStore(); + const runStore = new TrackedRunStore(); const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { @@ -1177,6 +1189,18 @@ describe("TriggerChatTransport", function () { runId: "run_stream_subscribe_string_error", }); expect(errors[0]?.error.message).toBe("stream subscribe string failure"); + expect(runStore.setSnapshots).toHaveLength(2); + expect(runStore.setSnapshots[0]).toMatchObject({ + chatId: "chat-stream-subscribe-string-error", + runId: "run_stream_subscribe_string_error", + isActive: true, + }); + expect(runStore.setSnapshots[1]).toMatchObject({ + chatId: "chat-stream-subscribe-string-error", + runId: "run_stream_subscribe_string_error", + isActive: false, + }); + expect(runStore.deleteCalls).toEqual(["chat-stream-subscribe-string-error"]); expect(runStore.get("chat-stream-subscribe-string-error")).toBeUndefined(); }); @@ -2165,8 +2189,16 @@ async function waitForCondition( } class TrackedRunStore extends InMemoryTriggerChatRunStore { + public readonly setSnapshots: TriggerChatRunState[] = []; public readonly deleteCalls: string[] = []; + public set(state: TriggerChatRunState): void { + this.setSnapshots.push({ + ...state, + }); + super.set(state); + } + public delete(chatId: string): void { this.deleteCalls.push(chatId); super.delete(chatId); From e97fc5c1622cae5d7d4f26b65e7baabd418b08f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:23:53 +0000 Subject: [PATCH 072/217] Refactor run-state cleanup into shared transport helper Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index cc27d975e9..fccf341e91 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -235,9 +235,7 @@ export class TriggerChatTransport< try { stream = await this.fetchRunStream(runState, options.abortSignal); } catch (error) { - runState.isActive = false; - await this.runStore.set(runState); - await this.runStore.delete(runState.chatId); + await this.markRunInactiveAndDelete(runState); await this.reportError({ phase: "streamSubscribe", chatId: runState.chatId, @@ -268,9 +266,7 @@ export class TriggerChatTransport< try { stream = await this.fetchRunStream(runState, undefined, runState.lastEventId); } catch (error) { - runState.isActive = false; - await this.runStore.set(runState); - await this.runStore.delete(options.chatId); + await this.markRunInactiveAndDelete(runState); await this.reportError({ phase: "reconnect", chatId: runState.chatId, @@ -347,16 +343,12 @@ export class TriggerChatTransport< const runState = await this.runStore.get(chatId); if (runState) { - runState.isActive = false; - await this.runStore.set(runState); - await this.runStore.delete(chatId); + await this.markRunInactiveAndDelete(runState); } } catch (error) { const runState = await this.runStore.get(chatId); if (runState) { - runState.isActive = false; - await this.runStore.set(runState); - await this.runStore.delete(chatId); + await this.markRunInactiveAndDelete(runState); await this.reportError({ phase: "consumeTrackingStream", chatId: runState.chatId, @@ -387,6 +379,12 @@ export class TriggerChatTransport< return `${normalizedBaseUrl}/realtime/v1/streams/${encodedRunId}/${encodedStreamKey}`; } + private async markRunInactiveAndDelete(runState: TriggerChatRunState) { + runState.isActive = false; + await this.runStore.set(runState); + await this.runStore.delete(runState.chatId); + } + private async reportError(event: TriggerChatTransportError) { if (!this.onError) { return; From fe5cd3d958121579a3e9ed52db400c60193e0880 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:26:06 +0000 Subject: [PATCH 073/217] Use immutable run-state updates in tracking and cleanup paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index fccf341e91..f324164a96 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -337,8 +337,10 @@ export class TriggerChatTransport< return; } - runState.lastEventId = part.id; - await this.runStore.set(runState); + await this.runStore.set({ + ...runState, + lastEventId: part.id, + }); } const runState = await this.runStore.get(chatId); @@ -380,8 +382,10 @@ export class TriggerChatTransport< } private async markRunInactiveAndDelete(runState: TriggerChatRunState) { - runState.isActive = false; - await this.runStore.set(runState); + await this.runStore.set({ + ...runState, + isActive: false, + }); await this.runStore.delete(runState.chatId); } From e7823033376177d141072f7b1410ac9d048fa8af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:28:14 +0000 Subject: [PATCH 074/217] Cover async run-store cleanup on stream subscribe failures Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 23648ed65e..e171f7d7ec 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1249,6 +1249,55 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-stream-subscribe-onerror-failure")).toBeUndefined(); }); + it("cleans up async run-store state when stream subscription fails", async function () { + const runStore = new AsyncTrackedRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_async_failure", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_async_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("stream subscribe async failure"); + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-async-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("stream subscribe async failure"); + + expect(runStore.setCalls).toEqual([ + "chat-stream-subscribe-async-failure", + "chat-stream-subscribe-async-failure", + ]); + expect(runStore.deleteCalls).toEqual(["chat-stream-subscribe-async-failure"]); + await expect( + runStore.get("chat-stream-subscribe-async-failure") + ).resolves.toBeUndefined(); + }); + it("supports creating transport with factory function", async function () { let observedRunId: string | undefined; let callbackCompleted = false; From bf7bee81013d190fd28a8fe683fcc01cbb9e7bac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:30:23 +0000 Subject: [PATCH 075/217] Assert onError remains silent on successful chat streams Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e171f7d7ec..5491d69e66 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1442,6 +1442,66 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("callback failed"); }); + it("does not call onError during successful trigger and stream flows", async function () { + const errors: TriggerChatTransportError[] = []; + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_no_error_callback", + }); + res.end(JSON.stringify({ id: "run_no_error_callback" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_no_error_callback/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "no_error_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "no_error_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-no-error-callback", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(errors).toHaveLength(0); + }); + it("normalizes non-Error onTriggeredRun failures before reporting onError", async function () { const errors: TriggerChatTransportError[] = []; From 0779d5436f98135b659e51e8a0d7ef7740cc4063 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:32:42 +0000 Subject: [PATCH 076/217] Assert reconnect non-error paths do not invoke onError Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 5491d69e66..764c659701 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -495,11 +495,15 @@ describe("TriggerChatTransport", function () { }); it("returns null on reconnect when no active run exists", async function () { + const errors: TriggerChatTransportError[] = []; const transport = new TriggerChatTransport({ task: "chat-task", stream: "chat-stream", accessToken: "pk_trigger", baseURL: "https://api.trigger.dev", + onError: function onError(error) { + errors.push(error); + }, }); const stream = await transport.reconnectToStream({ @@ -507,9 +511,11 @@ describe("TriggerChatTransport", function () { }); expect(stream).toBeNull(); + expect(errors).toHaveLength(0); }); it("removes inactive run entries during reconnect attempts", async function () { + const errors: TriggerChatTransportError[] = []; const runStore = new TrackedRunStore(); runStore.set({ chatId: "chat-inactive", @@ -525,6 +531,9 @@ describe("TriggerChatTransport", function () { stream: "chat-stream", accessToken: "pk_trigger", runStore, + onError: function onError(error) { + errors.push(error); + }, }); const stream = await transport.reconnectToStream({ @@ -532,6 +541,7 @@ describe("TriggerChatTransport", function () { }); expect(stream).toBeNull(); + expect(errors).toHaveLength(0); expect(runStore.deleteCalls).toContain("chat-inactive"); expect(runStore.get("chat-inactive")).toBeUndefined(); }); From 032371289802e3462251af359a1ff1fd6265b29a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:34:58 +0000 Subject: [PATCH 077/217] Assert reconnect success path does not invoke onError Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 764c659701..5765bcc16a 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2117,6 +2117,7 @@ describe("TriggerChatTransport", function () { let reconnectLastEventId: string | undefined; let firstStreamResponse: ServerResponse | undefined; let firstStreamChunkSent = false; + const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); const server = await startServer(function (req, res) { @@ -2177,6 +2178,9 @@ describe("TriggerChatTransport", function () { accessToken: "pk_trigger", baseURL: server.url, runStore, + onError: function onError(error) { + errors.push(error); + }, }); try { @@ -2212,6 +2216,7 @@ describe("TriggerChatTransport", function () { expect(reconnectChunks[1]).toMatchObject({ chunk: { type: "text-end", id: "msg_2" }, }); + expect(errors).toHaveLength(0); } finally { if (firstStreamResponse) { firstStreamResponse.end(); From 90198dea8e6f21e8bbe7ef8f9da01cec20720f07 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:37:16 +0000 Subject: [PATCH 078/217] Preserve thrown string when stream subscribe onError callback fails Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 5765bcc16a..0f132dfde9 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1259,6 +1259,54 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-stream-subscribe-onerror-failure")).toBeUndefined(); }); + it( + "keeps original non-Error stream subscription failure when onError callback also fails", + async function () { + const runStore = new InMemoryTriggerChatRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_string_onerror_failure", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_string_onerror_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw "stream subscribe string root"; + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-string-onerror-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toBe("stream subscribe string root"); + + expect(runStore.get("chat-stream-subscribe-string-onerror-failure")).toBeUndefined(); + } + ); + it("cleans up async run-store state when stream subscription fails", async function () { const runStore = new AsyncTrackedRunStore(); From 382577f7350914772a46bff40f7870f107909e5f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:40:23 +0000 Subject: [PATCH 079/217] Avoid masking transport failures when run-store cleanup fails Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 109 ++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 16 +++- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 0f132dfde9..ba5f956d43 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1307,6 +1307,58 @@ describe("TriggerChatTransport", function () { } ); + it("preserves stream subscribe failures when cleanup run-store set throws", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetRunStore(2); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_cleanup_set_failure", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_cleanup_set_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("stream subscribe root cause"); + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-cleanup-set-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("stream subscribe root cause"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "streamSubscribe", + chatId: "chat-stream-subscribe-cleanup-set-failure", + runId: "run_stream_subscribe_cleanup_set_failure", + }); + expect(errors[0]?.error.message).toBe("stream subscribe root cause"); + }); + it("cleans up async run-store state when stream subscription fails", async function () { const runStore = new AsyncTrackedRunStore(); @@ -1900,6 +1952,46 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-reconnect-error")).toBeUndefined(); }); + it("preserves reconnect failures when cleanup run-store set throws", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetRunStore(2); + runStore.set({ + chatId: "chat-reconnect-cleanup-set-failure", + runId: "run_reconnect_cleanup_set_failure", + publicAccessToken: "pk_reconnect_cleanup_set_failure", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("reconnect root cause"); + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-set-failure", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-reconnect-cleanup-set-failure", + runId: "run_reconnect_cleanup_set_failure", + }); + expect(errors[0]?.error.message).toBe("reconnect root cause"); + }); + it("normalizes non-Error reconnect failures before reporting onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); @@ -2377,6 +2469,23 @@ class TrackedRunStore extends InMemoryTriggerChatRunStore { } } +class FailingCleanupSetRunStore extends InMemoryTriggerChatRunStore { + private setCalls = 0; + + constructor(private readonly failOnSetCall: number) { + super(); + } + + public set(state: TriggerChatRunState): void { + this.setCalls += 1; + if (this.setCalls === this.failOnSetCall) { + throw new Error("cleanup set failed"); + } + + super.set(state); + } +} + class AsyncTrackedRunStore implements TriggerChatRunStore { private readonly runs = new Map(); public readonly getCalls: string[] = []; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index f324164a96..eb7cc9cd82 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -235,7 +235,7 @@ export class TriggerChatTransport< try { stream = await this.fetchRunStream(runState, options.abortSignal); } catch (error) { - await this.markRunInactiveAndDelete(runState); + await this.tryMarkRunInactiveAndDelete(runState); await this.reportError({ phase: "streamSubscribe", chatId: runState.chatId, @@ -266,7 +266,7 @@ export class TriggerChatTransport< try { stream = await this.fetchRunStream(runState, undefined, runState.lastEventId); } catch (error) { - await this.markRunInactiveAndDelete(runState); + await this.tryMarkRunInactiveAndDelete(runState); await this.reportError({ phase: "reconnect", chatId: runState.chatId, @@ -345,12 +345,12 @@ export class TriggerChatTransport< const runState = await this.runStore.get(chatId); if (runState) { - await this.markRunInactiveAndDelete(runState); + await this.tryMarkRunInactiveAndDelete(runState); } } catch (error) { const runState = await this.runStore.get(chatId); if (runState) { - await this.markRunInactiveAndDelete(runState); + await this.tryMarkRunInactiveAndDelete(runState); await this.reportError({ phase: "consumeTrackingStream", chatId: runState.chatId, @@ -389,6 +389,14 @@ export class TriggerChatTransport< await this.runStore.delete(runState.chatId); } + private async tryMarkRunInactiveAndDelete(runState: TriggerChatRunState) { + try { + await this.markRunInactiveAndDelete(runState); + } catch { + // Best effort cleanup only; never mask the original transport failure. + } + } + private async reportError(event: TriggerChatTransportError) { if (!this.onError) { return; From b912e4f51c4a2540fe6d119a0aba6f48e858e564 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:42:41 +0000 Subject: [PATCH 080/217] Document best-effort run-store cleanup error semantics Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 +++ packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 3 +++ 3 files changed, 7 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 1e395d50f1..b5babce4e2 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -622,6 +622,9 @@ The default payload sent to your task is a rich, typed object that includes: optional `runId`, and the underlying `error` (non-Error throws are normalized to `Error` instances). +Run-store cleanup is handled as best effort, and cleanup failures won't mask the original +transport failure that triggered `onError`. + ```ts import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9ebd05dcb0..cb26234345 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -15,3 +15,4 @@ - Added optional `onError` callback support for observing non-fatal transport issues. - Added phase-aware `onError` reporting across send, stream-subscribe, reconnect, and stream-consumption paths. - Added normalization of non-Error throw values into Error instances before `onError` reporting. +- Added best-effort run-store cleanup so cleanup failures do not mask root transport errors. diff --git a/packages/ai/README.md b/packages/ai/README.md index b4e8d6f444..2b66461ed7 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -140,6 +140,9 @@ The callback receives: - `runId` (may be `undefined` before a run is created) - `error` +Cleanup operations against custom `runStore` implementations are best-effort. If store cleanup +fails, the original transport error is still preserved and surfaced. + ## Reconnect semantics - `reconnectToStream({ chatId })` resumes only while a stream is still active. From 654bab082355519ca83be1bddae8fe6a3ed95271 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:45:01 +0000 Subject: [PATCH 081/217] Cover cleanup delete failures across stream subscribe and reconnect Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 109 ++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index ba5f956d43..750ff7110f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1359,6 +1359,58 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("stream subscribe root cause"); }); + it("preserves stream subscribe failures when cleanup run-store delete throws", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteRunStore(1); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_cleanup_delete_failure", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_cleanup_delete_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("stream subscribe root cause"); + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-cleanup-delete-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("stream subscribe root cause"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "streamSubscribe", + chatId: "chat-stream-subscribe-cleanup-delete-failure", + runId: "run_stream_subscribe_cleanup_delete_failure", + }); + expect(errors[0]?.error.message).toBe("stream subscribe root cause"); + }); + it("cleans up async run-store state when stream subscription fails", async function () { const runStore = new AsyncTrackedRunStore(); @@ -1992,6 +2044,46 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("reconnect root cause"); }); + it("preserves reconnect failures when cleanup run-store delete throws", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteRunStore(1); + runStore.set({ + chatId: "chat-reconnect-cleanup-delete-failure", + runId: "run_reconnect_cleanup_delete_failure", + publicAccessToken: "pk_reconnect_cleanup_delete_failure", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("reconnect root cause"); + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-delete-failure", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-reconnect-cleanup-delete-failure", + runId: "run_reconnect_cleanup_delete_failure", + }); + expect(errors[0]?.error.message).toBe("reconnect root cause"); + }); + it("normalizes non-Error reconnect failures before reporting onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); @@ -2486,6 +2578,23 @@ class FailingCleanupSetRunStore extends InMemoryTriggerChatRunStore { } } +class FailingCleanupDeleteRunStore extends InMemoryTriggerChatRunStore { + private deleteCalls = 0; + + constructor(private readonly failOnDeleteCall: number) { + super(); + } + + public delete(chatId: string): void { + this.deleteCalls += 1; + if (this.deleteCalls === this.failOnDeleteCall) { + throw new Error("cleanup delete failed"); + } + + super.delete(chatId); + } +} + class AsyncTrackedRunStore implements TriggerChatRunStore { private readonly runs = new Map(); public readonly getCalls: string[] = []; From ed2207fdf3569905d1c7e5bf93c60c9aa5832280 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:47:26 +0000 Subject: [PATCH 082/217] Cover consumeTracking cleanup failures without masking root errors Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 118 ++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 750ff7110f..2863be137b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1963,6 +1963,124 @@ describe("TriggerChatTransport", function () { }); }); + it("preserves consumeTrackingStream failures when cleanup run-store set throws", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetRunStore(2); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tracking_cleanup_set_failure", + }); + res.end(JSON.stringify({ id: "run_tracking_cleanup_set_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + return new ReadableStream({ + start(controller) { + controller.error(new Error("tracking failed root cause")); + }, + }); + }; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tracking-cleanup-set-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await expect(readChunks(stream)).rejects.toThrowError("tracking failed root cause"); + + await waitForCondition(function () { + return errors.length === 1; + }); + + expect(errors[0]).toMatchObject({ + phase: "consumeTrackingStream", + chatId: "chat-tracking-cleanup-set-failure", + runId: "run_tracking_cleanup_set_failure", + }); + expect(errors[0]?.error.message).toBe("tracking failed root cause"); + }); + + it("preserves consumeTrackingStream failures when cleanup run-store delete throws", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteRunStore(1); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tracking_cleanup_delete_failure", + }); + res.end(JSON.stringify({ id: "run_tracking_cleanup_delete_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + return new ReadableStream({ + start(controller) { + controller.error(new Error("tracking failed root cause")); + }, + }); + }; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tracking-cleanup-delete-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await expect(readChunks(stream)).rejects.toThrowError("tracking failed root cause"); + + await waitForCondition(function () { + return errors.length === 1; + }); + + expect(errors[0]).toMatchObject({ + phase: "consumeTrackingStream", + chatId: "chat-tracking-cleanup-delete-failure", + runId: "run_tracking_cleanup_delete_failure", + }); + expect(errors[0]?.error.message).toBe("tracking failed root cause"); + }); + it("reports reconnect failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); From c441984d3a1ff0ec1c17e1e40f16a5624f145456 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:50:02 +0000 Subject: [PATCH 083/217] Cover completion path behavior when cleanup delete fails Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 2863be137b..edc836e344 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2337,6 +2337,74 @@ describe("TriggerChatTransport", function () { expect(trackedRunStore.get("chat-cleanup")).toBeUndefined(); }); + it("keeps completed streams successful when cleanup delete fails", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteRunStore(1); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_cleanup_delete_failure", + }); + res.end(JSON.stringify({ id: "run_cleanup_delete_failure" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_cleanup_delete_failure/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "cleanup_delete_failure_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "cleanup_delete_failure_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-cleanup-delete-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(errors).toHaveLength(0); + + await waitForCondition(function () { + const state = runStore.get("chat-cleanup-delete-failure"); + return Boolean(state && state.isActive === false); + }); + }); + it("returns null from reconnect after stream completion cleanup", async function () { const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { From e0219cefe4b1e81d7f8563c570874613f516f7b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:52:29 +0000 Subject: [PATCH 084/217] Cover consumeTracking root errors when cleanup and onError both fail Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index edc836e344..e1143689d6 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1963,6 +1963,56 @@ describe("TriggerChatTransport", function () { }); }); + it( + "preserves consumeTrackingStream root failures when cleanup and onError callbacks both fail", + async function () { + const runStore = new FailingCleanupSetRunStore(2); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tracking_cleanup_and_onerror_failure", + }); + res.end(JSON.stringify({ id: "run_tracking_cleanup_and_onerror_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + return new ReadableStream({ + start(controller) { + controller.error(new Error("tracking failed root cause")); + }, + }); + }; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tracking-cleanup-and-onerror-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await expect(readChunks(stream)).rejects.toThrowError("tracking failed root cause"); + } + ); + it("preserves consumeTrackingStream failures when cleanup run-store set throws", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupSetRunStore(2); From d4266a611bfc330d119f8af929cff6dd95ea8d86 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:54:41 +0000 Subject: [PATCH 085/217] Cover combined cleanup and onError failure resilience paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 81 +++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e1143689d6..b6abc12413 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1411,6 +1411,52 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("stream subscribe root cause"); }); + it( + "preserves stream subscribe root failures when cleanup and onError callbacks both fail", + async function () { + const runStore = new FailingCleanupSetRunStore(2); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_cleanup_and_onerror_failure", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_cleanup_and_onerror_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("stream subscribe root cause"); + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-cleanup-and-onerror-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("stream subscribe root cause"); + } + ); + it("cleans up async run-store state when stream subscription fails", async function () { const runStore = new AsyncTrackedRunStore(); @@ -2252,6 +2298,41 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("reconnect root cause"); }); + it( + "preserves reconnect root failures when cleanup and onError callbacks both fail", + async function () { + const runStore = new FailingCleanupDeleteRunStore(1); + runStore.set({ + chatId: "chat-reconnect-cleanup-and-onerror-failure", + runId: "run_reconnect_cleanup_and_onerror_failure", + publicAccessToken: "pk_reconnect_cleanup_and_onerror_failure", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("reconnect root cause"); + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-and-onerror-failure", + }); + + expect(stream).toBeNull(); + } + ); + it("normalizes non-Error reconnect failures before reporting onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); From fc3d9b806112b609bf2883d0b4fa06bb10848b1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:57:04 +0000 Subject: [PATCH 086/217] Cover completion path behavior when cleanup set fails Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index b6abc12413..a1dd656021 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2536,6 +2536,74 @@ describe("TriggerChatTransport", function () { }); }); + it("keeps completed streams successful when cleanup set fails", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetRunStore(4); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_cleanup_set_failure", + }); + res.end(JSON.stringify({ id: "run_cleanup_set_failure" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_cleanup_set_failure/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "cleanup_set_failure_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "cleanup_set_failure_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-cleanup-set-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(errors).toHaveLength(0); + + await waitForCondition(function () { + const state = runStore.get("chat-cleanup-set-failure"); + return Boolean(state && state.isActive === true && state.lastEventId === "2-0"); + }); + }); + it("returns null from reconnect after stream completion cleanup", async function () { const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { From 43205f7cadee955503765e7408130197c587bfe8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 01:59:55 +0000 Subject: [PATCH 087/217] Attempt run-store delete even when inactive-state write fails Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 12 ++++++++++-- packages/ai/src/chatTransport.ts | 27 ++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index a1dd656021..be677d5512 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1357,6 +1357,8 @@ describe("TriggerChatTransport", function () { runId: "run_stream_subscribe_cleanup_set_failure", }); expect(errors[0]?.error.message).toBe("stream subscribe root cause"); + expect(runStore.deleteCalls).toContain("chat-stream-subscribe-cleanup-set-failure"); + expect(runStore.get("chat-stream-subscribe-cleanup-set-failure")).toBeUndefined(); }); it("preserves stream subscribe failures when cleanup run-store delete throws", async function () { @@ -2599,9 +2601,9 @@ describe("TriggerChatTransport", function () { expect(errors).toHaveLength(0); await waitForCondition(function () { - const state = runStore.get("chat-cleanup-set-failure"); - return Boolean(state && state.isActive === true && state.lastEventId === "2-0"); + return runStore.get("chat-cleanup-set-failure") === undefined; }); + expect(runStore.deleteCalls).toContain("chat-cleanup-set-failure"); }); it("returns null from reconnect after stream completion cleanup", async function () { @@ -2948,6 +2950,7 @@ class TrackedRunStore extends InMemoryTriggerChatRunStore { class FailingCleanupSetRunStore extends InMemoryTriggerChatRunStore { private setCalls = 0; + public readonly deleteCalls: string[] = []; constructor(private readonly failOnSetCall: number) { super(); @@ -2961,6 +2964,11 @@ class FailingCleanupSetRunStore extends InMemoryTriggerChatRunStore { super.set(state); } + + public delete(chatId: string): void { + this.deleteCalls.push(chatId); + super.delete(chatId); + } } class FailingCleanupDeleteRunStore extends InMemoryTriggerChatRunStore { diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index eb7cc9cd82..5c6392852f 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -382,11 +382,28 @@ export class TriggerChatTransport< } private async markRunInactiveAndDelete(runState: TriggerChatRunState) { - await this.runStore.set({ - ...runState, - isActive: false, - }); - await this.runStore.delete(runState.chatId); + let cleanupError: Error | undefined; + + try { + await this.runStore.set({ + ...runState, + isActive: false, + }); + } catch (error) { + cleanupError = normalizeError(error); + } + + try { + await this.runStore.delete(runState.chatId); + } catch (error) { + if (!cleanupError) { + cleanupError = normalizeError(error); + } + } + + if (cleanupError) { + throw cleanupError; + } } private async tryMarkRunInactiveAndDelete(runState: TriggerChatRunState) { From aab79a08dcc207d8097e397045fe1c9bb474b38b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:01:50 +0000 Subject: [PATCH 088/217] Document dual-step best-effort run-store cleanup behavior Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 ++- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index b5babce4e2..41a66d59b4 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -623,7 +623,8 @@ optional `runId`, and the underlying `error` (non-Error throws are normalized to instances). Run-store cleanup is handled as best effort, and cleanup failures won't mask the original -transport failure that triggered `onError`. +transport failure that triggered `onError`. Cleanup still attempts both persistence steps +(`set` inactive state and `delete`) even when one step fails. ```ts import type { TriggerChatRunState, TriggerChatRunStore } from "@trigger.dev/ai"; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cb26234345..4de19ced8d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -16,3 +16,4 @@ - Added phase-aware `onError` reporting across send, stream-subscribe, reconnect, and stream-consumption paths. - Added normalization of non-Error throw values into Error instances before `onError` reporting. - Added best-effort run-store cleanup so cleanup failures do not mask root transport errors. +- Improved best-effort run-store cleanup to attempt both inactive-state writes and deletes even if one step fails. diff --git a/packages/ai/README.md b/packages/ai/README.md index 2b66461ed7..1a084c0cfa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -141,7 +141,8 @@ The callback receives: - `error` Cleanup operations against custom `runStore` implementations are best-effort. If store cleanup -fails, the original transport error is still preserved and surfaced. +fails, the original transport error is still preserved and surfaced. The transport also attempts +both cleanup steps (`set` inactive state and `delete`) even if one of them fails. ## Reconnect semantics From 7eb4f5df25262dea0dec5882366956367dded023 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:04:22 +0000 Subject: [PATCH 089/217] Cover cleanup set+delete dual failure path during stream subscribe Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index be677d5512..508faacebf 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1413,6 +1413,59 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("stream subscribe root cause"); }); + it("attempts both cleanup steps when set and delete both throw", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetAndDeleteRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_stream_subscribe_cleanup_both_failure", + }); + res.end(JSON.stringify({ id: "run_stream_subscribe_cleanup_both_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("stream subscribe root cause"); + }; + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-stream-subscribe-cleanup-both-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).rejects.toThrowError("stream subscribe root cause"); + + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "streamSubscribe", + chatId: "chat-stream-subscribe-cleanup-both-failure", + runId: "run_stream_subscribe_cleanup_both_failure", + }); + expect(runStore.setCalls).toContain("chat-stream-subscribe-cleanup-both-failure"); + expect(runStore.deleteCalls).toContain("chat-stream-subscribe-cleanup-both-failure"); + }); + it( "preserves stream subscribe root failures when cleanup and onError callbacks both fail", async function () { @@ -2988,6 +3041,31 @@ class FailingCleanupDeleteRunStore extends InMemoryTriggerChatRunStore { } } +class FailingCleanupSetAndDeleteRunStore extends InMemoryTriggerChatRunStore { + private setCallCount = 0; + public readonly setCalls: string[] = []; + public readonly deleteCalls: string[] = []; + + constructor(private readonly failOnSetCall: number = 2) { + super(); + } + + public set(state: TriggerChatRunState): void { + this.setCallCount += 1; + this.setCalls.push(state.chatId); + if (this.setCallCount === this.failOnSetCall) { + throw new Error("cleanup set failed"); + } + + super.set(state); + } + + public delete(chatId: string): void { + this.deleteCalls.push(chatId); + throw new Error("cleanup delete failed"); + } +} + class AsyncTrackedRunStore implements TriggerChatRunStore { private readonly runs = new Map(); public readonly getCalls: string[] = []; From 3c240685bad7eafcae5d2cdebe3126ba8c499b89 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:06:40 +0000 Subject: [PATCH 090/217] Cover reconnect path when cleanup set and delete both fail Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 508faacebf..293021fd19 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2353,6 +2353,48 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("reconnect root cause"); }); + it("attempts both reconnect cleanup steps when set and delete both throw", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetAndDeleteRunStore(); + runStore.set({ + chatId: "chat-reconnect-cleanup-both-failure", + runId: "run_reconnect_cleanup_both_failure", + publicAccessToken: "pk_reconnect_cleanup_both_failure", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("reconnect root cause"); + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-both-failure", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-reconnect-cleanup-both-failure", + runId: "run_reconnect_cleanup_both_failure", + }); + expect(errors[0]?.error.message).toBe("reconnect root cause"); + expect(runStore.setCalls).toContain("chat-reconnect-cleanup-both-failure"); + expect(runStore.deleteCalls).toContain("chat-reconnect-cleanup-both-failure"); + }); + it( "preserves reconnect root failures when cleanup and onError callbacks both fail", async function () { From 526b66f9b33c1ea730a8d1cc70c7aed7943952ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:09:04 +0000 Subject: [PATCH 091/217] Cover completion path when cleanup set and delete both fail Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 293021fd19..266c7bc90b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2701,6 +2701,76 @@ describe("TriggerChatTransport", function () { expect(runStore.deleteCalls).toContain("chat-cleanup-set-failure"); }); + it("keeps completed streams successful when cleanup set and delete both fail", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetAndDeleteRunStore(4); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_cleanup_set_delete_failure", + }); + res.end(JSON.stringify({ id: "run_cleanup_set_delete_failure" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_cleanup_set_delete_failure/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "cleanup_set_delete_failure_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "cleanup_set_delete_failure_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-cleanup-set-delete-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(errors).toHaveLength(0); + + await waitForCondition(function () { + const state = runStore.get("chat-cleanup-set-delete-failure"); + return Boolean(state && state.isActive === true && state.lastEventId === "2-0"); + }); + expect(runStore.setCalls).toContain("chat-cleanup-set-delete-failure"); + expect(runStore.deleteCalls).toContain("chat-cleanup-set-delete-failure"); + }); + it("returns null from reconnect after stream completion cleanup", async function () { const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { From 50b9407ef36afd37b4265191c76632cb51fe7d7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:11:17 +0000 Subject: [PATCH 092/217] Cover consumeTracking path when cleanup set and delete both fail Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 266c7bc90b..8faa266eff 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2232,6 +2232,70 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("tracking failed root cause"); }); + it( + "attempts consumeTracking cleanup set and delete when both cleanup steps throw", + async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetAndDeleteRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tracking_cleanup_both_failure", + }); + res.end(JSON.stringify({ id: "run_tracking_cleanup_both_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + return new ReadableStream({ + start(controller) { + controller.error(new Error("tracking failed root cause")); + }, + }); + }; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tracking-cleanup-both-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await expect(readChunks(stream)).rejects.toThrowError("tracking failed root cause"); + + await waitForCondition(function () { + return errors.length === 1; + }); + + expect(errors[0]).toMatchObject({ + phase: "consumeTrackingStream", + chatId: "chat-tracking-cleanup-both-failure", + runId: "run_tracking_cleanup_both_failure", + }); + expect(errors[0]?.error.message).toBe("tracking failed root cause"); + expect(runStore.setCalls).toContain("chat-tracking-cleanup-both-failure"); + expect(runStore.deleteCalls).toContain("chat-tracking-cleanup-both-failure"); + } + ); + it("reports reconnect failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); From 677fc0694019175cfbbe30769ca6e6028f691a79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:13:39 +0000 Subject: [PATCH 093/217] Cover reconnect behavior when cleanup and onError all fail Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 8faa266eff..cba7d672a3 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2494,6 +2494,43 @@ describe("TriggerChatTransport", function () { } ); + it( + "preserves reconnect root failures when cleanup set/delete and onError callbacks all fail", + async function () { + const runStore = new FailingCleanupSetAndDeleteRunStore(); + runStore.set({ + chatId: "chat-reconnect-cleanup-all-failure", + runId: "run_reconnect_cleanup_all_failure", + publicAccessToken: "pk_reconnect_cleanup_all_failure", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("reconnect root cause"); + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-all-failure", + }); + + expect(stream).toBeNull(); + expect(runStore.setCalls).toContain("chat-reconnect-cleanup-all-failure"); + expect(runStore.deleteCalls).toContain("chat-reconnect-cleanup-all-failure"); + } + ); + it("normalizes non-Error reconnect failures before reporting onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); From 4006a685e97347ddaeaed7888e9ebc62ad68db33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:16:35 +0000 Subject: [PATCH 094/217] Handle inactive reconnect cleanup delete failures non-fatally Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 64 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 11 ++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index cba7d672a3..5a80b31179 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -546,6 +546,70 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-inactive")).toBeUndefined(); }); + it("reports inactive reconnect cleanup delete failures through onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteRunStore(1); + runStore.set({ + chatId: "chat-inactive-delete-failure", + runId: "run_inactive_delete_failure", + publicAccessToken: "pk_inactive_delete_failure", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-failure", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-inactive-delete-failure", + runId: "run_inactive_delete_failure", + }); + expect(errors[0]?.error.message).toBe("cleanup delete failed"); + }); + + it("returns null when inactive reconnect cleanup delete and onError both fail", async function () { + const runStore = new FailingCleanupDeleteRunStore(1); + runStore.set({ + chatId: "chat-inactive-delete-onerror-failure", + runId: "run_inactive_delete_onerror_failure", + publicAccessToken: "pk_inactive_delete_onerror_failure", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-onerror-failure", + }); + + expect(stream).toBeNull(); + }); + it("supports custom payload mapping and trigger options resolver", async function () { let receivedTriggerBody: Record | undefined; let receivedResolverChatId: string | undefined; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 5c6392852f..9d5d3465d1 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -258,7 +258,16 @@ export class TriggerChatTransport< } if (!runState.isActive) { - await this.runStore.delete(options.chatId); + try { + await this.runStore.delete(options.chatId); + } catch (error) { + await this.reportError({ + phase: "reconnect", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); + } return null; } From 864125c992d68d8db1231f6d683ab12a619c6586 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:19:09 +0000 Subject: [PATCH 095/217] Cover inactive reconnect cleanup string failures and normalization Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 5a80b31179..3dea1d655f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -610,6 +610,70 @@ describe("TriggerChatTransport", function () { expect(stream).toBeNull(); }); + it("normalizes non-Error inactive reconnect cleanup delete failures through onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); + runStore.set({ + chatId: "chat-inactive-delete-string-failure", + runId: "run_inactive_delete_string_failure", + publicAccessToken: "pk_inactive_delete_string_failure", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-string-failure", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-inactive-delete-string-failure", + runId: "run_inactive_delete_string_failure", + }); + expect(errors[0]?.error.message).toBe("cleanup delete string failure"); + }); + + it("returns null when inactive reconnect string cleanup delete and onError both fail", async function () { + const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); + runStore.set({ + chatId: "chat-inactive-delete-string-onerror-failure", + runId: "run_inactive_delete_string_onerror_failure", + publicAccessToken: "pk_inactive_delete_string_onerror_failure", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-string-onerror-failure", + }); + + expect(stream).toBeNull(); + }); + it("supports custom payload mapping and trigger options resolver", async function () { let receivedTriggerBody: Record | undefined; let receivedResolverChatId: string | undefined; @@ -3318,6 +3382,21 @@ class FailingCleanupDeleteRunStore extends InMemoryTriggerChatRunStore { } } +class FailingCleanupDeleteValueRunStore extends InMemoryTriggerChatRunStore { + private deleteCalls = 0; + + constructor(private readonly thrownValue: unknown) { + super(); + } + + public delete(_chatId: string): void { + this.deleteCalls += 1; + if (this.deleteCalls === 1) { + throw this.thrownValue; + } + } +} + class FailingCleanupSetAndDeleteRunStore extends InMemoryTriggerChatRunStore { private setCallCount = 0; public readonly setCalls: string[] = []; From eaf59f2ab62aff7a2ac4e58a2f2837319289b03b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:21:30 +0000 Subject: [PATCH 096/217] Cover inactive reconnect cleanup delete failures without onError Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 3dea1d655f..07ef8a3591 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -610,6 +610,31 @@ describe("TriggerChatTransport", function () { expect(stream).toBeNull(); }); + it("returns null when inactive reconnect cleanup delete fails without onError callback", async function () { + const runStore = new FailingCleanupDeleteRunStore(1); + runStore.set({ + chatId: "chat-inactive-delete-no-onerror", + runId: "run_inactive_delete_no_onerror", + publicAccessToken: "pk_inactive_delete_no_onerror", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-no-onerror", + }); + + expect(stream).toBeNull(); + }); + it("normalizes non-Error inactive reconnect cleanup delete failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); From 9cf96d932b7a2e90f5665f8a5ade7d9c7e8fada0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:23:57 +0000 Subject: [PATCH 097/217] Cover inactive reconnect string cleanup failures without onError Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 07ef8a3591..b458fde148 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -635,6 +635,31 @@ describe("TriggerChatTransport", function () { expect(stream).toBeNull(); }); + it("returns null when inactive reconnect string cleanup delete fails without onError callback", async function () { + const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); + runStore.set({ + chatId: "chat-inactive-delete-string-no-onerror", + runId: "run_inactive_delete_string_no_onerror", + publicAccessToken: "pk_inactive_delete_string_no_onerror", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-string-no-onerror", + }); + + expect(stream).toBeNull(); + }); + it("normalizes non-Error inactive reconnect cleanup delete failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); From 87f92e7080b68761f8b7492c965eb0188c792db2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:36:30 +0000 Subject: [PATCH 098/217] Document reconnect inactive-cleanup error reporting semantics Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 2 ++ 3 files changed, 5 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 41a66d59b4..7aec078751 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -648,6 +648,8 @@ class MemoryRunStore implements TriggerChatRunStore { `reconnectToStream()` only resumes active streams. When a stream completes or errors, the transport clears stored run state and future reconnect attempts return `null`. +If stale inactive reconnect state cannot be cleaned up, reconnect still returns `null` and +the failure is surfaced through `onError` with phase `reconnect`. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4de19ced8d..e2f74c42eb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -17,3 +17,4 @@ - Added normalization of non-Error throw values into Error instances before `onError` reporting. - Added best-effort run-store cleanup so cleanup failures do not mask root transport errors. - Improved best-effort run-store cleanup to attempt both inactive-state writes and deletes even if one step fails. +- Added reconnect cleanup error reporting for stale inactive state while still returning `null`. diff --git a/packages/ai/README.md b/packages/ai/README.md index 1a084c0cfa..7b3323c405 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -148,6 +148,8 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - `reconnectToStream({ chatId })` resumes only while a stream is still active. - Once a stream completes or errors, its run state is cleaned up and reconnect returns `null`. +- If reconnect finds stale inactive state and run-store cleanup fails, `onError` receives a + `"reconnect"` phase event and reconnect still returns `null`. - Provide a custom `runStore` if you need state shared across processes/instances. ## `ai.tool(...)` example From 8e8f3e0ad3af3c6c6c001da836b0a66d52337b06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:39:19 +0000 Subject: [PATCH 099/217] Cover consumeTracking behavior when cleanup and onError all fail Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index b458fde148..6ff23bc08f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2474,6 +2474,58 @@ describe("TriggerChatTransport", function () { } ); + it( + "preserves consumeTracking root failures when cleanup set/delete and onError callbacks all fail", + async function () { + const runStore = new FailingCleanupSetAndDeleteRunStore(); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_tracking_cleanup_all_failure", + }); + res.end(JSON.stringify({ id: "run_tracking_cleanup_all_failure" })); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: async function onError() { + throw new Error("onError failed"); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + return new ReadableStream({ + start(controller) { + controller.error(new Error("tracking failed root cause")); + }, + }); + }; + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tracking-cleanup-all-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + await expect(readChunks(stream)).rejects.toThrowError("tracking failed root cause"); + expect(runStore.setCalls).toContain("chat-tracking-cleanup-all-failure"); + expect(runStore.deleteCalls).toContain("chat-tracking-cleanup-all-failure"); + } + ); + it("reports reconnect failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new InMemoryTriggerChatRunStore(); From 8dffb95173a00ebfbfa3fa5569ae2d63900e62dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:42:59 +0000 Subject: [PATCH 100/217] Cover reconnect after completion with dual cleanup-step failures Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 83 +++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 6ff23bc08f..518ae6aa2d 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -3102,6 +3102,89 @@ describe("TriggerChatTransport", function () { expect(runStore.deleteCalls).toContain("chat-cleanup-set-delete-failure"); }); + it("returns null on reconnect after completion when cleanup set and delete both fail", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupSetAndDeleteRunStore(4); + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_done_cleanup_both_failure", + }); + res.end(JSON.stringify({ id: "run_done_cleanup_both_failure" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_done_cleanup_both_failure/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "done_cleanup_both_failure_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "done_cleanup_both_failure_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: server.url, + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-done-cleanup-both-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + + await waitForCondition(function () { + return runStore.deleteCalls.includes("chat-done-cleanup-both-failure"); + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + throw new Error("reconnect root cause"); + }; + + const reconnect = await transport.reconnectToStream({ + chatId: "chat-done-cleanup-both-failure", + }); + + expect(reconnect).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-done-cleanup-both-failure", + runId: "run_done_cleanup_both_failure", + }); + expect(errors[0]?.error.message).toBe("reconnect root cause"); + }); + it("returns null from reconnect after stream completion cleanup", async function () { const server = await startServer(function (req, res) { if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { From 0a5d8cfdc10bc0e14cbbd455799cd3063c3ad55a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:45:58 +0000 Subject: [PATCH 101/217] Assert cleanup set failures still drive delete attempts Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 518ae6aa2d..7983be4e53 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2349,6 +2349,8 @@ describe("TriggerChatTransport", function () { runId: "run_tracking_cleanup_set_failure", }); expect(errors[0]?.error.message).toBe("tracking failed root cause"); + expect(runStore.deleteCalls).toContain("chat-tracking-cleanup-set-failure"); + expect(runStore.get("chat-tracking-cleanup-set-failure")).toBeUndefined(); }); it("preserves consumeTrackingStream failures when cleanup run-store delete throws", async function () { @@ -2605,6 +2607,8 @@ describe("TriggerChatTransport", function () { runId: "run_reconnect_cleanup_set_failure", }); expect(errors[0]?.error.message).toBe("reconnect root cause"); + expect(runStore.deleteCalls).toContain("chat-reconnect-cleanup-set-failure"); + expect(runStore.get("chat-reconnect-cleanup-set-failure")).toBeUndefined(); }); it("preserves reconnect failures when cleanup run-store delete throws", async function () { From 3732bd60966b17103833d09289ca9c3fed6a6134 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:50:00 +0000 Subject: [PATCH 102/217] Cover retry behavior for inactive reconnect cleanup failures Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 7983be4e53..9f4a53e0ed 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -582,6 +582,52 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("cleanup delete failed"); }); + it("retries inactive reconnect cleanup on subsequent reconnect attempts", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteRunStore(1); + runStore.set({ + chatId: "chat-inactive-delete-retry", + runId: "run_inactive_delete_retry", + publicAccessToken: "pk_inactive_delete_retry", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const firstReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-retry", + }); + + expect(firstReconnect).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-inactive-delete-retry", + runId: "run_inactive_delete_retry", + }); + expect(runStore.get("chat-inactive-delete-retry")).toMatchObject({ + isActive: false, + }); + + const secondReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-retry", + }); + + expect(secondReconnect).toBeNull(); + expect(errors).toHaveLength(1); + expect(runStore.get("chat-inactive-delete-retry")).toBeUndefined(); + }); + it("returns null when inactive reconnect cleanup delete and onError both fail", async function () { const runStore = new FailingCleanupDeleteRunStore(1); runStore.set({ From 2b6723e41c170ea9afdb44c42ddc25d40f1ed724 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:52:44 +0000 Subject: [PATCH 103/217] Retry inactive reconnect cleanup after string delete failures Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 50 ++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 9f4a53e0ed..28479f3f71 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -706,6 +706,52 @@ describe("TriggerChatTransport", function () { expect(stream).toBeNull(); }); + it("retries inactive reconnect string cleanup on subsequent reconnect attempts", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); + runStore.set({ + chatId: "chat-inactive-delete-string-retry", + runId: "run_inactive_delete_string_retry", + publicAccessToken: "pk_inactive_delete_string_retry", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const firstReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-string-retry", + }); + + expect(firstReconnect).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-inactive-delete-string-retry", + runId: "run_inactive_delete_string_retry", + }); + expect(runStore.get("chat-inactive-delete-string-retry")).toMatchObject({ + isActive: false, + }); + + const secondReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-string-retry", + }); + + expect(secondReconnect).toBeNull(); + expect(errors).toHaveLength(1); + expect(runStore.get("chat-inactive-delete-string-retry")).toBeUndefined(); + }); + it("normalizes non-Error inactive reconnect cleanup delete failures through onError", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); @@ -3624,11 +3670,13 @@ class FailingCleanupDeleteValueRunStore extends InMemoryTriggerChatRunStore { super(); } - public delete(_chatId: string): void { + public delete(chatId: string): void { this.deleteCalls += 1; if (this.deleteCalls === 1) { throw this.thrownValue; } + + super.delete(chatId); } } From bec4401afd91e003d3355fbb3dcba6690070d89c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:55:08 +0000 Subject: [PATCH 104/217] Assert full run-state transition snapshots on stream completion Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 28479f3f71..40a8214b21 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2989,6 +2989,31 @@ describe("TriggerChatTransport", function () { return trackedRunStore.deleteCalls.includes("chat-cleanup"); }); + expect(trackedRunStore.setSnapshots).toHaveLength(4); + expect(trackedRunStore.setSnapshots[0]).toMatchObject({ + chatId: "chat-cleanup", + runId: "run_cleanup", + isActive: true, + lastEventId: undefined, + }); + expect(trackedRunStore.setSnapshots[1]).toMatchObject({ + chatId: "chat-cleanup", + runId: "run_cleanup", + isActive: true, + lastEventId: "1-0", + }); + expect(trackedRunStore.setSnapshots[2]).toMatchObject({ + chatId: "chat-cleanup", + runId: "run_cleanup", + isActive: true, + lastEventId: "2-0", + }); + expect(trackedRunStore.setSnapshots[3]).toMatchObject({ + chatId: "chat-cleanup", + runId: "run_cleanup", + isActive: false, + lastEventId: "2-0", + }); expect(trackedRunStore.get("chat-cleanup")).toBeUndefined(); }); From d604b942ad032363afd9ad53ee1e2439ba300903 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:57:33 +0000 Subject: [PATCH 105/217] Assert inactive reconnect cleanup never attempts stream fetch Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 40a8214b21..5219a6e149 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -582,6 +582,49 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("cleanup delete failed"); }); + it("does not attempt reconnect stream fetch for inactive run cleanup failures", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteRunStore(1); + runStore.set({ + chatId: "chat-inactive-no-fetch", + runId: "run_inactive_no_fetch", + publicAccessToken: "pk_inactive_no_fetch", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + let fetchAttempted = false; + (transport as any).fetchRunStream = async function fetchRunStream() { + fetchAttempted = true; + throw new Error("unexpected reconnect fetch"); + }; + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive-no-fetch", + }); + + expect(stream).toBeNull(); + expect(fetchAttempted).toBe(false); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-inactive-no-fetch", + runId: "run_inactive_no_fetch", + }); + expect(errors[0]?.error.message).toBe("cleanup delete failed"); + }); + it("retries inactive reconnect cleanup on subsequent reconnect attempts", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupDeleteRunStore(1); From 5f6e78b5c16c517b2ec7fba6ad3c11309619d025 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 02:59:56 +0000 Subject: [PATCH 106/217] Document retry behavior for stale inactive reconnect cleanup Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 7aec078751..3160adf301 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -650,6 +650,7 @@ class MemoryRunStore implements TriggerChatRunStore { the transport clears stored run state and future reconnect attempts return `null`. If stale inactive reconnect state cannot be cleaned up, reconnect still returns `null` and the failure is surfaced through `onError` with phase `reconnect`. +Subsequent reconnect calls will retry stale inactive-state cleanup until it succeeds. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e2f74c42eb..215cbb349f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -18,3 +18,4 @@ - Added best-effort run-store cleanup so cleanup failures do not mask root transport errors. - Improved best-effort run-store cleanup to attempt both inactive-state writes and deletes even if one step fails. - Added reconnect cleanup error reporting for stale inactive state while still returning `null`. +- Added retry semantics for stale inactive reconnect cleanup on subsequent reconnect attempts. diff --git a/packages/ai/README.md b/packages/ai/README.md index 7b3323c405..06fc74c3fb 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -150,6 +150,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - Once a stream completes or errors, its run state is cleaned up and reconnect returns `null`. - If reconnect finds stale inactive state and run-store cleanup fails, `onError` receives a `"reconnect"` phase event and reconnect still returns `null`. +- If inactive-state cleanup fails, later reconnect calls retry that cleanup until it succeeds. - Provide a custom `runStore` if you need state shared across processes/instances. ## `ai.tool(...)` example From fa195ff1575d9d057cf2f29ee255243afc7c1f59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:02:43 +0000 Subject: [PATCH 107/217] Cover object cleanup-delete normalization for inactive reconnect Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 5219a6e149..d7b647d86d 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -831,6 +831,44 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("cleanup delete string failure"); }); + it("normalizes object inactive reconnect cleanup delete failures through onError", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteValueRunStore({ + reason: "cleanup delete object failure", + }); + runStore.set({ + chatId: "chat-inactive-delete-object-failure", + runId: "run_inactive_delete_object_failure", + publicAccessToken: "pk_inactive_delete_object_failure", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-object-failure", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-inactive-delete-object-failure", + runId: "run_inactive_delete_object_failure", + }); + expect(errors[0]?.error.message).toBe("[object Object]"); + }); + it("returns null when inactive reconnect string cleanup delete and onError both fail", async function () { const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); runStore.set({ From 81d62b56cf838ea80849153666ba40ced985ea80 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:07:33 +0000 Subject: [PATCH 108/217] Assert active reconnect delete failures retry via inactive cleanup path Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index d7b647d86d..425d600b6f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2787,6 +2787,7 @@ describe("TriggerChatTransport", function () { it("preserves reconnect failures when cleanup run-store delete throws", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupDeleteRunStore(1); + let fetchCalls = 0; runStore.set({ chatId: "chat-reconnect-cleanup-delete-failure", runId: "run_reconnect_cleanup_delete_failure", @@ -2807,6 +2808,7 @@ describe("TriggerChatTransport", function () { }); (transport as any).fetchRunStream = async function fetchRunStream() { + fetchCalls += 1; throw new Error("reconnect root cause"); }; @@ -2822,6 +2824,20 @@ describe("TriggerChatTransport", function () { runId: "run_reconnect_cleanup_delete_failure", }); expect(errors[0]?.error.message).toBe("reconnect root cause"); + expect(fetchCalls).toBe(1); + expect(runStore.get("chat-reconnect-cleanup-delete-failure")).toMatchObject({ + isActive: false, + lastEventId: "100-0", + }); + + const secondReconnect = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-delete-failure", + }); + + expect(secondReconnect).toBeNull(); + expect(fetchCalls).toBe(1); + expect(errors).toHaveLength(1); + expect(runStore.get("chat-reconnect-cleanup-delete-failure")).toBeUndefined(); }); it("attempts both reconnect cleanup steps when set and delete both throw", async function () { From 80dcd2d05476555fbc018d4c7e1a3542c5f47697 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:11:10 +0000 Subject: [PATCH 109/217] Cover repeated inactive cleanup failures with per-attempt onError Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 425d600b6f..40947ddaf6 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -625,6 +625,59 @@ describe("TriggerChatTransport", function () { expect(errors[0]?.error.message).toBe("cleanup delete failed"); }); + it("retries inactive cleanup and reports each persistent delete failure", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new AlwaysFailCleanupDeleteRunStore(); + runStore.set({ + chatId: "chat-inactive-delete-always-fails", + runId: "run_inactive_delete_always_fails", + publicAccessToken: "pk_inactive_delete_always_fails", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + let fetchCalls = 0; + (transport as any).fetchRunStream = async function fetchRunStream() { + fetchCalls += 1; + throw new Error("unexpected reconnect fetch"); + }; + + const firstReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-always-fails", + }); + const secondReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-always-fails", + }); + + expect(firstReconnect).toBeNull(); + expect(secondReconnect).toBeNull(); + expect(fetchCalls).toBe(0); + expect(errors).toHaveLength(2); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-inactive-delete-always-fails", + runId: "run_inactive_delete_always_fails", + }); + expect(errors[1]).toMatchObject({ + phase: "reconnect", + chatId: "chat-inactive-delete-always-fails", + runId: "run_inactive_delete_always_fails", + }); + expect(errors[0]?.error.message).toBe("cleanup delete always fails"); + expect(errors[1]?.error.message).toBe("cleanup delete always fails"); + }); + it("retries inactive reconnect cleanup on subsequent reconnect attempts", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupDeleteRunStore(1); @@ -3802,6 +3855,12 @@ class FailingCleanupDeleteValueRunStore extends InMemoryTriggerChatRunStore { } } +class AlwaysFailCleanupDeleteRunStore extends InMemoryTriggerChatRunStore { + public delete(_chatId: string): void { + throw new Error("cleanup delete always fails"); + } +} + class FailingCleanupSetAndDeleteRunStore extends InMemoryTriggerChatRunStore { private setCallCount = 0; public readonly setCalls: string[] = []; From 89ec29d7e820716ee79b1f0d127f73bc95fa8680 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:15:04 +0000 Subject: [PATCH 110/217] Cover reconnect delete string failures on active cleanup path Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 40947ddaf6..65f9bf8899 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2893,6 +2893,62 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-reconnect-cleanup-delete-failure")).toBeUndefined(); }); + it("preserves reconnect root failure when cleanup delete throws a non-Error value", async function () { + const errors: TriggerChatTransportError[] = []; + const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); + let fetchCalls = 0; + runStore.set({ + chatId: "chat-reconnect-cleanup-delete-string-failure", + runId: "run_reconnect_cleanup_delete_string_failure", + publicAccessToken: "pk_reconnect_cleanup_delete_string_failure", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + onError: function onError(error) { + errors.push(error); + }, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + fetchCalls += 1; + throw new Error("reconnect root cause"); + }; + + const firstReconnect = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-delete-string-failure", + }); + + expect(firstReconnect).toBeNull(); + expect(fetchCalls).toBe(1); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + phase: "reconnect", + chatId: "chat-reconnect-cleanup-delete-string-failure", + runId: "run_reconnect_cleanup_delete_string_failure", + }); + expect(errors[0]?.error.message).toBe("reconnect root cause"); + expect(runStore.get("chat-reconnect-cleanup-delete-string-failure")).toMatchObject({ + isActive: false, + lastEventId: "100-0", + }); + + const secondReconnect = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-delete-string-failure", + }); + + expect(secondReconnect).toBeNull(); + expect(fetchCalls).toBe(1); + expect(errors).toHaveLength(1); + expect(runStore.get("chat-reconnect-cleanup-delete-string-failure")).toBeUndefined(); + }); + it("attempts both reconnect cleanup steps when set and delete both throw", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupSetAndDeleteRunStore(); From 63454efc1cf995f96eaf25bda4cbe551c69b5a4b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:19:24 +0000 Subject: [PATCH 111/217] Cover repeated inactive cleanup failures without onError Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 65f9bf8899..25eec32423 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -777,6 +777,45 @@ describe("TriggerChatTransport", function () { expect(stream).toBeNull(); }); + it("returns null on repeated inactive cleanup failures without onError callback", async function () { + const runStore = new AlwaysFailCleanupDeleteRunStore(); + runStore.set({ + chatId: "chat-inactive-delete-always-no-onerror", + runId: "run_inactive_delete_always_no_onerror", + publicAccessToken: "pk_inactive_delete_always_no_onerror", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + }); + + let fetchCalls = 0; + (transport as any).fetchRunStream = async function fetchRunStream() { + fetchCalls += 1; + throw new Error("unexpected reconnect fetch"); + }; + + const firstReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-always-no-onerror", + }); + const secondReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-always-no-onerror", + }); + + expect(firstReconnect).toBeNull(); + expect(secondReconnect).toBeNull(); + expect(fetchCalls).toBe(0); + expect(runStore.get("chat-inactive-delete-always-no-onerror")).toMatchObject({ + isActive: false, + }); + }); + it("returns null when inactive reconnect string cleanup delete fails without onError callback", async function () { const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); runStore.set({ From 42a2535c842483cba2647d783200be759af0a31d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:22:30 +0000 Subject: [PATCH 112/217] Track cleanup delete attempts in reconnect retry tests Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 25eec32423..599af93e0e 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -663,6 +663,7 @@ describe("TriggerChatTransport", function () { expect(firstReconnect).toBeNull(); expect(secondReconnect).toBeNull(); expect(fetchCalls).toBe(0); + expect(runStore.deleteCallCount).toBe(2); expect(errors).toHaveLength(2); expect(errors[0]).toMatchObject({ phase: "reconnect", @@ -811,6 +812,7 @@ describe("TriggerChatTransport", function () { expect(firstReconnect).toBeNull(); expect(secondReconnect).toBeNull(); expect(fetchCalls).toBe(0); + expect(runStore.deleteCallCount).toBe(2); expect(runStore.get("chat-inactive-delete-always-no-onerror")).toMatchObject({ isActive: false, }); @@ -869,6 +871,7 @@ describe("TriggerChatTransport", function () { expect(firstReconnect).toBeNull(); expect(errors).toHaveLength(1); + expect(runStore.deleteCallCount).toBe(1); expect(errors[0]).toMatchObject({ phase: "reconnect", chatId: "chat-inactive-delete-string-retry", @@ -884,6 +887,7 @@ describe("TriggerChatTransport", function () { expect(secondReconnect).toBeNull(); expect(errors).toHaveLength(1); + expect(runStore.deleteCallCount).toBe(2); expect(runStore.get("chat-inactive-delete-string-retry")).toBeUndefined(); }); @@ -3934,15 +3938,15 @@ class FailingCleanupDeleteRunStore extends InMemoryTriggerChatRunStore { } class FailingCleanupDeleteValueRunStore extends InMemoryTriggerChatRunStore { - private deleteCalls = 0; + public deleteCallCount = 0; constructor(private readonly thrownValue: unknown) { super(); } public delete(chatId: string): void { - this.deleteCalls += 1; - if (this.deleteCalls === 1) { + this.deleteCallCount += 1; + if (this.deleteCallCount === 1) { throw this.thrownValue; } @@ -3951,7 +3955,10 @@ class FailingCleanupDeleteValueRunStore extends InMemoryTriggerChatRunStore { } class AlwaysFailCleanupDeleteRunStore extends InMemoryTriggerChatRunStore { + public deleteCallCount = 0; + public delete(_chatId: string): void { + this.deleteCallCount += 1; throw new Error("cleanup delete always fails"); } } From 7e0eb51e8060581b2d78a90bb02e742b97403f51 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:25:10 +0000 Subject: [PATCH 113/217] Extract inactive reconnect cleanup into dedicated helper Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 9d5d3465d1..c3eb6dd59a 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -258,16 +258,7 @@ export class TriggerChatTransport< } if (!runState.isActive) { - try { - await this.runStore.delete(options.chatId); - } catch (error) { - await this.reportError({ - phase: "reconnect", - chatId: runState.chatId, - runId: runState.runId, - error: normalizeError(error), - }); - } + await this.cleanupInactiveReconnectState(runState); return null; } @@ -423,6 +414,19 @@ export class TriggerChatTransport< } } + private async cleanupInactiveReconnectState(runState: TriggerChatRunState) { + try { + await this.runStore.delete(runState.chatId); + } catch (error) { + await this.reportError({ + phase: "reconnect", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); + } + } + private async reportError(event: TriggerChatTransportError) { if (!this.onError) { return; From 5307d734ee547a78b561384631c2d28ee0972104 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:28:10 +0000 Subject: [PATCH 114/217] Cover active reconnect cleanup delete failures without onError Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 599af93e0e..4ca410761f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2936,6 +2936,50 @@ describe("TriggerChatTransport", function () { expect(runStore.get("chat-reconnect-cleanup-delete-failure")).toBeUndefined(); }); + it("returns null when active reconnect cleanup delete fails without onError callback", async function () { + const runStore = new FailingCleanupDeleteRunStore(1); + let fetchCalls = 0; + runStore.set({ + chatId: "chat-reconnect-cleanup-delete-no-onerror", + runId: "run_reconnect_cleanup_delete_no_onerror", + publicAccessToken: "pk_reconnect_cleanup_delete_no_onerror", + streamKey: "chat-stream", + lastEventId: "100-0", + isActive: true, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + }); + + (transport as any).fetchRunStream = async function fetchRunStream() { + fetchCalls += 1; + throw new Error("reconnect root cause"); + }; + + const firstReconnect = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-delete-no-onerror", + }); + + expect(firstReconnect).toBeNull(); + expect(fetchCalls).toBe(1); + expect(runStore.get("chat-reconnect-cleanup-delete-no-onerror")).toMatchObject({ + isActive: false, + lastEventId: "100-0", + }); + + const secondReconnect = await transport.reconnectToStream({ + chatId: "chat-reconnect-cleanup-delete-no-onerror", + }); + + expect(secondReconnect).toBeNull(); + expect(fetchCalls).toBe(1); + expect(runStore.get("chat-reconnect-cleanup-delete-no-onerror")).toBeUndefined(); + }); + it("preserves reconnect root failure when cleanup delete throws a non-Error value", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); From 69035260fd7762180f5872df3c8646c76323a241 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:30:28 +0000 Subject: [PATCH 115/217] Document reconnect behavior when onError is omitted Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 3160adf301..994a37188f 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -651,6 +651,7 @@ the transport clears stored run state and future reconnect attempts return `null If stale inactive reconnect state cannot be cleaned up, reconnect still returns `null` and the failure is surfaced through `onError` with phase `reconnect`. Subsequent reconnect calls will retry stale inactive-state cleanup until it succeeds. +If `onError` is omitted, reconnect still returns `null` and continues without callback reporting. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/README.md b/packages/ai/README.md index 06fc74c3fb..a22cb8b94c 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -151,6 +151,8 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - If reconnect finds stale inactive state and run-store cleanup fails, `onError` receives a `"reconnect"` phase event and reconnect still returns `null`. - If inactive-state cleanup fails, later reconnect calls retry that cleanup until it succeeds. +- If `onError` is not provided, reconnect still returns `null` and continues operating + without surfacing callback events. - Provide a custom `runStore` if you need state shared across processes/instances. ## `ai.tool(...)` example From 82f89508ca13fba4d773ee0d5fa7f97311ca2ad0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:33:10 +0000 Subject: [PATCH 116/217] Cover stream URL encoding for trigger run IDs Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 62 +++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 4ca410761f..70d58fe8b6 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -152,6 +152,68 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_encoded_stream/chat%2Fspecial%20stream"); }); + it("encodes run IDs in stream URL paths", async function () { + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_encoded_run_id", + }); + res.end(JSON.stringify({ id: "run/with space" })); + return; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run%2Fwith%20space/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "encoded_run_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "encoded_run_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: server.url, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-encoded-run-id", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedStreamPath).toBe("/realtime/v1/streams/run%2Fwith%20space/chat-stream"); + }); + it("uses defined stream object id when provided", async function () { let observedStreamPath: string | undefined; From 5eeafc2e4f04ce81a60c759532a63ca09f21c7cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:36:23 +0000 Subject: [PATCH 117/217] Cover trailing slash normalization for transport baseURL Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 70d58fe8b6..06a1108609 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -214,6 +214,65 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run%2Fwith%20space/chat-stream"); }); + it("normalizes trailing slash in baseURL for stream URLs", async function () { + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_trailing_baseurl", + }); + res.end(JSON.stringify({ id: "run_trailing_baseurl" })); + return; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_trailing_baseurl/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "trailing_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "trailing_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: `${server.url}/`, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trailing-baseurl", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedStreamPath).toBe("/realtime/v1/streams/run_trailing_baseurl/chat-stream"); + }); + it("uses defined stream object id when provided", async function () { let observedStreamPath: string | undefined; From c12315f8f07dd0d598f4d64cb260ff48895bf846 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:40:00 +0000 Subject: [PATCH 118/217] Cover baseURL path-prefix support for trigger and stream routes Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 06a1108609..2e688f1c0c 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -273,6 +273,74 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_trailing_baseurl/chat-stream"); }); + it("supports baseURL path prefixes for trigger and stream routes", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/custom-base/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_path_prefix", + }); + res.end(JSON.stringify({ id: "run_path_prefix" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/custom-base/realtime/v1/streams/run_path_prefix/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "path_prefix_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "path_prefix_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: `${server.url}/custom-base`, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-path-prefix", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/custom-base/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe("/custom-base/realtime/v1/streams/run_path_prefix/chat-stream"); + }); + it("uses defined stream object id when provided", async function () { let observedStreamPath: string | undefined; From a736fa9768d44c9be289a5ddbd6bfc8029ff9c12 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:46:00 +0000 Subject: [PATCH 119/217] Normalize baseURL once for trigger and stream endpoints Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 62 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 9 ++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 2e688f1c0c..e7ad6f6e36 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -273,6 +273,68 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_trailing_baseurl/chat-stream"); }); + it("normalizes repeated trailing slashes in baseURL for stream URLs", async function () { + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_multi_trailing_baseurl", + }); + res.end(JSON.stringify({ id: "run_multi_trailing_baseurl" })); + return; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_multi_trailing_baseurl/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "multi_trailing_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "multi_trailing_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: `${server.url}///`, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-multi-trailing-baseurl", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedStreamPath).toBe("/realtime/v1/streams/run_multi_trailing_baseurl/chat-stream"); + }); + it("supports baseURL path prefixes for trigger and stream routes", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index c3eb6dd59a..8f058a938a 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -145,7 +145,7 @@ export class TriggerChatTransport< this.payloadMapper = resolvePayloadMapper(options.payloadMapper); this.triggerOptions = options.triggerOptions; this.runStore = options.runStore ?? new InMemoryTriggerChatRunStore(); - this.baseURL = options.baseURL ?? "https://api.trigger.dev"; + this.baseURL = normalizeBaseUrl(options.baseURL ?? "https://api.trigger.dev"); this.previewBranch = options.previewBranch; this.requestOptions = options.requestOptions; this.triggerClient = new ApiClient( @@ -374,11 +374,10 @@ export class TriggerChatTransport< } private createStreamUrl(runId: string, streamKey: string): string { - const normalizedBaseUrl = this.baseURL.replace(/\/$/, ""); const encodedRunId = encodeURIComponent(runId); const encodedStreamKey = encodeURIComponent(streamKey); - return `${normalizedBaseUrl}/realtime/v1/streams/${encodedRunId}/${encodedStreamKey}`; + return `${this.baseURL}/realtime/v1/streams/${encodedRunId}/${encodedStreamKey}`; } private async markRunInactiveAndDelete(runState: TriggerChatRunState) { @@ -460,6 +459,10 @@ function resolvePayloadMapper< return createDefaultPayload as TriggerChatPayloadMapper; } +function normalizeBaseUrl(baseURL: string) { + return baseURL.replace(/\/+$/, ""); +} + function createTransportRequest( options: TriggerChatSendMessagesOptions ): TriggerChatTransportRequest { From 8be24727b8b81a454f86a1b7510f967b04fe8803 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:49:09 +0000 Subject: [PATCH 120/217] Cover baseURL path-prefix trailing slash normalization Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e7ad6f6e36..b12cdb1bf1 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -403,6 +403,76 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/custom-base/realtime/v1/streams/run_path_prefix/chat-stream"); }); + it("supports trailing slashes on baseURL path prefixes", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/custom-prefix/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_path_prefix_trailing", + }); + res.end(JSON.stringify({ id: "run_path_prefix_trailing" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/custom-prefix/realtime/v1/streams/run_path_prefix_trailing/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "path_prefix_trailing_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "path_prefix_trailing_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: `${server.url}/custom-prefix///`, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-path-prefix-trailing", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/custom-prefix/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe( + "/custom-prefix/realtime/v1/streams/run_path_prefix_trailing/chat-stream" + ); + }); + it("uses defined stream object id when provided", async function () { let observedStreamPath: string | undefined; From fcb140b95b08130dcf7f5368904158771b35c120 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:52:28 +0000 Subject: [PATCH 121/217] Assert baseURL trailing-slash normalization for trigger endpoint Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index b12cdb1bf1..725a43fa71 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -273,10 +273,15 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_trailing_baseurl/chat-stream"); }); - it("normalizes repeated trailing slashes in baseURL for stream URLs", async function () { + it("normalizes repeated trailing slashes in baseURL for trigger and stream URLs", async function () { + let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { res.writeHead(200, { "content-type": "application/json", @@ -332,6 +337,7 @@ describe("TriggerChatTransport", function () { const chunks = await readChunks(stream); expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/api/v1/tasks/chat-task/trigger"); expect(observedStreamPath).toBe("/realtime/v1/streams/run_multi_trailing_baseurl/chat-stream"); }); From e2438abda10fb4cc1ac5dbb320859fccda39dc0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:56:50 +0000 Subject: [PATCH 122/217] Cover prefixed baseURL plus run/stream URL encoding Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 725a43fa71..1694a3c1e9 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -479,6 +479,77 @@ describe("TriggerChatTransport", function () { ); }); + it("combines path prefixes with run and stream URL encoding", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/prefixed/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_prefixed_encoded", + }); + res.end(JSON.stringify({ id: "run/with space" })); + return; + } + + if ( + req.method === "GET" && + req.url === + "/prefixed/realtime/v1/streams/run%2Fwith%20space/chat%2Fspecial%20stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "prefixed_encoded_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "prefixed_encoded_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: `${server.url}/prefixed///`, + stream: "chat/special stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-prefixed-encoded", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/prefixed/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe( + "/prefixed/realtime/v1/streams/run%2Fwith%20space/chat%2Fspecial%20stream" + ); + }); + it("uses defined stream object id when provided", async function () { let observedStreamPath: string | undefined; From b44b4e5c382d8542090fce566643875e66ae291e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 03:59:50 +0000 Subject: [PATCH 123/217] Document baseURL normalization semantics for transport endpoints Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 +++ packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 994a37188f..6c389922c9 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -653,6 +653,9 @@ the failure is surfaced through `onError` with phase `reconnect`. Subsequent reconnect calls will retry stale inactive-state cleanup until it succeeds. If `onError` is omitted, reconnect still returns `null` and continues without callback reporting. +`baseURL` supports optional path prefixes and trailing slashes; both trigger and stream URLs +are normalized consistently. + For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: - `TriggerChatHeadersInput` diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 215cbb349f..35106e2e7f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -19,3 +19,4 @@ - Improved best-effort run-store cleanup to attempt both inactive-state writes and deletes even if one step fails. - Added reconnect cleanup error reporting for stale inactive state while still returning `null`. - Added retry semantics for stale inactive reconnect cleanup on subsequent reconnect attempts. +- Added consistent baseURL normalization for trigger and stream endpoints (including path prefixes and trailing slashes). diff --git a/packages/ai/README.md b/packages/ai/README.md index a22cb8b94c..1867761aea 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -155,6 +155,11 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails without surfacing callback events. - Provide a custom `runStore` if you need state shared across processes/instances. +## Base URL behavior + +- `baseURL` supports optional path prefixes (for example reverse-proxy mounts). +- Trailing slashes are normalized automatically before trigger/stream requests. + ## `ai.tool(...)` example ```ts From 7d7540114b8c654b2e1fec517f6f2687c8245c33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:03:13 +0000 Subject: [PATCH 124/217] Cover inactive string cleanup retries without onError callback Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 1694a3c1e9..240dcb63a0 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1241,6 +1241,51 @@ describe("TriggerChatTransport", function () { expect(stream).toBeNull(); }); + it("retries inactive reconnect string cleanup without onError callback", async function () { + const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); + runStore.set({ + chatId: "chat-inactive-delete-string-retry-no-onerror", + runId: "run_inactive_delete_string_retry_no_onerror", + publicAccessToken: "pk_inactive_delete_string_retry_no_onerror", + streamKey: "chat-stream", + lastEventId: "10-0", + isActive: false, + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + runStore, + }); + + let fetchCalls = 0; + (transport as any).fetchRunStream = async function fetchRunStream() { + fetchCalls += 1; + throw new Error("unexpected reconnect fetch"); + }; + + const firstReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-string-retry-no-onerror", + }); + + expect(firstReconnect).toBeNull(); + expect(fetchCalls).toBe(0); + expect(runStore.deleteCallCount).toBe(1); + expect(runStore.get("chat-inactive-delete-string-retry-no-onerror")).toMatchObject({ + isActive: false, + }); + + const secondReconnect = await transport.reconnectToStream({ + chatId: "chat-inactive-delete-string-retry-no-onerror", + }); + + expect(secondReconnect).toBeNull(); + expect(fetchCalls).toBe(0); + expect(runStore.deleteCallCount).toBe(2); + expect(runStore.get("chat-inactive-delete-string-retry-no-onerror")).toBeUndefined(); + }); + it("retries inactive reconnect string cleanup on subsequent reconnect attempts", async function () { const errors: TriggerChatTransportError[] = []; const runStore = new FailingCleanupDeleteValueRunStore("cleanup delete string failure"); From b38d231f7546c71034ca783d1e9303e6f3009383 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:08:33 +0000 Subject: [PATCH 125/217] Trim baseURL whitespace before endpoint normalization Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 +- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 65 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 2 +- 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 6c389922c9..eddba511fb 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -654,7 +654,7 @@ Subsequent reconnect calls will retry stale inactive-state cleanup until it succ If `onError` is omitted, reconnect still returns `null` and continues without callback reporting. `baseURL` supports optional path prefixes and trailing slashes; both trigger and stream URLs -are normalized consistently. +are normalized consistently, and surrounding whitespace is trimmed before normalization. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 35106e2e7f..b299cce95d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -20,3 +20,4 @@ - Added reconnect cleanup error reporting for stale inactive state while still returning `null`. - Added retry semantics for stale inactive reconnect cleanup on subsequent reconnect attempts. - Added consistent baseURL normalization for trigger and stream endpoints (including path prefixes and trailing slashes). +- Added surrounding-whitespace trimming for `baseURL` before endpoint normalization. diff --git a/packages/ai/README.md b/packages/ai/README.md index 1867761aea..eb034a9e55 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -159,6 +159,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - `baseURL` supports optional path prefixes (for example reverse-proxy mounts). - Trailing slashes are normalized automatically before trigger/stream requests. +- Surrounding whitespace is trimmed before normalization. ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 240dcb63a0..e0d84808d7 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -479,6 +479,71 @@ describe("TriggerChatTransport", function () { ); }); + it("trims surrounding whitespace from baseURL values", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_trimmed_baseurl", + }); + res.end(JSON.stringify({ id: "run_trimmed_baseurl" })); + return; + } + + if (req.method === "GET" && req.url === "/realtime/v1/streams/run_trimmed_baseurl/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "trimmed_baseurl_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "trimmed_baseurl_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: ` ${server.url}/ `, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trimmed-baseurl", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe("/realtime/v1/streams/run_trimmed_baseurl/chat-stream"); + }); + it("combines path prefixes with run and stream URL encoding", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 8f058a938a..29debdbda1 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -460,7 +460,7 @@ function resolvePayloadMapper< } function normalizeBaseUrl(baseURL: string) { - return baseURL.replace(/\/+$/, ""); + return baseURL.trim().replace(/\/+$/, ""); } function createTransportRequest( From c132ca458089297c548f1efb48f80b207fe86331 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:10:58 +0000 Subject: [PATCH 126/217] Add baseURL whitespace trim path-prefix transport test Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e0d84808d7..42e756bd95 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -544,6 +544,71 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_trimmed_baseurl/chat-stream"); }); + it("preserves baseURL path prefixes after trimming surrounding whitespace", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/trimmed-prefix/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_trimmed_prefix", + }); + res.end(JSON.stringify({ id: "run_trimmed_prefix" })); + return; + } + + if (req.method === "GET" && req.url === "/trimmed-prefix/realtime/v1/streams/run_trimmed_prefix/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "trimmed_prefix_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "trimmed_prefix_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: ` ${server.url}/trimmed-prefix/// `, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trimmed-prefix-baseurl", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/trimmed-prefix/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe("/trimmed-prefix/realtime/v1/streams/run_trimmed_prefix/chat-stream"); + }); + it("combines path prefixes with run and stream URL encoding", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; From 29a8d6e4bd4023067d9c32995117c798103cae96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:13:20 +0000 Subject: [PATCH 127/217] Add trimmed baseURL prefixed encoding transport coverage Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 42e756bd95..07ac282348 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -680,6 +680,77 @@ describe("TriggerChatTransport", function () { ); }); + it("combines trimmed baseURL path prefixes with run and stream URL encoding", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/trimmed-prefixed/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_trimmed_prefixed_encoded", + }); + res.end(JSON.stringify({ id: "run/with space trim" })); + return; + } + + if ( + req.method === "GET" && + req.url === + "/trimmed-prefixed/realtime/v1/streams/run%2Fwith%20space%20trim/chat%2Fspecial%20stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "trimmed_prefixed_encoded_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "trimmed_prefixed_encoded_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: ` ${server.url}/trimmed-prefixed/// `, + stream: "chat/special stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trimmed-prefixed-encoded", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/trimmed-prefixed/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe( + "/trimmed-prefixed/realtime/v1/streams/run%2Fwith%20space%20trim/chat%2Fspecial%20stream" + ); + }); + it("uses defined stream object id when provided", async function () { let observedStreamPath: string | undefined; From 4ae1207428ded654efde66ecf54ab1e38c54a80f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:15:43 +0000 Subject: [PATCH 128/217] Validate normalized baseURL cannot be empty Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 ++- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 11 +++++++++++ packages/ai/src/chatTransport.ts | 8 +++++++- 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index eddba511fb..8c33a30e27 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -654,7 +654,8 @@ Subsequent reconnect calls will retry stale inactive-state cleanup until it succ If `onError` is omitted, reconnect still returns `null` and continues without callback reporting. `baseURL` supports optional path prefixes and trailing slashes; both trigger and stream URLs -are normalized consistently, and surrounding whitespace is trimmed before normalization. +are normalized consistently, surrounding whitespace is trimmed before normalization, and +the resulting value must not be empty. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b299cce95d..24d16d7718 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -21,3 +21,4 @@ - Added retry semantics for stale inactive reconnect cleanup on subsequent reconnect attempts. - Added consistent baseURL normalization for trigger and stream endpoints (including path prefixes and trailing slashes). - Added surrounding-whitespace trimming for `baseURL` before endpoint normalization. +- Added explicit validation that `baseURL` is non-empty after normalization. diff --git a/packages/ai/README.md b/packages/ai/README.md index eb034a9e55..96c6031749 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -160,6 +160,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - `baseURL` supports optional path prefixes (for example reverse-proxy mounts). - Trailing slashes are normalized automatically before trigger/stream requests. - Surrounding whitespace is trimmed before normalization. +- `baseURL` must not be empty after trimming/normalization. ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 07ac282348..ba28af13bf 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -609,6 +609,17 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/trimmed-prefix/realtime/v1/streams/run_trimmed_prefix/chat-stream"); }); + it("throws when baseURL is empty after trimming", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " /// ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + it("combines path prefixes with run and stream URL encoding", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 29debdbda1..f934d13c5d 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -460,7 +460,13 @@ function resolvePayloadMapper< } function normalizeBaseUrl(baseURL: string) { - return baseURL.trim().replace(/\/+$/, ""); + const normalizedBaseUrl = baseURL.trim().replace(/\/+$/, ""); + + if (normalizedBaseUrl.length === 0) { + throw new Error("baseURL must not be empty"); + } + + return normalizedBaseUrl; } function createTransportRequest( From edeed642968a3510f48c18de74a144b2176cca93 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:17:49 +0000 Subject: [PATCH 129/217] Add factory-path baseURL empty validation coverage Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index ba28af13bf..6a4fdda9d8 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -2734,6 +2734,17 @@ describe("TriggerChatTransport", function () { }); }); + it("throws from factory when baseURL is empty after trimming", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " /// ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; const errors: TriggerChatTransportError[] = []; From 3a9047b59bb508d580edc1ca7145189b052080df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:20:17 +0000 Subject: [PATCH 130/217] Validate baseURL is an absolute URL Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 +- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 33 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 6 +++++ 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 8c33a30e27..26c7c906be 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -655,7 +655,7 @@ If `onError` is omitted, reconnect still returns `null` and continues without ca `baseURL` supports optional path prefixes and trailing slashes; both trigger and stream URLs are normalized consistently, surrounding whitespace is trimmed before normalization, and -the resulting value must not be empty. +the resulting value must not be empty. The value must also be a valid absolute URL. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 24d16d7718..34b4d6ac64 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -22,3 +22,4 @@ - Added consistent baseURL normalization for trigger and stream endpoints (including path prefixes and trailing slashes). - Added surrounding-whitespace trimming for `baseURL` before endpoint normalization. - Added explicit validation that `baseURL` is non-empty after normalization. +- Added explicit validation that `baseURL` is a valid absolute URL. diff --git a/packages/ai/README.md b/packages/ai/README.md index 96c6031749..3fa5acc466 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -161,6 +161,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - Trailing slashes are normalized automatically before trigger/stream requests. - Surrounding whitespace is trimmed before normalization. - `baseURL` must not be empty after trimming/normalization. +- `baseURL` must be a valid absolute URL. ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 6a4fdda9d8..36722f492e 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -620,6 +620,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws when baseURL is not a valid absolute URL", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "not-a-valid-url", + stream: "chat-stream", + }); + }).toThrowError("baseURL must be a valid absolute URL"); + }); + + it("throws when baseURL is a relative path", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "/relative/path", + stream: "chat-stream", + }); + }).toThrowError("baseURL must be a valid absolute URL"); + }); + it("combines path prefixes with run and stream URL encoding", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; @@ -2745,6 +2767,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws from factory when baseURL is not a valid absolute URL", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "invalid-base-url", + stream: "chat-stream", + }); + }).toThrowError("baseURL must be a valid absolute URL"); + }); + it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; const errors: TriggerChatTransportError[] = []; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index f934d13c5d..f86c5a12fe 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -466,6 +466,12 @@ function normalizeBaseUrl(baseURL: string) { throw new Error("baseURL must not be empty"); } + try { + new URL(normalizedBaseUrl); + } catch { + throw new Error("baseURL must be a valid absolute URL"); + } + return normalizedBaseUrl; } From 9afa035574bb2246effed481e3b7c48f92123a3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:22:40 +0000 Subject: [PATCH 131/217] Require http(s) protocol for chat transport baseURL Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 ++- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 10 +++++++++- 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 26c7c906be..49798ca75c 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -655,7 +655,8 @@ If `onError` is omitted, reconnect still returns `null` and continues without ca `baseURL` supports optional path prefixes and trailing slashes; both trigger and stream URLs are normalized consistently, surrounding whitespace is trimmed before normalization, and -the resulting value must not be empty. The value must also be a valid absolute URL. +the resulting value must not be empty. The value must also be a valid absolute URL using +the `http` or `https` protocol. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 34b4d6ac64..085ab4be41 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -23,3 +23,4 @@ - Added surrounding-whitespace trimming for `baseURL` before endpoint normalization. - Added explicit validation that `baseURL` is non-empty after normalization. - Added explicit validation that `baseURL` is a valid absolute URL. +- Added explicit validation that `baseURL` uses `http` or `https`. diff --git a/packages/ai/README.md b/packages/ai/README.md index 3fa5acc466..f20d576632 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -162,6 +162,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - Surrounding whitespace is trimmed before normalization. - `baseURL` must not be empty after trimming/normalization. - `baseURL` must be a valid absolute URL. +- `baseURL` must use the `http` or `https` protocol. ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 36722f492e..69ab5e031a 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -642,6 +642,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must be a valid absolute URL"); }); + it("throws when baseURL protocol is not http or https", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://example.com", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("combines path prefixes with run and stream URL encoding", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; @@ -2778,6 +2789,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must be a valid absolute URL"); }); + it("throws from factory when baseURL protocol is not http or https", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://example.com", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; const errors: TriggerChatTransportError[] = []; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index f86c5a12fe..ebc263b6ca 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -466,12 +466,20 @@ function normalizeBaseUrl(baseURL: string) { throw new Error("baseURL must not be empty"); } + let parsedBaseUrl: URL; try { - new URL(normalizedBaseUrl); + parsedBaseUrl = new URL(normalizedBaseUrl); } catch { throw new Error("baseURL must be a valid absolute URL"); } + if ( + parsedBaseUrl.protocol !== "http:" && + parsedBaseUrl.protocol !== "https:" + ) { + throw new Error("baseURL must use http or https protocol"); + } + return normalizedBaseUrl; } From 915573cfd07424d8429c5444418d2bced5f4750a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:24:49 +0000 Subject: [PATCH 132/217] Cover uppercase HTTP baseURL protocol handling Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 70 +++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 69ab5e031a..ff6e51fd5f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -653,6 +653,76 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("accepts uppercase http protocol in baseURL", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_uppercase_protocol", + }); + res.end(JSON.stringify({ id: "run_uppercase_protocol" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_uppercase_protocol/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "uppercase_protocol_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "uppercase_protocol_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const uppercasedProtocolBaseUrl = server.url.replace(/^http:\/\//, "HTTP://"); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: uppercasedProtocolBaseUrl, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-uppercase-protocol", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe("/realtime/v1/streams/run_uppercase_protocol/chat-stream"); + }); + it("combines path prefixes with run and stream URL encoding", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; From db22f32e729565e40adf63c3220a9eaac04ec900 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:27:03 +0000 Subject: [PATCH 133/217] Reject baseURL query and hash components Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 +- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 4 +++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 49798ca75c..f0ac175de4 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -656,7 +656,7 @@ If `onError` is omitted, reconnect still returns `null` and continues without ca `baseURL` supports optional path prefixes and trailing slashes; both trigger and stream URLs are normalized consistently, surrounding whitespace is trimmed before normalization, and the resulting value must not be empty. The value must also be a valid absolute URL using -the `http` or `https` protocol. +the `http` or `https` protocol, without query parameters or hash fragments. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 085ab4be41..7d61540b24 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -24,3 +24,4 @@ - Added explicit validation that `baseURL` is non-empty after normalization. - Added explicit validation that `baseURL` is a valid absolute URL. - Added explicit validation that `baseURL` uses `http` or `https`. +- Added explicit validation that `baseURL` excludes query parameters and hash fragments. diff --git a/packages/ai/README.md b/packages/ai/README.md index f20d576632..44c8ef0dc7 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -163,6 +163,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - `baseURL` must not be empty after trimming/normalization. - `baseURL` must be a valid absolute URL. - `baseURL` must use the `http` or `https` protocol. +- `baseURL` must not include query parameters or hash fragments. ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index ff6e51fd5f..f3218df061 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -653,6 +653,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws when baseURL includes query parameters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://example.com/base?query=1", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + + it("throws when baseURL includes hash fragments", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://example.com/base#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("accepts uppercase http protocol in baseURL", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; @@ -2870,6 +2892,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws from factory when baseURL includes query parameters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://example.com/base?query=1", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + + it("throws from factory when baseURL includes hash fragments", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://example.com/base#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; const errors: TriggerChatTransportError[] = []; diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index ebc263b6ca..aba58f5d2b 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -480,6 +480,10 @@ function normalizeBaseUrl(baseURL: string) { throw new Error("baseURL must use http or https protocol"); } + if (parsedBaseUrl.search.length > 0 || parsedBaseUrl.hash.length > 0) { + throw new Error("baseURL must not include query parameters or hash fragments"); + } + return normalizedBaseUrl; } From 7f4cca5feda99df8fa295d9b8fd69051a6cfb007 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:29:09 +0000 Subject: [PATCH 134/217] Cover accepted https baseURL validation paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index f3218df061..4b47251bda 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -675,6 +675,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("accepts https baseURL values without throwing", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom-prefix", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts uppercase http protocol in baseURL", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; @@ -2914,6 +2925,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("accepts https baseURL values from factory without throwing", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom-prefix", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; const errors: TriggerChatTransportError[] = []; From 39492e67ab6c82cddf3852300cba7ac01d4fa916 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:31:21 +0000 Subject: [PATCH 135/217] Reject credential-bearing baseURL values Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 ++- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 4 ++++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index f0ac175de4..375cacd2e9 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -656,7 +656,8 @@ If `onError` is omitted, reconnect still returns `null` and continues without ca `baseURL` supports optional path prefixes and trailing slashes; both trigger and stream URLs are normalized consistently, surrounding whitespace is trimmed before normalization, and the resulting value must not be empty. The value must also be a valid absolute URL using -the `http` or `https` protocol, without query parameters or hash fragments. +the `http` or `https` protocol, without query parameters, hash fragments, or embedded +username/password credentials. For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7d61540b24..f298742f49 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -25,3 +25,4 @@ - Added explicit validation that `baseURL` is a valid absolute URL. - Added explicit validation that `baseURL` uses `http` or `https`. - Added explicit validation that `baseURL` excludes query parameters and hash fragments. +- Added explicit validation that `baseURL` excludes username/password credentials. diff --git a/packages/ai/README.md b/packages/ai/README.md index 44c8ef0dc7..c83a66a0db 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -164,6 +164,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - `baseURL` must be a valid absolute URL. - `baseURL` must use the `http` or `https` protocol. - `baseURL` must not include query parameters or hash fragments. +- `baseURL` must not include username/password URL credentials. ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 4b47251bda..947ba07f62 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -675,6 +675,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws when baseURL includes username or password credentials", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://user:pass@example.com/base", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include username or password credentials"); + }); + it("accepts https baseURL values without throwing", function () { expect(function () { new TriggerChatTransport({ @@ -2925,6 +2936,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws from factory when baseURL includes username or password credentials", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://user:pass@example.com/base", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include username or password credentials"); + }); + it("accepts https baseURL values from factory without throwing", function () { expect(function () { createTriggerChatTransport({ diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index aba58f5d2b..62fcbe3679 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -484,6 +484,10 @@ function normalizeBaseUrl(baseURL: string) { throw new Error("baseURL must not include query parameters or hash fragments"); } + if (parsedBaseUrl.username.length > 0 || parsedBaseUrl.password.length > 0) { + throw new Error("baseURL must not include username or password credentials"); + } + return normalizedBaseUrl; } From ae2942e1235dbcb2a4c4e479ac0a657096dcc0df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:33:06 +0000 Subject: [PATCH 136/217] Document baseURL validation in ai package changeset Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index eae5d349f3..45a3341c05 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -8,3 +8,4 @@ Add a new `@trigger.dev/ai` package with: - a typed `TriggerChatTransport` that plugs into AI SDK UI `useChat()` and runs chat backends as Trigger.dev tasks - rich default task payloads (`chatId`, trigger metadata, messages, request context) with optional payload mapping - reconnect-aware stream handling on top of Trigger.dev Realtime Streams v2 +- strict `baseURL` normalization/validation (trimming, path-safe slash handling, absolute `http(s)` URLs only, no query/hash/credentials) From 861e42a2197301064c50bf7f104269177ab0ccc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:35:19 +0000 Subject: [PATCH 137/217] Cover trimmed query-bearing baseURL rejection paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 947ba07f62..f5c9a1aab6 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -664,6 +664,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws when trimmed baseURL includes query parameters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://example.com/base/?query=1 ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws when baseURL includes hash fragments", function () { expect(function () { new TriggerChatTransport({ @@ -2925,6 +2936,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws from factory when trimmed baseURL includes query parameters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://example.com/base/?query=1 ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws from factory when baseURL includes hash fragments", function () { expect(function () { createTriggerChatTransport({ From cb701f2f62475f49ec4f0fb70bfc181b6f4883c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:37:22 +0000 Subject: [PATCH 138/217] Cover trimmed hash and credential baseURL rejection Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index f5c9a1aab6..717c320163 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -686,6 +686,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws when trimmed baseURL includes hash fragments", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://example.com/base/#fragment ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws when baseURL includes username or password credentials", function () { expect(function () { new TriggerChatTransport({ @@ -697,6 +708,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include username or password credentials"); }); + it("throws when trimmed baseURL includes username or password credentials", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://user:pass@example.com/base/ ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include username or password credentials"); + }); + it("accepts https baseURL values without throwing", function () { expect(function () { new TriggerChatTransport({ @@ -2958,6 +2980,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws from factory when trimmed baseURL includes hash fragments", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://example.com/base/#fragment ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws from factory when baseURL includes username or password credentials", function () { expect(function () { createTriggerChatTransport({ @@ -2969,6 +3002,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include username or password credentials"); }); + it("throws from factory when trimmed baseURL includes username or password credentials", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://user:pass@example.com/base/ ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include username or password credentials"); + }); + it("accepts https baseURL values from factory without throwing", function () { expect(function () { createTriggerChatTransport({ From c970ff0be8768387ba814a21716a730360bbda4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:39:21 +0000 Subject: [PATCH 139/217] Cover uppercase HTTPS baseURL acceptance paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 717c320163..fc5ddba42d 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -730,6 +730,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts uppercase https protocol in baseURL", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "HTTPS://api.trigger.dev/custom-prefix", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts uppercase http protocol in baseURL", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; @@ -3024,6 +3035,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts uppercase https protocol from factory without throwing", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "HTTPS://api.trigger.dev/custom-prefix", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; const errors: TriggerChatTransportError[] = []; From c00d630487157968033bdafcbfb4f925e70765b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:41:30 +0000 Subject: [PATCH 140/217] Add explicit baseURL examples to ai docs Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 10 ++++++++++ packages/ai/README.md | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 375cacd2e9..1f5128c045 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -659,6 +659,16 @@ the resulting value must not be empty. The value must also be a valid absolute U the `http` or `https` protocol, without query parameters, hash fragments, or embedded username/password credentials. +Examples: + +- ✅ `https://api.trigger.dev` +- ✅ `https://api.trigger.dev/custom-prefix` +- ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) +- ❌ `https://api.trigger.dev?foo=bar` +- ❌ `https://api.trigger.dev#fragment` +- ❌ `https://user:pass@api.trigger.dev` +- ❌ `ftp://api.trigger.dev` + For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: - `TriggerChatHeadersInput` diff --git a/packages/ai/README.md b/packages/ai/README.md index c83a66a0db..1cfe52c613 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -166,6 +166,16 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - `baseURL` must not include query parameters or hash fragments. - `baseURL` must not include username/password URL credentials. +Examples: + +- ✅ `https://api.trigger.dev` +- ✅ `https://api.trigger.dev/custom-prefix` +- ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) +- ❌ `https://api.trigger.dev?foo=bar` (query string) +- ❌ `https://api.trigger.dev#fragment` (hash fragment) +- ❌ `https://user:pass@api.trigger.dev` (credentials) +- ❌ `ftp://api.trigger.dev` (non-http protocol) + ## `ai.tool(...)` example ```ts From 2f1c683c0a472681b0bbcc7bf7595577c34a84cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:43:29 +0000 Subject: [PATCH 141/217] Cover ws and wss baseURL protocol rejection Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index fc5ddba42d..6177740fcc 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -653,6 +653,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws when baseURL protocol is ws", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ws://example.com", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + + it("throws when baseURL protocol is wss", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "wss://example.com", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws when baseURL includes query parameters", function () { expect(function () { new TriggerChatTransport({ @@ -2958,6 +2980,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws from factory when baseURL protocol is ws", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ws://example.com", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + + it("throws from factory when baseURL protocol is wss", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "wss://example.com", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws from factory when baseURL includes query parameters", function () { expect(function () { createTriggerChatTransport({ From d56f58d66ac8606e45b89a5f87434c46700247d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:45:09 +0000 Subject: [PATCH 142/217] Document ws and wss baseURL rejection examples Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 1f5128c045..682ea765e5 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -668,6 +668,7 @@ Examples: - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` - ❌ `ftp://api.trigger.dev` +- ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/README.md b/packages/ai/README.md index 1cfe52c613..57bb7da018 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -175,6 +175,7 @@ Examples: - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) - ❌ `ftp://api.trigger.dev` (non-http protocol) +- ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) ## `ai.tool(...)` example From 5a017a732e324fbbc2cee64c14cf2547d8c3345d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:47:10 +0000 Subject: [PATCH 143/217] Refactor baseURL validation into focused helpers Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 62fcbe3679..0d73b4e8e4 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -473,22 +473,32 @@ function normalizeBaseUrl(baseURL: string) { throw new Error("baseURL must be a valid absolute URL"); } + assertValidBaseUrlProtocol(parsedBaseUrl); + assertBaseUrlHasNoQueryOrHash(parsedBaseUrl); + assertBaseUrlHasNoCredentials(parsedBaseUrl); + + return normalizedBaseUrl; +} + +function assertValidBaseUrlProtocol(parsedBaseUrl: URL) { if ( parsedBaseUrl.protocol !== "http:" && parsedBaseUrl.protocol !== "https:" ) { throw new Error("baseURL must use http or https protocol"); } +} +function assertBaseUrlHasNoQueryOrHash(parsedBaseUrl: URL) { if (parsedBaseUrl.search.length > 0 || parsedBaseUrl.hash.length > 0) { throw new Error("baseURL must not include query parameters or hash fragments"); } +} +function assertBaseUrlHasNoCredentials(parsedBaseUrl: URL) { if (parsedBaseUrl.username.length > 0 || parsedBaseUrl.password.length > 0) { throw new Error("baseURL must not include username or password credentials"); } - - return normalizedBaseUrl; } function createTransportRequest( From 341e3452cd81981e1569358637eec8fd618af426 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:49:35 +0000 Subject: [PATCH 144/217] Cover trimmed ws and wss baseURL rejection Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 6177740fcc..a5985f7cd5 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -664,6 +664,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws when trimmed baseURL protocol is ws", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " ws://example.com ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws when baseURL protocol is wss", function () { expect(function () { new TriggerChatTransport({ @@ -675,6 +686,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws when trimmed baseURL protocol is wss", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " wss://example.com ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws when baseURL includes query parameters", function () { expect(function () { new TriggerChatTransport({ @@ -2991,6 +3013,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws from factory when trimmed baseURL protocol is ws", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " ws://example.com ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws from factory when baseURL protocol is wss", function () { expect(function () { createTriggerChatTransport({ @@ -3002,6 +3035,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws from factory when trimmed baseURL protocol is wss", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " wss://example.com ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws from factory when baseURL includes query parameters", function () { expect(function () { createTriggerChatTransport({ From 9c3228b759ac6e50c626f11debf91f9a818f0f0c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:50:12 +0000 Subject: [PATCH 145/217] Document exact baseURL validation error messages Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 8 ++++++++ packages/ai/README.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 682ea765e5..fbc404245c 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -670,6 +670,14 @@ Examples: - ❌ `ftp://api.trigger.dev` - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` +Validation errors use these exact messages: + +- `baseURL must not be empty` +- `baseURL must be a valid absolute URL` +- `baseURL must use http or https protocol` +- `baseURL must not include query parameters or hash fragments` +- `baseURL must not include username or password credentials` + For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: - `TriggerChatHeadersInput` diff --git a/packages/ai/README.md b/packages/ai/README.md index 57bb7da018..89e21c8891 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -177,6 +177,14 @@ Examples: - ❌ `ftp://api.trigger.dev` (non-http protocol) - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) +Validation errors use these exact messages: + +- `baseURL must not be empty` +- `baseURL must be a valid absolute URL` +- `baseURL must use http or https protocol` +- `baseURL must not include query parameters or hash fragments` +- `baseURL must not include username or password credentials` + ## `ai.tool(...)` example ```ts From 8309d579aef7a684c2067195e60286d69c8dcb35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:52:22 +0000 Subject: [PATCH 146/217] Cover baseURL validation error precedence Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index a5985f7cd5..08c0015106 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -752,6 +752,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include username or password credentials"); }); + it("prioritizes protocol validation over query/hash validation", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://example.com/base?query=1", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + + it("prioritizes query/hash validation over credential validation", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://user:pass@example.com/base?query=1", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws when trimmed baseURL includes username or password credentials", function () { expect(function () { new TriggerChatTransport({ @@ -3101,6 +3123,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include username or password credentials"); }); + it("prioritizes protocol validation over query/hash validation in factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://example.com/base?query=1", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + + it("prioritizes query/hash validation over credential validation in factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://user:pass@example.com/base?query=1", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws from factory when trimmed baseURL includes username or password credentials", function () { expect(function () { createTriggerChatTransport({ From be5487fb4c564f0f8d6699e5751976edad8cbfec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:54:34 +0000 Subject: [PATCH 147/217] Cover hash-over-credential validation precedence Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 08c0015106..6bea5eea25 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -774,6 +774,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("prioritizes hash validation over credential validation", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://user:pass@example.com/base#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws when trimmed baseURL includes username or password credentials", function () { expect(function () { new TriggerChatTransport({ @@ -3145,6 +3156,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("prioritizes hash validation over credential validation in factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://user:pass@example.com/base#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws from factory when trimmed baseURL includes username or password credentials", function () { expect(function () { createTriggerChatTransport({ From b9eb04c6134d56917d120853ef38d3b89c4ec357 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 04:56:36 +0000 Subject: [PATCH 148/217] Cover factory acceptance for uppercase HTTP baseURL Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 6bea5eea25..e1d21f2089 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -3200,6 +3200,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts uppercase http protocol from factory without throwing", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "HTTP://api.trigger.dev/custom-prefix", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; const errors: TriggerChatTransportError[] = []; From 1f7a16508ef0eda6bc3e1d74930f0d018ca7d1e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:00:11 +0000 Subject: [PATCH 149/217] Document case-insensitive baseURL protocol matching Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index fbc404245c..bbd2648cb6 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -658,6 +658,7 @@ are normalized consistently, surrounding whitespace is trimmed before normalizat the resulting value must not be empty. The value must also be a valid absolute URL using the `http` or `https` protocol, without query parameters, hash fragments, or embedded username/password credentials. +Protocol matching is case-insensitive (`HTTP://...` and `HTTPS://...` are accepted). Examples: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f298742f49..2a3bfee94c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -26,3 +26,4 @@ - Added explicit validation that `baseURL` uses `http` or `https`. - Added explicit validation that `baseURL` excludes query parameters and hash fragments. - Added explicit validation that `baseURL` excludes username/password credentials. +- Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). diff --git a/packages/ai/README.md b/packages/ai/README.md index 89e21c8891..e7a9c409be 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -165,6 +165,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails - `baseURL` must use the `http` or `https` protocol. - `baseURL` must not include query parameters or hash fragments. - `baseURL` must not include username/password URL credentials. +- Protocol matching is case-insensitive (`HTTP://...` and `HTTPS://...` are accepted). Examples: From b751ad836f34ddffeaf4baf017a92c47de53c456 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:02:25 +0000 Subject: [PATCH 150/217] Centralize baseURL validation error messages Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 0d73b4e8e4..c88a3ed1e5 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -448,6 +448,14 @@ export function createTriggerChatTransport< return new TriggerChatTransport(options); } +const BASE_URL_VALIDATION_ERRORS = { + empty: "baseURL must not be empty", + invalidAbsoluteUrl: "baseURL must be a valid absolute URL", + invalidProtocol: "baseURL must use http or https protocol", + queryOrHash: "baseURL must not include query parameters or hash fragments", + credentials: "baseURL must not include username or password credentials", +} as const; + function resolvePayloadMapper< UI_MESSAGE extends UIMessage, PAYLOAD, @@ -463,14 +471,14 @@ function normalizeBaseUrl(baseURL: string) { const normalizedBaseUrl = baseURL.trim().replace(/\/+$/, ""); if (normalizedBaseUrl.length === 0) { - throw new Error("baseURL must not be empty"); + throw new Error(BASE_URL_VALIDATION_ERRORS.empty); } let parsedBaseUrl: URL; try { parsedBaseUrl = new URL(normalizedBaseUrl); } catch { - throw new Error("baseURL must be a valid absolute URL"); + throw new Error(BASE_URL_VALIDATION_ERRORS.invalidAbsoluteUrl); } assertValidBaseUrlProtocol(parsedBaseUrl); @@ -485,19 +493,19 @@ function assertValidBaseUrlProtocol(parsedBaseUrl: URL) { parsedBaseUrl.protocol !== "http:" && parsedBaseUrl.protocol !== "https:" ) { - throw new Error("baseURL must use http or https protocol"); + throw new Error(BASE_URL_VALIDATION_ERRORS.invalidProtocol); } } function assertBaseUrlHasNoQueryOrHash(parsedBaseUrl: URL) { if (parsedBaseUrl.search.length > 0 || parsedBaseUrl.hash.length > 0) { - throw new Error("baseURL must not include query parameters or hash fragments"); + throw new Error(BASE_URL_VALIDATION_ERRORS.queryOrHash); } } function assertBaseUrlHasNoCredentials(parsedBaseUrl: URL) { if (parsedBaseUrl.username.length > 0 || parsedBaseUrl.password.length > 0) { - throw new Error("baseURL must not include username or password credentials"); + throw new Error(BASE_URL_VALIDATION_ERRORS.credentials); } } From 2a60d1d8983dc64abe2ed0bc469f42b37707b858 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:03:22 +0000 Subject: [PATCH 151/217] Document deterministic baseURL validation ordering Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 +++ packages/ai/CHANGELOG.md | 2 ++ packages/ai/README.md | 3 +++ 3 files changed, 8 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index bbd2648cb6..30d35d7e82 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -679,6 +679,9 @@ Validation errors use these exact messages: - `baseURL must not include query parameters or hash fragments` - `baseURL must not include username or password credentials` +When multiple issues are present, validation order is deterministic: +protocol → query/hash → credentials. + For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: - `TriggerChatHeadersInput` diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2a3bfee94c..1e4cd55862 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -27,3 +27,5 @@ - Added explicit validation that `baseURL` excludes query parameters and hash fragments. - Added explicit validation that `baseURL` excludes username/password credentials. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). +- Added deterministic validation ordering for multi-issue baseURL values + (protocol → query/hash → credentials). diff --git a/packages/ai/README.md b/packages/ai/README.md index e7a9c409be..41d573c37d 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -186,6 +186,9 @@ Validation errors use these exact messages: - `baseURL must not include query parameters or hash fragments` - `baseURL must not include username or password credentials` +When multiple issues are present, validation order is deterministic: +protocol → query/hash → credentials. + ## `ai.tool(...)` example ```ts From 9863544dd1dc252fb7e921f8d231bdf3d9d4fce2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:05:08 +0000 Subject: [PATCH 152/217] Note baseURL validation ordering in changeset Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index 45a3341c05..6272f15aa5 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -9,3 +9,4 @@ Add a new `@trigger.dev/ai` package with: - rich default task payloads (`chatId`, trigger metadata, messages, request context) with optional payload mapping - reconnect-aware stream handling on top of Trigger.dev Realtime Streams v2 - strict `baseURL` normalization/validation (trimming, path-safe slash handling, absolute `http(s)` URLs only, no query/hash/credentials) +- deterministic baseURL validation error ordering for multi-issue inputs (protocol → query/hash → credentials) From 7d3eac26ea9d3ce5e9ebd160cae6d622a2bd08c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:07:21 +0000 Subject: [PATCH 153/217] Cover whitespace-wrapped uppercase HTTP baseURL acceptance Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e1d21f2089..33951c01f2 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -818,6 +818,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts whitespace-wrapped uppercase http protocol in baseURL", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " HTTP://api.trigger.dev/custom-prefix/// ", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts uppercase http protocol in baseURL", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; @@ -3211,6 +3222,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts whitespace-wrapped uppercase http protocol from factory without throwing", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " HTTP://api.trigger.dev/custom-prefix/// ", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("continues streaming when onTriggeredRun callback throws", async function () { let callbackCalled = false; const errors: TriggerChatTransportError[] = []; From 5db7fb77af31971247652a59ff8c0c1d7c708a5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:09:53 +0000 Subject: [PATCH 154/217] Cover whitespace uppercase HTTPS baseURL acceptance Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 33951c01f2..5c6ad93aed 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -818,6 +818,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts whitespace-wrapped uppercase https protocol in baseURL", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " HTTPS://api.trigger.dev/custom-prefix/// ", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts whitespace-wrapped uppercase http protocol in baseURL", function () { expect(function () { new TriggerChatTransport({ @@ -3211,6 +3222,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts whitespace-wrapped uppercase https protocol from factory without throwing", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " HTTPS://api.trigger.dev/custom-prefix/// ", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts uppercase http protocol from factory without throwing", function () { expect(function () { createTriggerChatTransport({ From 0cb113b4fe8c992874afbfa8f74a4b613c8af192 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:12:24 +0000 Subject: [PATCH 155/217] Cover runtime uppercase HTTP baseURL prefix normalization Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 5c6ad93aed..91d963f813 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -910,6 +910,78 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_uppercase_protocol/chat-stream"); }); + it("supports whitespace-wrapped uppercase http protocol with path prefix", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/uppercase-http-prefix/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_uppercase_http_prefix", + }); + res.end(JSON.stringify({ id: "run_uppercase_http_prefix" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/uppercase-http-prefix/realtime/v1/streams/run_uppercase_http_prefix/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "uppercase_http_prefix_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "uppercase_http_prefix_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const uppercaseHttpBaseUrl = server.url.replace(/^http:\/\//, "HTTP://"); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: ` ${uppercaseHttpBaseUrl}/uppercase-http-prefix/// `, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-uppercase-http-prefix", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/uppercase-http-prefix/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe( + "/uppercase-http-prefix/realtime/v1/streams/run_uppercase_http_prefix/chat-stream" + ); + }); + it("combines path prefixes with run and stream URL encoding", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; From c69808e7c7af29e4bdca619cb146478e53d39a0d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:14:33 +0000 Subject: [PATCH 156/217] Expand baseURL precedence coverage for mixed invalid inputs Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 91d963f813..9624c20276 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -763,6 +763,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("prioritizes protocol validation over query/hash/credential validation", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://user:pass@example.com/base?query=1#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("prioritizes query/hash validation over credential validation", function () { expect(function () { new TriggerChatTransport({ @@ -3239,6 +3250,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("prioritizes protocol validation over query/hash/credential validation in factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://user:pass@example.com/base?query=1#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("prioritizes query/hash validation over credential validation in factory", function () { expect(function () { createTriggerChatTransport({ From b4f5a8b08af64a768d66cb07d2ed74f9b9957bed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:15:12 +0000 Subject: [PATCH 157/217] Add baseURL validation ordering examples to docs Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 5 +++++ packages/ai/README.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 30d35d7e82..3393ca5595 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -682,6 +682,11 @@ Validation errors use these exact messages: When multiple issues are present, validation order is deterministic: protocol → query/hash → credentials. +Examples of ordering: + +- `ftp://example.com?x=1` → `baseURL must use http or https protocol` +- `https://user:pass@example.com?x=1` → `baseURL must not include query parameters or hash fragments` + For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: - `TriggerChatHeadersInput` diff --git a/packages/ai/README.md b/packages/ai/README.md index 41d573c37d..88baea253b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -189,6 +189,11 @@ Validation errors use these exact messages: When multiple issues are present, validation order is deterministic: protocol → query/hash → credentials. +Examples of ordering: + +- `ftp://example.com?x=1` → `baseURL must use http or https protocol` +- `https://user:pass@example.com?x=1` → `baseURL must not include query parameters or hash fragments` + ## `ai.tool(...)` example ```ts From 4d0270d207eb6ec3169581c31453f3cbee210098 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:19:07 +0000 Subject: [PATCH 158/217] Cover omitted baseURL default constructor paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 9624c20276..3baf214b48 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -620,6 +620,16 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("uses default baseURL when omitted", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("throws when baseURL is not a valid absolute URL", function () { expect(function () { new TriggerChatTransport({ @@ -3118,6 +3128,16 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("uses default baseURL in factory when omitted", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("throws from factory when baseURL is not a valid absolute URL", function () { expect(function () { createTriggerChatTransport({ From e6e6bee5c35ffc3264df6b61b9a3f4e68628c517 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:21:41 +0000 Subject: [PATCH 159/217] Cover newline and tab baseURL whitespace trimming Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 68 +++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 3baf214b48..9349435fc9 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -544,6 +544,74 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/realtime/v1/streams/run_trimmed_baseurl/chat-stream"); }); + it("trims newline and tab whitespace around baseURL values", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_trimmed_newline_tab_baseurl", + }); + res.end(JSON.stringify({ id: "run_trimmed_newline_tab_baseurl" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/realtime/v1/streams/run_trimmed_newline_tab_baseurl/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "trimmed_newline_tab_baseurl_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "trimmed_newline_tab_baseurl_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: `\n\t${server.url}/\t\n`, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-trimmed-newline-tab-baseurl", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe("/realtime/v1/streams/run_trimmed_newline_tab_baseurl/chat-stream"); + }); + it("preserves baseURL path prefixes after trimming surrounding whitespace", async function () { let observedTriggerPath: string | undefined; let observedStreamPath: string | undefined; From 3aa5ad1fa9411cae9c140275d24fd7500e7a4548 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:22:48 +0000 Subject: [PATCH 160/217] Cover non-breaking-space baseURL normalization paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 9349435fc9..fa3410c35a 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -918,6 +918,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts non-breaking-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u00A0https://api.trigger.dev/custom-prefix/\u00A0", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts whitespace-wrapped uppercase http protocol in baseURL", function () { expect(function () { new TriggerChatTransport({ @@ -3415,6 +3426,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts non-breaking-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u00A0https://api.trigger.dev/custom-prefix/\u00A0", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts uppercase http protocol from factory without throwing", function () { expect(function () { createTriggerChatTransport({ From bf05b9449e52aab72eb4c3ff8b53bb11f0b37b68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:25:06 +0000 Subject: [PATCH 161/217] Document default baseURL behavior when omitted Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 ++- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 3393ca5595..421ba0f40b 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -653,7 +653,8 @@ the failure is surfaced through `onError` with phase `reconnect`. Subsequent reconnect calls will retry stale inactive-state cleanup until it succeeds. If `onError` is omitted, reconnect still returns `null` and continues without callback reporting. -`baseURL` supports optional path prefixes and trailing slashes; both trigger and stream URLs +`baseURL` defaults to `https://api.trigger.dev` when omitted. +It supports optional path prefixes and trailing slashes; both trigger and stream URLs are normalized consistently, surrounding whitespace is trimmed before normalization, and the resulting value must not be empty. The value must also be a valid absolute URL using the `http` or `https` protocol, without query parameters, hash fragments, or embedded diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1e4cd55862..1904453295 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -29,3 +29,4 @@ - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (protocol → query/hash → credentials). +- Documented explicit default `baseURL` value (`https://api.trigger.dev`) when omitted. diff --git a/packages/ai/README.md b/packages/ai/README.md index 88baea253b..bd75c9ccfa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -157,6 +157,7 @@ both cleanup steps (`set` inactive state and `delete`) even if one of them fails ## Base URL behavior +- `baseURL` defaults to `https://api.trigger.dev` when omitted. - `baseURL` supports optional path prefixes (for example reverse-proxy mounts). - Trailing slashes are normalized automatically before trigger/stream requests. - Surrounding whitespace is trimmed before normalization. From ed520b6e2a3f77a0cf8e8cb6d5d5a56079ed35a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:27:29 +0000 Subject: [PATCH 162/217] Cover empty-after-NBSP baseURL validation paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index fa3410c35a..0bc41f1089 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -688,6 +688,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws when baseURL is empty after trimming non-breaking spaces", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u00A0///\u00A0", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + it("uses default baseURL when omitted", function () { expect(function () { new TriggerChatTransport({ @@ -3207,6 +3218,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws from factory when baseURL is empty after trimming non-breaking spaces", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u00A0///\u00A0", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + it("uses default baseURL in factory when omitted", function () { expect(function () { createTriggerChatTransport({ From e92a84e51e3a5059b74d258bb7d1ce8b28b124bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:29:18 +0000 Subject: [PATCH 163/217] Expand changeset notes for baseURL defaults and protocol casing Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index 6272f15aa5..b8ba395bba 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -10,3 +10,4 @@ Add a new `@trigger.dev/ai` package with: - reconnect-aware stream handling on top of Trigger.dev Realtime Streams v2 - strict `baseURL` normalization/validation (trimming, path-safe slash handling, absolute `http(s)` URLs only, no query/hash/credentials) - deterministic baseURL validation error ordering for multi-issue inputs (protocol → query/hash → credentials) +- explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance From 2644de574c0f5cc7c461effe99ef39d5a463723a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:32:31 +0000 Subject: [PATCH 164/217] Reject internal whitespace in normalized baseURL values Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 1 + docs/tasks/streams.mdx | 2 ++ packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 9 +++++++++ 6 files changed, 37 insertions(+) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index b8ba395bba..f401b9bc9a 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -9,5 +9,6 @@ Add a new `@trigger.dev/ai` package with: - rich default task payloads (`chatId`, trigger metadata, messages, request context) with optional payload mapping - reconnect-aware stream handling on top of Trigger.dev Realtime Streams v2 - strict `baseURL` normalization/validation (trimming, path-safe slash handling, absolute `http(s)` URLs only, no query/hash/credentials) +- rejection of internal whitespace characters in normalized `baseURL` values - deterministic baseURL validation error ordering for multi-issue inputs (protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 421ba0f40b..afa1720771 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -671,11 +671,13 @@ Examples: - ❌ `https://user:pass@api.trigger.dev` - ❌ `ftp://api.trigger.dev` - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` +- ❌ `https://api.trigger.dev/\ninternal` Validation errors use these exact messages: - `baseURL must not be empty` - `baseURL must be a valid absolute URL` +- `baseURL must not contain internal whitespace characters` - `baseURL must use http or https protocol` - `baseURL must not include query parameters or hash fragments` - `baseURL must not include username or password credentials` diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1904453295..201fdd03e9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -26,6 +26,7 @@ - Added explicit validation that `baseURL` uses `http` or `https`. - Added explicit validation that `baseURL` excludes query parameters and hash fragments. - Added explicit validation that `baseURL` excludes username/password credentials. +- Added explicit validation that `baseURL` excludes internal whitespace characters. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (protocol → query/hash → credentials). diff --git a/packages/ai/README.md b/packages/ai/README.md index bd75c9ccfa..8d4d2fea29 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -178,11 +178,13 @@ Examples: - ❌ `https://user:pass@api.trigger.dev` (credentials) - ❌ `ftp://api.trigger.dev` (non-http protocol) - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) +- ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) Validation errors use these exact messages: - `baseURL must not be empty` - `baseURL must be a valid absolute URL` +- `baseURL must not contain internal whitespace characters` - `baseURL must use http or https protocol` - `baseURL must not include query parameters or hash fragments` - `baseURL must not include username or password credentials` diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 0bc41f1089..63039372f7 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -720,6 +720,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must be a valid absolute URL"); }); + it("throws when baseURL contains internal whitespace characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\ninternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL is a relative path", function () { expect(function () { new TriggerChatTransport({ @@ -3250,6 +3261,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must be a valid absolute URL"); }); + it("throws from factory when baseURL contains internal whitespace characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\ninternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL protocol is not http or https", function () { expect(function () { createTriggerChatTransport({ diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index c88a3ed1e5..b05dfd17b6 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -451,6 +451,7 @@ export function createTriggerChatTransport< const BASE_URL_VALIDATION_ERRORS = { empty: "baseURL must not be empty", invalidAbsoluteUrl: "baseURL must be a valid absolute URL", + containsWhitespace: "baseURL must not contain internal whitespace characters", invalidProtocol: "baseURL must use http or https protocol", queryOrHash: "baseURL must not include query parameters or hash fragments", credentials: "baseURL must not include username or password credentials", @@ -474,6 +475,8 @@ function normalizeBaseUrl(baseURL: string) { throw new Error(BASE_URL_VALIDATION_ERRORS.empty); } + assertBaseUrlHasNoInternalWhitespace(normalizedBaseUrl); + let parsedBaseUrl: URL; try { parsedBaseUrl = new URL(normalizedBaseUrl); @@ -488,6 +491,12 @@ function normalizeBaseUrl(baseURL: string) { return normalizedBaseUrl; } +function assertBaseUrlHasNoInternalWhitespace(baseUrl: string) { + if (/\s/.test(baseUrl)) { + throw new Error(BASE_URL_VALIDATION_ERRORS.containsWhitespace); + } +} + function assertValidBaseUrlProtocol(parsedBaseUrl: URL) { if ( parsedBaseUrl.protocol !== "http:" && From 0aa2edb7527da13117a47d528f64f847f8100be0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:34:31 +0000 Subject: [PATCH 165/217] Prioritize internal-whitespace baseURL validation Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 2 +- docs/tasks/streams.mdx | 3 ++- packages/ai/CHANGELOG.md | 2 +- packages/ai/README.md | 3 ++- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index f401b9bc9a..c7b9bc2a22 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -10,5 +10,5 @@ Add a new `@trigger.dev/ai` package with: - reconnect-aware stream handling on top of Trigger.dev Realtime Streams v2 - strict `baseURL` normalization/validation (trimming, path-safe slash handling, absolute `http(s)` URLs only, no query/hash/credentials) - rejection of internal whitespace characters in normalized `baseURL` values -- deterministic baseURL validation error ordering for multi-issue inputs (protocol → query/hash → credentials) +- deterministic baseURL validation error ordering for multi-issue inputs (internal whitespace → protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index afa1720771..0f97488f31 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -683,12 +683,13 @@ Validation errors use these exact messages: - `baseURL must not include username or password credentials` When multiple issues are present, validation order is deterministic: -protocol → query/hash → credentials. +internal whitespace → protocol → query/hash → credentials. Examples of ordering: - `ftp://example.com?x=1` → `baseURL must use http or https protocol` - `https://user:pass@example.com?x=1` → `baseURL must not include query parameters or hash fragments` +- `ftp://user:pass@example.com/in valid?x=1` → `baseURL must not contain internal whitespace characters` For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 201fdd03e9..4707559993 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -29,5 +29,5 @@ - Added explicit validation that `baseURL` excludes internal whitespace characters. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values - (protocol → query/hash → credentials). + (internal whitespace → protocol → query/hash → credentials). - Documented explicit default `baseURL` value (`https://api.trigger.dev`) when omitted. diff --git a/packages/ai/README.md b/packages/ai/README.md index 8d4d2fea29..84670de508 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -190,12 +190,13 @@ Validation errors use these exact messages: - `baseURL must not include username or password credentials` When multiple issues are present, validation order is deterministic: -protocol → query/hash → credentials. +internal whitespace → protocol → query/hash → credentials. Examples of ordering: - `ftp://example.com?x=1` → `baseURL must use http or https protocol` - `https://user:pass@example.com?x=1` → `baseURL must not include query parameters or hash fragments` +- `ftp://user:pass@example.com/in valid?x=1` → `baseURL must not contain internal whitespace characters` ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 63039372f7..e2e2104017 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -874,6 +874,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("prioritizes internal-whitespace validation over protocol/query/hash/credential validation", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://user:pass@example.com/in valid?query=1#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("prioritizes query/hash validation over credential validation", function () { expect(function () { new TriggerChatTransport({ @@ -3404,6 +3415,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("prioritizes internal-whitespace validation over protocol/query/hash/credential validation in factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://user:pass@example.com/in valid?query=1#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("prioritizes query/hash validation over credential validation in factory", function () { expect(function () { createTriggerChatTransport({ From a70ecde3f011cfcbaeb50e5eb2e93dac1668285d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:35:34 +0000 Subject: [PATCH 166/217] Cover internal tab characters in baseURL validation Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e2e2104017..6380c7cdee 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -731,6 +731,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal tab characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\tinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL is a relative path", function () { expect(function () { new TriggerChatTransport({ @@ -3283,6 +3294,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal tab characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\tinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL protocol is not http or https", function () { expect(function () { createTriggerChatTransport({ From 6e8c236232b60ccf8013892fed5dc4ebf654d0ed Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:36:16 +0000 Subject: [PATCH 167/217] Document internal-tab baseURL invalid examples Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 0f97488f31..cb45b2d675 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -672,6 +672,7 @@ Examples: - ❌ `ftp://api.trigger.dev` - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` - ❌ `https://api.trigger.dev/\ninternal` +- ❌ `https://api.trigger.dev/\tinternal` Validation errors use these exact messages: diff --git a/packages/ai/README.md b/packages/ai/README.md index 84670de508..b29387b158 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -179,6 +179,7 @@ Examples: - ❌ `ftp://api.trigger.dev` (non-http protocol) - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) +- ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) Validation errors use these exact messages: From 6363e7e37a4161d4cde7b0fda575574f75dd3bae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:39:04 +0000 Subject: [PATCH 168/217] Cover percent-encoded whitespace baseURL acceptance Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index cb45b2d675..2405def2ea 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -666,6 +666,7 @@ Examples: - ✅ `https://api.trigger.dev` - ✅ `https://api.trigger.dev/custom-prefix` - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) +- ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ❌ `https://api.trigger.dev?foo=bar` - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` diff --git a/packages/ai/README.md b/packages/ai/README.md index b29387b158..a6fdbbf557 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -173,6 +173,7 @@ Examples: - ✅ `https://api.trigger.dev` - ✅ `https://api.trigger.dev/custom-prefix` - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) +- ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ❌ `https://api.trigger.dev?foo=bar` (query string) - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 6380c7cdee..fda664bd6b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -973,6 +973,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts percent-encoded whitespace in baseURL paths", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom%20prefix", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts whitespace-wrapped uppercase http protocol in baseURL", function () { expect(function () { new TriggerChatTransport({ @@ -3525,6 +3536,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts percent-encoded whitespace in baseURL paths from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom%20prefix", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts uppercase http protocol from factory without throwing", function () { expect(function () { createTriggerChatTransport({ From 43cf6c94865752715d7b885198e437bc3b2e49d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:40:50 +0000 Subject: [PATCH 169/217] Cover percent-encoded delimiter paths in baseURL validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 2405def2ea..bdc3221b29 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -667,6 +667,7 @@ Examples: - ✅ `https://api.trigger.dev/custom-prefix` - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) +- ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ❌ `https://api.trigger.dev?foo=bar` - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` diff --git a/packages/ai/README.md b/packages/ai/README.md index a6fdbbf557..f80f8cc5d2 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -174,6 +174,7 @@ Examples: - ✅ `https://api.trigger.dev/custom-prefix` - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) +- ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ❌ `https://api.trigger.dev?foo=bar` (query string) - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index fda664bd6b..c66530e11f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -984,6 +984,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts percent-encoded query and hash markers in baseURL paths", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom%3Fprefix%23segment", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts whitespace-wrapped uppercase http protocol in baseURL", function () { expect(function () { new TriggerChatTransport({ @@ -3547,6 +3558,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts percent-encoded query and hash markers in baseURL paths from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom%3Fprefix%23segment", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts uppercase http protocol from factory without throwing", function () { expect(function () { createTriggerChatTransport({ From c8bdcd935b5c0ff1f60f5260430b49208c3f3988 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:42:07 +0000 Subject: [PATCH 170/217] Cover carriage-return baseURL whitespace validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index bdc3221b29..0f42854d74 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -675,6 +675,7 @@ Examples: - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/\tinternal` +- ❌ `https://api.trigger.dev/\rinternal` Validation errors use these exact messages: diff --git a/packages/ai/README.md b/packages/ai/README.md index f80f8cc5d2..81875cc2fa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -182,6 +182,7 @@ Examples: - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) +- ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) Validation errors use these exact messages: diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index c66530e11f..04491cff58 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -742,6 +742,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal carriage-return characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\rinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL is a relative path", function () { expect(function () { new TriggerChatTransport({ @@ -3327,6 +3338,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal carriage-return characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\rinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL protocol is not http or https", function () { expect(function () { createTriggerChatTransport({ From c82bf64db2b953372d700effd053f90f5ef53c00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:43:24 +0000 Subject: [PATCH 171/217] Hoist baseURL internal-whitespace regex constant Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index b05dfd17b6..fd6b62e1a3 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -457,6 +457,8 @@ const BASE_URL_VALIDATION_ERRORS = { credentials: "baseURL must not include username or password credentials", } as const; +const INTERNAL_WHITESPACE_REGEX = /\s/; + function resolvePayloadMapper< UI_MESSAGE extends UIMessage, PAYLOAD, @@ -492,7 +494,7 @@ function normalizeBaseUrl(baseURL: string) { } function assertBaseUrlHasNoInternalWhitespace(baseUrl: string) { - if (/\s/.test(baseUrl)) { + if (INTERNAL_WHITESPACE_REGEX.test(baseUrl)) { throw new Error(BASE_URL_VALIDATION_ERRORS.containsWhitespace); } } From 666b991926bccf958e90ac0a2cebc28dbd67cda1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:44:54 +0000 Subject: [PATCH 172/217] Cover trimmed wrapper query/hash validation path Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 04491cff58..13ec2f76c1 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -841,6 +841,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws query/hash validation after trimming wrapper whitespace", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://api.trigger.dev/base?query=1 ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws when baseURL includes hash fragments", function () { expect(function () { new TriggerChatTransport({ @@ -3426,6 +3437,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws query/hash validation after trimming wrapper whitespace in factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://api.trigger.dev/base?query=1 ", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws from factory when baseURL includes hash fragments", function () { expect(function () { createTriggerChatTransport({ From 4432ba45d4a52c4ee8d9c5b859c48ab224904271 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:46:18 +0000 Subject: [PATCH 173/217] Cover wrapped percent-encoded delimiter baseURL paths Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 13ec2f76c1..5a8a28f843 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1017,6 +1017,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts wrapper-whitespace around percent-encoded query/hash markers", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://api.trigger.dev/custom%3Fprefix%23segment/// ", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts whitespace-wrapped uppercase http protocol in baseURL", function () { expect(function () { new TriggerChatTransport({ @@ -3613,6 +3624,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts wrapper-whitespace around percent-encoded query/hash markers from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " https://api.trigger.dev/custom%3Fprefix%23segment/// ", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts uppercase http protocol from factory without throwing", function () { expect(function () { createTriggerChatTransport({ From 0d4cc703fd8635fd64cc6c006058eaf9b54e9e14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:50:43 +0000 Subject: [PATCH 174/217] Reject invisible separator characters in baseURL Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 1 + docs/tasks/streams.mdx | 1 + packages/ai/CHANGELOG.md | 2 +- packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 2 +- 6 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index c7b9bc2a22..b71656ebda 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -10,5 +10,6 @@ Add a new `@trigger.dev/ai` package with: - reconnect-aware stream handling on top of Trigger.dev Realtime Streams v2 - strict `baseURL` normalization/validation (trimming, path-safe slash handling, absolute `http(s)` URLs only, no query/hash/credentials) - rejection of internal whitespace characters in normalized `baseURL` values +- rejection of internal invisible separator characters (e.g. zero-width spaces) in normalized `baseURL` values - deterministic baseURL validation error ordering for multi-issue inputs (internal whitespace → protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 0f42854d74..ee5c3ecc96 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -676,6 +676,7 @@ Examples: - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/\tinternal` - ❌ `https://api.trigger.dev/\rinternal` +- ❌ `https://api.trigger.dev/\u200Binternal` Validation errors use these exact messages: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4707559993..e58889b73a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -26,7 +26,7 @@ - Added explicit validation that `baseURL` uses `http` or `https`. - Added explicit validation that `baseURL` excludes query parameters and hash fragments. - Added explicit validation that `baseURL` excludes username/password credentials. -- Added explicit validation that `baseURL` excludes internal whitespace characters. +- Added explicit validation that `baseURL` excludes internal whitespace/invisible separator characters. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (internal whitespace → protocol → query/hash → credentials). diff --git a/packages/ai/README.md b/packages/ai/README.md index 81875cc2fa..40ff531c81 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -183,6 +183,7 @@ Examples: - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) - ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) +- ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) Validation errors use these exact messages: diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 5a8a28f843..04525700f8 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -753,6 +753,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal zero-width-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u200Binternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL is a relative path", function () { expect(function () { new TriggerChatTransport({ @@ -3371,6 +3382,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal zero-width-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u200Binternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL protocol is not http or https", function () { expect(function () { createTriggerChatTransport({ diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index fd6b62e1a3..7c667f991f 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -457,7 +457,7 @@ const BASE_URL_VALIDATION_ERRORS = { credentials: "baseURL must not include username or password credentials", } as const; -const INTERNAL_WHITESPACE_REGEX = /\s/; +const INTERNAL_WHITESPACE_REGEX = /[\s\u200B\u200C\u200D\u2060\uFEFF]/u; function resolvePayloadMapper< UI_MESSAGE extends UIMessage, From dec91eb6b7281efedae4ee37d6a88c2f8580dcb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:53:16 +0000 Subject: [PATCH 175/217] Cover BOM wrapper and internal BOM baseURL handling Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 2 +- docs/tasks/streams.mdx | 2 ++ packages/ai/CHANGELOG.md | 2 +- packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index b71656ebda..3b7471d1c8 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -10,6 +10,6 @@ Add a new `@trigger.dev/ai` package with: - reconnect-aware stream handling on top of Trigger.dev Realtime Streams v2 - strict `baseURL` normalization/validation (trimming, path-safe slash handling, absolute `http(s)` URLs only, no query/hash/credentials) - rejection of internal whitespace characters in normalized `baseURL` values -- rejection of internal invisible separator characters (e.g. zero-width spaces) in normalized `baseURL` values +- rejection of internal invisible separator characters (e.g. zero-width/BOM characters) in normalized `baseURL` values - deterministic baseURL validation error ordering for multi-issue inputs (internal whitespace → protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index ee5c3ecc96..282926ffbb 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -668,6 +668,7 @@ Examples: - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) +- ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `https://api.trigger.dev?foo=bar` - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` @@ -677,6 +678,7 @@ Examples: - ❌ `https://api.trigger.dev/\tinternal` - ❌ `https://api.trigger.dev/\rinternal` - ❌ `https://api.trigger.dev/\u200Binternal` +- ❌ `https://api.trigger.dev/\uFEFFinternal` Validation errors use these exact messages: diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e58889b73a..1a9ccec21c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -26,7 +26,7 @@ - Added explicit validation that `baseURL` uses `http` or `https`. - Added explicit validation that `baseURL` excludes query parameters and hash fragments. - Added explicit validation that `baseURL` excludes username/password credentials. -- Added explicit validation that `baseURL` excludes internal whitespace/invisible separator characters. +- Added explicit validation that `baseURL` excludes internal whitespace/invisible separator characters (including zero-width/BOM characters). - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (internal whitespace → protocol → query/hash → credentials). diff --git a/packages/ai/README.md b/packages/ai/README.md index 40ff531c81..38422d2840 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -175,6 +175,7 @@ Examples: - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) +- ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `https://api.trigger.dev?foo=bar` (query string) - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) @@ -184,6 +185,7 @@ Examples: - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) - ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) +- ❌ `https://api.trigger.dev/\uFEFFinternal` (internal BOM characters) Validation errors use these exact messages: diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 04525700f8..26963f197e 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -764,6 +764,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal BOM characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\uFEFFinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL is a relative path", function () { expect(function () { new TriggerChatTransport({ @@ -1006,6 +1017,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts BOM-wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts percent-encoded whitespace in baseURL paths", function () { expect(function () { new TriggerChatTransport({ @@ -3393,6 +3415,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal BOM characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\uFEFFinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL protocol is not http or https", function () { expect(function () { createTriggerChatTransport({ @@ -3624,6 +3657,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts BOM-wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts percent-encoded whitespace in baseURL paths from factory", function () { expect(function () { createTriggerChatTransport({ From b3bd45c74b8f1059f52d3f8e98afcffc8f24dcb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:54:41 +0000 Subject: [PATCH 176/217] Cover vertical-tab baseURL whitespace validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 282926ffbb..307095d1da 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -676,6 +676,7 @@ Examples: - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/\tinternal` +- ❌ `https://api.trigger.dev/\vinternal` - ❌ `https://api.trigger.dev/\rinternal` - ❌ `https://api.trigger.dev/\u200Binternal` - ❌ `https://api.trigger.dev/\uFEFFinternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 38422d2840..768d470671 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -183,6 +183,7 @@ Examples: - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) +- ❌ `https://api.trigger.dev/\vinternal` (internal vertical-tab characters) - ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) - ❌ `https://api.trigger.dev/\uFEFFinternal` (internal BOM characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 26963f197e..d14e2e23a0 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -742,6 +742,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal vertical-tab characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\vinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal carriage-return characters", function () { expect(function () { new TriggerChatTransport({ @@ -3393,6 +3404,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal vertical-tab characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\vinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal carriage-return characters", function () { expect(function () { createTriggerChatTransport({ From 168cf85ff2060e6c13570516b70d7eb74c5129fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:56:02 +0000 Subject: [PATCH 177/217] Cover form-feed baseURL whitespace validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 307095d1da..25f341f1e8 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -677,6 +677,7 @@ Examples: - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/\tinternal` - ❌ `https://api.trigger.dev/\vinternal` +- ❌ `https://api.trigger.dev/\finternal` - ❌ `https://api.trigger.dev/\rinternal` - ❌ `https://api.trigger.dev/\u200Binternal` - ❌ `https://api.trigger.dev/\uFEFFinternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 768d470671..61e418e94b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -184,6 +184,7 @@ Examples: - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) - ❌ `https://api.trigger.dev/\vinternal` (internal vertical-tab characters) +- ❌ `https://api.trigger.dev/\finternal` (internal form-feed characters) - ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) - ❌ `https://api.trigger.dev/\uFEFFinternal` (internal BOM characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index d14e2e23a0..3d34ac8db3 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -753,6 +753,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal form-feed characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\finternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal carriage-return characters", function () { expect(function () { new TriggerChatTransport({ @@ -3415,6 +3426,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal form-feed characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\finternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal carriage-return characters", function () { expect(function () { createTriggerChatTransport({ From b72e6b6d97783a14b7e3f8a708bf1b07a6999dcf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:57:24 +0000 Subject: [PATCH 178/217] Cover internal-space baseURL validation paths Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 25f341f1e8..57ad8ce6f6 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -675,6 +675,7 @@ Examples: - ❌ `ftp://api.trigger.dev` - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` - ❌ `https://api.trigger.dev/\ninternal` +- ❌ `https://api.trigger.dev/in valid` - ❌ `https://api.trigger.dev/\tinternal` - ❌ `https://api.trigger.dev/\vinternal` - ❌ `https://api.trigger.dev/\finternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 61e418e94b..c643916d5b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -182,6 +182,7 @@ Examples: - ❌ `ftp://api.trigger.dev` (non-http protocol) - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) +- ❌ `https://api.trigger.dev/in valid` (internal space characters) - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) - ❌ `https://api.trigger.dev/\vinternal` (internal vertical-tab characters) - ❌ `https://api.trigger.dev/\finternal` (internal form-feed characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 3d34ac8db3..fee02cec8b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -731,6 +731,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/in valid", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal tab characters", function () { expect(function () { new TriggerChatTransport({ @@ -3404,6 +3415,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/in valid", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal tab characters", function () { expect(function () { createTriggerChatTransport({ From fcec4c15ea2e347f3923ce6915e9ff91a68c112f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:58:31 +0000 Subject: [PATCH 179/217] Document internal-whitespace regex intent Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 7c667f991f..7e96d1e962 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -457,6 +457,8 @@ const BASE_URL_VALIDATION_ERRORS = { credentials: "baseURL must not include username or password credentials", } as const; +// Includes standard whitespace plus common invisible separator/control marks +// that can make URLs look valid while behaving unexpectedly. const INTERNAL_WHITESPACE_REGEX = /[\s\u200B\u200C\u200D\u2060\uFEFF]/u; function resolvePayloadMapper< From 722f9edbadbed2dcd5257f2fd554c4dc6e0c74ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 05:59:33 +0000 Subject: [PATCH 180/217] Clarify invisible separator handling in baseURL docs Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 +- packages/ai/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 57ad8ce6f6..ba83bdf056 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -687,7 +687,7 @@ Validation errors use these exact messages: - `baseURL must not be empty` - `baseURL must be a valid absolute URL` -- `baseURL must not contain internal whitespace characters` +- `baseURL must not contain internal whitespace characters (including invisible separators)` - `baseURL must use http or https protocol` - `baseURL must not include query parameters or hash fragments` - `baseURL must not include username or password credentials` diff --git a/packages/ai/README.md b/packages/ai/README.md index c643916d5b..465c136963 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -194,7 +194,7 @@ Validation errors use these exact messages: - `baseURL must not be empty` - `baseURL must be a valid absolute URL` -- `baseURL must not contain internal whitespace characters` +- `baseURL must not contain internal whitespace characters (including invisible separators)` - `baseURL must use http or https protocol` - `baseURL must not include query parameters or hash fragments` - `baseURL must not include username or password credentials` From e1ada2380e86945d3445920e94c28d37709668a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:01:18 +0000 Subject: [PATCH 181/217] Cover narrow no-break-space baseURL validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index ba83bdf056..417c7a907c 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -681,6 +681,7 @@ Examples: - ❌ `https://api.trigger.dev/\finternal` - ❌ `https://api.trigger.dev/\rinternal` - ❌ `https://api.trigger.dev/\u200Binternal` +- ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\uFEFFinternal` Validation errors use these exact messages: diff --git a/packages/ai/README.md b/packages/ai/README.md index 465c136963..fcde3f5183 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -188,6 +188,7 @@ Examples: - ❌ `https://api.trigger.dev/\finternal` (internal form-feed characters) - ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) +- ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\uFEFFinternal` (internal BOM characters) Validation errors use these exact messages: diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index fee02cec8b..300d952afc 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -775,6 +775,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal narrow no-break space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u202Finternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal carriage-return characters", function () { expect(function () { new TriggerChatTransport({ @@ -3459,6 +3470,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal narrow no-break space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u202Finternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal carriage-return characters", function () { expect(function () { createTriggerChatTransport({ From c8e659959f4546f62575c19ea81e1df5f85d5d33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:03:07 +0000 Subject: [PATCH 182/217] Cover line-separator baseURL whitespace validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 417c7a907c..3ab020daad 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -682,6 +682,7 @@ Examples: - ❌ `https://api.trigger.dev/\rinternal` - ❌ `https://api.trigger.dev/\u200Binternal` - ❌ `https://api.trigger.dev/\u202Finternal` +- ❌ `https://api.trigger.dev/\u2028internal` - ❌ `https://api.trigger.dev/\uFEFFinternal` Validation errors use these exact messages: diff --git a/packages/ai/README.md b/packages/ai/README.md index fcde3f5183..d6f883a158 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -189,6 +189,7 @@ Examples: - ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) +- ❌ `https://api.trigger.dev/\u2028internal` (internal line-separator characters) - ❌ `https://api.trigger.dev/\uFEFFinternal` (internal BOM characters) Validation errors use these exact messages: diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 300d952afc..29b878423a 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -786,6 +786,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal line-separator characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2028internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal carriage-return characters", function () { expect(function () { new TriggerChatTransport({ @@ -3481,6 +3492,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal line-separator characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2028internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal carriage-return characters", function () { expect(function () { createTriggerChatTransport({ From 9bb67add0fad55ae9a54e4ecd1315506472b9735 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:04:51 +0000 Subject: [PATCH 183/217] Cover paragraph-separator baseURL whitespace validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 3ab020daad..4dd6020b5c 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -683,6 +683,7 @@ Examples: - ❌ `https://api.trigger.dev/\u200Binternal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u2028internal` +- ❌ `https://api.trigger.dev/\u2029internal` - ❌ `https://api.trigger.dev/\uFEFFinternal` Validation errors use these exact messages: diff --git a/packages/ai/README.md b/packages/ai/README.md index d6f883a158..d21ad3b7db 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -190,6 +190,7 @@ Examples: - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u2028internal` (internal line-separator characters) +- ❌ `https://api.trigger.dev/\u2029internal` (internal paragraph-separator characters) - ❌ `https://api.trigger.dev/\uFEFFinternal` (internal BOM characters) Validation errors use these exact messages: diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 29b878423a..9f12352c62 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -797,6 +797,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal paragraph-separator characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2029internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal carriage-return characters", function () { expect(function () { new TriggerChatTransport({ @@ -3503,6 +3514,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal paragraph-separator characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2029internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal carriage-return characters", function () { expect(function () { createTriggerChatTransport({ From 99d96a48607696ff23c73da1e00162daabc8289d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:06:15 +0000 Subject: [PATCH 184/217] Cover BOM-wrapped uppercase HTTP baseURL acceptance Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 9f12352c62..a0d410992f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1094,6 +1094,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts BOM-wrapped uppercase HTTP baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\uFEFFHTTP://api.trigger.dev/custom-prefix/\uFEFF", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts percent-encoded whitespace in baseURL paths", function () { expect(function () { new TriggerChatTransport({ @@ -3800,6 +3811,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts BOM-wrapped uppercase HTTP baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\uFEFFHTTP://api.trigger.dev/custom-prefix/\uFEFF", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts percent-encoded whitespace in baseURL paths from factory", function () { expect(function () { createTriggerChatTransport({ From 958ed8b8814243add52c61f563fb3772878f7256 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:08:55 +0000 Subject: [PATCH 185/217] Cover internal non-breaking-space baseURL rejection Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index a0d410992f..a7ae294c42 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -742,6 +742,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal non-breaking-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u00A0internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal tab characters", function () { expect(function () { new TriggerChatTransport({ @@ -3470,6 +3481,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal non-breaking-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u00A0internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal tab characters", function () { expect(function () { createTriggerChatTransport({ From 2c92d07610f15554b38cc40da9414a853c887b3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:10:37 +0000 Subject: [PATCH 186/217] Cover word-joiner baseURL whitespace validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 4dd6020b5c..0772191c83 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -684,6 +684,7 @@ Examples: - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u2028internal` - ❌ `https://api.trigger.dev/\u2029internal` +- ❌ `https://api.trigger.dev/\u2060internal` - ❌ `https://api.trigger.dev/\uFEFFinternal` Validation errors use these exact messages: diff --git a/packages/ai/README.md b/packages/ai/README.md index d21ad3b7db..ad0fea5bdc 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -191,6 +191,7 @@ Examples: - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u2028internal` (internal line-separator characters) - ❌ `https://api.trigger.dev/\u2029internal` (internal paragraph-separator characters) +- ❌ `https://api.trigger.dev/\u2060internal` (internal word-joiner characters) - ❌ `https://api.trigger.dev/\uFEFFinternal` (internal BOM characters) Validation errors use these exact messages: diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index a7ae294c42..973dded71c 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -841,6 +841,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal word-joiner characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2060internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal BOM characters", function () { expect(function () { new TriggerChatTransport({ @@ -3580,6 +3591,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal word-joiner characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2060internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal BOM characters", function () { expect(function () { createTriggerChatTransport({ From 8d074d70df7099e6270e4d21057eb57310144e94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:12:17 +0000 Subject: [PATCH 187/217] Cover zero-width-non-joiner baseURL whitespace validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 0772191c83..3a67db2ed1 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -681,6 +681,7 @@ Examples: - ❌ `https://api.trigger.dev/\finternal` - ❌ `https://api.trigger.dev/\rinternal` - ❌ `https://api.trigger.dev/\u200Binternal` +- ❌ `https://api.trigger.dev/\u200Cinternal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u2028internal` - ❌ `https://api.trigger.dev/\u2029internal` diff --git a/packages/ai/README.md b/packages/ai/README.md index ad0fea5bdc..6455935d56 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -188,6 +188,7 @@ Examples: - ❌ `https://api.trigger.dev/\finternal` (internal form-feed characters) - ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) +- ❌ `https://api.trigger.dev/\u200Cinternal` (internal zero-width-non-joiner characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u2028internal` (internal line-separator characters) - ❌ `https://api.trigger.dev/\u2029internal` (internal paragraph-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 973dded71c..ff5a088190 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -841,6 +841,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal zero-width-non-joiner characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u200Cinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal word-joiner characters", function () { expect(function () { new TriggerChatTransport({ @@ -3591,6 +3602,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal zero-width-non-joiner characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u200Cinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal word-joiner characters", function () { expect(function () { createTriggerChatTransport({ From 2df81ffb822bcbc377b6a630fc09e51b44e41a62 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:13:58 +0000 Subject: [PATCH 188/217] Cover zero-width-joiner baseURL whitespace validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 3a67db2ed1..3b7e7c2e64 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -682,6 +682,7 @@ Examples: - ❌ `https://api.trigger.dev/\rinternal` - ❌ `https://api.trigger.dev/\u200Binternal` - ❌ `https://api.trigger.dev/\u200Cinternal` +- ❌ `https://api.trigger.dev/\u200Dinternal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u2028internal` - ❌ `https://api.trigger.dev/\u2029internal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 6455935d56..0c56d04769 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -189,6 +189,7 @@ Examples: - ❌ `https://api.trigger.dev/\rinternal` (internal carriage-return characters) - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) - ❌ `https://api.trigger.dev/\u200Cinternal` (internal zero-width-non-joiner characters) +- ❌ `https://api.trigger.dev/\u200Dinternal` (internal zero-width-joiner characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u2028internal` (internal line-separator characters) - ❌ `https://api.trigger.dev/\u2029internal` (internal paragraph-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index ff5a088190..1c0144e522 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -852,6 +852,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal zero-width-joiner characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u200Dinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal word-joiner characters", function () { expect(function () { new TriggerChatTransport({ @@ -3613,6 +3624,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal zero-width-joiner characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u200Dinternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal word-joiner characters", function () { expect(function () { createTriggerChatTransport({ From fb71d5a6866ede86ad3db7af4c2e6a0f11855114 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:15:31 +0000 Subject: [PATCH 189/217] Cover word-joiner wrapper baseURL rejection Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 3b7e7c2e64..c7498403c8 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -669,6 +669,7 @@ Examples: - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) +- ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` diff --git a/packages/ai/README.md b/packages/ai/README.md index 0c56d04769..7368bb542d 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -176,6 +176,7 @@ Examples: - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) +- ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` (query string) - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 1c0144e522..3b1dd54bc9 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -874,6 +874,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL is wrapped with word-joiner characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2060https://api.trigger.dev/custom-prefix/\u2060", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal BOM characters", function () { expect(function () { new TriggerChatTransport({ @@ -3646,6 +3657,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL is wrapped with word-joiner characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2060https://api.trigger.dev/custom-prefix/\u2060", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal BOM characters", function () { expect(function () { createTriggerChatTransport({ From 9fa12b045b7fabaf0b6f252b0fcc15f2004eec24 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:17:12 +0000 Subject: [PATCH 190/217] Cover zero-width-space wrapper baseURL rejection Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index c7498403c8..e16f14550d 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -670,6 +670,7 @@ Examples: - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) +- ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` diff --git a/packages/ai/README.md b/packages/ai/README.md index 7368bb542d..e6af6818d7 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -177,6 +177,7 @@ Examples: - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) +- ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` (query string) - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 3b1dd54bc9..e784a22e33 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -841,6 +841,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL is wrapped with zero-width-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u200Bhttps://api.trigger.dev/custom-prefix/\u200B", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal zero-width-non-joiner characters", function () { expect(function () { new TriggerChatTransport({ @@ -3624,6 +3635,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL is wrapped with zero-width-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u200Bhttps://api.trigger.dev/custom-prefix/\u200B", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal zero-width-non-joiner characters", function () { expect(function () { createTriggerChatTransport({ From 140fc20e4978ed46b18fd41a619bfb156d27c116 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:18:46 +0000 Subject: [PATCH 191/217] Cover zero-width-non-joiner wrapper baseURL rejection Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index e16f14550d..7bfb4d3756 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -671,6 +671,7 @@ Examples: - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) - ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) +- ❌ `\u200Chttps://api.trigger.dev/custom-prefix/\u200C` (zero-width-non-joiner wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` diff --git a/packages/ai/README.md b/packages/ai/README.md index e6af6818d7..422bfafc7b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -178,6 +178,7 @@ Examples: - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) - ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) +- ❌ `\u200Chttps://api.trigger.dev/custom-prefix/\u200C` (zero-width-non-joiner wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` (query string) - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e784a22e33..75c00d45fa 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -852,6 +852,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL is wrapped with zero-width-non-joiner characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u200Chttps://api.trigger.dev/custom-prefix/\u200C", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal zero-width-non-joiner characters", function () { expect(function () { new TriggerChatTransport({ @@ -3646,6 +3657,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL is wrapped with zero-width-non-joiner characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u200Chttps://api.trigger.dev/custom-prefix/\u200C", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal zero-width-non-joiner characters", function () { expect(function () { createTriggerChatTransport({ From 9baa514e09592fa13c3e10c7b3579bad72dd518e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:20:17 +0000 Subject: [PATCH 192/217] Cover zero-width-joiner wrapper baseURL rejection Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 7bfb4d3756..5b6f830120 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -672,6 +672,7 @@ Examples: - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) - ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) - ❌ `\u200Chttps://api.trigger.dev/custom-prefix/\u200C` (zero-width-non-joiner wrappers are rejected) +- ❌ `\u200Dhttps://api.trigger.dev/custom-prefix/\u200D` (zero-width-joiner wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` diff --git a/packages/ai/README.md b/packages/ai/README.md index 422bfafc7b..6d715546c7 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -179,6 +179,7 @@ Examples: - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) - ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) - ❌ `\u200Chttps://api.trigger.dev/custom-prefix/\u200C` (zero-width-non-joiner wrappers are rejected) +- ❌ `\u200Dhttps://api.trigger.dev/custom-prefix/\u200D` (zero-width-joiner wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` (query string) - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 75c00d45fa..e2e660b171 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -863,6 +863,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL is wrapped with zero-width-joiner characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u200Dhttps://api.trigger.dev/custom-prefix/\u200D", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal zero-width-non-joiner characters", function () { expect(function () { new TriggerChatTransport({ @@ -3668,6 +3679,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL is wrapped with zero-width-joiner characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u200Dhttps://api.trigger.dev/custom-prefix/\u200D", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal zero-width-non-joiner characters", function () { expect(function () { createTriggerChatTransport({ From abdddacbe84e11cf686d2fd67630ff21462fb647 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:21:59 +0000 Subject: [PATCH 193/217] Add invisible-separator precedence coverage and docs example Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 5b6f830120..63dfaf7af7 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -710,6 +710,7 @@ Examples of ordering: - `ftp://example.com?x=1` → `baseURL must use http or https protocol` - `https://user:pass@example.com?x=1` → `baseURL must not include query parameters or hash fragments` - `ftp://user:pass@example.com/in valid?x=1` → `baseURL must not contain internal whitespace characters` +- `ftp://user:pass@example.com/\u2060invalid?x=1#fragment` → `baseURL must not contain internal whitespace characters` For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/README.md b/packages/ai/README.md index 6d715546c7..9f3374fd0b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -217,6 +217,7 @@ Examples of ordering: - `ftp://example.com?x=1` → `baseURL must use http or https protocol` - `https://user:pass@example.com?x=1` → `baseURL must not include query parameters or hash fragments` - `ftp://user:pass@example.com/in valid?x=1` → `baseURL must not contain internal whitespace characters` +- `ftp://user:pass@example.com/\u2060invalid?x=1#fragment` → `baseURL must not contain internal whitespace characters` ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e2e660b171..5c99471f4f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1094,6 +1094,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("prioritizes invisible-separator validation over protocol/query/hash/credential validation", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://user:pass@example.com/\u2060invalid?query=1#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("prioritizes query/hash validation over credential validation", function () { expect(function () { new TriggerChatTransport({ @@ -3899,6 +3910,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("prioritizes invisible-separator validation over protocol/query/hash/credential validation in factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://user:pass@example.com/\u2060invalid?query=1#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("prioritizes query/hash validation over credential validation in factory", function () { expect(function () { createTriggerChatTransport({ From 1a2f243cfde0c4f918eb16640625c7ee0a159ec0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:22:50 +0000 Subject: [PATCH 194/217] Document rejection of invisible-separator baseURL wrappers Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 1 + packages/ai/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index 3b7471d1c8..4525722d3b 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -11,5 +11,6 @@ Add a new `@trigger.dev/ai` package with: - strict `baseURL` normalization/validation (trimming, path-safe slash handling, absolute `http(s)` URLs only, no query/hash/credentials) - rejection of internal whitespace characters in normalized `baseURL` values - rejection of internal invisible separator characters (e.g. zero-width/BOM characters) in normalized `baseURL` values +- rejection of invisible separator wrappers around otherwise valid `baseURL` values (for example `\u200B...` and `\u2060...`) - deterministic baseURL validation error ordering for multi-issue inputs (internal whitespace → protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1a9ccec21c..1a9b3a8ba3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -27,6 +27,7 @@ - Added explicit validation that `baseURL` excludes query parameters and hash fragments. - Added explicit validation that `baseURL` excludes username/password credentials. - Added explicit validation that `baseURL` excludes internal whitespace/invisible separator characters (including zero-width/BOM characters). +- Clarified that invisible separator characters are rejected even when wrapped around an otherwise valid `baseURL`. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (internal whitespace → protocol → query/hash → credentials). From 2c7f40ccaa66765e3f1b15a180a84bb073c2511b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:25:02 +0000 Subject: [PATCH 195/217] Fix docs to list exact internal-whitespace error message Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 5 ++++- packages/ai/README.md | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 63dfaf7af7..9ebe0cf7cb 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -697,11 +697,14 @@ Validation errors use these exact messages: - `baseURL must not be empty` - `baseURL must be a valid absolute URL` -- `baseURL must not contain internal whitespace characters (including invisible separators)` +- `baseURL must not contain internal whitespace characters` - `baseURL must use http or https protocol` - `baseURL must not include query parameters or hash fragments` - `baseURL must not include username or password credentials` +The internal-whitespace error also applies to invisible separator characters +like `\u200B`, `\u200C`, `\u200D`, `\u2060`, and `\uFEFF`. + When multiple issues are present, validation order is deterministic: internal whitespace → protocol → query/hash → credentials. diff --git a/packages/ai/README.md b/packages/ai/README.md index 9f3374fd0b..e075652f7d 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -204,11 +204,14 @@ Validation errors use these exact messages: - `baseURL must not be empty` - `baseURL must be a valid absolute URL` -- `baseURL must not contain internal whitespace characters (including invisible separators)` +- `baseURL must not contain internal whitespace characters` - `baseURL must use http or https protocol` - `baseURL must not include query parameters or hash fragments` - `baseURL must not include username or password credentials` +The internal-whitespace error also applies to invisible separator characters +like `\u200B`, `\u200C`, `\u200D`, `\u2060`, and `\uFEFF`. + When multiple issues are present, validation order is deterministic: internal whitespace → protocol → query/hash → credentials. From 06322fdd62c831be5025150c959e64f15de0dca4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:28:39 +0000 Subject: [PATCH 196/217] Cover additional unicode whitespace baseURL rejections Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 9ebe0cf7cb..5a89dba81b 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -687,7 +687,9 @@ Examples: - ❌ `https://api.trigger.dev/\u200Binternal` - ❌ `https://api.trigger.dev/\u200Cinternal` - ❌ `https://api.trigger.dev/\u200Dinternal` +- ❌ `https://api.trigger.dev/\u1680internal` - ❌ `https://api.trigger.dev/\u202Finternal` +- ❌ `https://api.trigger.dev/\u3000internal` - ❌ `https://api.trigger.dev/\u2028internal` - ❌ `https://api.trigger.dev/\u2029internal` - ❌ `https://api.trigger.dev/\u2060internal` diff --git a/packages/ai/README.md b/packages/ai/README.md index e075652f7d..7b65a99175 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -194,7 +194,9 @@ Examples: - ❌ `https://api.trigger.dev/\u200Binternal` (internal zero-width-space characters) - ❌ `https://api.trigger.dev/\u200Cinternal` (internal zero-width-non-joiner characters) - ❌ `https://api.trigger.dev/\u200Dinternal` (internal zero-width-joiner characters) +- ❌ `https://api.trigger.dev/\u1680internal` (internal ogham-space-mark characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) +- ❌ `https://api.trigger.dev/\u3000internal` (internal ideographic-space characters) - ❌ `https://api.trigger.dev/\u2028internal` (internal line-separator characters) - ❌ `https://api.trigger.dev/\u2029internal` (internal paragraph-separator characters) - ❌ `https://api.trigger.dev/\u2060internal` (internal word-joiner characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 5c99471f4f..6725178c61 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -797,6 +797,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal ogham-space-mark characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u1680internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + + it("throws when baseURL contains internal ideographic-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u3000internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal line-separator characters", function () { expect(function () { new TriggerChatTransport({ @@ -3624,6 +3646,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal ogham-space-mark characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u1680internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + + it("throws from factory when baseURL contains internal ideographic-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u3000internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal line-separator characters", function () { expect(function () { createTriggerChatTransport({ From f96b54598c742e9199aa88bd2c75dd45ad2076a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:31:44 +0000 Subject: [PATCH 197/217] Cover unicode-trimmable baseURL wrappers in acceptance tests Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 3 ++ packages/ai/README.md | 3 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 5a89dba81b..4e1ff07c2b 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -668,6 +668,9 @@ Examples: - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) +- ✅ `\u00A0https://api.trigger.dev/custom-prefix/\u00A0` (non-breaking-space wrapper trimmed) +- ✅ `\u1680https://api.trigger.dev/custom-prefix/\u1680` (ogham-space-mark wrapper trimmed) +- ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) - ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) diff --git a/packages/ai/README.md b/packages/ai/README.md index 7b65a99175..c9082bc0ab 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -175,6 +175,9 @@ Examples: - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) +- ✅ `\u00A0https://api.trigger.dev/custom-prefix/\u00A0` (non-breaking-space wrapper trimmed) +- ✅ `\u1680https://api.trigger.dev/custom-prefix/\u1680` (ogham-space-mark wrapper trimmed) +- ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) - ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 6725178c61..b9a6141134 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1204,6 +1204,28 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts ogham-space-mark wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u1680https://api.trigger.dev/custom-prefix/\u1680", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + + it("accepts ideographic-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u3000https://api.trigger.dev/custom-prefix/\u3000", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts BOM-wrapped baseURL values", function () { expect(function () { new TriggerChatTransport({ @@ -4042,6 +4064,28 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts ogham-space-mark wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u1680https://api.trigger.dev/custom-prefix/\u1680", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + + it("accepts ideographic-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u3000https://api.trigger.dev/custom-prefix/\u3000", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts BOM-wrapped baseURL values from factory", function () { expect(function () { createTriggerChatTransport({ From 195f5f37eeda6e345b5f60b1db56d3dc0fc49000 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:34:26 +0000 Subject: [PATCH 198/217] Cover newline-tab wrapped baseURL acceptance Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 4e1ff07c2b..ccda8c277e 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -666,6 +666,7 @@ Examples: - ✅ `https://api.trigger.dev` - ✅ `https://api.trigger.dev/custom-prefix` - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) +- ✅ `\n\thttps://api.trigger.dev/custom-prefix/\t\n` (newline/tab wrappers trimmed) - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ✅ `\u00A0https://api.trigger.dev/custom-prefix/\u00A0` (non-breaking-space wrapper trimmed) diff --git a/packages/ai/README.md b/packages/ai/README.md index c9082bc0ab..beee87ee2e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -173,6 +173,7 @@ Examples: - ✅ `https://api.trigger.dev` - ✅ `https://api.trigger.dev/custom-prefix` - ✅ ` https://api.trigger.dev/custom-prefix/// ` (trimmed + normalized) +- ✅ `\n\thttps://api.trigger.dev/custom-prefix/\t\n` (newline/tab wrappers trimmed) - ✅ `https://api.trigger.dev/custom%20prefix` (percent-encoded whitespace) - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ✅ `\u00A0https://api.trigger.dev/custom-prefix/\u00A0` (non-breaking-space wrapper trimmed) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index b9a6141134..591352bce0 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1193,6 +1193,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts newline-and-tab wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\thttps://api.trigger.dev/custom-prefix/\t\n", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts non-breaking-space wrapped baseURL values", function () { expect(function () { new TriggerChatTransport({ @@ -4053,6 +4064,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts newline-and-tab wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\thttps://api.trigger.dev/custom-prefix/\t\n", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts non-breaking-space wrapped baseURL values from factory", function () { expect(function () { createTriggerChatTransport({ From bed3c81a173073590e7a8ef2b41088b8e02c50d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:37:01 +0000 Subject: [PATCH 199/217] Cover empty-after-trim unicode wrapper baseURL validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index ccda8c277e..4653c3ebfc 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -682,6 +682,8 @@ Examples: - ❌ `https://user:pass@api.trigger.dev` - ❌ `ftp://api.trigger.dev` - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` +- ❌ `\u1680///\u1680` (empty after trimming wrapper whitespace) +- ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/in valid` - ❌ `https://api.trigger.dev/\tinternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index beee87ee2e..526964b94b 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -189,6 +189,8 @@ Examples: - ❌ `https://user:pass@api.trigger.dev` (credentials) - ❌ `ftp://api.trigger.dev` (non-http protocol) - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) +- ❌ `\u1680///\u1680` (empty after trimming wrapper whitespace) +- ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/in valid` (internal space characters) - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 591352bce0..7eb16683fd 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -699,6 +699,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws when baseURL is empty after trimming ogham-space-mark wrappers", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u1680///\u1680", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + + it("throws when baseURL is empty after trimming ideographic-space wrappers", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u3000///\u3000", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + it("uses default baseURL when omitted", function () { expect(function () { new TriggerChatTransport({ @@ -3581,6 +3603,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws from factory when baseURL is empty after trimming ogham-space-mark wrappers", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u1680///\u1680", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + + it("throws from factory when baseURL is empty after trimming ideographic-space wrappers", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u3000///\u3000", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + it("uses default baseURL in factory when omitted", function () { expect(function () { createTriggerChatTransport({ From 25f4cdeeda81e9b8c85e37e75d4cd96ba4507a93 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:37:56 +0000 Subject: [PATCH 200/217] Document unicode wrapper trimming and empty-after-trim behavior Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index 4525722d3b..4832ad34d9 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -12,5 +12,7 @@ Add a new `@trigger.dev/ai` package with: - rejection of internal whitespace characters in normalized `baseURL` values - rejection of internal invisible separator characters (e.g. zero-width/BOM characters) in normalized `baseURL` values - rejection of invisible separator wrappers around otherwise valid `baseURL` values (for example `\u200B...` and `\u2060...`) +- support for trimming additional unicode wrapper whitespace (`\u1680`, `\u3000`) while still rejecting + values that normalize to empty after trimming - deterministic baseURL validation error ordering for multi-issue inputs (internal whitespace → protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1a9b3a8ba3..2b9044b21a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -28,6 +28,8 @@ - Added explicit validation that `baseURL` excludes username/password credentials. - Added explicit validation that `baseURL` excludes internal whitespace/invisible separator characters (including zero-width/BOM characters). - Clarified that invisible separator characters are rejected even when wrapped around an otherwise valid `baseURL`. +- Added explicit test/docs coverage for additional unicode-trimmable wrappers (`\u1680`, `\u3000`) and + confirmed empty-after-trim values still throw `baseURL must not be empty`. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (internal whitespace → protocol → query/hash → credentials). From 7924efb0557f0f16a52044040c7fd98b9417bdff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:40:36 +0000 Subject: [PATCH 201/217] Cover additional unicode spacing baseURL rejections Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 4653c3ebfc..1c770415e1 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -694,7 +694,9 @@ Examples: - ❌ `https://api.trigger.dev/\u200Cinternal` - ❌ `https://api.trigger.dev/\u200Dinternal` - ❌ `https://api.trigger.dev/\u1680internal` +- ❌ `https://api.trigger.dev/\u2007internal` - ❌ `https://api.trigger.dev/\u202Finternal` +- ❌ `https://api.trigger.dev/\u205Finternal` - ❌ `https://api.trigger.dev/\u3000internal` - ❌ `https://api.trigger.dev/\u2028internal` - ❌ `https://api.trigger.dev/\u2029internal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 526964b94b..dccda8d804 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -201,7 +201,9 @@ Examples: - ❌ `https://api.trigger.dev/\u200Cinternal` (internal zero-width-non-joiner characters) - ❌ `https://api.trigger.dev/\u200Dinternal` (internal zero-width-joiner characters) - ❌ `https://api.trigger.dev/\u1680internal` (internal ogham-space-mark characters) +- ❌ `https://api.trigger.dev/\u2007internal` (internal figure-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) +- ❌ `https://api.trigger.dev/\u205Finternal` (internal medium-mathematical-space characters) - ❌ `https://api.trigger.dev/\u3000internal` (internal ideographic-space characters) - ❌ `https://api.trigger.dev/\u2028internal` (internal line-separator characters) - ❌ `https://api.trigger.dev/\u2029internal` (internal paragraph-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 7eb16683fd..ffcb6f33e4 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -841,6 +841,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal figure-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2007internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + + it("throws when baseURL contains internal medium-mathematical-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u205Finternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal line-separator characters", function () { expect(function () { new TriggerChatTransport({ @@ -3745,6 +3767,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal figure-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2007internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + + it("throws from factory when baseURL contains internal medium-mathematical-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u205Finternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal line-separator characters", function () { expect(function () { createTriggerChatTransport({ From ae87637c93dce4b88338763d011f9002facb6e72 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:43:24 +0000 Subject: [PATCH 202/217] Cover additional unicode wrapper trimming acceptance Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 1c770415e1..d39565ca18 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -671,6 +671,8 @@ Examples: - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ✅ `\u00A0https://api.trigger.dev/custom-prefix/\u00A0` (non-breaking-space wrapper trimmed) - ✅ `\u1680https://api.trigger.dev/custom-prefix/\u1680` (ogham-space-mark wrapper trimmed) +- ✅ `\u2007https://api.trigger.dev/custom-prefix/\u2007` (figure-space wrapper trimmed) +- ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) diff --git a/packages/ai/README.md b/packages/ai/README.md index dccda8d804..3f5de2e64f 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -178,6 +178,8 @@ Examples: - ✅ `https://api.trigger.dev/custom%3Fprefix%23segment` (percent-encoded `?` / `#`) - ✅ `\u00A0https://api.trigger.dev/custom-prefix/\u00A0` (non-breaking-space wrapper trimmed) - ✅ `\u1680https://api.trigger.dev/custom-prefix/\u1680` (ogham-space-mark wrapper trimmed) +- ✅ `\u2007https://api.trigger.dev/custom-prefix/\u2007` (figure-space wrapper trimmed) +- ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) - ❌ `\u2060https://api.trigger.dev/custom-prefix/\u2060` (word-joiner wrappers are rejected) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index ffcb6f33e4..b3ac7df82b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1270,6 +1270,28 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts figure-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2007https://api.trigger.dev/custom-prefix/\u2007", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + + it("accepts medium-mathematical-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u205Fhttps://api.trigger.dev/custom-prefix/\u205F", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values", function () { expect(function () { new TriggerChatTransport({ @@ -4185,6 +4207,28 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts figure-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2007https://api.trigger.dev/custom-prefix/\u2007", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + + it("accepts medium-mathematical-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u205Fhttps://api.trigger.dev/custom-prefix/\u205F", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values from factory", function () { expect(function () { createTriggerChatTransport({ From a9fd2930f42f912f9c9f26b1bd2a33db7b052e98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:46:11 +0000 Subject: [PATCH 203/217] Cover additional empty-after-trim unicode wrapper baseURLs Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index d39565ca18..edf92aa957 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -685,6 +685,8 @@ Examples: - ❌ `ftp://api.trigger.dev` - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` - ❌ `\u1680///\u1680` (empty after trimming wrapper whitespace) +- ❌ `\u2007///\u2007` (empty after trimming wrapper whitespace) +- ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/in valid` diff --git a/packages/ai/README.md b/packages/ai/README.md index 3f5de2e64f..2d0c726e28 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -192,6 +192,8 @@ Examples: - ❌ `ftp://api.trigger.dev` (non-http protocol) - ❌ `ws://api.trigger.dev` / `wss://api.trigger.dev` (websocket protocols are rejected) - ❌ `\u1680///\u1680` (empty after trimming wrapper whitespace) +- ❌ `\u2007///\u2007` (empty after trimming wrapper whitespace) +- ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/in valid` (internal space characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index b3ac7df82b..7ff8adbb06 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -721,6 +721,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws when baseURL is empty after trimming figure-space wrappers", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2007///\u2007", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + + it("throws when baseURL is empty after trimming medium-mathematical-space wrappers", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u205F///\u205F", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + it("uses default baseURL when omitted", function () { expect(function () { new TriggerChatTransport({ @@ -3669,6 +3691,28 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws from factory when baseURL is empty after trimming figure-space wrappers", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2007///\u2007", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + + it("throws from factory when baseURL is empty after trimming medium-mathematical-space wrappers", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u205F///\u205F", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not be empty"); + }); + it("uses default baseURL in factory when omitted", function () { expect(function () { createTriggerChatTransport({ From cc0b1d6dd009b0786575015ff67beb33736a52ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:48:51 +0000 Subject: [PATCH 204/217] Document expanded unicode whitespace coverage in release notes Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index 4832ad34d9..c578c8550b 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -14,5 +14,7 @@ Add a new `@trigger.dev/ai` package with: - rejection of invisible separator wrappers around otherwise valid `baseURL` values (for example `\u200B...` and `\u2060...`) - support for trimming additional unicode wrapper whitespace (`\u1680`, `\u3000`) while still rejecting values that normalize to empty after trimming +- expanded unicode whitespace handling coverage to include figure space (`\u2007`) and medium + mathematical space (`\u205F`) for both wrapper trimming and internal-whitespace rejection - deterministic baseURL validation error ordering for multi-issue inputs (internal whitespace → protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2b9044b21a..deade890e3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -30,6 +30,8 @@ - Clarified that invisible separator characters are rejected even when wrapped around an otherwise valid `baseURL`. - Added explicit test/docs coverage for additional unicode-trimmable wrappers (`\u1680`, `\u3000`) and confirmed empty-after-trim values still throw `baseURL must not be empty`. +- Expanded unicode whitespace coverage with `\u2007` (figure space) and `\u205F` (medium mathematical space) + across internal-whitespace rejection, wrapper trimming acceptance, and empty-after-trim validation. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (internal whitespace → protocol → query/hash → credentials). From 3e7fe1fb29395e002b16f2273e84c11499200ecc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:52:10 +0000 Subject: [PATCH 205/217] Cover newline-tab wrapped invalid baseURL validation paths Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 66 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index edf92aa957..8be0c9786a 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -688,6 +688,7 @@ Examples: - ❌ `\u2007///\u2007` (empty after trimming wrapper whitespace) - ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) +- ❌ `\n\thttps://api.trigger.dev/base/?query=1\t\n` (query is still rejected after trimming wrappers) - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/in valid` - ❌ `https://api.trigger.dev/\tinternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 2d0c726e28..3686bf8aa6 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -195,6 +195,7 @@ Examples: - ❌ `\u2007///\u2007` (empty after trimming wrapper whitespace) - ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) +- ❌ `\n\thttps://api.trigger.dev/base/?query=1\t\n` (query is still rejected after trimming wrappers) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/in valid` (internal space characters) - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 7ff8adbb06..2978d43ca7 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1105,6 +1105,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws when newline-and-tab wrapped baseURL includes query parameters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\thttps://example.com/base/?query=1\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws query/hash validation after trimming wrapper whitespace", function () { expect(function () { new TriggerChatTransport({ @@ -1138,6 +1149,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws when newline-and-tab wrapped baseURL includes hash fragments", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\thttps://example.com/base/#fragment\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws when baseURL includes username or password credentials", function () { expect(function () { new TriggerChatTransport({ @@ -1226,6 +1248,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include username or password credentials"); }); + it("throws when newline-and-tab wrapped baseURL includes username or password credentials", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\thttps://user:pass@example.com/base/\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include username or password credentials"); + }); + it("accepts https baseURL values without throwing", function () { expect(function () { new TriggerChatTransport({ @@ -4064,6 +4097,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws from factory when newline-and-tab wrapped baseURL includes query parameters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\thttps://example.com/base/?query=1\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws query/hash validation after trimming wrapper whitespace in factory", function () { expect(function () { createTriggerChatTransport({ @@ -4097,6 +4141,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include query parameters or hash fragments"); }); + it("throws from factory when newline-and-tab wrapped baseURL includes hash fragments", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\thttps://example.com/base/#fragment\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include query parameters or hash fragments"); + }); + it("throws from factory when baseURL includes username or password credentials", function () { expect(function () { createTriggerChatTransport({ @@ -4185,6 +4240,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not include username or password credentials"); }); + it("throws from factory when newline-and-tab wrapped baseURL includes username or password credentials", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\thttps://user:pass@example.com/base/\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not include username or password credentials"); + }); + it("accepts https baseURL values from factory without throwing", function () { expect(function () { createTriggerChatTransport({ From bcd5a3aac7aa7c1017cc30dbfd21a854902e2d65 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:55:05 +0000 Subject: [PATCH 206/217] Cover newline-tab wrapped websocket protocol validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 8be0c9786a..c99f14085d 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -689,6 +689,7 @@ Examples: - ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `\n\thttps://api.trigger.dev/base/?query=1\t\n` (query is still rejected after trimming wrappers) +- ❌ `\n\tws://api.trigger.dev\t\n` / `\n\twss://api.trigger.dev\t\n` (trimmed wrappers still reject websocket protocols) - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/in valid` - ❌ `https://api.trigger.dev/\tinternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 3686bf8aa6..fc8aeec103 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -196,6 +196,7 @@ Examples: - ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `\n\thttps://api.trigger.dev/base/?query=1\t\n` (query is still rejected after trimming wrappers) +- ❌ `\n\tws://api.trigger.dev\t\n` / `\n\twss://api.trigger.dev\t\n` (trimmed wrappers still reject websocket protocols) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/in valid` (internal space characters) - ❌ `https://api.trigger.dev/\tinternal` (internal tab characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 2978d43ca7..79035fca4a 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1061,6 +1061,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws when newline-and-tab wrapped baseURL protocol is ws", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\tws://example.com\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws when baseURL protocol is wss", function () { expect(function () { new TriggerChatTransport({ @@ -1083,6 +1094,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws when newline-and-tab wrapped baseURL protocol is wss", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\twss://example.com\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws when baseURL includes query parameters", function () { expect(function () { new TriggerChatTransport({ @@ -4053,6 +4075,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws from factory when newline-and-tab wrapped baseURL protocol is ws", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\tws://example.com\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws from factory when baseURL protocol is wss", function () { expect(function () { createTriggerChatTransport({ @@ -4075,6 +4108,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must use http or https protocol"); }); + it("throws from factory when newline-and-tab wrapped baseURL protocol is wss", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\n\twss://example.com\t\n", + stream: "chat-stream", + }); + }).toThrowError("baseURL must use http or https protocol"); + }); + it("throws from factory when baseURL includes query parameters", function () { expect(function () { createTriggerChatTransport({ From 17a19752b20db88dfaf8a4c5c50be146eedcbb44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:57:04 +0000 Subject: [PATCH 207/217] Document trimmed-wrapper hash and credential baseURL rejections Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index c99f14085d..791ee48b2f 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -689,6 +689,8 @@ Examples: - ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `\n\thttps://api.trigger.dev/base/?query=1\t\n` (query is still rejected after trimming wrappers) +- ❌ `\n\thttps://api.trigger.dev/base/#fragment\t\n` (hash is still rejected after trimming wrappers) +- ❌ `\n\thttps://user:pass@api.trigger.dev/base/\t\n` (credentials are still rejected after trimming wrappers) - ❌ `\n\tws://api.trigger.dev\t\n` / `\n\twss://api.trigger.dev\t\n` (trimmed wrappers still reject websocket protocols) - ❌ `https://api.trigger.dev/\ninternal` - ❌ `https://api.trigger.dev/in valid` diff --git a/packages/ai/README.md b/packages/ai/README.md index fc8aeec103..b0aba5a092 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -196,6 +196,8 @@ Examples: - ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `\n\thttps://api.trigger.dev/base/?query=1\t\n` (query is still rejected after trimming wrappers) +- ❌ `\n\thttps://api.trigger.dev/base/#fragment\t\n` (hash is still rejected after trimming wrappers) +- ❌ `\n\thttps://user:pass@api.trigger.dev/base/\t\n` (credentials are still rejected after trimming wrappers) - ❌ `\n\tws://api.trigger.dev\t\n` / `\n\twss://api.trigger.dev\t\n` (trimmed wrappers still reject websocket protocols) - ❌ `https://api.trigger.dev/\ninternal` (internal whitespace characters) - ❌ `https://api.trigger.dev/in valid` (internal space characters) From d1e9220f71c5c9e1515685d4726da226a198709d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 06:59:56 +0000 Subject: [PATCH 208/217] Cover unicode-wrapped baseURL path-prefix runtime normalization Co-authored-by: Eric Allam --- packages/ai/src/chatTransport.test.ts | 133 ++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 79035fca4a..209a33f7c9 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -677,6 +677,71 @@ describe("TriggerChatTransport", function () { expect(observedStreamPath).toBe("/trimmed-prefix/realtime/v1/streams/run_trimmed_prefix/chat-stream"); }); + it("preserves baseURL path prefixes after trimming unicode wrapper whitespace", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/unicode-prefix/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_unicode_prefix", + }); + res.end(JSON.stringify({ id: "run_unicode_prefix" })); + return; + } + + if (req.method === "GET" && req.url === "/unicode-prefix/realtime/v1/streams/run_unicode_prefix/chat-stream") { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "unicode_prefix_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "unicode_prefix_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: `\u3000${server.url}/unicode-prefix///\u3000`, + stream: "chat-stream", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-unicode-prefix-baseurl", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/unicode-prefix/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe("/unicode-prefix/realtime/v1/streams/run_unicode_prefix/chat-stream"); + }); + it("throws when baseURL is empty after trimming", function () { expect(function () { new TriggerChatTransport({ @@ -3702,6 +3767,74 @@ describe("TriggerChatTransport", function () { }); }); + it("supports creating transport with factory function and unicode-wrapped baseURL path prefixes", async function () { + let observedTriggerPath: string | undefined; + let observedStreamPath: string | undefined; + + const server = await startServer(function (req, res) { + if (req.method === "POST") { + observedTriggerPath = req.url ?? ""; + } + + if (req.method === "GET") { + observedStreamPath = req.url ?? ""; + } + + if (req.method === "POST" && req.url === "/factory-unicode-prefix/api/v1/tasks/chat-task/trigger") { + res.writeHead(200, { + "content-type": "application/json", + "x-trigger-jwt": "pk_run_factory_unicode_prefix", + }); + res.end(JSON.stringify({ id: "run_factory_unicode_prefix" })); + return; + } + + if ( + req.method === "GET" && + req.url === "/factory-unicode-prefix/realtime/v1/streams/run_factory_unicode_prefix/chat-stream" + ) { + res.writeHead(200, { + "content-type": "text/event-stream", + }); + writeSSE( + res, + "1-0", + JSON.stringify({ type: "text-start", id: "factory_unicode_prefix_1" }) + ); + writeSSE( + res, + "2-0", + JSON.stringify({ type: "text-end", id: "factory_unicode_prefix_1" }) + ); + res.end(); + return; + } + + res.writeHead(404); + res.end(); + }); + + const transport = createTriggerChatTransport({ + task: "chat-task", + stream: "chat-stream", + accessToken: "pk_trigger", + baseURL: `\u3000${server.url}/factory-unicode-prefix///\u3000`, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-factory-unicode-prefix", + messageId: undefined, + messages: [], + abortSignal: undefined, + }); + + const chunks = await readChunks(stream); + expect(chunks).toHaveLength(2); + expect(observedTriggerPath).toBe("/factory-unicode-prefix/api/v1/tasks/chat-task/trigger"); + expect(observedStreamPath).toBe("/factory-unicode-prefix/realtime/v1/streams/run_factory_unicode_prefix/chat-stream"); + }); + it("throws from factory when baseURL is empty after trimming", function () { expect(function () { createTriggerChatTransport({ From 28becff37725877687d135edcd798454cbb68353 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:05:29 +0000 Subject: [PATCH 209/217] Reject mongolian-vowel-separator baseURL invisible whitespace Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 2 ++ docs/tasks/streams.mdx | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ packages/ai/src/chatTransport.ts | 2 +- 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index c578c8550b..30833180eb 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -16,5 +16,7 @@ Add a new `@trigger.dev/ai` package with: values that normalize to empty after trimming - expanded unicode whitespace handling coverage to include figure space (`\u2007`) and medium mathematical space (`\u205F`) for both wrapper trimming and internal-whitespace rejection +- expanded invisible-separator rejection coverage to include mongolian vowel separator (`\u180E`) + in both wrapper and internal `baseURL` positions - deterministic baseURL validation error ordering for multi-issue inputs (internal whitespace → protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 791ee48b2f..59cd34ad11 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -679,6 +679,7 @@ Examples: - ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) - ❌ `\u200Chttps://api.trigger.dev/custom-prefix/\u200C` (zero-width-non-joiner wrappers are rejected) - ❌ `\u200Dhttps://api.trigger.dev/custom-prefix/\u200D` (zero-width-joiner wrappers are rejected) +- ❌ `\u180Ehttps://api.trigger.dev/custom-prefix/\u180E` (mongolian-vowel-separator wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` - ❌ `https://api.trigger.dev#fragment` - ❌ `https://user:pass@api.trigger.dev` @@ -705,6 +706,7 @@ Examples: - ❌ `https://api.trigger.dev/\u2007internal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u205Finternal` +- ❌ `https://api.trigger.dev/\u180Einternal` - ❌ `https://api.trigger.dev/\u3000internal` - ❌ `https://api.trigger.dev/\u2028internal` - ❌ `https://api.trigger.dev/\u2029internal` diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index deade890e3..0a05e3529b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -32,6 +32,8 @@ confirmed empty-after-trim values still throw `baseURL must not be empty`. - Expanded unicode whitespace coverage with `\u2007` (figure space) and `\u205F` (medium mathematical space) across internal-whitespace rejection, wrapper trimming acceptance, and empty-after-trim validation. +- Expanded invisible-separator coverage to reject `\u180E` (mongolian vowel separator) in both + internal and wrapper `baseURL` positions. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (internal whitespace → protocol → query/hash → credentials). diff --git a/packages/ai/README.md b/packages/ai/README.md index b0aba5a092..d9648515ec 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -186,6 +186,7 @@ Examples: - ❌ `\u200Bhttps://api.trigger.dev/custom-prefix/\u200B` (zero-width-space wrappers are rejected) - ❌ `\u200Chttps://api.trigger.dev/custom-prefix/\u200C` (zero-width-non-joiner wrappers are rejected) - ❌ `\u200Dhttps://api.trigger.dev/custom-prefix/\u200D` (zero-width-joiner wrappers are rejected) +- ❌ `\u180Ehttps://api.trigger.dev/custom-prefix/\u180E` (mongolian-vowel-separator wrappers are rejected) - ❌ `https://api.trigger.dev?foo=bar` (query string) - ❌ `https://api.trigger.dev#fragment` (hash fragment) - ❌ `https://user:pass@api.trigger.dev` (credentials) @@ -212,6 +213,7 @@ Examples: - ❌ `https://api.trigger.dev/\u2007internal` (internal figure-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u205Finternal` (internal medium-mathematical-space characters) +- ❌ `https://api.trigger.dev/\u180Einternal` (internal mongolian-vowel-separator characters) - ❌ `https://api.trigger.dev/\u3000internal` (internal ideographic-space characters) - ❌ `https://api.trigger.dev/\u2028internal` (internal line-separator characters) - ❌ `https://api.trigger.dev/\u2029internal` (internal paragraph-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 209a33f7c9..19d7eb8362 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -950,6 +950,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal mongolian-vowel-separator characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u180Einternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal line-separator characters", function () { expect(function () { new TriggerChatTransport({ @@ -1027,6 +1038,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL is wrapped with mongolian-vowel-separator characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u180Ehttps://api.trigger.dev/custom-prefix/\u180E", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal zero-width-non-joiner characters", function () { expect(function () { new TriggerChatTransport({ @@ -4043,6 +4065,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal mongolian-vowel-separator characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u180Einternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal line-separator characters", function () { expect(function () { createTriggerChatTransport({ @@ -4120,6 +4153,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL is wrapped with mongolian-vowel-separator characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u180Ehttps://api.trigger.dev/custom-prefix/\u180E", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal zero-width-non-joiner characters", function () { expect(function () { createTriggerChatTransport({ diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts index 7e96d1e962..98922e4e9f 100644 --- a/packages/ai/src/chatTransport.ts +++ b/packages/ai/src/chatTransport.ts @@ -459,7 +459,7 @@ const BASE_URL_VALIDATION_ERRORS = { // Includes standard whitespace plus common invisible separator/control marks // that can make URLs look valid while behaving unexpectedly. -const INTERNAL_WHITESPACE_REGEX = /[\s\u200B\u200C\u200D\u2060\uFEFF]/u; +const INTERNAL_WHITESPACE_REGEX = /[\s\u180E\u200B\u200C\u200D\u2060\uFEFF]/u; function resolvePayloadMapper< UI_MESSAGE extends UIMessage, From 842d83b2a0ad983b9b56df57151134c4a8f08525 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:08:48 +0000 Subject: [PATCH 210/217] Cover mongolian separator precedence in mixed-invalid baseURLs Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 59cd34ad11..448ba9fe17 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -734,6 +734,7 @@ Examples of ordering: - `https://user:pass@example.com?x=1` → `baseURL must not include query parameters or hash fragments` - `ftp://user:pass@example.com/in valid?x=1` → `baseURL must not contain internal whitespace characters` - `ftp://user:pass@example.com/\u2060invalid?x=1#fragment` → `baseURL must not contain internal whitespace characters` +- `ftp://user:pass@example.com/\u180Einvalid?x=1#fragment` → `baseURL must not contain internal whitespace characters` For richer TypeScript ergonomics in app code, `@trigger.dev/ai` also exports: diff --git a/packages/ai/README.md b/packages/ai/README.md index d9648515ec..ca15a8cd0d 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -241,6 +241,7 @@ Examples of ordering: - `https://user:pass@example.com?x=1` → `baseURL must not include query parameters or hash fragments` - `ftp://user:pass@example.com/in valid?x=1` → `baseURL must not contain internal whitespace characters` - `ftp://user:pass@example.com/\u2060invalid?x=1#fragment` → `baseURL must not contain internal whitespace characters` +- `ftp://user:pass@example.com/\u180Einvalid?x=1#fragment` → `baseURL must not contain internal whitespace characters` ## `ai.tool(...)` example diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 19d7eb8362..f823441f9b 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1324,6 +1324,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("prioritizes mongolian-vowel-separator validation over protocol/query/hash/credential validation", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://user:pass@example.com/\u180Einvalid?query=1#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("prioritizes query/hash validation over credential validation", function () { expect(function () { new TriggerChatTransport({ @@ -4428,6 +4439,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("prioritizes mongolian-vowel-separator validation over protocol/query/hash/credential validation in factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://user:pass@example.com/\u180Einvalid?query=1#fragment", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("prioritizes query/hash validation over credential validation in factory", function () { expect(function () { createTriggerChatTransport({ From 79a2d39a5cda6ee98d01f5d544f33f8ee6d231fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:11:30 +0000 Subject: [PATCH 211/217] Cover mongolian separator slash-wrapper validation behavior Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 1 + packages/ai/README.md | 1 + packages/ai/src/chatTransport.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 448ba9fe17..302987d687 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -688,6 +688,7 @@ Examples: - ❌ `\u1680///\u1680` (empty after trimming wrapper whitespace) - ❌ `\u2007///\u2007` (empty after trimming wrapper whitespace) - ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) +- ❌ `\u180E///\u180E` (rejected as internal invisible-separator whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `\n\thttps://api.trigger.dev/base/?query=1\t\n` (query is still rejected after trimming wrappers) - ❌ `\n\thttps://api.trigger.dev/base/#fragment\t\n` (hash is still rejected after trimming wrappers) diff --git a/packages/ai/README.md b/packages/ai/README.md index ca15a8cd0d..4c15758eaa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -195,6 +195,7 @@ Examples: - ❌ `\u1680///\u1680` (empty after trimming wrapper whitespace) - ❌ `\u2007///\u2007` (empty after trimming wrapper whitespace) - ❌ `\u205F///\u205F` (empty after trimming wrapper whitespace) +- ❌ `\u180E///\u180E` (rejected as internal invisible-separator whitespace) - ❌ `\u3000///\u3000` (empty after trimming wrapper whitespace) - ❌ `\n\thttps://api.trigger.dev/base/?query=1\t\n` (query is still rejected after trimming wrappers) - ❌ `\n\thttps://api.trigger.dev/base/#fragment\t\n` (hash is still rejected after trimming wrappers) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index f823441f9b..e488a48f3f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -808,6 +808,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws internal-whitespace validation for mongolian-vowel-separator wrapper slashes", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u180E///\u180E", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("uses default baseURL when omitted", function () { expect(function () { new TriggerChatTransport({ @@ -3934,6 +3945,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not be empty"); }); + it("throws internal-whitespace validation from factory for mongolian-vowel-separator wrapper slashes", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u180E///\u180E", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("uses default baseURL in factory when omitted", function () { expect(function () { createTriggerChatTransport({ From 7caa78f690de07d0ee7bb595e6e2a9dbb9d9bb95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:14:34 +0000 Subject: [PATCH 212/217] Cover hair-space baseURL wrapper and internal validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 302987d687..1bbdc15c9e 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -672,6 +672,7 @@ Examples: - ✅ `\u00A0https://api.trigger.dev/custom-prefix/\u00A0` (non-breaking-space wrapper trimmed) - ✅ `\u1680https://api.trigger.dev/custom-prefix/\u1680` (ogham-space-mark wrapper trimmed) - ✅ `\u2007https://api.trigger.dev/custom-prefix/\u2007` (figure-space wrapper trimmed) +- ✅ `\u200Ahttps://api.trigger.dev/custom-prefix/\u200A` (hair-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -705,6 +706,7 @@ Examples: - ❌ `https://api.trigger.dev/\u200Dinternal` - ❌ `https://api.trigger.dev/\u1680internal` - ❌ `https://api.trigger.dev/\u2007internal` +- ❌ `https://api.trigger.dev/\u200Ainternal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u205Finternal` - ❌ `https://api.trigger.dev/\u180Einternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 4c15758eaa..ceb22329a7 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -179,6 +179,7 @@ Examples: - ✅ `\u00A0https://api.trigger.dev/custom-prefix/\u00A0` (non-breaking-space wrapper trimmed) - ✅ `\u1680https://api.trigger.dev/custom-prefix/\u1680` (ogham-space-mark wrapper trimmed) - ✅ `\u2007https://api.trigger.dev/custom-prefix/\u2007` (figure-space wrapper trimmed) +- ✅ `\u200Ahttps://api.trigger.dev/custom-prefix/\u200A` (hair-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -212,6 +213,7 @@ Examples: - ❌ `https://api.trigger.dev/\u200Dinternal` (internal zero-width-joiner characters) - ❌ `https://api.trigger.dev/\u1680internal` (internal ogham-space-mark characters) - ❌ `https://api.trigger.dev/\u2007internal` (internal figure-space characters) +- ❌ `https://api.trigger.dev/\u200Ainternal` (internal hair-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u205Finternal` (internal medium-mathematical-space characters) - ❌ `https://api.trigger.dev/\u180Einternal` (internal mongolian-vowel-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index e488a48f3f..d20d0a682e 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -961,6 +961,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal hair-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u200Ainternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { new TriggerChatTransport({ @@ -1478,6 +1489,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts hair-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u200Ahttps://api.trigger.dev/custom-prefix/\u200A", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values", function () { expect(function () { new TriggerChatTransport({ @@ -4098,6 +4120,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal hair-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u200Ainternal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { createTriggerChatTransport({ @@ -4604,6 +4637,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts hair-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u200Ahttps://api.trigger.dev/custom-prefix/\u200A", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values from factory", function () { expect(function () { createTriggerChatTransport({ From a3dbae6e3ccc1b0fb839472e36768ebd0fb43ca6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:17:23 +0000 Subject: [PATCH 213/217] Cover thin-space baseURL wrapper and internal validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 1bbdc15c9e..72fb57f321 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -673,6 +673,7 @@ Examples: - ✅ `\u1680https://api.trigger.dev/custom-prefix/\u1680` (ogham-space-mark wrapper trimmed) - ✅ `\u2007https://api.trigger.dev/custom-prefix/\u2007` (figure-space wrapper trimmed) - ✅ `\u200Ahttps://api.trigger.dev/custom-prefix/\u200A` (hair-space wrapper trimmed) +- ✅ `\u2009https://api.trigger.dev/custom-prefix/\u2009` (thin-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -707,6 +708,7 @@ Examples: - ❌ `https://api.trigger.dev/\u1680internal` - ❌ `https://api.trigger.dev/\u2007internal` - ❌ `https://api.trigger.dev/\u200Ainternal` +- ❌ `https://api.trigger.dev/\u2009internal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u205Finternal` - ❌ `https://api.trigger.dev/\u180Einternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index ceb22329a7..09c72eda77 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -180,6 +180,7 @@ Examples: - ✅ `\u1680https://api.trigger.dev/custom-prefix/\u1680` (ogham-space-mark wrapper trimmed) - ✅ `\u2007https://api.trigger.dev/custom-prefix/\u2007` (figure-space wrapper trimmed) - ✅ `\u200Ahttps://api.trigger.dev/custom-prefix/\u200A` (hair-space wrapper trimmed) +- ✅ `\u2009https://api.trigger.dev/custom-prefix/\u2009` (thin-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -214,6 +215,7 @@ Examples: - ❌ `https://api.trigger.dev/\u1680internal` (internal ogham-space-mark characters) - ❌ `https://api.trigger.dev/\u2007internal` (internal figure-space characters) - ❌ `https://api.trigger.dev/\u200Ainternal` (internal hair-space characters) +- ❌ `https://api.trigger.dev/\u2009internal` (internal thin-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u205Finternal` (internal medium-mathematical-space characters) - ❌ `https://api.trigger.dev/\u180Einternal` (internal mongolian-vowel-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index d20d0a682e..725673a7c2 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -972,6 +972,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal thin-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2009internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { new TriggerChatTransport({ @@ -1500,6 +1511,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts thin-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2009https://api.trigger.dev/custom-prefix/\u2009", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values", function () { expect(function () { new TriggerChatTransport({ @@ -4131,6 +4153,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal thin-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2009internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { createTriggerChatTransport({ @@ -4648,6 +4681,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts thin-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2009https://api.trigger.dev/custom-prefix/\u2009", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values from factory", function () { expect(function () { createTriggerChatTransport({ From 580b9867761e51524cb18c1383b2ce21356120c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:20:00 +0000 Subject: [PATCH 214/217] Cover punctuation-space baseURL wrapper and internal validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 72fb57f321..757c40cc81 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -674,6 +674,7 @@ Examples: - ✅ `\u2007https://api.trigger.dev/custom-prefix/\u2007` (figure-space wrapper trimmed) - ✅ `\u200Ahttps://api.trigger.dev/custom-prefix/\u200A` (hair-space wrapper trimmed) - ✅ `\u2009https://api.trigger.dev/custom-prefix/\u2009` (thin-space wrapper trimmed) +- ✅ `\u2008https://api.trigger.dev/custom-prefix/\u2008` (punctuation-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -709,6 +710,7 @@ Examples: - ❌ `https://api.trigger.dev/\u2007internal` - ❌ `https://api.trigger.dev/\u200Ainternal` - ❌ `https://api.trigger.dev/\u2009internal` +- ❌ `https://api.trigger.dev/\u2008internal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u205Finternal` - ❌ `https://api.trigger.dev/\u180Einternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 09c72eda77..db2b9e3fd7 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -181,6 +181,7 @@ Examples: - ✅ `\u2007https://api.trigger.dev/custom-prefix/\u2007` (figure-space wrapper trimmed) - ✅ `\u200Ahttps://api.trigger.dev/custom-prefix/\u200A` (hair-space wrapper trimmed) - ✅ `\u2009https://api.trigger.dev/custom-prefix/\u2009` (thin-space wrapper trimmed) +- ✅ `\u2008https://api.trigger.dev/custom-prefix/\u2008` (punctuation-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -216,6 +217,7 @@ Examples: - ❌ `https://api.trigger.dev/\u2007internal` (internal figure-space characters) - ❌ `https://api.trigger.dev/\u200Ainternal` (internal hair-space characters) - ❌ `https://api.trigger.dev/\u2009internal` (internal thin-space characters) +- ❌ `https://api.trigger.dev/\u2008internal` (internal punctuation-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u205Finternal` (internal medium-mathematical-space characters) - ❌ `https://api.trigger.dev/\u180Einternal` (internal mongolian-vowel-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 725673a7c2..54f841425f 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -983,6 +983,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal punctuation-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2008internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { new TriggerChatTransport({ @@ -1522,6 +1533,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts punctuation-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2008https://api.trigger.dev/custom-prefix/\u2008", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values", function () { expect(function () { new TriggerChatTransport({ @@ -4164,6 +4186,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal punctuation-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2008internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { createTriggerChatTransport({ @@ -4692,6 +4725,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts punctuation-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2008https://api.trigger.dev/custom-prefix/\u2008", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values from factory", function () { expect(function () { createTriggerChatTransport({ From 2ce01e144b4b3edfb97a36231a4fa266f701caa3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:22:34 +0000 Subject: [PATCH 215/217] Cover six-per-em-space baseURL wrapper and internal validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 757c40cc81..52b607d887 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -675,6 +675,7 @@ Examples: - ✅ `\u200Ahttps://api.trigger.dev/custom-prefix/\u200A` (hair-space wrapper trimmed) - ✅ `\u2009https://api.trigger.dev/custom-prefix/\u2009` (thin-space wrapper trimmed) - ✅ `\u2008https://api.trigger.dev/custom-prefix/\u2008` (punctuation-space wrapper trimmed) +- ✅ `\u2006https://api.trigger.dev/custom-prefix/\u2006` (six-per-em-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -711,6 +712,7 @@ Examples: - ❌ `https://api.trigger.dev/\u200Ainternal` - ❌ `https://api.trigger.dev/\u2009internal` - ❌ `https://api.trigger.dev/\u2008internal` +- ❌ `https://api.trigger.dev/\u2006internal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u205Finternal` - ❌ `https://api.trigger.dev/\u180Einternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index db2b9e3fd7..477f38e81d 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -182,6 +182,7 @@ Examples: - ✅ `\u200Ahttps://api.trigger.dev/custom-prefix/\u200A` (hair-space wrapper trimmed) - ✅ `\u2009https://api.trigger.dev/custom-prefix/\u2009` (thin-space wrapper trimmed) - ✅ `\u2008https://api.trigger.dev/custom-prefix/\u2008` (punctuation-space wrapper trimmed) +- ✅ `\u2006https://api.trigger.dev/custom-prefix/\u2006` (six-per-em-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -218,6 +219,7 @@ Examples: - ❌ `https://api.trigger.dev/\u200Ainternal` (internal hair-space characters) - ❌ `https://api.trigger.dev/\u2009internal` (internal thin-space characters) - ❌ `https://api.trigger.dev/\u2008internal` (internal punctuation-space characters) +- ❌ `https://api.trigger.dev/\u2006internal` (internal six-per-em-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u205Finternal` (internal medium-mathematical-space characters) - ❌ `https://api.trigger.dev/\u180Einternal` (internal mongolian-vowel-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index 54f841425f..b158f687c6 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -994,6 +994,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal six-per-em-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2006internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { new TriggerChatTransport({ @@ -1544,6 +1555,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts six-per-em-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2006https://api.trigger.dev/custom-prefix/\u2006", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values", function () { expect(function () { new TriggerChatTransport({ @@ -4197,6 +4219,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal six-per-em-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2006internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { createTriggerChatTransport({ @@ -4736,6 +4769,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts six-per-em-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2006https://api.trigger.dev/custom-prefix/\u2006", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values from factory", function () { expect(function () { createTriggerChatTransport({ From 7fe37c297af2047f800231f2985c87aa2c97aa67 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:25:19 +0000 Subject: [PATCH 216/217] Cover em-space baseURL wrapper and internal validation Co-authored-by: Eric Allam --- docs/tasks/streams.mdx | 2 ++ packages/ai/README.md | 2 ++ packages/ai/src/chatTransport.test.ts | 44 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docs/tasks/streams.mdx b/docs/tasks/streams.mdx index 52b607d887..3045e8901f 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -676,6 +676,7 @@ Examples: - ✅ `\u2009https://api.trigger.dev/custom-prefix/\u2009` (thin-space wrapper trimmed) - ✅ `\u2008https://api.trigger.dev/custom-prefix/\u2008` (punctuation-space wrapper trimmed) - ✅ `\u2006https://api.trigger.dev/custom-prefix/\u2006` (six-per-em-space wrapper trimmed) +- ✅ `\u2003https://api.trigger.dev/custom-prefix/\u2003` (em-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -713,6 +714,7 @@ Examples: - ❌ `https://api.trigger.dev/\u2009internal` - ❌ `https://api.trigger.dev/\u2008internal` - ❌ `https://api.trigger.dev/\u2006internal` +- ❌ `https://api.trigger.dev/\u2003internal` - ❌ `https://api.trigger.dev/\u202Finternal` - ❌ `https://api.trigger.dev/\u205Finternal` - ❌ `https://api.trigger.dev/\u180Einternal` diff --git a/packages/ai/README.md b/packages/ai/README.md index 477f38e81d..772139871c 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -183,6 +183,7 @@ Examples: - ✅ `\u2009https://api.trigger.dev/custom-prefix/\u2009` (thin-space wrapper trimmed) - ✅ `\u2008https://api.trigger.dev/custom-prefix/\u2008` (punctuation-space wrapper trimmed) - ✅ `\u2006https://api.trigger.dev/custom-prefix/\u2006` (six-per-em-space wrapper trimmed) +- ✅ `\u2003https://api.trigger.dev/custom-prefix/\u2003` (em-space wrapper trimmed) - ✅ `\u205Fhttps://api.trigger.dev/custom-prefix/\u205F` (medium-mathematical-space wrapper trimmed) - ✅ `\u3000https://api.trigger.dev/custom-prefix/\u3000` (ideographic-space wrapper trimmed) - ✅ `\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF` (BOM wrapper trimmed) @@ -220,6 +221,7 @@ Examples: - ❌ `https://api.trigger.dev/\u2009internal` (internal thin-space characters) - ❌ `https://api.trigger.dev/\u2008internal` (internal punctuation-space characters) - ❌ `https://api.trigger.dev/\u2006internal` (internal six-per-em-space characters) +- ❌ `https://api.trigger.dev/\u2003internal` (internal em-space characters) - ❌ `https://api.trigger.dev/\u202Finternal` (internal narrow no-break space characters) - ❌ `https://api.trigger.dev/\u205Finternal` (internal medium-mathematical-space characters) - ❌ `https://api.trigger.dev/\u180Einternal` (internal mongolian-vowel-separator characters) diff --git a/packages/ai/src/chatTransport.test.ts b/packages/ai/src/chatTransport.test.ts index b158f687c6..50bc5f568e 100644 --- a/packages/ai/src/chatTransport.test.ts +++ b/packages/ai/src/chatTransport.test.ts @@ -1005,6 +1005,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws when baseURL contains internal em-space characters", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2003internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { new TriggerChatTransport({ @@ -1566,6 +1577,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts em-space wrapped baseURL values", function () { + expect(function () { + new TriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2003https://api.trigger.dev/custom-prefix/\u2003", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values", function () { expect(function () { new TriggerChatTransport({ @@ -4230,6 +4252,17 @@ describe("TriggerChatTransport", function () { }).toThrowError("baseURL must not contain internal whitespace characters"); }); + it("throws from factory when baseURL contains internal em-space characters", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/\u2003internal", + stream: "chat-stream", + }); + }).toThrowError("baseURL must not contain internal whitespace characters"); + }); + it("throws from factory when baseURL contains internal mongolian-vowel-separator characters", function () { expect(function () { createTriggerChatTransport({ @@ -4780,6 +4813,17 @@ describe("TriggerChatTransport", function () { }).not.toThrow(); }); + it("accepts em-space wrapped baseURL values from factory", function () { + expect(function () { + createTriggerChatTransport({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u2003https://api.trigger.dev/custom-prefix/\u2003", + stream: "chat-stream", + }); + }).not.toThrow(); + }); + it("accepts ideographic-space wrapped baseURL values from factory", function () { expect(function () { createTriggerChatTransport({ From 65d96e712355067134e88ee7ec5144422a516b2b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 15 Feb 2026 07:27:29 +0000 Subject: [PATCH 217/217] Document expanded unicode-space baseURL coverage in release notes Co-authored-by: Eric Allam --- .changeset/curly-radios-visit.md | 2 ++ packages/ai/CHANGELOG.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md index 30833180eb..82482a02e0 100644 --- a/.changeset/curly-radios-visit.md +++ b/.changeset/curly-radios-visit.md @@ -18,5 +18,7 @@ Add a new `@trigger.dev/ai` package with: mathematical space (`\u205F`) for both wrapper trimming and internal-whitespace rejection - expanded invisible-separator rejection coverage to include mongolian vowel separator (`\u180E`) in both wrapper and internal `baseURL` positions +- expanded unicode spacing coverage to include hair space (`\u200A`), thin space (`\u2009`), + punctuation space (`\u2008`), six-per-em space (`\u2006`), and em space (`\u2003`) - deterministic baseURL validation error ordering for multi-issue inputs (internal whitespace → protocol → query/hash → credentials) - explicit default `baseURL` behavior (`https://api.trigger.dev`) and case-insensitive `HTTP(S)` protocol acceptance diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0a05e3529b..5cc0183cf9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -34,6 +34,9 @@ across internal-whitespace rejection, wrapper trimming acceptance, and empty-after-trim validation. - Expanded invisible-separator coverage to reject `\u180E` (mongolian vowel separator) in both internal and wrapper `baseURL` positions. +- Expanded unicode space coverage to include `\u200A` (hair space), `\u2009` (thin space), + `\u2008` (punctuation space), `\u2006` (six-per-em space), and `\u2003` (em space) + across wrapper-trimming acceptance and internal-whitespace rejection scenarios. - Documented that `HTTP://` and `HTTPS://` are accepted (case-insensitive protocol matching). - Added deterministic validation ordering for multi-issue baseURL values (internal whitespace → protocol → query/hash → credentials).