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. diff --git a/.changeset/curly-radios-visit.md b/.changeset/curly-radios-visit.md new file mode 100644 index 0000000000..82482a02e0 --- /dev/null +++ b/.changeset/curly-radios-visit.md @@ -0,0 +1,24 @@ +--- +"@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 +- 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...`) +- 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 +- 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/docs/tasks/schemaTask.mdx b/docs/tasks/schemaTask.mdx index 3692d1d703..3bf1f2b5d8 100644 --- a/docs/tasks/schemaTask.mdx +++ b/docs/tasks/schemaTask.mdx @@ -80,8 +80,11 @@ 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/sdk/ai"; +import { ai } from "@trigger.dev/ai"; import { schemaTask } from "@trigger.dev/sdk"; import { z } from "zod"; import { generateText } from "ai"; @@ -118,7 +121,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 +186,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..3045e8901f 100644 --- a/docs/tasks/streams.mdx +++ b/docs/tasks/streams.mdx @@ -517,6 +517,242 @@ 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`) + +### 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) +- `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 + +`onError` receives phase-aware details (`payloadMapper`, `triggerOptions`, `triggerTask`, +`streamSubscribe`, `onTriggeredRun`, `consumeTrackingStream`, `reconnect`) plus `chatId`, +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`. 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"; + +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); + } +} +``` + +`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`. +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` 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 +username/password credentials. +Protocol matching is case-insensitive (`HTTP://...` and `HTTPS://...` are accepted). + +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) +- ✅ `\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) +- ✅ `\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) +- ❌ `\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) +- ❌ `\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` +- ❌ `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) +- ❌ `\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) +- ❌ `\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` +- ❌ `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/\u200Cinternal` +- ❌ `https://api.trigger.dev/\u200Dinternal` +- ❌ `https://api.trigger.dev/\u1680internal` +- ❌ `https://api.trigger.dev/\u2007internal` +- ❌ `https://api.trigger.dev/\u200Ainternal` +- ❌ `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` +- ❌ `https://api.trigger.dev/\u3000internal` +- ❌ `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: + +- `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` + +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. + +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` +- `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: + +- `TriggerChatHeadersInput` +- `TriggerChatSendMessagesOptions` +- `TriggerChatReconnectOptions` + ## Complete Example: AI Streaming ### Define the stream diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md new file mode 100644 index 0000000000..5cc0183cf9 --- /dev/null +++ b/packages/ai/CHANGELOG.md @@ -0,0 +1,43 @@ +# @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. +- 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. +- 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. +- 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. +- 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. +- 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`. +- 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. +- 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). +- Documented explicit default `baseURL` value (`https://api.trigger.dev`) when omitted. diff --git a/packages/ai/README.md b/packages/ai/README.md new file mode 100644 index 0000000000..772139871c --- /dev/null +++ b/packages/ai/README.md @@ -0,0 +1,275 @@ +# @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 +- helper types such as `TriggerChatSendMessagesOptions`, `TriggerChatReconnectOptions`, and `TriggerChatHeadersInput` + +## 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`) + +Incoming `request.headers` can be supplied as a plain object, `Headers`, or tuple arrays. + +Typed request option helper aliases are exported: + +- `TriggerChatSendMessagesOptions` +- `TriggerChatReconnectOptions` +- `TriggerChatHeadersInput` +- `TriggerChatTransportError` / `TriggerChatOnError` +- `normalizeTriggerChatHeaders(...)` + +```ts +import type { TriggerChatTransportPayload } from "@trigger.dev/ai"; +import { UIMessage } from "ai"; + +type Payload = TriggerChatTransportPayload; +``` + +## Custom payload mapping + +If your task expects a custom payload shape, provide `payloadMapper` (sync or async): + +```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: async function payloadMapper(request) { + await Promise.resolve(); + + 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, + }; + }, +}); +``` + +`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: + +```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); + } +} +``` + +`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). + +The callback receives: + +- `phase`: `"payloadMapper" | "triggerOptions" | "triggerTask" | "streamSubscribe" | "onTriggeredRun" | "consumeTrackingStream" | "reconnect"` +- `chatId` +- `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. The transport also attempts +both cleanup steps (`set` inactive state and `delete`) even if one of them fails. + +## 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`. +- 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. + +## 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. +- `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. +- `baseURL` must not include username/password URL credentials. +- Protocol matching is case-insensitive (`HTTP://...` and `HTTPS://...` are accepted). + +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) +- ✅ `\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) +- ✅ `\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) +- ❌ `\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) +- ❌ `\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) +- ❌ `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) +- ❌ `\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) +- ❌ `\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) +- ❌ `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/\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/\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/\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) +- ❌ `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) +- ❌ `https://api.trigger.dev/\uFEFFinternal` (internal BOM 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` + +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. + +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` +- `ftp://user:pass@example.com/\u180Einvalid?x=1#fragment` → `baseURL must not contain internal whitespace characters` + +## `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. diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 0000000000..ebec165a0d --- /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 --exclude \"**/.tshy-build/**\"", + "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..cddcbbacef --- /dev/null +++ b/packages/ai/src/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("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("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 new file mode 100644 index 0000000000..e54f893082 --- /dev/null +++ b/packages/ai/src/ai.ts @@ -0,0 +1,125 @@ +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 function execute(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 { + const tool = runMetadata.getKey(METADATA_KEY); + 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..50bc5f568e --- /dev/null +++ b/packages/ai/src/chatTransport.test.ts @@ -0,0 +1,6897 @@ +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { + InMemoryTriggerChatRunStore, + createTriggerChatTransport, + normalizeTriggerChatHeaders, + TriggerChatTransport, +} from "./chatTransport.js"; +import type { TriggerChatStream } from "./types.js"; +import type { UIMessage, UIMessageChunk } from "ai"; +import type { + TriggerChatTransportError, + TriggerChatRunState, + TriggerChatRunStore, +} from "./types.js"; + +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("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("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("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("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("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", + "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(observedTriggerPath).toBe("/api/v1/tasks/chat-task/trigger"); + 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; + + 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("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("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("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; + + 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("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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " /// ", + stream: "chat-stream", + }); + }).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("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("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("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({ + 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({ + 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 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 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 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({ + 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 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 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 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 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 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 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 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 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 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 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({ + 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({ + 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 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({ + 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 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 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 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 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 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({ + 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 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({ + 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 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({ + 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "/relative/path", + stream: "chat-stream", + }); + }).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("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 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 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "wss://example.com", + stream: "chat-stream", + }); + }).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 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({ + 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 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 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({ + 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({ + 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 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 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({ + 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("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 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 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 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 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({ + 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("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({ + 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("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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom-prefix", + stream: "chat-stream", + }); + }).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 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 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u00A0https://api.trigger.dev/custom-prefix/\u00A0", + stream: "chat-stream", + }); + }).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 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 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 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 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 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 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({ + 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF", + stream: "chat-stream", + }); + }).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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom%20prefix", + stream: "chat-stream", + }); + }).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 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({ + 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; + + 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("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; + + 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("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; + + 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("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; + + 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("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"]], + }); + + 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("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({ + "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({ + "x-headers": "headers-value", + }); + }); + + 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({ + chatId: "missing-chat", + }); + + 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", + 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, + onError: function onError(error) { + errors.push(error); + }, + }); + + const stream = await transport.reconnectToStream({ + chatId: "chat-inactive", + }); + + expect(stream).toBeNull(); + expect(errors).toHaveLength(0); + expect(runStore.deleteCalls).toContain("chat-inactive"); + 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("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 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(runStore.deleteCallCount).toBe(2); + 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); + 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({ + 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("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("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.deleteCallCount).toBe(2); + 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({ + 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("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"); + 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(runStore.deleteCallCount).toBe(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.deleteCallCount).toBe(2); + 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"); + 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("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({ + 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; + 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: async function payloadMapper(request) { + await sleep(1); + + 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: async function triggerOptions(request) { + await sleep(1); + + 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("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("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") { + 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"); + }, + onError: function onError(error) { + errors.push(error); + }, + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-mapper-failure", + messageId: undefined, + messages: [], + abortSignal: undefined, + }) + ).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("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("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[] = []; + + 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"); + }, + onError: function onError(error) { + errors.push(error); + }, + }); + + 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); + 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("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("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) { + 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("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("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("reports stream subscription 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_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(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 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_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.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(); + }); + + 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( + "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("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"); + 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 () { + 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("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 () { + 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(); + + 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; + let observedState: TriggerChatRunState | 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: async function onTriggeredRun(state) { + await sleep(1); + observedRunId = state.runId; + observedState = state; + callbackCompleted = true; + }, + }); + + 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"); + expect(callbackCompleted).toBe(true); + expect(observedState).toMatchObject({ + chatId: "chat-factory", + runId: "run_factory", + streamKey: "chat-stream", + lastEventId: undefined, + isActive: true, + }); + }); + + 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: " /// ", + stream: "chat-stream", + }); + }).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("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("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("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({ + 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "invalid-base-url", + stream: "chat-stream", + }); + }).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 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 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({ + 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 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 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 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 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 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 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 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 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 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 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({ + 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({ + 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 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({ + 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 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 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 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 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 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({ + 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 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({ + 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 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({ + 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "ftp://example.com", + stream: "chat-stream", + }); + }).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 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 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({ + 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 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 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({ + 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 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 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({ + 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({ + 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 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 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({ + 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("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 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 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 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 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({ + 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("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({ + 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("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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom-prefix", + stream: "chat-stream", + }); + }).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("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 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\u00A0https://api.trigger.dev/custom-prefix/\u00A0", + stream: "chat-stream", + }); + }).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 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 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 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 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 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 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({ + 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "\uFEFFhttps://api.trigger.dev/custom-prefix/\uFEFF", + stream: "chat-stream", + }); + }).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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "https://api.trigger.dev/custom%20prefix", + stream: "chat-stream", + }); + }).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 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({ + task: "chat-task", + accessToken: "pk_trigger", + baseURL: "HTTP://api.trigger.dev/custom-prefix", + stream: "chat-stream", + }); + }).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[] = []; + + 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"); + }, + onError: function onError(error) { + errors.push(error); + }, + }); + + 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); + 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("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[] = []; + + 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") { + 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("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("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( + "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); + + 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"); + 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 () { + 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( + "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( + "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(); + 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("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"); + 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 () { + 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", + 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() { + fetchCalls += 1; + 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"); + 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("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"); + 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(); + 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 () { + 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( + "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(); + 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("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(); + + 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.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(); + }); + + 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("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 () { + return runStore.get("chat-cleanup-set-failure") === undefined; + }); + 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 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") { + 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("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"); + 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(); + }); + + it("reconnects active streams using tracked lastEventId", async 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) { + 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, + onError: function onError(error) { + errors.push(error); + }, + }); + + 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 === "1-0"); + }); + + const reconnectStream = await transport.reconnectToStream({ + chatId: "chat-2", + }); + + expect(reconnectStream).not.toBeNull(); + + const reconnectChunks = await readChunks(reconnectStream!); + expect(reconnectLastEventId).toBe("1-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" }, + }); + expect(errors).toHaveLength(0); + } 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 | Promise, + timeoutInMs = 5000 +) { + const start = Date.now(); + + while (Date.now() - start < timeoutInMs) { + if (await condition()) { + return; + } + + await new Promise(function (resolve) { + setTimeout(resolve, 25); + }); + } + + throw new Error(`Condition was not met within ${timeoutInMs}ms`); +} + +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); + } +} + +class FailingCleanupSetRunStore extends InMemoryTriggerChatRunStore { + private setCalls = 0; + public readonly deleteCalls: string[] = []; + + 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); + } + + public delete(chatId: string): void { + this.deleteCalls.push(chatId); + super.delete(chatId); + } +} + +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 FailingCleanupDeleteValueRunStore extends InMemoryTriggerChatRunStore { + public deleteCallCount = 0; + + constructor(private readonly thrownValue: unknown) { + super(); + } + + public delete(chatId: string): void { + this.deleteCallCount += 1; + if (this.deleteCallCount === 1) { + throw this.thrownValue; + } + + super.delete(chatId); + } +} + +class AlwaysFailCleanupDeleteRunStore extends InMemoryTriggerChatRunStore { + public deleteCallCount = 0; + + public delete(_chatId: string): void { + this.deleteCallCount += 1; + throw new Error("cleanup delete always fails"); + } +} + +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[] = []; + 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); + }); +} diff --git a/packages/ai/src/chatTransport.ts b/packages/ai/src/chatTransport.ts new file mode 100644 index 0000000000..98922e4e9f --- /dev/null +++ b/packages/ai/src/chatTransport.ts @@ -0,0 +1,674 @@ +import { + ApiClient, + ApiRequestOptions, + makeIdempotencyKey, + SSEStreamPart, + SSEStreamSubscription, + stringifyIO, + TriggerOptions, +} from "@trigger.dev/core/v3"; +import type { + ChatTransport, + InferUIMessageChunk, + UIMessage, + UIMessageChunk, +} from "ai"; +import type { + TriggerChatHeadersInput, + TriggerChatOnError, + TriggerChatReconnectOptions, + TriggerChatSendMessagesOptions, + TriggerChatOnTriggeredRun, + TriggerChatPayloadMapper, + TriggerChatRunState, + TriggerChatRunStore, + TriggerChatStream, + TriggerChatTaskContext, + TriggerChatTransportError, + 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?: TriggerChatOnTriggeredRun; + onError?: TriggerChatOnError; +}; + +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: TriggerChatOnTriggeredRun | undefined; + private readonly onError: TriggerChatOnError | 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 = normalizeBaseUrl(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; + this.onError = options.onError; + } + + public async sendMessages( + options: TriggerChatSendMessagesOptions + ): Promise> { + const transportRequest = createTransportRequest(options); + 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, + runId: run.id, + publicAccessToken: run.publicAccessToken, + streamKey: this.streamKey, + lastEventId: undefined, + isActive: true, + }; + + await this.runStore.set(runState); + + if (this.onTriggeredRun) { + try { + await this.onTriggeredRun({ + ...runState, + }); + } catch (error) { + await this.reportError({ + phase: "onTriggeredRun", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); + // Ignore callback errors so chat streaming can continue. + } + } + + let stream: ReadableStream>>; + try { + stream = await this.fetchRunStream(runState, options.abortSignal); + } catch (error) { + await this.tryMarkRunInactiveAndDelete(runState); + await this.reportError({ + phase: "streamSubscribe", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); + throw error; + } + + return this.createTrackedStream(runState.chatId, stream); + } + + public async reconnectToStream( + options: TriggerChatReconnectOptions + ): Promise | null> { + const runState = await this.runStore.get(options.chatId); + + if (!runState) { + return null; + } + + if (!runState.isActive) { + await this.cleanupInactiveReconnectState(runState); + return null; + } + + let stream: ReadableStream>>; + try { + stream = await this.fetchRunStream(runState, undefined, runState.lastEventId); + } catch (error) { + await this.tryMarkRunInactiveAndDelete(runState); + await this.reportError({ + phase: "reconnect", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); + return null; + } + + 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 subscription = new SSEStreamSubscription( + this.createStreamUrl(runState.runId, runState.streamKey), + { + headers: streamClient.getHeaders(), + signal: abortSignal, + timeoutInSeconds: this.timeoutInSeconds, + lastEventId, + } + ); + + return (await subscription.subscribe()) as ReadableStream< + SSEStreamPart> + >; + } + + private createTrackedStream( + chatId: string, + stream: ReadableStream>> + ) { + const teeStreams = stream.tee(); + const trackingStream = teeStreams[0]; + const consumerStream = teeStreams[1]; + + this.consumeTrackingStream(chatId, trackingStream); + + return consumerStream.pipeThrough( + new TransformStream>, UIMessageChunk>({ + transform(part, controller) { + controller.enqueue(part.chunk as UIMessageChunk); + }, + }) + ); + } + + private async consumeTrackingStream( + chatId: string, + stream: ReadableStream>> + ) { + try { + for await (const part of stream) { + const runState = await this.runStore.get(chatId); + + if (!runState) { + return; + } + + await this.runStore.set({ + ...runState, + lastEventId: part.id, + }); + } + + const runState = await this.runStore.get(chatId); + if (runState) { + await this.tryMarkRunInactiveAndDelete(runState); + } + } catch (error) { + const runState = await this.runStore.get(chatId); + if (runState) { + await this.tryMarkRunInactiveAndDelete(runState); + await this.reportError({ + phase: "consumeTrackingStream", + chatId: runState.chatId, + runId: runState.runId, + error: normalizeError(error), + }); + } + } + } + + 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; + } + + private createStreamUrl(runId: string, streamKey: string): string { + const encodedRunId = encodeURIComponent(runId); + const encodedStreamKey = encodeURIComponent(streamKey); + + return `${this.baseURL}/realtime/v1/streams/${encodedRunId}/${encodedStreamKey}`; + } + + private async markRunInactiveAndDelete(runState: TriggerChatRunState) { + 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) { + try { + await this.markRunInactiveAndDelete(runState); + } catch { + // Best effort cleanup only; never mask the original transport failure. + } + } + + 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; + } + + try { + await this.onError(event); + } catch { + // Never let error callbacks interfere with transport behavior. + } + } +} + +export function createTriggerChatTransport< + UI_MESSAGE extends UIMessage = UIMessage, + PAYLOAD = TriggerChatTransportPayload, +>( + options: TriggerChatTransportOptions +) { + return new TriggerChatTransport(options); +} + +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", +} 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\u180E\u200B\u200C\u200D\u2060\uFEFF]/u; + +function resolvePayloadMapper< + UI_MESSAGE extends UIMessage, + PAYLOAD, +>(payloadMapper: TriggerChatPayloadMapper | undefined) { + if (payloadMapper) { + return payloadMapper; + } + + return createDefaultPayload as TriggerChatPayloadMapper; +} + +function normalizeBaseUrl(baseURL: string) { + const normalizedBaseUrl = baseURL.trim().replace(/\/+$/, ""); + + if (normalizedBaseUrl.length === 0) { + throw new Error(BASE_URL_VALIDATION_ERRORS.empty); + } + + assertBaseUrlHasNoInternalWhitespace(normalizedBaseUrl); + + let parsedBaseUrl: URL; + try { + parsedBaseUrl = new URL(normalizedBaseUrl); + } catch { + throw new Error(BASE_URL_VALIDATION_ERRORS.invalidAbsoluteUrl); + } + + assertValidBaseUrlProtocol(parsedBaseUrl); + assertBaseUrlHasNoQueryOrHash(parsedBaseUrl); + assertBaseUrlHasNoCredentials(parsedBaseUrl); + + return normalizedBaseUrl; +} + +function assertBaseUrlHasNoInternalWhitespace(baseUrl: string) { + if (INTERNAL_WHITESPACE_REGEX.test(baseUrl)) { + throw new Error(BASE_URL_VALIDATION_ERRORS.containsWhitespace); + } +} + +function assertValidBaseUrlProtocol(parsedBaseUrl: URL) { + if ( + parsedBaseUrl.protocol !== "http:" && + parsedBaseUrl.protocol !== "https:" + ) { + throw new Error(BASE_URL_VALIDATION_ERRORS.invalidProtocol); + } +} + +function assertBaseUrlHasNoQueryOrHash(parsedBaseUrl: URL) { + if (parsedBaseUrl.search.length > 0 || parsedBaseUrl.hash.length > 0) { + throw new Error(BASE_URL_VALIDATION_ERRORS.queryOrHash); + } +} + +function assertBaseUrlHasNoCredentials(parsedBaseUrl: URL) { + if (parsedBaseUrl.username.length > 0 || parsedBaseUrl.password.length > 0) { + throw new Error(BASE_URL_VALIDATION_ERRORS.credentials); + } +} + +function createTransportRequest( + options: TriggerChatSendMessagesOptions +): 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: TriggerChatHeadersInput | 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()) { + 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; +} + +/** + * Converts supported header input shapes into a normalized plain object. + */ +export function normalizeTriggerChatHeaders( + headers: TriggerChatHeadersInput | undefined +): Record | undefined { + return normalizeHeaders(headers); +} + +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 await 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, + }; +} + +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 new file mode 100644 index 0000000000..dc37c8420a --- /dev/null +++ b/packages/ai/src/chatTransport.types.test.ts @@ -0,0 +1,220 @@ +import { expectTypeOf, it } from "vitest"; +import type { InferUIMessageChunk, UIMessage } from "ai"; +import { + createTriggerChatTransport, + TriggerChatTransport, + InMemoryTriggerChatRunStore, + normalizeTriggerChatHeaders, + TriggerChatTransportOptions, + type TriggerChatOnError, + type TriggerChatTransportError, + type TriggerChatHeadersInput, + type TriggerChatReconnectOptions, + type TriggerChatSendMessagesOptions, + 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 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}`, + }; + }, + onTriggeredRun: async function onTriggeredRun(_state) { + return; + }, + onError: async function onError(_error: TriggerChatTransportError) { + return; + }, + }; + + 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< + | "payloadMapper" + | "triggerOptions" + | "triggerTask" + | "streamSubscribe" + | "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", + 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", + 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(); +}); + +it("accepts tuple-style headers in sendMessages options", function () { + const transport = new TriggerChatTransport({ + task: "ai-chat", + 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"] = sendOptions.headers; + expectTypeOf(reconnectOptions).toBeObject(); + 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(); +}); + +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", + }); + + expectTypeOf(normalizedHeaders).toEqualTypeOf | undefined>(); +}); diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 0000000000..3a5e71613c --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,26 @@ +export { ai, type ToolCallExecutionOptions, type ToolOptions } from "./ai.js"; +export { + createTriggerChatTransport, + InMemoryTriggerChatRunStore, + normalizeTriggerChatHeaders, + TriggerChatTransport, + type TriggerChatTransportOptions, +} from "./chatTransport.js"; +export type { + TriggerChatHeadersInput, + TriggerChatOnError, + TriggerChatPayloadMapper, + TriggerChatOnTriggeredRun, + TriggerChatReconnectOptions, + TriggerChatRunState, + TriggerChatRunStore, + TriggerChatSendMessagesOptions, + TriggerChatStream, + TriggerChatTaskContext, + TriggerChatTransportError, + TriggerChatTransportErrorPhase, + 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..0053f0de93 --- /dev/null +++ b/packages/ai/src/types.ts @@ -0,0 +1,130 @@ +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 TriggerChatHeadersInput = + | Record + | Headers + | Array<[string, string]>; + +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; +}; + +type MaybePromise = T | Promise; + +export type TriggerChatPayloadMapper< + UI_MESSAGE extends UIMessage = UIMessage, + PAYLOAD = TriggerChatTransportPayload, +> = (request: TriggerChatTransportRequest) => MaybePromise; + +export type TriggerChatTriggerOptionsResolver< + UI_MESSAGE extends UIMessage = UIMessage, +> = ( + request: TriggerChatTransportRequest +) => MaybePromise; + +export type TriggerChatOnTriggeredRun = ( + state: TriggerChatRunState +) => MaybePromise; + +export type TriggerChatTransportErrorPhase = + | "payloadMapper" + | "triggerOptions" + | "triggerTask" + | "streamSubscribe" + | "onTriggeredRun" + | "consumeTrackingStream" + | "reconnect"; + +export type TriggerChatTransportError = { + phase: TriggerChatTransportErrorPhase; + chatId: string; + runId: string | undefined; + error: Error; +}; + +export type TriggerChatOnError = ( + error: TriggerChatTransportError +) => MaybePromise; + +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; +} + +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; + }; 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/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. 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 new file mode 100644 index 0000000000..d383b8d8e8 --- /dev/null +++ b/packages/trigger-sdk/src/v3/ai.test.ts @@ -0,0 +1,142 @@ +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 () { + 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."); + }); + + 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/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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f504496dd1..4f7dc5030f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1373,6 +1373,31 @@ importers: specifier: 8.6.6 version: 8.6.6 + packages/ai: + dependencies: + '@trigger.dev/core': + specifier: workspace:^4.3.3 + version: link:../core + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.15.4 + version: 0.15.4 + ai: + specifier: ^6.0.0 + version: 6.0.3(zod@3.25.76) + rimraf: + specifier: ^3.0.2 + version: 3.0.2 + tshy: + specifier: ^3.0.2 + version: 3.0.2 + tsx: + specifier: 4.17.0 + version: 4.17.0 + zod: + specifier: 3.25.76 + version: 3.25.76 + packages/build: dependencies: '@prisma/config': @@ -2065,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 @@ -2707,6 +2735,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 @@ -2714,8 +2748,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) @@ -2846,14 +2880,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 @@ -2936,6 +2970,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.14': + resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@0.0.26': resolution: {integrity: sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg==} engines: {node: '>=18'} @@ -2960,6 +3000,10 @@ packages: resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@ai-sdk/react@1.0.0': resolution: {integrity: sha512-BDrZqQA07Btg64JCuhFvBgYV+tt2B8cXINzEqWknGoxqcwgdE8wSLG2gkXoLzyC2Rnj7oj0HHpOhLUxDCmoKZg==} engines: {node: '>=18'} @@ -2992,6 +3036,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'} @@ -11109,14 +11159,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'} @@ -11132,7 +11182,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==} @@ -11452,14 +11502,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 @@ -14224,29 +14274,34 @@ 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: 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 +15519,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} @@ -18958,21 +19009,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==} @@ -20241,13 +20293,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 @@ -20255,6 +20300,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 @@ -20339,6 +20391,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 @@ -20363,6 +20422,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) @@ -20393,6 +20456,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 @@ -20439,7 +20512,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: @@ -23121,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 @@ -23876,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) @@ -31446,10 +31519,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 @@ -31877,14 +31950,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) @@ -31893,6 +31958,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 @@ -34166,7 +34239,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 +36499,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.0.0: {} - lru-cache@11.2.4: {} lru-cache@4.1.5: @@ -38258,7 +38329,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: {} @@ -40900,6 +40971,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 @@ -41842,6 +41919,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, }; }, });